Erste Version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:26:25 +01:00
co-authored by Claude Opus 5.5
commit b69b9638ff
20 changed files with 1863 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
dist/
publish/
.vs/
*.user
+35
View File
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BSO.Launcher</RootNamespace>
<AssemblyName>BSO-Launcher</AssemblyName>
<Version>0.1.0</Version>
<Company>MBM.wtf</Company>
<Product>BSO Launcher</Product>
<Description>BSO Launcher - Bus Simulator Online</Description>
<ApplicationIcon>logo.ico</ApplicationIcon>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<!-- Eine einzelne .exe ohne vorinstalliertes .NET (wie der MSCO Launcher) -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
</ItemGroup>
<ItemGroup>
<!-- Oberflaeche (HTML/CSS/JS), wird aus der .exe ausgeliefert -->
<EmbeddedResource Include="ui\**\*" LogicalName="ui/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>
+399
View File
@@ -0,0 +1,399 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace BSO.Launcher;
/// <summary>
/// Fenster mit WebView2 (wie beim MSCO Launcher): die Oberflaeche (ui/*.html/css/js, in der .exe eingebettet) laeuft unter
/// https://app.bso/ und spricht ueber Nachrichten { id, cmd, args } mit dem Launcher; Antworten { id, ok, result | error },
/// Ereignisse { event, ... } (z.B. Download-Fortschritt).
/// </summary>
public sealed class MainForm : Form
{
const string Host = "app.bso";
readonly WebView2 web = new() { Dock = DockStyle.Fill, DefaultBackgroundColor = Color.FromArgb(0x0b, 0x0d, 0x12) };
readonly Settings settings = Settings.Load();
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" };
readonly HashSet<string> running = new();
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases" };
bool updateInstalled;
static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public MainForm()
{
Text = "BSO Launcher";
BackColor = Color.FromArgb(0x0b, 0x0d, 0x12);
ClientSize = new Size(1120, 700);
MinimumSize = new Size(900, 600);
StartPosition = FormStartPosition.CenterScreen;
LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet");
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
Controls.Add(web);
Load += async (_, _) => await InitWeb();
Resize += (_, _) => Emit(new { @event = "window", maximized = WindowState == FormWindowState.Maximized });
}
// ---------------------------------------------------------------- Ohne Windows-Titelleiste
// Die Titelleiste zeichnet die Oberflaeche selbst (ziehen, minimieren, maximieren, schliessen). Der Rahmen bleibt:
// das Fenster laesst sich weiter mit der Maus vergroessern, Aero Snap und Schatten bleiben.
const int WM_NCCALCSIZE = 0x0083, WM_NCLBUTTONDOWN = 0x00A1, HTCAPTION = 2;
[StructLayout(LayoutKind.Sequential)]
struct RECT { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] static extern bool ReleaseCapture();
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")] static extern int GetSystemMetricsForDpi(int index, uint dpi);
[DllImport("user32.dll")] static extern uint GetDpiForWindow(IntPtr hwnd);
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
try
{
int dark = 1, round = 2; // dunkler Rahmen, abgerundete Ecken (Windows 11)
DwmSetWindowAttribute(Handle, 20, ref dark, 4);
DwmSetWindowAttribute(Handle, 33, ref round, 4);
}
catch { }
SetWindowPos(Handle, IntPtr.Zero, 0, 0, 0, 0, 0x0020 | 0x0002 | 0x0001 | 0x0004); // FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_NCCALCSIZE && m.WParam != IntPtr.Zero)
{
// Nur die Titelleiste wegnehmen: oben beginnt der Inhalt direkt am Fensterrand
var before = Marshal.PtrToStructure<RECT>(m.LParam);
base.WndProc(ref m);
var after = Marshal.PtrToStructure<RECT>(m.LParam);
after.Top = before.Top;
if (WindowState == FormWindowState.Maximized)
{
// Maximiert ragt das Fenster um den Rahmen ueber den Bildschirm hinaus
uint dpi = GetDpiForWindow(Handle);
after.Top += GetSystemMetricsForDpi(33, dpi) + GetSystemMetricsForDpi(92, dpi); // SM_CYFRAME + SM_CXPADDEDBORDER
}
Marshal.StructureToPtr(after, m.LParam, false);
m.Result = IntPtr.Zero;
return;
}
base.WndProc(ref m);
}
void WindowCommand(string action)
{
switch (action)
{
case "drag":
ReleaseCapture();
SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, IntPtr.Zero);
break;
case "minimize":
WindowState = FormWindowState.Minimized;
break;
case "maximize":
WindowState = WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized;
break;
case "close":
Close();
break;
}
}
async Task InitWeb()
{
try
{
var env = await CoreWebView2Environment.CreateAsync(null, Path.Combine(Settings.Folder, "WebView2"));
await web.EnsureCoreWebView2Async(env);
}
catch (Exception e)
{
var answer = MessageBox.Show("Die Microsoft Edge WebView2 Runtime fehlt (" + e.Message + ").\n\nDownload-Seite oeffnen?",
Installer.AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Error);
if (answer == DialogResult.Yes) OpenUrl("https://developer.microsoft.com/microsoft-edge/webview2/");
Close();
return;
}
var core = web.CoreWebView2;
core.Settings.AreDevToolsEnabled = Debugger.IsAttached;
core.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached;
core.Settings.IsZoomControlEnabled = false;
core.Settings.IsStatusBarEnabled = false;
core.Settings.AreBrowserAcceleratorKeysEnabled = Debugger.IsAttached;
try { core.Settings.IsNonClientRegionSupportEnabled = true; } catch { } // CSS "app-region: drag" = Titelleiste
core.AddWebResourceRequestedFilter($"https://{Host}/*", CoreWebView2WebResourceContext.All);
core.WebResourceRequested += ServeUi;
// Befehle nur von der eigenen Oberflaeche (nicht aus fremden Seiten oder eingebetteten Frames)
core.WebMessageReceived += async (_, e) =>
{
if (!e.Source.StartsWith($"https://{Host}/", StringComparison.Ordinal)) return;
await OnMessage(e.WebMessageAsJson);
};
// Links nach aussen im Browser oeffnen, nicht im Launcher
core.NewWindowRequested += (_, e) => { e.Handled = true; OpenUrl(e.Uri); };
core.NavigationStarting += (_, e) =>
{
if (!e.Uri.StartsWith($"https://{Host}/")) { e.Cancel = true; OpenUrl(e.Uri); }
};
core.Navigate($"https://{Host}/index.html");
}
static void OpenUrl(string url)
{
if (url.StartsWith("https://") || url.StartsWith("http://"))
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
void ServeUi(object? sender, CoreWebView2WebResourceRequestedEventArgs e)
{
string path = new Uri(e.Request.Uri).AbsolutePath.TrimStart('/');
if (path.Length == 0) path = "index.html";
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("ui/" + path);
if (stream == null)
{
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(null, 404, "Not found", "");
return;
}
string type = Path.GetExtension(path) switch
{
".html" => "text/html; charset=utf-8",
".js" => "text/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".png" => "image/png",
".svg" => "image/svg+xml",
_ => "application/octet-stream"
};
e.Response = web.CoreWebView2.Environment.CreateWebResourceResponse(stream, 200, "OK",
$"Content-Type: {type}\nCache-Control: no-store\nX-Content-Type-Options: nosniff\nContent-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'");
}
// ---------------------------------------------------------------- Nachrichten aus der Oberflaeche
void Emit(object message)
{
if (IsDisposed) return;
string json = JsonSerializer.Serialize(message, Json);
if (InvokeRequired) BeginInvoke(() => web.CoreWebView2?.PostWebMessageAsJson(json));
else web.CoreWebView2?.PostWebMessageAsJson(json);
}
void Progress(string task, double value, string text) => Emit(new { @event = "progress", task, value, text });
async Task OnMessage(string raw)
{
int id = 0;
try
{
using var doc = JsonDocument.Parse(raw);
var msg = doc.RootElement;
id = msg.GetProperty("id").GetInt32();
string cmd = msg.GetProperty("cmd").GetString() ?? "";
JsonElement args = msg.TryGetProperty("args", out var a) ? a.Clone() : default;
if (!QuietCommands.Contains(cmd)) LauncherLog.Info("> " + cmd);
object? result;
if (Exclusive.Contains(cmd))
{
lock (running)
if (!running.Add(cmd)) throw new Exception("Laeuft schon - bitte warten");
try { result = await HandleCommand(cmd, args); }
finally
{
// Launcher-Update erfolgreich: der Launcher beendet sich gleich - nicht wieder freigeben
if (cmd != "installLauncherUpdate" || !updateInstalled) lock (running) running.Remove(cmd);
}
}
else result = await HandleCommand(cmd, args);
Emit(new { id, ok = true, result });
}
catch (Exception e)
{
LauncherLog.Error(e.Message);
Emit(new { id, ok = false, error = e.Message });
}
}
static string Arg(JsonElement args, string name) =>
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
static bool? BoolArg(JsonElement args, string name) =>
args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False
? v.GetBoolean() : null;
async Task<object?> HandleCommand(string cmd, JsonElement args)
{
switch (cmd)
{
case "window":
WindowCommand(Arg(args, "action"));
return null;
case "init":
return new
{
maximized = WindowState == FormWindowState.Maximized,
version = SelfUpdate.CurrentVersion,
username = settings.Username,
loggedIn = settings.Token != null,
master = Api.Base
};
// --- Konto
case "login":
case "register":
{
var r = cmd == "login"
? await Api.Login(Arg(args, "username"), Arg(args, "password"))
: await Api.Register(Arg(args, "username"), Arg(args, "password"), Arg(args, "email") is { Length: > 0 } mail ? mail : null);
if (!r.Ok) throw new Exception(r.Error ?? "Fehlgeschlagen");
settings.Token = r.Json.GetProperty("token").GetString();
settings.Username = r.Json.GetProperty("account").GetProperty("username").GetString();
settings.Save();
LauncherLog.Info((cmd == "login" ? "Angemeldet als " : "Registriert als ") + settings.Username);
return new { username = settings.Username };
}
case "me":
{
string? token = settings.Token;
if (token == null) return new { loggedIn = false };
var r = await Api.Me(token);
if (r.Status == 401) { settings.Token = null; settings.Save(); return new { loggedIn = false }; }
// Master gerade nicht erreichbar: angemeldet bleiben
return new { loggedIn = true, username = r.Ok ? r.Json.GetProperty("account").GetProperty("username").GetString() : settings.Username, offline = !r.Ok };
}
case "logout":
{
string? token = settings.Token;
if (token != null) await Api.Logout(token);
settings.Token = null;
settings.Save();
return null;
}
// --- Status, Installieren, Spielen
case "status":
return await Status();
case "installMod":
return await InstallMod();
case "launch":
return await Launch();
case "releases":
{
var r = await Api.Releases();
if (!r.Ok) throw new Exception(r.Error ?? "Neuigkeiten nicht verfuegbar");
return r.Json.GetProperty("releases");
}
case "installLauncherUpdate":
{
var latest = await Api.Latest("launcher") ?? throw new Exception("Kein Launcher-Update gefunden");
string version = await SelfUpdate.Install(latest, p => Progress("launcher", p, "Launcher-Update wird geladen …"));
updateInstalled = true;
LauncherLog.Info("Launcher aktualisiert auf " + version);
BeginInvoke(Close); // die neue Version startet gerade
return new { version };
}
// --- Einstellungen
case "setSettings":
if (args.ValueKind == JsonValueKind.Object && args.TryGetProperty("server", out var s) && s.ValueKind == JsonValueKind.String)
settings.Server = s.GetString()!.Trim();
settings.AutoConnect = BoolArg(args, "autoConnect") ?? settings.AutoConnect;
settings.KeepOpen = BoolArg(args, "keepOpen") ?? settings.KeepOpen;
settings.AutoUpdateMod = BoolArg(args, "autoUpdateMod") ?? settings.AutoUpdateMod;
settings.Save();
return null;
case "chooseGameFolder":
{
using var dialog = new FolderBrowserDialog { Description = "Ordner \"Bus-Simulator 2012\" waehlen", UseDescriptionForTitle = true };
if (dialog.ShowDialog(this) != DialogResult.OK) return new { changed = false };
string folder = dialog.SelectedPath;
// Auch Bin_High_Win64 selbst gewaehlt? Dann den Ordner darueber nehmen
if (!Game.IsGameFolder(folder) && Game.IsGameFolder(Path.GetDirectoryName(folder))) folder = Path.GetDirectoryName(folder)!;
if (!Game.IsGameFolder(folder)) throw new Exception("In diesem Ordner ist kein Bus-Simulator 2012 (Bin_High_Win64\\BusSimulator2012.exe fehlt)");
settings.GameFolder = folder;
settings.Save();
return new { changed = true };
}
case "openDotnet":
OpenUrl(Game.DotnetDownload);
return null;
case "openLog":
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{Settings.Folder}\"") { UseShellExecute = true });
return null;
case "uninstall":
if (Installer.Uninstall(false)) BeginInvoke(Close);
return null;
}
throw new Exception("Unbekannter Befehl: " + cmd);
}
async Task<object> Status()
{
string? folder = Game.FindFolder(settings);
var modTask = Api.Latest("mod");
var launcherTask = Api.Latest("launcher");
await Task.WhenAll(modTask, launcherTask);
Api.Release? mod = modTask.Result, launcher = launcherTask.Result;
string? installed = folder != null ? Game.ModVersion(folder) : null;
return new
{
game = new { found = folder != null, folder, running = Game.IsRunning() },
mod = new
{
installed,
latest = mod?.version,
changelog = mod?.changelog,
updateAvailable = mod != null && (installed == null || SelfUpdate.IsNewer(mod.version, installed))
},
dotnet = Game.DotnetInstalled(),
launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null,
masterReachable = mod != null || launcher != null,
settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod }
};
}
async Task<object> InstallMod()
{
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
var latest = await Api.Latest("mod") ?? throw new Exception("Noch keine BSO-Version auf dem Server");
byte[] zip = await Api.Download(latest, p => Progress("mod", p, $"BSO {latest.version} wird geladen …"));
Progress("mod", 1, "Installieren …");
Game.InstallMod(folder, zip);
return new { version = latest.version };
}
async Task<object> Launch()
{
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
if (!Game.DotnetInstalled()) throw new Exception("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen");
if (Game.IsRunning()) throw new Exception("Bus-Simulator 2012 laeuft schon");
// Vorher aktualisieren (oder installieren, wenn BSO noch fehlt)
string? installed = Game.ModVersion(folder);
if (installed == null || settings.AutoUpdateMod)
{
var latest = await Api.Latest("mod");
if (latest != null && (installed == null || SelfUpdate.IsNewer(latest.version, installed))) await InstallMod();
else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar");
}
Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect);
Game.Launch();
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
if (!settings.KeepOpen)
{
await Task.Delay(2500);
BeginInvoke(Close);
}
return new { started = true };
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace BSO.Launcher;
static class Program
{
[STAThread]
static int Main(string[] args)
{
ApplicationConfiguration.Initialize();
// Deinstallieren (aus "Apps & Features")
if (args.Contains("--uninstall"))
{
Installer.Uninstall(args.Contains("--quiet"));
return 0;
}
// Wie Discord: die heruntergeladene .exe installiert sich selbst und startet dann die installierte Kopie.
// Ist schon dieselbe oder eine neuere Version installiert, wird einfach diese geoeffnet. --portable = ohne Installation.
if (!Installer.RunningInstalled && !args.Contains("--portable"))
{
if (Installer.NeedsInstall()) Application.Run(new InstallForm());
else Installer.StartInstalled();
return 0;
}
// Nur ein Launcher gleichzeitig
using var single = new Mutex(true, "BSO-Launcher-Single", out bool first);
// Nach einem Selbst-Update: die alte Version beendet sich gerade - bis zu 20 s auf sie warten
if (!first && args.Contains(SelfUpdate.AfterUpdateArg))
{
try { first = single.WaitOne(TimeSpan.FromSeconds(20)); }
catch (AbandonedMutexException) { first = true; } // alte Version wurde hart beendet - Sperre gehoert jetzt uns
}
if (!first)
{
MessageBox.Show("Der BSO Launcher laeuft schon.", Installer.AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
return 0;
}
SelfUpdate.CleanupOldVersion();
if (Installer.RunningInstalled) Installer.Register(); // Version in "Apps & Features" nach einem Selbst-Update
Application.Run(new MainForm());
return 0;
}
}
+48
View File
@@ -0,0 +1,48 @@
# BSO Launcher
Launcher für **BSO – Bus Simulator Online** (Multiplayer-Mod für Bus-Simulator 2012), aufgebaut wie der MSCO Launcher:
WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebettet.
- Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod)
- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server)
- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website)
## Features
- **Installation wie Discord:** Die heruntergeladene `BSO-Launcher.exe` installiert sich selbst – ohne Admin-Rechte nach
`%LocalAppData%\Programs\BSO Launcher`, mit Verknüpfungen auf dem Desktop und im Startmenü und einem Eintrag unter
*Einstellungen → Apps*. `--portable` startet ohne Installation.
- **BSO-Konto:** Registrieren und Anmelden bei der BSO-Website. Die Anmeldung bleibt 30 Tage (Token DPAPI-verschlüsselt).
Der Kontoname ist der Name im Spiel.
- **Auto-Update:** neueste Mod-Version vor jedem Start (abschaltbar), Selbst-Update des Launchers mit einem Klick.
Downloads sind fortsetzbar, SHA-256-geprüft und müssen mit dem **BSO-Release-Schlüssel signiert** sein.
- **Prüft alles:** Spielordner (alle Steam-Bibliotheken), BSO-Version, .NET 10 Runtime (braucht die Mod).
- **SPIELEN:** schreibt Name und Server in `BSO.json`, startet Bus-Simulator 2012 über Steam und verbindet automatisch.
- Neuigkeiten (Changelogs), Einstellungen, Deinstallieren.
## Bauen und veröffentlichen
```powershell
dotnet publish -c Release -o publish # eine .exe, ohne vorinstalliertes .NET
.\release.ps1 -Product launcher -Version 0.1.0 -File publish\BSO-Launcher.exe -Changelog "..."
```
Zum Testen gegen eine lokale Website: `$env:BSO_LAUNCHER_API = "http://localhost:5080"`, dann
`publish\BSO-Launcher.exe --portable`.
## Release-Schlüssel
`new-release-key.ps1` legt einmalig den Schlüssel an (RSA 3072, privater Teil DPAPI-verschlüsselt unter
`%APPDATA%\BSO\release-key.dpapi`) und trägt den öffentlichen Teil in `Services\ReleaseSignature.cs` ein.
`release.ps1` (in jedem BSO-Repo) signiert damit `BSO-RELEASE|produkt|version|sha256` und lädt hoch – braucht
`BSO_RELEASE_TOKEN` (= `RELEASE_TOKEN` der Website) und optional `BSO_MASTER_URL`.
**Den Schlüssel sichern:** Mit einem neuen Schlüssel erkennen bereits verteilte Launcher keine Updates mehr.
## Daten
| Was | Wo |
|---|---|
| Einstellungen, Anmeldung | `%LocalAppData%\BSO Launcher\settings.json` |
| Protokoll | `%LocalAppData%\BSO Launcher\launcher.log` |
| Mod | `<Spielordner>\Bin_High_Win64\dinput8.dll` und `BSO\` |
+161
View File
@@ -0,0 +1,161 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace BSO.Launcher;
/// <summary>BSO Master: Konto und Releases (wie Api.cs beim MSCO Launcher).</summary>
public static class Api
{
public const string BaseUrl = "https://bso.mbm.wtf";
/// <summary>Zum Testen umstellbar: BSO_LAUNCHER_API=http://localhost:5080</summary>
public static string Base => (Environment.GetEnvironmentVariable("BSO_LAUNCHER_API") ?? BaseUrl).TrimEnd('/');
public static readonly HttpClient Http = CreateHttp();
static HttpClient CreateHttp()
{
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("BSO-Launcher/" + SelfUpdate.CurrentVersion);
return http;
}
public sealed record Release(string product, string version, string sha256, long size, string changelog, string date, string file, string url,
string? signature = null);
/// <summary>JSON-Antwort oder Fehlertext des Servers.</summary>
public sealed class Result
{
public bool Ok;
public int Status;
public JsonElement Json;
public string? Error;
}
static async Task<Result> Send(HttpMethod method, string path, object? body, string? token)
{
var req = new HttpRequestMessage(method, Base + path);
if (body != null) req.Content = JsonContent.Create(body);
if (!string.IsNullOrEmpty(token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
try
{
using var res = await Http.SendAsync(req);
string text = await res.Content.ReadAsStringAsync();
var result = new Result { Ok = res.IsSuccessStatusCode, Status = (int)res.StatusCode };
try { result.Json = JsonDocument.Parse(text).RootElement.Clone(); } catch { }
if (!result.Ok)
result.Error = result.Json.ValueKind == JsonValueKind.Object && result.Json.TryGetProperty("error", out var e)
? e.GetString() : $"HTTP {(int)res.StatusCode}";
return result;
}
catch (Exception e)
{
return new Result { Ok = false, Status = 0, Error = "BSO-Server nicht erreichbar (" + e.Message + ")" };
}
}
public static Task<Result> Login(string username, string password) =>
Send(HttpMethod.Post, "/api/auth/login", new { username, password }, null);
public static Task<Result> Register(string username, string password, string? email) =>
Send(HttpMethod.Post, "/api/auth/register", new { username, password, email }, null);
public static Task<Result> Me(string token) => Send(HttpMethod.Get, "/api/auth/me", null, token);
public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
public static async Task<Release?> Latest(string product)
{
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
if (!r.Ok) return null;
try { return r.Json.Deserialize<Release>(); } catch { return null; }
}
public static Task<Result> Releases() => Send(HttpMethod.Get, "/api/releases", null, null);
/// <summary>
/// Mod/Launcher laden: Signatur des Releases und SHA-256 pruefen (nur echte, vom BSO-Release-Schluessel signierte
/// Dateien werden installiert). Fortschritt 0..1.
/// </summary>
public static async Task<byte[]> Download(Release release, Action<double> progress)
{
if (!ReleaseSignature.Verify(release.product, release.version, release.sha256, release.signature))
throw new Exception($"{release.product} {release.version} ist nicht von BSO signiert - nicht installiert");
return await DownloadVerified(release.url, release.sha256, release.size, progress);
}
/// <summary>Teil-Downloads (fortsetzbar) - liegen beim Launcher, benannt nach der Pruefsumme der Datei.</summary>
static string PartFolder => Path.Combine(Settings.Folder, "downloads");
/// <summary>
/// Datei laden und gegen die Pruefsumme pruefen. Fortsetzbar: geladen wird in eine Teildatei (&lt;sha256&gt;.part). Bricht
/// das Netz ab, wird gewartet und ab der letzten Stelle weitergeladen (HTTP Range).
/// </summary>
public static async Task<byte[]> DownloadVerified(string url, string sha256, long size, Action<double> progress)
{
if (!System.Text.RegularExpressions.Regex.IsMatch(sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("Ungueltige Pruefsumme");
Directory.CreateDirectory(PartFolder);
string part = Path.Combine(PartFolder, sha256!.ToLowerInvariant() + ".part");
int failures = 0;
while (true)
{
long have = File.Exists(part) ? new FileInfo(part).Length : 0;
if (size > 0 && have > size) { File.Delete(part); have = 0; }
try
{
if (size <= 0 || have < size) await DownloadPart(url, part, have, size, progress);
byte[] data = await File.ReadAllBytesAsync(part);
string sha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(data)).ToLowerInvariant();
if (!string.Equals(sha, sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(part); // kaputt - naechster Versuch von vorn
throw new InvalidDataException("Der Download ist beschaedigt (Pruefsumme passt nicht) - bitte nochmal versuchen");
}
try { File.Delete(part); } catch { }
return data;
}
catch (Exception e) when (e is HttpRequestException or IOException or TaskCanceledException or OperationCanceledException
&& e is not InvalidDataException)
{
// Netz weg / Verbindung abgebrochen: kurz warten, dann ab der letzten Stelle weiter
long now = File.Exists(part) ? new FileInfo(part).Length : 0;
failures = now > have ? 1 : failures + 1;
if (failures > 8) throw new Exception("Download abgebrochen (keine Verbindung) - nochmal versuchen, er macht dort weiter");
await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 2 * failures)));
}
}
}
static async Task DownloadPart(string url, string part, long have, long size, Action<double> progress)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (have > 0) req.Headers.Range = new RangeHeaderValue(have, null);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.StatusCode == System.Net.HttpStatusCode.RequestedRangeNotSatisfiable)
{
File.Delete(part); // Teildatei passt nicht zur Datei auf dem Server - von vorn
throw new IOException("Range not satisfiable");
}
res.EnsureSuccessStatusCode();
bool resumed = have > 0 && res.StatusCode == System.Net.HttpStatusCode.PartialContent;
if (!resumed) have = 0; // Server liefert die ganze Datei
long total = size > 0 ? size : have + (res.Content.Headers.ContentLength ?? 0);
await using var input = await res.Content.ReadAsStreamAsync();
await using var output = new FileStream(part, resumed ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None);
var chunk = new byte[81920];
long done = have;
if (total > 0) progress(Math.Min(1.0, done / (double)total));
while (true)
{
// Haengt die Verbindung (keine Daten mehr), nach 30 s abbrechen und neu ansetzen
using var stall = new CancellationTokenSource(TimeSpan.FromSeconds(30));
int read = await input.ReadAsync(chunk, stall.Token);
if (read <= 0) break;
await output.WriteAsync(chunk.AsMemory(0, read));
done += read;
if (total > 0) progress(Math.Min(1.0, done / (double)total));
}
await output.FlushAsync();
if (size > 0 && done < size) throw new IOException("Verbindung zu frueh beendet");
}
}
+118
View File
@@ -0,0 +1,118 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Win32;
namespace BSO.Launcher;
/// <summary>
/// Bus-Simulator 2012 finden (alle Steam-Bibliotheken), BSO in den Spielordner installieren (dinput8.dll + BSO\),
/// BSO.json fuer die Mod schreiben (Name = Kontoname, Server) und das Spiel ueber Steam starten.
/// </summary>
public static class Game
{
public const string SteamAppId = "253770";
const string GameFolderName = "Bus-Simulator 2012";
const string BinFolder = "Bin_High_Win64"; // BSO laeuft nur in der 64-Bit-Version
const string ExeName = "BusSimulator2012.exe";
/// <summary>Spielordner (der mit Bin_High_Win64) oder null.</summary>
public static string? FindFolder(Settings settings)
{
if (IsGameFolder(settings.GameFolder)) return settings.GameFolder;
foreach (string library in SteamLibraries())
{
string folder = Path.Combine(library, "steamapps", "common", GameFolderName);
if (IsGameFolder(folder)) return folder;
}
return null;
}
public static bool IsGameFolder(string? folder) =>
!string.IsNullOrEmpty(folder) && File.Exists(Path.Combine(folder, BinFolder, ExeName));
public static string Bin(string folder) => Path.Combine(folder, BinFolder);
static IEnumerable<string> SteamLibraries()
{
string? steam = null;
try { steam = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam")?.GetValue("SteamPath") as string; } catch { }
steam ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam");
steam = steam.Replace('/', '\\');
yield return steam;
string vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf");
if (!File.Exists(vdf)) yield break;
foreach (Match m in Regex.Matches(File.ReadAllText(vdf), "\"path\"\\s+\"([^\"]+)\""))
yield return m.Groups[1].Value.Replace(@"\\", @"\");
}
/// <summary>Installierte BSO-Version (aus BSO.dll) oder null.</summary>
public static string? ModVersion(string folder)
{
string dll = Path.Combine(Bin(folder), "BSO", "BSO.dll");
if (!File.Exists(dll) || !File.Exists(Path.Combine(Bin(folder), "dinput8.dll"))) return null;
return FileVersionInfo.GetVersionInfo(dll).ProductVersion?.Split('+')[0];
}
/// <summary>Die Mod braucht die .NET 10 Runtime (x64) - der Launcher selbst bringt seine mit, die Mod nicht.</summary>
public static bool DotnetInstalled()
{
string shared = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "shared", "Microsoft.NETCore.App");
return Directory.Exists(shared) && Directory.GetDirectories(shared, "10.*").Length > 0;
}
public const string DotnetDownload = "https://dotnet.microsoft.com/download/dotnet/10.0";
public static bool IsRunning() => Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ExeName)).Length > 0;
/// <summary>Mod-Paket (BSO-Mod.zip: dinput8.dll, BSO\*) in den Spielordner entpacken. BSO.json bleibt.</summary>
public static void InstallMod(string folder, byte[] zip)
{
if (IsRunning()) throw new Exception("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Mod-Dateien gesperrt");
string bin = Bin(folder);
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
foreach (ZipArchiveEntry entry in archive.Entries)
{
if (entry.FullName.EndsWith('/')) continue;
string target = Path.GetFullPath(Path.Combine(bin, entry.FullName));
if (!target.StartsWith(Path.GetFullPath(bin) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new Exception("Ungueltiger Pfad im Mod-Paket: " + entry.FullName);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target, true);
}
LauncherLog.Info("BSO installiert nach " + bin);
}
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden.</summary>
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect)
{
string file = Path.Combine(Bin(folder), "BSO", "BSO.json");
JsonObject json;
try { json = File.Exists(file) ? JsonNode.Parse(File.ReadAllText(file)) as JsonObject ?? new JsonObject() : new JsonObject(); }
catch { json = new JsonObject(); }
if (!string.IsNullOrEmpty(name)) json["Name"] = name;
json["Server"] = server;
json["AutoConnect"] = autoConnect;
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
/// <summary>Ueber Steam starten (Overlay, Steam-Anmeldung des Spiels).</summary>
public static void Launch() =>
Process.Start(new ProcessStartInfo("steam://rungameid/" + SteamAppId) { UseShellExecute = true });
/// <summary>BSO aus dem Spielordner entfernen (Deinstallieren). Gibt Probleme als Text zurueck.</summary>
public static List<string> RemoveBso(Settings settings)
{
var problems = new List<string>();
string? folder = FindFolder(settings);
if (folder == null) return problems;
string bin = Bin(folder);
try { File.Delete(Path.Combine(bin, "dinput8.dll")); } catch (Exception e) { problems.Add("dinput8.dll: " + e.Message); }
try { if (Directory.Exists(Path.Combine(bin, "BSO"))) Directory.Delete(Path.Combine(bin, "BSO"), true); }
catch (Exception e) { problems.Add("BSO-Ordner: " + e.Message); }
return problems;
}
}
+244
View File
@@ -0,0 +1,244 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Win32;
namespace BSO.Launcher;
/// <summary>
/// Installation wie bei Discord (wie beim MSCO Launcher): die heruntergeladene .exe installiert sich selbst ohne
/// Admin-Rechte nach %LocalAppData%\Programs\BSO Launcher, legt Verknuepfungen (Desktop, Startmenue) an, traegt sich unter
/// "Apps &amp; Features" ein (Deinstallieren) und startet dann den installierten Launcher.
/// </summary>
public static class Installer
{
public const string AppName = "BSO Launcher";
const string ExeName = "BSO-Launcher.exe";
const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\BSO-Launcher";
public static string InstallDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
public static string InstalledExe => Path.Combine(InstallDir, ExeName);
static string Self => Environment.ProcessPath ?? Application.ExecutablePath;
static string StartMenuShortcut =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk");
static string DesktopShortcut =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
/// <summary>Laeuft gerade die installierte Kopie?</summary>
public static bool RunningInstalled =>
string.Equals(Path.GetFullPath(Self), Path.GetFullPath(InstalledExe), StringComparison.OrdinalIgnoreCase);
public static string? InstalledVersion()
{
if (!File.Exists(InstalledExe)) return null;
return FileVersionInfo.GetVersionInfo(InstalledExe).ProductVersion?.Split('+')[0];
}
/// <summary>Muss installiert (oder aktualisiert) werden? false = installierte Version ist gleich/neuer.</summary>
public static bool NeedsInstall()
{
string? installed = InstalledVersion();
return installed == null || SelfUpdate.IsNewer(SelfUpdate.CurrentVersion, installed);
}
/// <summary>Installieren. progress(0..1, Text).</summary>
public static void Install(Action<double, string> progress)
{
progress(0.1, "Laufenden Launcher schliessen …");
foreach (var p in Process.GetProcessesByName("BSO-Launcher"))
{
try
{
if (p.Id == Environment.ProcessId) continue;
if (!string.Equals(p.MainModule?.FileName, InstalledExe, StringComparison.OrdinalIgnoreCase)) continue;
p.CloseMainWindow();
if (!p.WaitForExit(3000)) p.Kill(true);
}
catch { }
}
progress(0.3, "Dateien kopieren …");
Directory.CreateDirectory(InstallDir);
string tmp = InstalledExe + ".new";
File.Copy(Self, tmp, true);
File.Move(tmp, InstalledExe, true);
progress(0.6, "Verknuepfungen anlegen …");
CreateShortcut(StartMenuShortcut);
CreateShortcut(DesktopShortcut);
progress(0.8, "Registrieren …");
Register();
progress(1.0, "Fertig");
}
/// <summary>Eintrag unter "Apps &amp; Features" (bzw. dessen Version nach einem Selbst-Update).</summary>
public static void Register()
{
try
{
using var key = Registry.CurrentUser.CreateSubKey(UninstallKey);
key.SetValue("DisplayName", AppName);
key.SetValue("DisplayVersion", SelfUpdate.CurrentVersion);
key.SetValue("Publisher", "MBM.wtf");
key.SetValue("DisplayIcon", InstalledExe + ",0");
key.SetValue("InstallLocation", InstallDir);
key.SetValue("UninstallString", $"\"{InstalledExe}\" --uninstall");
key.SetValue("QuietUninstallString", $"\"{InstalledExe}\" --uninstall --quiet");
key.SetValue("URLInfoAbout", Api.BaseUrl);
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
try { key.SetValue("EstimatedSize", (int)(new FileInfo(InstalledExe).Length / 1024), RegistryValueKind.DWord); } catch { }
}
catch { /* Eintrag ist nur Komfort */ }
}
static void CreateShortcut(string path)
{
try
{
Type? shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType == null) return;
dynamic shell = Activator.CreateInstance(shellType)!;
dynamic link = shell.CreateShortcut(path);
link.TargetPath = InstalledExe;
link.WorkingDirectory = InstallDir;
link.IconLocation = InstalledExe + ",0";
link.Description = "BSO - Bus Simulator Online";
link.Save();
}
catch { /* ohne Verknuepfung geht es auch */ }
}
public static void StartInstalled()
{
Process.Start(new ProcessStartInfo(InstalledExe) { UseShellExecute = true, WorkingDirectory = InstallDir });
}
/// <summary>Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt).</summary>
static void RemoveAfterExit(params string[] paths)
{
static string Quote(string p) => "'" + p.Replace("'", "''") + "'";
string list = string.Join(",", paths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(Quote));
string script =
$"Wait-Process -Id {Environment.ProcessId} -ErrorAction SilentlyContinue; " +
$"foreach ($i in 1..30) {{ $left = $false; foreach ($p in @({list})) {{ " +
"if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue; " +
"if (Test-Path -LiteralPath $p) { $left = $true } } }; if (-not $left) { break }; Start-Sleep -Seconds 1 }";
var psi = new ProcessStartInfo("powershell.exe") { CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden };
foreach (string a in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script })
psi.ArgumentList.Add(a);
try { Process.Start(psi); } catch { }
}
/// <summary>Deinstallieren (aus "Apps &amp; Features" oder den Einstellungen). false = abgebrochen.</summary>
public static bool Uninstall(bool quiet)
{
if (!quiet)
{
var answer = MessageBox.Show(
"BSO komplett deinstallieren?\n\nDas entfernt den Launcher, BSO aus dem Bus-Simulator-2012-Ordner (dinput8.dll, BSO-Ordner) " +
"und deine Anmeldung im Launcher.",
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
if (answer != DialogResult.Yes) return false;
while (Game.IsRunning())
{
if (MessageBox.Show("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", AppName,
MessageBoxButtons.RetryCancel, MessageBoxIcon.Warning) != DialogResult.Retry) return false;
}
}
string dir = InstallDir;
foreach (var p in Process.GetProcessesByName("BSO-Launcher"))
try { if (p.Id != Environment.ProcessId) p.Kill(true); } catch { }
List<string> problems = Game.RemoveBso(Settings.Load());
try { File.Delete(StartMenuShortcut); } catch { }
try { File.Delete(DesktopShortcut); } catch { }
try { Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); } catch { }
// Die laufende .exe laesst sich nicht selbst loeschen: aufraeumen, sobald dieser Prozess beendet ist
RemoveAfterExit(dir, Settings.Folder);
if (!quiet)
MessageBox.Show(problems.Count == 0
? "BSO wurde komplett deinstalliert."
: "BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n" + string.Join("\n", problems),
AppName, MessageBoxButtons.OK, problems.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
return true;
}
public static Image? Logo()
{
var s = Assembly.GetExecutingAssembly().GetManifestResourceStream("ui/logo.png");
return s == null ? null : Image.FromStream(s);
}
}
/// <summary>Kleines Installationsfenster (wie der Discord-Installer): Logo, Status, Fortschrittsbalken.</summary>
public sealed class InstallForm : Form
{
readonly Label status = new();
double progress;
string? error;
public InstallForm()
{
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(420, 260);
BackColor = Color.FromArgb(0x15, 0x19, 0x22);
ShowInTaskbar = true;
Text = "BSO Launcher wird installiert";
DoubleBuffered = true;
try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { }
status.ForeColor = Color.FromArgb(0x8b, 0x95, 0xa5);
status.Font = new Font("Segoe UI", 9.5f);
status.TextAlign = ContentAlignment.MiddleCenter;
status.SetBounds(20, 178, 380, 24);
status.Text = "Vorbereiten …";
Controls.Add(status);
Shown += async (_, _) => await Run();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
using (var border = new Pen(Color.FromArgb(0x26, 0x2c, 0x38))) g.DrawRectangle(border, 0, 0, Width - 1, Height - 1);
var logo = Installer.Logo();
if (logo != null) g.DrawImage(logo, (Width - 72) / 2, 34, 72, 72);
using var title = new Font("Segoe UI", 15f, FontStyle.Bold);
TextRenderer.DrawText(g, "BSO Launcher", title, new Rectangle(0, 116, Width, 34), Color.White, TextFormatFlags.HorizontalCenter);
using var sub = new Font("Segoe UI", 9f);
TextRenderer.DrawText(g, "Bus Simulator Online", sub, new Rectangle(0, 148, Width, 22), Color.FromArgb(0x8b, 0x95, 0xa5), TextFormatFlags.HorizontalCenter);
// Fortschrittsbalken
var bar = new Rectangle(40, 214, Width - 80, 8);
using (var back = new SolidBrush(Color.FromArgb(0x23, 0x2a, 0x36))) g.FillRectangle(back, bar);
if (progress > 0)
{
var fill = new Rectangle(bar.X, bar.Y, (int)(bar.Width * progress), bar.Height);
using var brush = new System.Drawing.Drawing2D.LinearGradientBrush(bar, Color.FromArgb(0x1f, 0x6f, 0xe5), Color.FromArgb(0x5e, 0xe4, 0xff), 0f);
g.FillRectangle(error != null ? Brushes.IndianRed : brush, fill);
}
}
async Task Run()
{
try
{
await Task.Run(() => Installer.Install((p, text) => BeginInvoke(() => { progress = p; status.Text = text; Invalidate(); })));
await Task.Delay(400);
Installer.StartInstalled();
Close();
}
catch (Exception e)
{
error = e.Message;
status.ForeColor = Color.FromArgb(0xff, 0x8a, 0x8d);
status.Text = "Installation fehlgeschlagen: " + e.Message;
Invalidate();
await Task.Delay(6000);
Close();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace BSO.Launcher;
/// <summary>Protokoll des Launchers: %LocalAppData%\BSO Launcher\launcher.log (wird beim Start ab 1 MB neu begonnen).</summary>
public static class LauncherLog
{
static readonly object Gate = new();
public static string FilePath => Path.Combine(Settings.Folder, "launcher.log");
public static void Info(string line) => Write("INFO ", line);
public static void Error(string line) => Write("ERROR", line);
static void Write(string level, string line)
{
lock (Gate)
{
try
{
Directory.CreateDirectory(Settings.Folder);
var info = new FileInfo(FilePath);
if (info.Exists && info.Length > 1024 * 1024) info.Delete();
File.AppendAllText(FilePath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {level} {line}{Environment.NewLine}");
}
catch { }
}
}
}
+32
View File
@@ -0,0 +1,32 @@
using System.Security.Cryptography;
using System.Text;
namespace BSO.Launcher;
/// <summary>
/// Prueft die Signatur von Mod-, Server- und Launcher-Releases (wie bei MSCO). tools\release.ps1 signiert
/// "BSO-RELEASE|produkt|version|sha256" mit dem privaten Release-Schluessel (liegt nur beim Entwickler, DPAPI-verschluesselt);
/// hier ist nur der oeffentliche Schluessel eingebaut (eingetragen von tools\new-release-key.ps1). Ohne gueltige Signatur
/// wird nichts installiert - selbst wer den Master oder den Release-Token uebernimmt, kann keine fremde Datei verteilen.
/// </summary>
public static class ReleaseSignature
{
const string PublicKey = "<RSAKeyValue><Modulus>mWnJlmK+ZHcbVdN+AEvB2pU8cYLmIAygylltXK7/Gb6Qs0vDlOK9vrXy8+Yx0//WkWrV2pgvuPErlnmwCdHp5RTEr4whXqfGXs2KphQD7wI7kSAA9ETUULgztv4oaW8ndmaAJvBdnLVF3IB0snH51abd0Y+JCnQ/64J8CP0zECdoLFebmGP6wYV75oZrNh9v9EqaQ3qH56SnwUH8dFoR7nkoi6M2o+S1/pL4hTpBGMWmm1DJXsck4HJTlblEDPkT9ksgJf9oj3aZrLGwne+ZOLQ+DJF1SDmFT75bWPUvW8WP4TJnG85epn7JwsKLFNy2wb2UtJLAdF+KCIgefnQU/qXvSYRg/LIWxX9FVysQW2re1dcm+kI+NLddA88i6xpi7Ziste0NzyKD6SnaEP8fNVuoobpgW+S8Gfq7pemxA9/EibqXdyCbsT+IHF5SaEHU6EbC50bRF2ssoMevf2pPv/6omgHaT1BUiQPEetMEx0HOzcnMlmVNk6hNkiXBngop</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>";
/// <summary>Text, der signiert wird: passt nur zu genau dieser Datei, diesem Produkt und dieser Version.</summary>
public static string Message(string product, string version, string sha256) =>
$"BSO-RELEASE|{product}|{version}|{sha256.ToLowerInvariant()}";
public static bool Verify(string product, string version, string sha256, string? signature)
{
if (string.IsNullOrWhiteSpace(signature)) return false;
try
{
using var rsa = RSA.Create();
rsa.FromXmlString(PublicKey);
return rsa.VerifyData(Encoding.UTF8.GetBytes(Message(product, version, sha256)), Convert.FromBase64String(signature),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch { return false; }
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Diagnostics;
using System.Reflection;
namespace BSO.Launcher;
/// <summary>
/// Der Launcher aktualisiert sich selbst (wie bei MSCO): neue .exe vom Master laden (Signatur + SHA-256 geprueft), die
/// laufende Datei in "*.old" umbenennen (geht unter Windows auch waehrend sie laeuft), die neue an ihre Stelle legen und
/// neu starten.
/// </summary>
public static class SelfUpdate
{
public static string CurrentVersion =>
(Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0")
.Split('+')[0];
static string ExePath => Environment.ProcessPath ?? Application.ExecutablePath;
public static bool IsNewer(string candidate, string than) =>
Version.TryParse(candidate, out var a) && Version.TryParse(than, out var b) && a > b;
/// <summary>Nach einem Update: die alte Datei aufraeumen.</summary>
public static void CleanupOldVersion()
{
try
{
string old = ExePath + ".old";
if (File.Exists(old)) File.Delete(old);
}
catch { /* laeuft evtl. noch kurz */ }
}
/// <summary>Startoption der neuen Version nach einem Selbst-Update: auf das Ende der alten warten.</summary>
public const string AfterUpdateArg = "--after-update";
public static async Task<string> Install(Api.Release release, Action<double> progress)
{
byte[] data = await Api.Download(release, progress);
string exe = ExePath, fresh = exe + ".new", old = exe + ".old";
await File.WriteAllBytesAsync(fresh, data);
if (File.Exists(old)) File.Delete(old);
File.Move(exe, old);
File.Move(fresh, exe);
// Die neue Version wartet, bis diese hier beendet ist (sonst meldet sie "laeuft schon" und beendet sich)
var start = new ProcessStartInfo(exe) { UseShellExecute = true };
start.ArgumentList.Add(AfterUpdateArg);
if (!Installer.RunningInstalled) start.ArgumentList.Add("--portable");
Process.Start(start);
return release.version;
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BSO.Launcher;
/// <summary>
/// Einstellungen und Anmeldung in %LocalAppData%\BSO Launcher\settings.json. Der Token ist mit DPAPI verschluesselt
/// (nur dieser Windows-Benutzer auf diesem PC kann ihn lesen).
/// </summary>
public sealed class Settings
{
public static string Folder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BSO Launcher");
static string FilePath => Path.Combine(Folder, "settings.json");
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public string? Username { get; set; }
public string? ProtectedToken { get; set; }
/// <summary>Spielordner von Hand gewaehlt (sonst automatisch aus den Steam-Bibliotheken).</summary>
public string? GameFolder { get; set; }
public string Server { get; set; } = "127.0.0.1:7777";
public bool AutoConnect { get; set; } = true;
public bool KeepOpen { get; set; }
public bool AutoUpdateMod { get; set; } = true;
[System.Text.Json.Serialization.JsonIgnore]
public string? Token
{
get
{
if (string.IsNullOrEmpty(ProtectedToken)) return null;
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(ProtectedToken), null, DataProtectionScope.CurrentUser)); }
catch { return null; }
}
set => ProtectedToken = value == null ? null
: Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(value), null, DataProtectionScope.CurrentUser));
}
public static Settings Load()
{
try
{
if (File.Exists(FilePath)) return JsonSerializer.Deserialize<Settings>(File.ReadAllText(FilePath)) ?? new Settings();
}
catch (Exception e) { LauncherLog.Error("settings.json nicht lesbar: " + e.Message); }
return new Settings();
}
public void Save()
{
Directory.CreateDirectory(Folder);
string tmp = FilePath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(this, Options));
File.Move(tmp, FilePath, true);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+23
View File
@@ -0,0 +1,23 @@
# Legt den Release-Schluessel an (einmalig, wie bei MSCO): RSA 3072, der private Teil DPAPI-verschluesselt unter
# %APPDATA%\BSO\release-key.dpapi (nur dieser Windows-Benutzer kann ihn lesen). Den oeffentlichen Teil traegt das Skript in
# Services\ReleaseSignature.cs ein - danach installiert der Launcher nur noch damit signierte Releases.
# ACHTUNG: Neuer Schluessel = alte Launcher erkennen neue Releases nicht mehr. Den Schluessel also sichern, nicht neu anlegen.
param([switch]$Force)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Security
$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi"
if ((Test-Path $keyFile) -and -not $Force) { throw "Schluessel gibt es schon: $keyFile (mit -Force ueberschreiben - siehe ACHTUNG oben)" }
$rsa = [System.Security.Cryptography.RSA]::Create(3072)
$private = [Text.Encoding]::UTF8.GetBytes($rsa.ToXmlString($true))
$protected = [System.Security.Cryptography.ProtectedData]::Protect($private, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
New-Item -ItemType Directory -Force (Split-Path $keyFile) | Out-Null
[IO.File]::WriteAllBytes($keyFile, $protected)
$public = $rsa.ToXmlString($false)
$code = Join-Path $PSScriptRoot "Services\ReleaseSignature.cs"
$text = [IO.File]::ReadAllText($code)
$text = [regex]::Replace($text, '"<RSAKeyValue>.*?</RSAKeyValue>"', '"' + $public + '"')
[IO.File]::WriteAllText($code, $text)
Write-Host "Release-Schluessel angelegt: $keyFile"
Write-Host "Oeffentlicher Schluessel eingetragen in $code"
+45
View File
@@ -0,0 +1,45 @@
# Signiert ein Release und laedt es auf den BSO Master (wie release.ps1 bei MSCO).
# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."]
# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN des Masters),
# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf).
# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel.
param(
[Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product,
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][string]$File,
[string]$Changelog = ""
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Security
Add-Type -AssemblyName System.Net.Http
$master = ($env:BSO_MASTER_URL, "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/')
$token = $env:BSO_RELEASE_TOKEN
if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return }
$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi"
if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" }
$data = [IO.File]::ReadAllBytes((Resolve-Path $File))
$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join ""
$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser)
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha")
$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1))
$http = New-Object System.Net.Http.HttpClient
$http.Timeout = [TimeSpan]::FromMinutes(10)
$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
$form = New-Object System.Net.Http.MultipartFormDataContent
foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) {
$form.Add((New-Object System.Net.Http.StringContent($pair[1])), "`"$($pair[0])`"")
}
$content = New-Object System.Net.Http.ByteArrayContent(, $data)
$form.Add($content, "`"file`"", "`"$(Split-Path $File -Leaf)`"")
$res = $http.PostAsync("$master/api/releases/upload", $form).Result
$body = $res.Content.ReadAsStringAsync().Result
if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$Product $Version gibt es schon auf $master"; return }
if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" }
Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)"
+163
View File
@@ -0,0 +1,163 @@
:root {
--bg: #0b0d12;
--panel: #151922;
--panel-2: #1c212c;
--sidebar: #080a0e;
--border: #262c38;
--text: #eaf0f7;
--muted: #8b95a5;
--accent: #2f9bff;
--accent-2: #5ee4ff;
--online: #3dd68c;
--pending: #f2c230;
--danger: #e5484d;
--radius: 12px;
--titlebar: 36px;
--icons: "Segoe Fluent Icons", "Segoe MDL2 Assets";
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
input { user-select: text; }
.hidden { display: none !important; }
.muted { color: var(--muted); }
.small { font-size: 12.5px; }
.center { text-align: center; }
.error { color: #ff8a8d; margin: 0; }
h1, h2, h3 { margin: 0; letter-spacing: -0.01em; }
h1 { font-size: 26px; }
h2 { font-size: 17px; }
code, .mono { font-family: "Cascadia Mono", Consolas, monospace; font-size: 12.5px; }
/* ---------------------------------------------------------------- Titelleiste */
.titlebar { position: fixed; inset: 0 0 auto 0; height: var(--titlebar); display: flex; z-index: 50;
background: var(--sidebar); border-bottom: 1px solid #151922; }
.titlebar-drag { flex: 1; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--muted); font-size: 12px; font-weight: 600; }
.titlebar-drag img { width: 18px; height: 18px; }
.window-buttons { display: flex; }
.window-buttons button { width: 46px; border: 0; background: transparent; color: #c4ccd8; font: 10px var(--icons); cursor: default; }
.window-buttons button:hover { background: #1c212c; }
.window-buttons .close:hover { background: #c42b1c; color: #fff; }
/* ---------------------------------------------------------------- Knoepfe & Felder */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--text); font: inherit; font-weight: 700; padding: 9px 16px; border-radius: 10px;
cursor: pointer; transition: background .12s, border-color .12s, transform .06s; white-space: nowrap; }
.btn:hover { background: #252b38; border-color: #36404f; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: default; transform: none; }
.btn.primary { background: linear-gradient(180deg, #45adff, #1f7fe5); border-color: #5cb8ff; color: #fff;
box-shadow: 0 8px 24px rgba(47, 155, 255, .25); }
.btn.primary:hover { background: linear-gradient(180deg, #5cb8ff, #2f8cf0); }
.btn.danger { color: #ff9a9d; border-color: #5a2a2e; background: #2a1517; }
.btn.small { padding: 6px 12px; font-size: 12.5px; }
.btn.wide { width: 100%; }
.btn.play { font-size: 22px; letter-spacing: .08em; padding: 16px 56px; border-radius: 14px; }
input[type=text], input[type=password], input[type=email], input:not([type]) { width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); font: inherit; padding: 10px 12px; border-radius: 10px; outline: none; }
input:focus { border-color: var(--accent); }
label { display: grid; gap: 6px; font-size: 12.5px; font-weight: 600; color: #b4bdca; }
/* Schalter */
.switch { display: flex; align-items: center; justify-content: space-between; gap: 16px; font-size: 13.5px; font-weight: 600; color: var(--text); cursor: pointer; }
.switch input { appearance: none; width: 40px; height: 22px; border-radius: 11px; background: #2a3140; position: relative; cursor: pointer; flex: none;
transition: background .15s; }
.switch input::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #c4ccd8;
transition: transform .15s; }
.switch input:checked { background: var(--accent); }
.switch input:checked::after { transform: translateX(18px); background: #fff; }
.switch small { display: block; font-weight: 400; color: var(--muted); }
/* ---------------------------------------------------------------- Anmeldung */
.auth { position: fixed; inset: var(--titlebar) 0 0 0; display: grid; place-items: center; padding: 16px;
background: radial-gradient(900px 500px at 50% -10%, rgba(47, 155, 255, .2), transparent 60%), var(--bg); }
.auth-card { width: min(400px, 100%); background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 28px;
display: grid; gap: 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.tabs { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 12px; background: var(--bg); border: 1px solid var(--border); }
.tab { border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 700; padding: 8px; border-radius: 9px; cursor: pointer; }
.tab.on { background: var(--panel-2); color: var(--text); }
.brand { display: flex; align-items: center; gap: 12px; }
.brand img { width: 40px; height: 40px; }
.brand.big img { width: 56px; height: 56px; }
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: .02em; }
.brand.big .brand-title { font-size: 24px; }
.brand-sub { color: var(--muted); font-size: 12.5px; }
.beta-pill { font-size: 10px; font-weight: 800; letter-spacing: .08em; vertical-align: middle; padding: 2px 6px; border-radius: 6px;
color: var(--accent-2); background: rgba(94, 228, 255, .1); border: 1px solid rgba(94, 228, 255, .3); }
/* ---------------------------------------------------------------- Aufbau */
.layout { position: fixed; inset: var(--titlebar) 0 0 0; display: grid; grid-template-columns: 240px 1fr; }
.sidebar { background: var(--sidebar); border-right: 1px solid #151922; display: flex; flex-direction: column; padding: 20px 14px; gap: 22px; }
nav { display: grid; gap: 4px; }
nav button { display: flex; align-items: center; gap: 12px; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 600;
padding: 10px 12px; border-radius: 10px; cursor: pointer; text-align: left; }
nav button:hover { background: #12161e; color: var(--text); }
nav button.on { background: var(--panel-2); color: var(--text); }
.nav-icon { font: 16px var(--icons); width: 18px; }
.sidebar-foot { margin-top: auto; }
.me-card { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; background: var(--panel); border: 1px solid var(--border); }
.avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff;
background: linear-gradient(135deg, var(--accent-2), #1f6fe5); flex: none; }
.me-text { display: grid; min-width: 0; flex: 1; }
.me-text b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.me-text small { color: var(--muted); font-size: 11.5px; }
.icon-btn { border: 0; background: transparent; color: var(--muted); font: 15px var(--icons); padding: 6px; border-radius: 8px; cursor: pointer; }
.icon-btn:hover { background: var(--panel-2); color: var(--text); }
.content { overflow-y: auto; }
.page { max-width: 980px; margin: 0 auto; padding: 28px 32px 40px; display: grid; gap: 20px; }
/* ---------------------------------------------------------------- Spielen */
.hero { position: relative; overflow: hidden; border-radius: 18px; border: 1px solid var(--border); padding: 36px;
background: radial-gradient(700px 320px at 85% 0%, rgba(94, 228, 255, .16), transparent 60%),
radial-gradient(500px 300px at 0% 100%, rgba(31, 111, 229, .22), transparent 60%), var(--panel);
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 24px; }
.hero h1 { font-size: 32px; }
.hero p { margin: 6px 0 0; color: var(--muted); }
.hero img { width: 120px; height: 120px; filter: drop-shadow(0 12px 30px rgba(47, 155, 255, .35)); }
.hero-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-radius: 14px;
background: rgba(47, 155, 255, .1); border: 1px solid rgba(47, 155, 255, .35); }
.banner.warn { background: rgba(242, 194, 48, .08); border-color: rgba(242, 194, 48, .35); }
.progress { display: grid; gap: 8px; }
.bar { height: 8px; border-radius: 4px; background: #232a36; overflow: hidden; }
.bar > div { height: 100%; width: 0; background: linear-gradient(90deg, #1f6fe5, var(--accent-2)); transition: width .15s; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 10px; align-content: start; }
.card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.card .value { font-size: 16px; font-weight: 700; word-break: break-all; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); display: inline-block; }
.dot.ok { background: var(--online); box-shadow: 0 0 10px rgba(61, 214, 140, .6); }
.dot.warn { background: var(--pending); }
.dot.bad { background: var(--danger); }
.row { display: flex; gap: 8px; align-items: center; }
/* ---------------------------------------------------------------- Neuigkeiten */
.release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; }
.release header { display: flex; align-items: baseline; gap: 10px; }
.tag { font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; padding: 2px 8px; border-radius: 6px;
background: var(--panel-2); color: var(--accent-2); border: 1px solid var(--border); }
.release pre { margin: 0; white-space: pre-wrap; font: inherit; color: #c4ccd8; }
/* ---------------------------------------------------------------- Einstellungen */
.settings { display: grid; gap: 14px; }
.settings .card { gap: 16px; }
/* ---------------------------------------------------------------- Meldungen */
.toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 100; }
.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 10px 14px; border-radius: 10px;
max-width: 380px; box-shadow: 0 12px 30px rgba(0, 0, 0, .4); animation: in .18s ease-out; }
.toast.error { border-left-color: var(--danger); }
@keyframes in { from { opacity: 0; transform: translateY(8px); } }
+294
View File
@@ -0,0 +1,294 @@
"use strict";
// BSO Launcher - Oberflaeche. Spricht ueber window.chrome.webview mit dem Launcher (C#), nie direkt mit dem Netz.
// ---------------------------------------------------------------- Verbindung zum Launcher
let nextId = 1;
const pending = new Map();
const listeners = new Map();
function call(cmd, args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
window.chrome.webview.postMessage({ id, cmd, args: args || {} });
});
}
window.chrome.webview.addEventListener("message", (e) => {
const msg = e.data;
if (msg.event) { (listeners.get(msg.event) || []).forEach((f) => f(msg)); return; }
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error));
});
function on(event, f) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(f);
}
// ---------------------------------------------------------------- Hilfen
const $ = (id) => document.getElementById(id);
function h(tag, attrs, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else if (k === "class") el.className = v;
else el.setAttribute(k, v === true ? "" : v);
}
for (const c of children.flat()) if (c != null && c !== false) el.append(c.nodeType ? c : String(c));
return el;
}
function toast(text, error) {
const t = h("div", { class: "toast" + (error ? " error" : "") }, text);
$("toasts").append(t);
setTimeout(() => t.remove(), error ? 7000 : 4000);
}
/** Knopf waehrend einer Aktion sperren; Fehler als Meldung. */
async function busy(button, action) {
button.disabled = true;
try { return await action(); }
catch (e) { toast(e.message, true); }
finally { button.disabled = false; }
}
const date = (iso) => new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
// ---------------------------------------------------------------- Fenster
document.querySelectorAll("[data-window]").forEach((b) => b.addEventListener("click", () => call("window", { action: b.dataset.window })));
$("titlebarDrag").addEventListener("mousedown", (e) => { if (e.button === 0 && e.detail === 1) call("window", { action: "drag" }); });
$("titlebarDrag").addEventListener("dblclick", () => call("window", { action: "maximize" }));
on("window", (m) => { $("maximizeButton").innerHTML = m.maximized ? "&#xE923;" : "&#xE922;"; });
// ---------------------------------------------------------------- Anmeldung
let registerMode = false;
function setAuthMode(register) {
registerMode = register;
$("tabLogin").classList.toggle("on", !register);
$("tabRegister").classList.toggle("on", register);
$("emailRow").classList.toggle("hidden", !register);
$("password2Row").classList.toggle("hidden", !register);
$("authButton").textContent = register ? "Konto erstellen" : "Anmelden";
$("password").autocomplete = register ? "new-password" : "current-password";
$("authError").classList.add("hidden");
}
$("tabLogin").addEventListener("click", () => setAuthMode(false));
$("tabRegister").addEventListener("click", () => setAuthMode(true));
$("authForm").addEventListener("submit", async (e) => {
e.preventDefault();
const error = $("authError");
error.classList.add("hidden");
if (registerMode && $("password").value !== $("password2").value) {
error.textContent = "Die Passwoerter sind nicht gleich";
error.classList.remove("hidden");
return;
}
$("authButton").disabled = true;
try {
const r = await call(registerMode ? "register" : "login",
{ username: $("username").value, password: $("password").value, email: $("email").value });
$("password").value = $("password2").value = "";
showApp(r.username);
if (registerMode) toast("Willkommen bei BSO, " + r.username + "!");
} catch (err) {
error.textContent = err.message;
error.classList.remove("hidden");
} finally {
$("authButton").disabled = false;
}
});
$("logout").addEventListener("click", async () => {
await call("logout");
showAuth();
});
function showAuth() {
$("app").classList.add("hidden");
$("auth").classList.remove("hidden");
setAuthMode(false);
$("username").focus();
}
function showApp(username) {
$("auth").classList.add("hidden");
$("app").classList.remove("hidden");
$("meName").textContent = username;
$("meAvatar").textContent = (username || "?").slice(0, 1).toUpperCase();
showPage("home");
}
// ---------------------------------------------------------------- Seiten
let page = "home";
document.querySelectorAll("#nav [data-page]").forEach((b) => b.addEventListener("click", () => showPage(b.dataset.page)));
function showPage(name) {
page = name;
document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name));
const root = $("page");
root.replaceChildren(h("p", { class: "muted" }, "Laden …"));
({ home: renderHome, news: renderNews, settings: renderSettings })[name](root);
}
// Fortschritt (Downloads)
let progressBox = null;
on("progress", (m) => {
if (!progressBox) return;
progressBox.classList.remove("hidden");
progressBox.querySelector(".bar > div").style.width = Math.round(m.value * 100) + "%";
progressBox.querySelector("span").textContent = m.text + " " + Math.round(m.value * 100) + " %";
});
async function renderHome(root) {
let s;
try { s = await call("status"); }
catch (e) { root.replaceChildren(h("p", { class: "error" }, e.message)); return; }
if (page !== "home") return;
progressBox = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div")));
const play = h("button", { class: "btn primary play", type: "button" }, s.game.running ? "LAEUFT" : "SPIELEN");
play.disabled = s.game.running;
play.addEventListener("click", () => busy(play, async () => {
await call("launch");
toast("Bus-Simulator 2012 startet …");
}));
const ready = s.game.found && s.dotnet && (s.mod.installed || s.mod.latest);
const line = s.mod.installed ? `BSO ${s.mod.installed} installiert · Server ${s.settings.server}` : "BSO wird beim ersten Start installiert";
const children = [
h("div", { class: "hero" },
h("div", {}, h("h1", {}, "Bus Simulator Online"), h("p", {}, "Multiplayer fuer Bus-Simulator 2012")),
h("img", { src: "logo.svg", alt: "" }),
h("div", { class: "hero-actions" }, play, h("span", { class: "muted small" }, ready ? line : "Vor dem Start fehlt noch etwas - siehe unten"))),
progressBox
];
if (s.launcherUpdate) {
const b = h("button", { class: "btn primary small", type: "button" }, "Jetzt aktualisieren");
b.addEventListener("click", () => busy(b, async () => { await call("installLauncherUpdate"); toast("Launcher startet neu …"); }));
children.push(h("div", { class: "banner" }, h("span", {}, h("b", {}, "Launcher " + s.launcherUpdate), " ist da."), b));
}
if (!s.masterReachable)
children.push(h("div", { class: "banner warn" }, h("span", {}, "BSO-Server nicht erreichbar - Updates werden spaeter geprueft.")));
children.push(h("div", { class: "cards" }, gameCard(s), modCard(s), dotnetCard(s), serverCard(s)));
root.replaceChildren(...children);
}
function card(title, state, value, ...extra) {
return h("div", { class: "card" }, h("h3", {}, h("span", { class: "dot " + state }), title), h("div", { class: "value" }, value), ...extra);
}
function gameCard(s) {
const choose = h("button", { class: "btn small", type: "button" }, s.game.found ? "Anderen Ordner waehlen" : "Ordner waehlen");
choose.addEventListener("click", () => busy(choose, async () => { if ((await call("chooseGameFolder")).changed) showPage("home"); }));
return card("Bus-Simulator 2012", s.game.found ? "ok" : "bad", s.game.found ? "Gefunden" : "Nicht gefunden",
s.game.found ? h("span", { class: "mono muted" }, s.game.folder) : h("span", { class: "muted small" }, "Ueber Steam installiert? Sonst den Ordner von Hand waehlen."),
h("div", { class: "row" }, choose));
}
function modCard(s) {
const m = s.mod;
const state = m.installed && !m.updateAvailable ? "ok" : m.latest ? "warn" : "bad";
const value = m.installed ? `Version ${m.installed}` : "Nicht installiert";
const info = m.updateAvailable ? `Neu: ${m.latest}` : m.latest ? "Aktuell" : "Keine Version auf dem Server";
const extra = [h("span", { class: "muted small" }, info)];
if (m.updateAvailable && s.game.found) {
const b = h("button", { class: "btn small", type: "button" }, m.installed ? "Aktualisieren" : "Installieren");
b.addEventListener("click", () => busy(b, async () => {
const r = await call("installMod");
toast("BSO " + r.version + " installiert");
showPage("home");
}));
extra.push(h("div", { class: "row" }, b));
}
return card("BSO-Mod", state, value, ...extra);
}
function dotnetCard(s) {
if (s.dotnet) return card(".NET 10 Runtime", "ok", "Installiert", h("span", { class: "muted small" }, "Die Mod laeuft mit .NET 10 (x64)."));
const b = h("button", { class: "btn small", type: "button" }, "Herunterladen");
b.addEventListener("click", () => call("openDotnet"));
return card(".NET 10 Runtime", "bad", "Fehlt",
h("span", { class: "muted small" }, "BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren."),
h("div", { class: "row" }, b));
}
function serverCard(s) {
const input = h("input", { value: s.settings.server, placeholder: "host:7777", spellcheck: "false" });
const auto = h("input", { type: "checkbox" });
auto.checked = s.settings.autoConnect;
const save = () => call("setSettings", { server: input.value.trim(), autoConnect: auto.checked }).then(() => toast("Gespeichert"));
input.addEventListener("change", save);
auto.addEventListener("change", save);
return card("Server", "ok", "Mitspielen",
h("label", {}, h("span", {}, "Adresse"), input),
h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto));
}
async function renderNews(root) {
let list;
try { list = await call("releases"); }
catch (e) { root.replaceChildren(h("h1", {}, "Neuigkeiten"), h("p", { class: "error" }, e.message)); return; }
if (page !== "news") return;
const names = { mod: "Mod", launcher: "Launcher", server: "Server" };
root.replaceChildren(h("h1", {}, "Neuigkeiten"),
...(list.length ? list.map((r) => h("article", { class: "release" },
h("header", {}, h("span", { class: "tag" }, names[r.product] || r.product), h("h2", {}, r.version), h("span", { class: "muted small" }, date(r.date))),
h("pre", {}, r.changelog || "Keine Beschreibung"))) : [h("p", { class: "muted" }, "Noch keine Versionen veroeffentlicht.")]));
}
async function renderSettings(root) {
const [init, s] = await Promise.all([call("init"), call("status").catch(() => null)]);
if (page !== "settings") return;
const toggle = (label, hint, key, value) => {
const box = h("input", { type: "checkbox" });
box.checked = value;
box.addEventListener("change", () => call("setSettings", { [key]: box.checked }).then(() => toast("Gespeichert")));
return h("label", { class: "switch" }, h("span", {}, label, h("small", {}, hint)), box);
};
const folder = h("button", { class: "btn small", type: "button" }, "Spielordner waehlen");
folder.addEventListener("click", () => busy(folder, async () => { if ((await call("chooseGameFolder")).changed) { toast("Spielordner gespeichert"); showPage("settings"); } }));
const log = h("button", { class: "btn small", type: "button" }, "Protokoll-Ordner oeffnen");
log.addEventListener("click", () => call("openLog"));
const uninstall = h("button", { class: "btn small danger", type: "button" }, "BSO deinstallieren");
uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall")));
root.replaceChildren(h("h1", {}, "Einstellungen"), h("div", { class: "settings" },
h("div", { class: "card" },
toggle("BSO automatisch aktualisieren", "Vor jedem Start die neueste Mod-Version installieren", "autoUpdateMod", s?.settings.autoUpdateMod ?? true),
toggle("Launcher offen lassen", "Sonst schliesst er sich nach dem Spielstart", "keepOpen", s?.settings.keepOpen ?? false)),
h("div", { class: "card" },
h("h3", {}, "Spiel"),
h("span", { class: "mono muted" }, s?.game.folder || "Nicht gefunden"),
h("div", { class: "row" }, folder)),
h("div", { class: "card" },
h("h3", {}, "Launcher"),
h("span", { class: "muted small" }, `Version ${init.version} · Server ${init.master}`),
h("div", { class: "row" }, log, uninstall))));
}
// ---------------------------------------------------------------- Start
(async () => {
const init = await call("init");
$("meVersion").textContent = "Launcher " + init.version;
if (!init.loggedIn) { showAuth(); return; }
const me = await call("me");
if (me.loggedIn) showApp(me.username);
else showAuth();
})();
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BSO Launcher</title>
<link rel="stylesheet" href="app.css">
<script src="app.js" defer></script>
</head>
<body>
<!-- Eigene Titelleiste (das Fenster hat keine von Windows) -->
<header class="titlebar">
<div class="titlebar-drag" id="titlebarDrag">
<img src="logo.png" alt="">
<span>BSO Launcher</span>
</div>
<div class="window-buttons">
<button type="button" data-window="minimize" title="Minimieren" aria-label="Minimieren">&#xE921;</button>
<button type="button" data-window="maximize" id="maximizeButton" title="Maximieren" aria-label="Maximieren">&#xE922;</button>
<button type="button" data-window="close" class="close" title="Schliessen" aria-label="Schliessen">&#xE8BB;</button>
</div>
</header>
<!-- Anmeldung / Registrierung -->
<main id="auth" class="auth hidden">
<form id="authForm" class="auth-card" autocomplete="on">
<div class="brand big">
<img src="logo.svg" alt="">
<div>
<div class="brand-title">BSO <span class="beta-pill">BETA</span></div>
<div class="brand-sub">Bus Simulator Online</div>
</div>
</div>
<div class="tabs">
<button type="button" class="tab on" id="tabLogin">Anmelden</button>
<button type="button" class="tab" id="tabRegister">Konto erstellen</button>
</div>
<label><span>Benutzername</span>
<input id="username" autocomplete="username" maxlength="32" required>
</label>
<label id="emailRow" class="hidden"><span>E-Mail (optional)</span>
<input id="email" type="email" autocomplete="email" maxlength="190">
</label>
<label><span>Passwort</span>
<input id="password" type="password" autocomplete="current-password" maxlength="200" required>
</label>
<label id="password2Row" class="hidden"><span>Passwort wiederholen</span>
<input id="password2" type="password" autocomplete="new-password" maxlength="200">
</label>
<p id="authError" class="error hidden"></p>
<button class="btn primary wide" id="authButton" type="submit">Anmelden</button>
<p class="muted small center">Dein Benutzername ist auch dein Name im Spiel. Passwoerter liegen nur verschluesselt auf dem BSO-Server.</p>
</form>
</main>
<!-- Launcher -->
<div id="app" class="layout hidden">
<aside class="sidebar">
<div class="brand">
<img src="logo.svg" alt="">
<div>
<div class="brand-title">BSO <span class="beta-pill">BETA</span></div>
<div class="brand-sub">Launcher</div>
</div>
</div>
<nav id="nav">
<button type="button" data-page="home" class="on"><span class="nav-icon">&#xE768;</span>Spielen</button>
<button type="button" data-page="news"><span class="nav-icon">&#xE789;</span>Neuigkeiten</button>
<button type="button" data-page="settings"><span class="nav-icon">&#xE713;</span>Einstellungen</button>
</nav>
<div class="sidebar-foot">
<div class="me-card">
<span class="avatar" id="meAvatar">?</span>
<span class="me-text"><b id="meName"></b><small id="meVersion"></small></span>
<button class="icon-btn" id="logout" type="button" title="Abmelden" aria-label="Abmelden">&#xF3B1;</button>
</div>
</div>
</aside>
<section class="content">
<div id="page" class="page"></div>
</section>
</div>
<div id="toasts" class="toasts"></div>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5ee4ff"/>
<stop offset="1" stop-color="#1f6fe5"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="#0d1016" stroke="url(#g)" stroke-width="3"/>
<!-- Funkwellen: online -->
<path d="M26.5 15.5a8 8 0 0 1 11 0" fill="none" stroke="url(#g)" stroke-width="2.6" stroke-linecap="round"/>
<path d="M23 12a13 13 0 0 1 18 0" fill="none" stroke="url(#g)" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<circle cx="32" cy="18.6" r="1.9" fill="#5ee4ff"/>
<!-- Stadtbus, Front rechts -->
<path d="M6.5 44V27.2c0-1.8 1.4-3.2 3.2-3.2h41.4c1.4 0 2.6.9 3.1 2.2l3 7.6c.2.5.3 1 .3 1.6V44z" fill="url(#g)"/>
<!-- Fenster, Tuer, Frontscheibe -->
<rect x="9.5" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
<rect x="18.2" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
<rect x="26.9" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
<rect x="35.6" y="27" width="7.2" height="6.4" rx="1.2" fill="#0d1016"/>
<rect x="44.6" y="27" width="5.4" height="14.5" rx="1" fill="#0d1016"/>
<path d="M47.3 27v14.5" stroke="url(#g)" stroke-width=".9"/>
<path d="M51.6 27h.9c.6 0 1.1.4 1.3.9l2.5 6.4h-4.7z" fill="#0d1016"/>
<!-- Zierstreifen, Scheinwerfer -->
<path d="M6.5 37h37.6" stroke="#0d1016" stroke-width="1.6" opacity=".5"/>
<rect x="54.4" y="38.4" width="2.8" height="1.9" rx=".8" fill="#fff4c9"/>
<!-- Raeder -->
<circle cx="16" cy="44" r="5.6" fill="#0d1016"/>
<circle cx="39" cy="44" r="5.6" fill="#0d1016"/>
<circle cx="16" cy="44" r="3.2" fill="none" stroke="#5ee4ff" stroke-width="2.2"/>
<circle cx="39" cy="44" r="3.2" fill="none" stroke="#5ee4ff" stroke-width="2.2"/>
<path d="M9 52.5h46" stroke="#232a36" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB