commit b69b9638ffbbe855b343ad1dc9b49d4ce0a9505c Author: MrMaho Date: Wed Oct 28 17:26:25 2026 +0100 Erste Version Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08a4571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +dist/ +publish/ +.vs/ +*.user diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj new file mode 100644 index 0000000..3200ccd --- /dev/null +++ b/BSO.Launcher.csproj @@ -0,0 +1,35 @@ + + + + WinExe + net10.0-windows + enable + true + enable + BSO.Launcher + BSO-Launcher + 0.1.0 + MBM.wtf + BSO Launcher + BSO Launcher - Bus Simulator Online + logo.ico + PerMonitorV2 + + win-x64 + true + true + true + true + embedded + + + + + + + + + + + + diff --git a/MainForm.cs b/MainForm.cs new file mode 100644 index 0000000..3535c79 --- /dev/null +++ b/MainForm.cs @@ -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; + +/// +/// 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). +/// +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(); + + /// Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start). + static readonly HashSet Exclusive = new() { "installMod", "launch", "installLauncherUpdate", "uninstall" }; + readonly HashSet running = new(); + static readonly HashSet 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(m.LParam); + base.WndProc(ref m); + var after = Marshal.PtrToStructure(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 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 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 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 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 }; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..01e6920 --- /dev/null +++ b/Program.cs @@ -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; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..784ba04 --- /dev/null +++ b/README.md @@ -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 | `\Bin_High_Win64\dinput8.dll` und `BSO\` | diff --git a/Services/Api.cs b/Services/Api.cs new file mode 100644 index 0000000..7989a84 --- /dev/null +++ b/Services/Api.cs @@ -0,0 +1,161 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace BSO.Launcher; + +/// BSO Master: Konto und Releases (wie Api.cs beim MSCO Launcher). +public static class Api +{ + public const string BaseUrl = "https://bso.mbm.wtf"; + /// Zum Testen umstellbar: BSO_LAUNCHER_API=http://localhost:5080 + 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); + + /// JSON-Antwort oder Fehlertext des Servers. + public sealed class Result + { + public bool Ok; + public int Status; + public JsonElement Json; + public string? Error; + } + + static async Task 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 Login(string username, string password) => + Send(HttpMethod.Post, "/api/auth/login", new { username, password }, null); + + public static Task Register(string username, string password, string? email) => + Send(HttpMethod.Post, "/api/auth/register", new { username, password, email }, null); + + public static Task Me(string token) => Send(HttpMethod.Get, "/api/auth/me", null, token); + + public static Task Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token); + + public static async Task 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(); } catch { return null; } + } + + public static Task Releases() => Send(HttpMethod.Get, "/api/releases", null, null); + + /// + /// Mod/Launcher laden: Signatur des Releases und SHA-256 pruefen (nur echte, vom BSO-Release-Schluessel signierte + /// Dateien werden installiert). Fortschritt 0..1. + /// + public static async Task Download(Release release, Action 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); + } + + /// Teil-Downloads (fortsetzbar) - liegen beim Launcher, benannt nach der Pruefsumme der Datei. + static string PartFolder => Path.Combine(Settings.Folder, "downloads"); + + /// + /// Datei laden und gegen die Pruefsumme pruefen. Fortsetzbar: geladen wird in eine Teildatei (<sha256>.part). Bricht + /// das Netz ab, wird gewartet und ab der letzten Stelle weitergeladen (HTTP Range). + /// + public static async Task DownloadVerified(string url, string sha256, long size, Action 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 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"); + } +} diff --git a/Services/Game.cs b/Services/Game.cs new file mode 100644 index 0000000..7c190da --- /dev/null +++ b/Services/Game.cs @@ -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; + +/// +/// 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. +/// +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"; + + /// Spielordner (der mit Bin_High_Win64) oder null. + 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 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(@"\\", @"\"); + } + + /// Installierte BSO-Version (aus BSO.dll) oder null. + 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]; + } + + /// Die Mod braucht die .NET 10 Runtime (x64) - der Launcher selbst bringt seine mit, die Mod nicht. + 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; + + /// Mod-Paket (BSO-Mod.zip: dinput8.dll, BSO\*) in den Spielordner entpacken. BSO.json bleibt. + 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); + } + + /// Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden. + 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 })); + } + + /// Ueber Steam starten (Overlay, Steam-Anmeldung des Spiels). + public static void Launch() => + Process.Start(new ProcessStartInfo("steam://rungameid/" + SteamAppId) { UseShellExecute = true }); + + /// BSO aus dem Spielordner entfernen (Deinstallieren). Gibt Probleme als Text zurueck. + public static List RemoveBso(Settings settings) + { + var problems = new List(); + 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; + } +} diff --git a/Services/Installer.cs b/Services/Installer.cs new file mode 100644 index 0000000..52fc0be --- /dev/null +++ b/Services/Installer.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Reflection; +using Microsoft.Win32; + +namespace BSO.Launcher; + +/// +/// 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 & Features" ein (Deinstallieren) und startet dann den installierten Launcher. +/// +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"); + + /// Laeuft gerade die installierte Kopie? + 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]; + } + + /// Muss installiert (oder aktualisiert) werden? false = installierte Version ist gleich/neuer. + public static bool NeedsInstall() + { + string? installed = InstalledVersion(); + return installed == null || SelfUpdate.IsNewer(SelfUpdate.CurrentVersion, installed); + } + + /// Installieren. progress(0..1, Text). + public static void Install(Action 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"); + } + + /// Eintrag unter "Apps & Features" (bzw. dessen Version nach einem Selbst-Update). + 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 }); + } + + /// Ordner/Dateien loeschen, sobald dieser Prozess beendet ist (seine .exe ist bis dahin gesperrt). + 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 { } + } + + /// Deinstallieren (aus "Apps & Features" oder den Einstellungen). false = abgebrochen. + 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 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); + } +} + +/// Kleines Installationsfenster (wie der Discord-Installer): Logo, Status, Fortschrittsbalken. +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(); + } + } +} diff --git a/Services/LauncherLog.cs b/Services/LauncherLog.cs new file mode 100644 index 0000000..54f6e95 --- /dev/null +++ b/Services/LauncherLog.cs @@ -0,0 +1,26 @@ +namespace BSO.Launcher; + +/// Protokoll des Launchers: %LocalAppData%\BSO Launcher\launcher.log (wird beim Start ab 1 MB neu begonnen). +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 { } + } + } +} diff --git a/Services/ReleaseSignature.cs b/Services/ReleaseSignature.cs new file mode 100644 index 0000000..af790e0 --- /dev/null +++ b/Services/ReleaseSignature.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; +using System.Text; + +namespace BSO.Launcher; + +/// +/// 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. +/// +public static class ReleaseSignature +{ + const string PublicKey = "mWnJlmK+ZHcbVdN+AEvB2pU8cYLmIAygylltXK7/Gb6Qs0vDlOK9vrXy8+Yx0//WkWrV2pgvuPErlnmwCdHp5RTEr4whXqfGXs2KphQD7wI7kSAA9ETUULgztv4oaW8ndmaAJvBdnLVF3IB0snH51abd0Y+JCnQ/64J8CP0zECdoLFebmGP6wYV75oZrNh9v9EqaQ3qH56SnwUH8dFoR7nkoi6M2o+S1/pL4hTpBGMWmm1DJXsck4HJTlblEDPkT9ksgJf9oj3aZrLGwne+ZOLQ+DJF1SDmFT75bWPUvW8WP4TJnG85epn7JwsKLFNy2wb2UtJLAdF+KCIgefnQU/qXvSYRg/LIWxX9FVysQW2re1dcm+kI+NLddA88i6xpi7Ziste0NzyKD6SnaEP8fNVuoobpgW+S8Gfq7pemxA9/EibqXdyCbsT+IHF5SaEHU6EbC50bRF2ssoMevf2pPv/6omgHaT1BUiQPEetMEx0HOzcnMlmVNk6hNkiXBngopAQAB"; + + /// Text, der signiert wird: passt nur zu genau dieser Datei, diesem Produkt und dieser Version. + 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; } + } +} diff --git a/Services/SelfUpdate.cs b/Services/SelfUpdate.cs new file mode 100644 index 0000000..af99bca --- /dev/null +++ b/Services/SelfUpdate.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; +using System.Reflection; + +namespace BSO.Launcher; + +/// +/// 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. +/// +public static class SelfUpdate +{ + public static string CurrentVersion => + (Assembly.GetExecutingAssembly().GetCustomAttribute()?.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; + + /// Nach einem Update: die alte Datei aufraeumen. + public static void CleanupOldVersion() + { + try + { + string old = ExePath + ".old"; + if (File.Exists(old)) File.Delete(old); + } + catch { /* laeuft evtl. noch kurz */ } + } + + /// Startoption der neuen Version nach einem Selbst-Update: auf das Ende der alten warten. + public const string AfterUpdateArg = "--after-update"; + + public static async Task Install(Api.Release release, Action 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; + } +} diff --git a/Services/Settings.cs b/Services/Settings.cs new file mode 100644 index 0000000..13902ee --- /dev/null +++ b/Services/Settings.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace BSO.Launcher; + +/// +/// 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). +/// +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; } + /// Spielordner von Hand gewaehlt (sonst automatisch aus den Steam-Bibliotheken). + 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(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); + } +} diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..b531e3d Binary files /dev/null and b/logo.ico differ diff --git a/new-release-key.ps1 b/new-release-key.ps1 new file mode 100644 index 0000000..2a36563 --- /dev/null +++ b/new-release-key.ps1 @@ -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, '".*?"', '"' + $public + '"') +[IO.File]::WriteAllText($code, $text) +Write-Host "Release-Schluessel angelegt: $keyFile" +Write-Host "Oeffentlicher Schluessel eingetragen in $code" diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..4a3273d --- /dev/null +++ b/release.ps1 @@ -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)" diff --git a/ui/app.css b/ui/app.css new file mode 100644 index 0000000..c105113 --- /dev/null +++ b/ui/app.css @@ -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); } } diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..a6d0661 --- /dev/null +++ b/ui/app.js @@ -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 ? "" : ""; }); + +// ---------------------------------------------------------------- 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(); +})(); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..9ceead9 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,86 @@ + + + + + + BSO Launcher + + + + + +
+
+ + BSO Launcher +
+
+ + + +
+
+ + +
+
+
+ +
+
BSO BETA
+
Bus Simulator Online
+
+
+
+ + +
+ + + + + + +

Dein Benutzername ist auch dein Name im Spiel. Passwoerter liegen nur verschluesselt auf dem BSO-Server.

+
+
+ + + + +
+ + diff --git a/ui/logo.png b/ui/logo.png new file mode 100644 index 0000000..358cb25 Binary files /dev/null and b/ui/logo.png differ diff --git a/ui/logo.svg b/ui/logo.svg new file mode 100644 index 0000000..e482c23 --- /dev/null +++ b/ui/logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +