From b69b9638ffbbe855b343ad1dc9b49d4ce0a9505c Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 17:26:25 +0100 Subject: [PATCH] Erste Version Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 + BSO.Launcher.csproj | 35 +++ MainForm.cs | 399 +++++++++++++++++++++++++++++++++++ Program.cs | 44 ++++ README.md | 48 +++++ Services/Api.cs | 161 ++++++++++++++ Services/Game.cs | 118 +++++++++++ Services/Installer.cs | 244 +++++++++++++++++++++ Services/LauncherLog.cs | 26 +++ Services/ReleaseSignature.cs | 32 +++ Services/SelfUpdate.cs | 51 +++++ Services/Settings.cs | 56 +++++ logo.ico | Bin 0 -> 45987 bytes new-release-key.ps1 | 23 ++ release.ps1 | 45 ++++ ui/app.css | 163 ++++++++++++++ ui/app.js | 294 ++++++++++++++++++++++++++ ui/index.html | 86 ++++++++ ui/logo.png | Bin 0 -> 9587 bytes ui/logo.svg | 32 +++ 20 files changed, 1863 insertions(+) create mode 100644 .gitignore create mode 100644 BSO.Launcher.csproj create mode 100644 MainForm.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 Services/Api.cs create mode 100644 Services/Game.cs create mode 100644 Services/Installer.cs create mode 100644 Services/LauncherLog.cs create mode 100644 Services/ReleaseSignature.cs create mode 100644 Services/SelfUpdate.cs create mode 100644 Services/Settings.cs create mode 100644 logo.ico create mode 100644 new-release-key.ps1 create mode 100644 release.ps1 create mode 100644 ui/app.css create mode 100644 ui/app.js create mode 100644 ui/index.html create mode 100644 ui/logo.png create mode 100644 ui/logo.svg 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 0000000000000000000000000000000000000000..b531e3d4040c9915707bffe1b845496f750245a9 GIT binary patch literal 45987 zcmagEQ<$eg&nNtE+jjS~ZClfJPusR_W7@WD+qP}ncK6QnKKosJ@?E?4!CgrzsiR6# z`6U1V5C8&zhzR(%5(4-@000*N0Dy`4UtR?a0BHOtBP9GUpAG>4Ktlll3=IF}X<-3? z!hf~`0{`VZ|Iu@}0Dzz0e|bkz03eF?-+}+W|D0c-_m5Bl0K(;E#o(Z^q5pAk65=9? z|JHw&07OXee}969AAJA-_(4KMP}wchNy$=NSrGO6y36rpZ!5I|@ zcM-*^e;TLb@nqVKmnl3@(s0;WgwJKq@J6A=G!d8#9s>(XG0>@dV3n2Jm4yLOx?E9v z=Z5U&P1V7ErZXoS!>v!}*HHGW9zPQ^5>0qjZCF(~Q;$`{ht%$RGeNhoX(&vKDn3d; z(w`PGE)AStSyi)&P{$-3q;9Ut0dDL96p#Wmuyf370U*3tB4j+k!t8SHP# zFs#K$`GtoSx(g+buLy`^*m?sv{79vf!Rg6N%VYU8vly0Nps{7Qg^7O{2FdF7EoOv5 zX7a?Sy(qc8C=+orgO`0mR!QRSx(&nCk->;cZ2i$l_ds+-yv7?LoP};}#s?=y8~Eb` zoLK<5N4}zu`OC^b%dU|5(ihQkTq%B01b+O5ev-nP7n(RJR3dzG=r}e5E5xQ1%V?p| z(AF8HxZ^m6u6eW9(5U@258Ne_x9CscK$)$l~v%@dSn#TA6X*5 zbWup4f?;I^-2IlenMJ3V9?(Oe?H4!2P@I()b-TE{U|T8lW`}`0LaQpsZwDnJL&2EA zuUt;ufa$s156>YJC?HaNlCL1`(ny4n1BPThkun9$AL+z2d~$Mxzmn{|3suJz0w<-5 z9UyKd$+Q&w$K~T4+AKmcM&9S(P4$+e1n2e`h3Q@*ZFu&Zo1=oA4KJjslI)VI%G>}7 z&o}FKDCFJdKEER!Wvj2J%Br5LL8aI#12t1tH*X4cDM7ME+2wvahCBO};NRu{`62vX zrT#~N|L6LH0BY5}5&D&=_Wnr#5~8vq)k1m!|3BXI&tCzU|KmNouDq`R02s`Fy{A&e z+X____2+7PTeU{Zlid31sa8Nr6DSyXKA$KK_&1Bl;Gh94p#=+O7&Zy!m)I_XfdQky zr??1mq@Qk3Y$~KABDBAXqGC~R5_-C2Te6PksrHSB=Lh4#DZBa zUZYH(8?*IQ(kghq*+vb)x>UZu6uvXX_|s;HK4fv1vS;J=zS97!`*2q0&;?EHDqm^M zlMfqkZTTM`RrpZXOkc+$@74~Kw88W%HVantDUOkB-NEwor2UjRd4RZy> z1lvsYj!CvfALB{U?KT+3JS%u7b+ElXebVLS$kirC!)e!dYWeoFkwTj!Nl~!%i#$Uy znzYE1Nex)i;(~_~`n=~Bp&mSen>%m$5GbqfV8d#OO<29!GmHpPB;B}0waR1J2~ij( z@ka}g%Qk$T`YQ&5ENyud(C)uatxsRS5#1>km zVs=7&#|{vk;kJ5o{dPw|@Y<8?v#_BW1~5Kx36t>CM9VJ3ClK^IKr7 zxmSK+!DAC-nyKs)IDi`UCNh%C0MV4? zd&h}kC9=w9M%04~TRPQ$8YJWU~i>s1YPM7R(53(m#lmvXJNt+IF^Jz{Z52$Zga4 z7s16+lmKWMveJ7}L>i~OqKQco#w^i*Q@>Lxfti^Oh0aFgA-O;G)cWj$l@LT6FogBL zRv+MOgT4(%sa^B*))^N4=~^)=LmLdhl)>$ar=lzH>UBV$o@Gi;x*3GAyFtgy z*^e`%P2A=to&eN!eh3d2&-VfcF)!PGUKJm@p!uiVHv^Yd+=B5;{ZC@*kUa z)78b~@Z#56^ux4sL`LZZc({J7KMGlr)yzUudm@aa;g+D3?V)?3H&MUta8dpR$WYr6N zv-H&Gc#k1`508r;@w_Qj*l4q^2@h53l5+U8KA#oF?IiaA{U@q+naZsHpQxIF`{ecw z-9h>Ph^mDD_6h$dsycdjJOTg^a{rC0jjHZe$|G1mU4{sruGS1X1h3_A`HY}}2V`Y| zaK#ph1%;%}=ja&7iBy7r6q`QdLdwezA)%V0<|V5%sKkN){1w_%QIOV5Z(I~BMUqG9 z1&6o|2VS+rxjF0F&N!RA>^{*ZXM+rud+vUj%6L2G_I-%@%h~9ix^4&>gA%3eFIfRO zw?&8ynFshFZod<`mk@YEBrq+s{G=px`_?W%xpeoJchGU<_?fFy_*-~QUc@v7(ino= zl?111m)AE(#Ji8BM6sb|C4`)j-g##exiH9G#KMVCOEbP=ea$2qdGU_+i&6$pj%&t8 z(!Xlp9o}T=W+2<%<8FoaZ^;K{qdXu7f)Y#CNYz?o%+;ryI+_$Y_|Z#MX&I*&STn>d zN#rr6x@(6lXQKgY5sTjbQuPAgK;bsH6daSM%ma4#v zPY{rGLsZcT+tkAyR5=cT1aqFs;SCJ(p}EWN4zN(z3c%k*#Xi^H#z9cmaBVhBluf4@zDdZabF{xgWs;rIBWW;((D>Hd7^QkKHS5!qFe z1?q%qz$KhaI99a-2;uV>k=m6s{0-SDYXp*PW;*Q9;F~zjR1n)Xh_x;HA>qdxgV?s) zNF@wtr`-Q4b3CIbCe;_bz?Tz}7TcJCvfvd*&hTCtLr4`OxCFhX+E{Hjq@+Tovcae$ zuA>`?{o4oNLGvr9ot=f)%Ny|hoglB>1{=~d4iAQ*mGlIubt&2IcOj$(ZwC_vDUL4v zk~L7&D~B6j=z;Tfl(7Hw($F~ynOY25n$OfRit-@3=^3H!b6(zo$< zMbBslhGO*-MF}Vlga(vJKbC6Ke^5cP@zE<6TV?>QJ9d)Nkym!!jad5lC6yxuOHx*_ z*|g{ofQHZD=x1v)LqD>A?h)%2nZq?o(S_mul(9#^LC%xZWkSqt0JDcHeVZ%_R(n+A zXL;n&Ox6!7HA+!tV)vE9Ld@|t-3c*LK7x4aKElF93n89qo|qKs>HAEMWNSnDqjG~wLF9pIfxMYcFhXDtbPk+V2|@o#zFOF)PYn^uDXKS>&>iu4$s+kQpk zNJY+@Bifq{uEe-t+TvpV4jGT+pV(~p5^NSoJO64{6{d%>38E% zYUcGy!Jcs!{gRXiM_7kM{Z#N~6#>JgB7G8`B^jX{=Hp`xZ;%^ zYiECrq)74da5TCEGqbUG#gBW)ohR!L>Q|w2%YM%+6!M!_Cy{zqZOYQX+iZ;UShJb| zWUJxpQvS+N%VM~5XeX;Ql!~weSm;5isYrx7ZvzB+Ru}1vr#`FvAGyu=qDe1pXEf^Y z=y3SDc}r|BYx9`0O)YC2czI9I?GY?#6~?`aRC#v(uTpnX@_Zlj@jqLqcEL6t`3rrQ znNY*ej8G@eo{y*EQts(7sBHeSI$;(ymWw(Mah1z~0X1pn81=O6w6VECWR ztP1=V3IM?D|2H#FYCt(DAEAA3Pi9ypNTUm(BQFO9hy&JI3RFP&ycN{Q^!O6iM0Q$vCCWgmH+km(&s}u1)9NOk-K7& z8^D1Ku0xk-`ol2%w`Il^Uxc3UX|b*`Xsb{8DJGIAJPaZgR3p)65%mVq`t)YfO_gZa z+%46S=D=y6_J+m@et1? z?Tpv4n=W#kYhnmHK0==RX5$7Y?AqBajsWGv|u3g?vdH#HYQwG1pOO&ge zIp5}v7x284;`@72&bMxm}MJI1l$%Dnn1^`Mank^Q=s zxC7|tRFqi_RbWpUe)lj}R9mXZY8I5;AOeZE3(@Rc^ztX(tqQ)duKwT+pOBjacuTlk zxc}OG7FcDDuF6Fi5ar7mZldklY6;lEunx#d67y?#Zqp$JHiGwj!ItuZ=7V$TUA>$Ndc3T}=1|*1x1g>dl=!J#OWxV6idzMY zJ_3T+@%={}gAJ1b({JKk!$uoAZ1@|oHBVpBjk_5A8fwAU-L*nRwnwyeNd!x}P@tLs751u)Jb`m|xN0mV2CG@P zQa5sl?Q-Bo)<$0TcajO!Tu5Mpnb%esN-95=jAxRSn*`v~p7v27PD z)mJcJFWIV7EXAEfw*3MqQUPVx&|}6EwL4~5zj-Q#D0g*gb73fB7qp1prFpWSN$)*9!?XK$zW^e zL*(Bdpu{XEP7~5v1`G%G@b;>ag3ARnzxXWk%XG@K7fUS|&RPjOiQGjuRF`trJ4m*- zAe!@Ek93_UKy^b$1lfX&M8Ksz_GkySIe{$v?rxK8G!M%D!nF@UIC%+-KnUIXRLSL$ z6L+4UTnG$Mb7j{YtgI~DRog$rw(oY^l!m%v)~6wz!HPVV$a4^>(KDYh-z1d2-e?{&wWLD)l3)U-duRQ%z$E{8Zi{4Q zJ-_7lEQ$X&&y_!L*w9nqsZZ*ia-H(r%MbIB-i&)F9UHV&{L=JXi_%H|oq02sP7Pz>{p8*qT zNH7`GIocq)ZLHR7R|qOscdB+>dOc@CgU<4`AhVXXJ;xP79!h2ou@pcH1`dUq5fSok zR=G`SJ#+I!$R`Wl+*kXq`!70$APVk-j>q63l7Zt(E(jNhRNOUI=u;>0>#vO6^v*@5 z!AgTt?4{*5peh|^{VSEK-zbVLNKN?tGCNL+CpM$?uA$ zaD<(_zkra0$uHjNh#XEQ5%;6Wd&0p)744Ad(g7ZiJ6Y^EQ#-ED59QYK*7PFK2CLGm1h z%bj&v?)l0HwCsTwEY%-{fAx9=Jjce1s(prhw)EF9(Ccl8@!!2qfFLgxs=lJRAr z%V~#0;c3W)k>n|T4fE%b%j$zMNqKl0fk=h5>u(?A$`3QQPJb!{KE@zB8oGQQ2gVuy zJJj#*fc~JY1|&8UQsf14OR@1wI}+10<@*HSL>pWnum&+OUR^o`{e5|@B zIyz|{v~^a7w9aO;{k->~rQY}BU^XAp`9HK@++B_1DhMUca)aPHqST|YL_kcTjwT65L?WaHXsv1((_dlVZM7=c=4=g z`(S>WR(s+Tk{e^S_%uoX_8I)mMcVPMM1mFSSE2x0Ki6bk5I=eZpfWZs$KTn=`qGDq zm~%8Z3$tnwvsOiw1Mqm@rw14FR5_sU@T0kW9QFAL>AIg=vXqU5t77W^MaJrtb>Gk$ zW4`d7@hz2Zp)lT-lL6m!{*#gf$`ZTVY0!}hXyxX6?<<$KWXei@+Vf=I`V8k zjNGa$@~>66Pr`3{rtg(LRb2_`#QcKt*zSjBP-bdAfAzphLi|4I8MoSMHz_}Go%Ha) zEXb3=jr{n)pTNG_Hr~IqHCmj#_ukDT8Ak6JQwt&xJm9lF)cv)RK!3p2X=>~y6DiTS*=kw1R%_6^>cv7*j-2L#0 zuqn&cn;80aqZ=X>I;Qh({j$-@e;p}}nL6H`C$2Zlz3~5g@&Ei-+SC81)$zZJ1)&d= zK=kOfi%k9M&LD8p)cw7u8uPI*S+qViiS`lPFGO393W zO2HL8tDw)nr^i98tQk^`OI(yTit2!JPdFwlTM>vewSZK5%hD_IYDGVP;9y2-+B_)#=#HO~mc)yHIc~bx;nY#x?3?GQ`hf5?F z6#V{IFVaZmK8%V&vP9tgt;peP!Np`jZkQ`J8P*wrq<#ch``yRrK0%iy0tsqT#$e4; zZAg9X*qI3NMp)hbKH^vWVT%p1CS)?JHOGzpQ!`FiPP(l6)I zzM}Eg=kOEaj*_7nU!^-OXj4TW(-DMT-l2xG!j^>8;lFW-c<<&eAIXPa8+~1IPrNB} zDT>$R=D{0$=ZIguwxFGokQ*H4K0?ZFi_csrQjOmKSepxu>ehJPAF_z+ZcpXgjYGT45Y%{W#2+ctc=)E8xNUVD+sZ+{!*CA^cC4LLX zvEOx&7$68$!P20H{G5)Mm2pJtIdc|mXg!gpX9ZOXxj8}*mf4|3#U1}16FE0_%{}V{ z4=Rtzr&j*jlQfb`74^yMS=M9<|H5xqCyY=G$&g{CQEyAErfL`3ZnQGe_aR5!PI?XTjTyT&JbaEvm%K^OLl8i{-dQACG0wPfG2gDTVEZ5K*E7|VJT&Kr)(iK8XhvPmk!&<@B} z)eVhADu`pfcQA;}Gr8HJY+a8y&mzb@6RG>NlHm)M_<8rsz-p}rwzCRIn~0PaLpbNN z0?pqe?@(ePCWL%JU}t_)aB3TG)qMvw6XD+Ei6|6Zs5MWq5T(I^;c^6K_Cj+f5c`*y z6hG_eP?w~To<7qMTY3?~O1{CCLA@vMYP^z%Wj1PMnUZCui+Qh{j<7>d%|*1DF*~Jv zoYb~%sqaBtAcrn$Ic>wWWA!U~2;VDCy(0lOb%4C4FseHK?}a;|H9;#zgPmw!w4n*dp-`XzwW_uuljO>2r9Ao?pC>i|!Iz zfo7{MU}zNt;rEyI^f+fLKgg0Z)Z)~gx9D4xpmyEiO>9^&R8#b^0E$Z)7FB&3R3TUS zH%iqCQ#_g(h=y9KV zyxBxe{=S;OV8oL^Fc9wpV5D39m`b;w6ed4x$RTtDZty8I%ql4Z!wa=2)>}^Pm9J*A zw2qdiTIG%Uo!*Tm5c6t>KEW0N%gZ5389RL`f@J+gjNe6Xe)LJWe7&=P1&B`=$i5>G zaR8{7B-e8&1&a7}QjEd_2C?a7QY>f|q)?PF z9IM%C589EhMx^{#+VgA6xn-f>Saezo>8`}{Ub(St9OC=wDIF~$ZRt8A1v#)KC+10R zmh4FB&F{1}7|9za&IZ#(;aA;b_U?$FP9O>$j-yQM+%F?=N3qZgOS?c1?}rWXYjp}l zTx$TfL%~!rdPhWK>$$rSN1{o7X4`mL!>06zncS6tyOC%9W@%0Oo0H5nGqU+WjUZ1w zDgWO5C!Ij+Q#|eJu#z&P{QRr6)?=1`Tw>7w^gOq&xJ*PEjH5ar#NP%w? z_*ih@?cG32pKd3+1w+}uu>K(Za{k*P67J^^Y|s}HWbCEjchw8A?>)%B0g#}5-6=wm zF?=b>Id%rOZ7Ss(wa|N2-)Nl{p0LUall;5g(6D3N9d+8~Gg)gbl%dYw`TZ~JEDs=4Mx*eVtrCJ)n}P;Rve0B$3oV6A=++B@9RrMn)y#jJn~fxL+pgiZHF< z);1cb(%(Q=`;Uk?=+hV>;KnC9yy{LmPCv-NtUC)x#*X=|HXZ5*wt*uVL4y2mzMzsX zVpo{tFL;7{BnVgsWH7IrgCJF@+A6UwXN9doe~t0Kf=vbEOiUf%rG3ke3 zyTrKslGT^A_YxTQtBCVGagZX6XP80s0wM4H8EdEbZnQs-BYc$Iq|)2S^qvpInNAq! zLbyQ;Dd=Y2K|>m*Z234yc0B4I-c=j^6#^a$@a8O(;f%IFow0nA?WQ5<6(t-L0X56+ zWGHJYhA!FBwS2U2wX$U$n6^veo*d)Uvq4~SY0HCBNwt{dX<7P1oaDEpR+v%oF&JJH z-pCF1!a(B_j8Gd0Qi9Tmmy!TAeFgh(IN+4nbc_Jh87K%6iZj*J0XH!=JEGIF`-W@bKr0K3Dmw3M6n{?tJhx8#loOy}>WV8U zHo5dG#@mEK?T^s3L(Hh$IWss^qpn#s*M;j4xU@{Db@Ku_Z&*eMTLB;8!OI3I#Lp&n z2?-+61FfF;TKVYJOUFX<*HDMO!;@0um^)?}NQB7S*dLftD0tDn#hkp6-U+=N{DzeB z_eq8V-oeHzfiauRwOiN#(}=3(3N{`nTUjIr8@I$D#`dk1Dk+fDoyItyP0nV8#I6Q5 zr03#vrPFlqQ!+zN{0c#n-#U{Xr7kO;6mL5}0zH%s3=UTt@@8X|3KWRF-N7;Ys|^ct zKi$US9Y*;T2`=`ahR2Oqma9ETNj2Xk+s_3T1?zlm25cU(A6?C|F~{2(m6iZ28Sm#n z*iPNd$aWJf@|ng~Q}kx6)e1p;Bq)n`<+P@51oq|qhohLhnW+}T$pop*uMaQGk4Hzn zMh+^?CrEJqPMyx5Dq%#%ZS>^WGnEa}a*u~Q$7b*ItQ;cSW2!>m2mN&l>t?SQcH35b z5ViDiL=vBGmGsgBFh*}bl5^@~D47Wbb?n@VBzK>h?_OPV#3{@>-F-bU+0HxY=AmKQ^o*nm1VS^Ev6KZG7;TGIGSk%9yQJuC$AdayN0p#1YxF zNoE}rD|JG6VUELB5+v5Ul3fj^pgU~HOSw0qMP1xzpTXS=u0aqn;*qm(y7wQPE99Gf zXy?J721Ug6_o;wxSdCDU7YmKiG2!uPBR^Bz_>ezc7CudNP_oJj%PY}i z#k6bRXdo1s9QGgF?HZ6Id5KS9eKcwq>{LYl$(?=0BaNuX9@OyDtAjfX8a5B+t;sn+ z_Urk+6s-c1y^mUq*$-nUtQG870o%Q!NeDbAPRv>?ICU_J?m}|*Wcxdw4$ZlAYEV=t z$Ud8IUf4!ueN_IL??SxqN4s;Elpy>Xg+=_*bzn|ngt40w%wz5y%ckFrV*Z|UF!PMe zP}gL=4+VxXU!;@U+n9*Qb7O3O{9-`P$%&($@FN%43OgIRLUBu1KR%W5Qewgy>ofUF zMlT%^`Qcsv^xz`yzNzNGfYf%oXf(SWTrtKguDDl@bh{Yi$F;ht1N&({t998oX7CM0 z_OSSVre`26N~+Hg*YSrc{!l_>+?Zb|hLJz=l}_PXaBN?PzU_S2VmDnC=O+K0o|EWO zPn)!yt=li8%eR;#4_;SvzME1U+lg3lcoUA36oC#kL&_CTchl4TkohV^8%5=#hN!Kp z%$w1|_pYoaUqjX_pgG+k2xod0dP=8t6|n^rML#^K{;H@YPk$4f{L!H!`;g*f(~LDg z`%uPzBn5_(H7E8gE*dM8^WjO^+2L&e)4J|86312Nt)5>ldC9bNs)Gb>tsYS;5bAo{ zKU`YOcf)c;(1@2~ZePtDM;kmsdPB(ZQBI!ZCvfm^L^@{294OW4iQwXQS^1-3`KNWf znLo_VN!-pmS6a$1HeMk+43U4pfboB@fPL?W3+bE=ok~1HZ9_G%r=M)-=Z$&M;GFD; z7TFq};h#Kv3e|zYvJS0yS9oQKk(xB0XY}Zqt*eC7X@@nc#@_YOEbUHjDBzYTW!{oD5YAM9d) zPk-Y7U>BF#P^oA`P2V5e+^#0qmmjbf48Y_0h(JIJn+ExyLvW&-@u?Ux+YxfQ(k$30 zGLKS`WY{PPGnhSQ()so<*N_y1VT1vf5xuYz43oo`7g?SiI!`)(w!B-gjKEKRFyCEN zv&zrQ9c8n)ni?SjUlj*J4I?clB;rt$YY2PQMn^+Mpu=d;W^=uO6#)Ut#B@aCzOz3G zr@V7FX?Db`o#f|#4EnELg0?Tt+JY+NQtkM1%=YN+p6b`O{oz6vC2lTGV?5p);p>&f z2v-E-pGgFLjXN$eHY`zs!vvA=`BJxFhUsSCR|WT$uBA?fHMc$G2f^%#NHYfvdH$ zd*RSN;ysc|_K62mE}lmf-gjAYET}1vO2>+ujz#n1d46(`?hx@Y5-_Cu!x-K)c`yN| z-9o<~+`Z6GxdD3zZ8HOi=AVw#2vOby*I$jX$u(~*B-@Fw>gOc6+9=+l({o$m@hT3f z<7v6Y+Zzt4RXO7iJH%aB##*+dV3jPNT2j6{PXgVs=nGO#(Ll5`1*h{Q&+Ro}5~55= z(v46ex__SEyny!Oykyh&W=|3@`=3}c7Yr_7a)gp?b$yJF#l#Vz7<=0$!fi((-=L`0 zb`CzAf`$8}zV&i$e!ab4a4*YQJwZt&@Wl~G{`92OB=?7**faM=C6UmhlGMyansPrX z*quKF%7`g^%rP}yZ$wqUTR5$(l&@5`TLf`$+!=?kllm2A8oWCGjZM@B__`ng6ORyE z_N(6-A9F62RG6p%*`D5`rJS%3!4D1rV5m#WQ;ygv$q^1XahmyhY`!cOtK12ma=gNl zdAS+FUgK1_kgyI+etFKjk8&7u6^}{ROLwK{qh*3;q~Ib7FX*Pq(kv%qiB= zaIqa|7P}TaNPfZ;*&*_tM2+mc6D)+sv*dGZ%q!LP|8Dmf`kZVrkYFbRZ>V0yof;J-$q3;}tk!8Ge5W9y0@VTaJe9e<=nHX6*ksSoWF|eXI9b5YM@lqK*HM1 zKr7^+n9!sQJ>q_TV;R|!;|<`Q;RzA+>dUoeJe=Q9_>8()*ughJ?KcCK;1UYhrmV0L z=iX0`)a?BfCSffLtt_(kN@wM03gOIaRPgjm~b`BC1h{{jc2Ogg(;ltaVq}} za{i~RQC^H&=*k+BdL^3ev9T%lVN?(nwGf^g%+U7}nTHC6<6TDQAG+$;jZ#GnLPLYF z1=xSWh$ZiF>Tj1qh=WfaHJ0gsun)tD+y5MSQM#uul~T$k^& zaZ*k$IGELGBv@a#`9e!OnEAbikew%V?w^-8RV7vP?K_sfn4`(Z>Z*JV_1%W6&-A0M%ck8Zhh5Z zG}fwLqSzB9_kuJiVn_MD>a9k$sWQJLDZ|nCK(Y zJyOuj3{ZAySNpc{_-vjWvH(~-mxBs9`cl4+7_|2>wPsD$<;yA`CVWRxl=_`(x4dFD zkV05*lo78d(M+3tT@cDPVLGUE4fJ5q)E#LP)J(i#S4JU+_o2XSY@NE*!OCzpJxP`+y z%wLv8;JPq)V=NVSUH&xhDS}`3kMy)S-<$gFTf{Ky!fh*s{TYm)YYR2PC;m~1NXhv< zeveKEqZu)VuTMS88#7Fhlu8(;uB=~v+n>u}Gv@!i?U0HJiZtK0r^*09MaPqFb#M1B z$Y2*u*9|WILGqO^Bf$u`tpWru^d(>Et%3>yYG}AOo*ru~xJaB18_pMqmGBE_AtcsV ze>rA5G(9@kdBT^_^#w&Z4m*JS=TW;5HL0(A+h5tsOUWe-Yw6YAnv+u|{%A_}VJ>I0 z8Q^@OlFv_AWQX~et~G>BlFGJ=diLWZ`?$bt?Hh@8wfQC6+?+pEiwWPqKJB*fSc46$ zH;PKU7UgO~SFE(n*9j^OWm@#CO2gGQqdb}SfbU!7nC_@$4NP3$y_$1^r0aF)9z=<>#np*v1suB7#jxw)-&_Ui5$jFV3LPF;O`e-w znc5%8@+0yv@gdpf%PD!BN7dku7>!oye?K3(tG=5vg)9VT((jdpmqRoiGX8)=k_Pt> zjl~x-h8fE{FRkR8fBgQZu4?sr{gnAko*XP>q1b(4L-|+M3O~j5b^`i#q=M>>h<4uq z>p*O~pm=KOZ*4h@E-D)&Uq067`@IRgs76L+Mu$D^MKhkDarPYHC2rr6f*d-{b>2Tt z=_ISrzGo}?9ul5hh~+mANgGfFT?WK>cPw+&%$i-MEFCK+FFMk;I*V3-Uovc@IcZN7 zXJYd;0(JWrsytFS=IDiq(oUD!f!W|4GusC*2Za~5eWy4*2)10_?(3^tRr*_kg{)`dvXRZ5<=b25YTl6|Di{n`I)P7Q;GlVhjk0P8!cRuD(n5q-F$FEk_mV(FWi|7D3UO zYwy%$h>WuX_F8C!mU_Blt_Lz0Af%roV3Ld-C7c zIAb?r&XT+MgiD+7Wu;aqnQO5HsCHd$Pw{)NL;UaJp+mHu`60o+7Kd;Ymj~?Qw}izI zoT;QD1v37`fIp6qKmOelX9Sn!pWKK8UARu6S)hbWpA3%!) z=HsF(00v_93&8$1i<1_St84D(-9()Cvab@WPC)UJT>i&wN%h@BaaQ=H5?OgqhKu#U z+><5lf%DB9LQ=jkq;z+Z4)t8y8zmKH7N)o!Z>N=OZf_^GP&$0Uca`Ta zv3obW;~ZUqxk|^j5ivfkkft9;5akLv>6Uk;Mw)MUaH>J=$zd}A|79@9cz#42ew9QW z_pYitw8Y8pa%`7hLWy3E50I&2gi80uhFpl`B!8#*`15N0;Xf?&ui6-)0X+kUgG&Ic zf?*O=wpjfa5VflcOF#&-^smfVv(n@$tF*7Z(Z*_)j|i$-^@>2EwQOk zL|5T~aDWZ1-tfO6a2K`jnyR}K0pi%(8pyw2N6GKyZzmvIo}cRc?QT)w_Cfv5z*THW zv{B5iF7kn7txRWAkZi6jr@>ZZIB*8Tf#+&nZ$WOAf|0qgF$bZ5wtF#n{7rIhb}6w3 zp#c51Xb=T^-tsQoHt5iEvqJ?<=Ng{KnIBcUdh_@yUog&+x)8=hjVWkogs&ebJ zNPu195R_=p@T(-<5EN*(dK<-Z1h31KSfk0XO{q!hE7`MsY{hFQhZ0&nqqA>cez#gp zh{Sj+#-4znm2p zDHSwOr`ZuGH<#}@sz>OoFiEH$&NJ$opz%1eJ06ly56ja|a${Yj3<0wY4&_y30l zB+D6Cc!6mzC6&IO5X`E34km^T%Mcul#;g8E?qCWLU9BCkjus&eLXDej-rf_@c8BJ4 zxf34v<0E~e*!WWB{v*m_qlT_@Q1RBxx_f%QGFxv*TheBc!iQ1!Pjy9kcN8baF})Yl z8}tGp|2S)>z~*h|k4!B0g~lLQ=re1tJFpBq5Mm!_?Yq^jMYFls=ffFv!BUmFMfu2j z%(n0^vsb6!HPhzH^|0*W_0|P>vvn#LGiiaJ^p;AIUuK)FsFhRm>TmASPS7ZJY)O9a zXftJ#S-eMzPhpv<`j2n%&iQ})w2moyT_VK`A2BVHj+~A@cZ2lJkkd$h!y9h1(9N4dU`2cfsyz}Wfy&@i3VQ}s$~Rh)$M z<|7)~TSdf!#v?ebyWsz8!Mfv z->!Ecgy8&sr*#+9;f%c2;j<`~rHd5~K_9!`9D8B(cs8f!pqxR)&h!;G8Qj#(L_`}W znrwg)p#6t`AS=08zg`wkmqZAqYW`^#S$#Var@zzdyAzp>+DCr6%W$ET0TZ~j58z*X z-vCH4Q86;rIebF|5^06a#-i{CG&9MCH?FW@+4%y6CRR+aOHss6p8#G?9?$3Yzn zipQSc5JLi>K7cZLoWlDtT@&~W^371x<523b>Gg30N?2Kpy>NS1m61-<*=ZCPX0p+@ zFr6%PL9#!ls&k$|#1*UoM6gF4yV#hlgX_Q zMgCP4zjP?BR~zF>(r*&H-$lhi*L2^&Rq5~imMY`KlbE40*t_y9{$4Uy$HW&aeONF| zL)Mr1a^}Mkl9jK48nbT&FA^D>t+fgACk1la3V7k=<9%kb8^Xue!%QWyrj){K=p&zN@6pO%!quish%CpZ<{UB_7n}zXPEJ~E|h|CsKPHM@s7K?I-E)4 zbhr`USaH$sJ``BLj6k|JZJdg{&O5~N?eTz)7n|P@|1ZA2GAOPm`f_l$0KuK032wn% zf(H`Z3GVJru;2uD5ANp^Y*60o6Y)v;E&#Q?ojEtIK!W3%h4YN0zne|-skA1l7< zF2;kUJ#hs{V>`?+{cLEDiGx1V9{oDyS%SieDKfw#*fyfbp7pCqJ^7PQk?7sqiZh?( z2kwLWQj3D^F2_HCcA`#KS=wSB%VOH$nJ95B4%7({D?N*z$5hskeii-loDm)HBeEwL0;7>k#B4q}uvc_06+rkvFS?qRJ&t;|RQFDMdn|A#Hh$4By zlNvrkTjde|s5lUtmgMd}!3k%wJOtOm8=@=wUS#KkY*7S)$VVTuqw)Dc9~^$4x_8$5 zQrdzsTXxb66!n=t@RbAiCm-?s)SB#37lz zF^AjhIQTY~kkh5?Qw?9oJG1wR^Smc%xGKrcz6ep&^$rO?40i?4XqA9$U)VH03yXZ5 zk{FWZPHt2nC0-9+78dRAhqkR5txu|-79{KAKcuCmuQJG%T@XDw;^FRNSB`IiQ{GSXyM5{h3My(wGTA^ib~O zAX@Q<`z0Ofl!=%~uR4!u+7k~tPMMh+LeTRzFW8Xuu6p`4Vy)yaunwr|XcibkQ`Qr0 z+4?q#;B$Z<<=?Gr1v(l9|D z!y#xplsHN*1&UnoynmQZ<(?t@fDTT zK7^r;lXh@C%$I(jZoZ$z5m8XDwO`iw1Rco6#B^>wRy1uCuL^b2cbRk$TLy{Va)XPj46T{X#K%J{@x1t z7*t`7DD^c=6NxNFy#PA^AbqZ3p4BLe!%>oZV0_+Aj$IJ&kM$&EyhVhJ2L&0yksANF zsEpS425C$L{6(HJFH^LV&|ISMQ{~oJUK)X%txhpxhhFf?@o(RfjVUrisr9&rf3;zM z4WJ!zs$Y=5%4WyiYOv*NG^R=1JPG*ly`@v{J8p6NejwV^j;e<&!L7tn80z1&(g>Vo zmQq8Myb;O7_su;8ytjWj^V-V)E#M$s>J1&N75#a!_EK}qj5*hJfN)pqbG^XSev56c znp5;XwN<_9X4P}2Vb79L+4w;&GP4gC_cOWd>C$ky(LJJ19Hmn&%3s(ys8;ihR7)&0 z8|)zUcV4xD*N(` zKS=8JOq0!-W2nsy(MJ}aX=f_0J1SG=Vffoc?oWhrgIoNB3v6?x9@>i${+OfuKbXqe z3nx;l+LB3Ws?Wwn?2?(O`+Db9&u}fhN`Zs}Y&wa;+E1`3@NPQ07kbjtxgf^T|tW5sqGY@JSW9tQKy z&l)sYT5c;&)m>g?R&K6l)FAhjC*Z$C7g8=e3aFTQV^+jlM*Tf0%o0Nlg)(r;d>X+btA+DX1m!ccZyP5LS z^5~zijr%qncT>-jj43!m)h`m@3R;CL{1Qa0nB0zU?-uxJz_? za=*gKAV@T{hW^TU`*dcAykf8bay>do%eZ24#dbKK$bQElK|avNU{Rx8&oZ|y8-8*0 z)H+vh#H|27C~G=EA=X$z^KhGAqQB1d+k1Ze>hV$3ZWkvoF7q2z{@#^@jlW~5|Bb1+ z%99{@&PNr0p(3=%{MX1>!)M#gC$^5tmOGvHP`Q?T|K)dxBLP@47;o0@8nW%by6!3= z`gL$hSznBGotOS$Rqe-M1R!BiuX1@RCP)k5k~u5LCuw)LUr?@->RT;YHl0 zVm8>4=XVRy+^o6NqPE>j!#mY5-v>VYbuHiNYP2xiqs;VxmC%I~<95c#)N`teYZnHk z(!gZ9-)*ncm-8lh&3oS=)xwp!BY}84m$%%LmrqUpJzrkjvT}|Q-ErF7@S(1WhRmLa zTi;;jYF?I_E zOefu{7ugr*iCjr7oAYlu;!H3teD7kh{;7Ln@85GePH&<(|69wskp%O}N$8WOr(RxK zmRsw5Q~cTuk(S0fFPyV6sg!FQ`S%@k-#xMi%Dq3EINR91I{-5|tkWHCmVR;He>8uo zFN7j}Gf8K4G_ZPTM>Bh+D5gU82h{1(PYF#0S>-!%xG8&j*jjJ8&01X=NGIB}IkW4{ zSA^Ljk5KApbrOp{VXQxH&f#$<(|>XvHa6Y4W}qPz4iLqDc8<7_J50BJ;EnsyZkw0q zIr*1@)oVP>3#*Uc1A3u&wa<|0;kXy_H6|L5Sa9GsPtF%Z7Zu8fD(g{;(WYl|gys#- zAk-vB)OPp)0`Xrf?-Ho-wOx0`Cu;0lt`r_R0$n|C{BhQEdCxa0e&c*Eh(|)UX*UZN z_{Sba3c$1++C_;Zq=>*Nu$cOCp>utP!Erv<+m~gERo@jH^i*ZM_L1!(@wejKx1zw_ zHli=ghM0>kof0X2-v<@JMzmW?XVVD>>&weXv(~fC%dsjPQk7!L%q;y{K>bj9U7e4b z5r2C&ZE{txe^Uw$=?Z!x=MY!amv~oks7+8{NjU=1%i1$%0(CyNKf?Kx!7YZ93!btj z)xFdkt?YUwB`wyw+>hhi{Vq2?bquihM%$DAg}nSA4tX7sxE|!w#BWS*R7d=TNXQOV zR=!*mqRMCC`Cz)?q(#&s`s7OPb>L0z2>aZa$R^jnw+L2516%j%rKBm2H=m5Ic$>bT zxtCb4HY&yr2K2@^YxknUs>;Df&Gy)!_4c$CWg$Nm)E0BHm~1LDE@X65-l9KACl;Xp z23ku+(AhsSna7coiK?^Iog04^tYDd9;YRn}&^!|V)Cyo`4obSLHlqigU95`*meU*P z;Q$IhGLrp>MQ*ATDi`j7Zt4Q@IJRm7)2~-ZIT7Sid39| zm&LMN{bMMNf>FgL5iYCjEnmgXOXY=0CQ+J&cdZzKaF<9pf9@1P528P4iJFC^#G2s%X?AbYwmB zV#wFSG>iDsdbao>cs)N7G12{3`s8SgYnQp zV*~y27u&x+KpiZ;!vvpj|Kl+vCP=oqHetxk@?GOSw}`( zC(kA#*S*FAxN5;0H4k!Px=k&Ps36iIV@H-%k$zGza-Nb_mx>l79U{Ug!f*-=93yzV z8F5Lf6>op|+an_@?4ab@Pgb3`z_~-rTXRkIvK(NH;<1s}$DAK3ppuZ#yUpw6`nO51 z^x8V~a^=}6>T?&^$xYg4-o;JZU(R^nYk#T>GnANEUgSsa+GFAVX=h%Z`JM~rtY*$V zf(>q+ETuIW`|U~NI=uh(J^E=b#Odk0=~#?FbwhJBk=dFUTog!Ww(${j_i;eX4*90$5ry})>EOgJ zuhwXbU6r+cAy#yE12mbI>H3m%$esDC<&*4wofC0{QGon&={a?eQ?YUf-ZA&@txUZ& zG>gqIpA(Z**#iYyS3kv;C$Bde*$9108q=+SgWuiaGE4nqGF2&XJLCJO8+U}d2jJB; zCU}`9Pa#CKTVn*SvP|G3F}E3Z{CN?VqN!9#X3@Lh6D~bg`5kBqblfuN#*uq35)Jw^ z@n3MAz~-P#^$o6TwS+I$ZWEu@yV&u}w{3sZ>Ug-T>mZvb3$&p5R!~J5=XUblcbI|P z`+V7o9T+5WO97QCrQMsP8qngPREZFQO{53GZhKpDUku|uY_=`&=YUPYJQ?zib7$y~ zNt5>@j|Em?srD29dgG#FLpPnM-ETFqeakUbml7O8Xf$gR&?y>hs4NS0h&tmyGpn$J=LO$3y7=@6ne6~Dz`;~mzA zN14-EiB&lTu$y-6eAk&gI5Qrwd+_n0#v6>)m^riNl-jZKhRX)hFFoP$GWVve%M-;t z-1eXDG+iJ)UsC-G7nrO3P-FShXf2u};TQQzjvuR$Gg#eibAL%lpBgtVG>OglYHP04 zW-g|)cWq9fCay=4lSnDk6`T6>e+~yTq`Q%VDB)a+Qz{ih) zK)ED#o)G3XK5P1Wjtj$fJbECfsB_}&(Shk;k?qLJe8I!FTTrn7vtlkDqvx(n-Y1tl z$b5n3AJ)A$E1Ezh{3*9Rzx(thtE5YhXm8E&IR0#iX{1jJ4(oB@{=`U(&H-eX-pD33 z3hGqH2=zKJ8+;z}f>B;MI1c_)h=2XE_L-8_NLsFlDD;@phaD0*tJ(-_83+HKFo*sxa?#uyenugiZnTFYx?96Li!}#Px8TItLrm)3Uu9Xc-XinlBsW%>I&jsu zeoqZZ4NB-oym>utC!D9?yPmGVd&ulwvPz~Z#Z+ysXqncZIMJFSxaacPm%Hv>lJ8Mw zk!I6?&?DeuAdENl_Qnt+5eIwtKj~r_=6+a1GiT4RlK&6o3MLl?)2jR*%9XxLxyAp5 zS-rB){875dzpcJZnQJJ!&rE zq|oNeCzD3f)6?VHNWPo*?QZYBoQC9Ore?DM3$o`G=f|4RFQP$+MjotC;c44`CbzgVPNmBfs`8@cD0c zzOIDYroTUcTH&pvbBtYDB0|I0uNKt!k%0kh6Aj~x@l=+F+5s@PkI4?PgDS4}_6B^LP-0mS@uQ_>91| z{Qapl#n4DT&%Y7%1tV9avV&xhcu)nPU-wg!Fk$FWyeT=VltN0+I>dW0!g^Eve3+@? ziqFK0yV$wL6_W=5dLZs%0a@0T55_zo44~NXc!=tQWC064zx5c0qz1p_o>JZ^`P~ef zr&)T$9E)TC+a0wc{HYwCQNkIYbbZY5M6wo=k9=y21P9Bkb@Yyp3uTW5)43xB%Fkxhj^nZ zz~?Tnsm}*21pSk$VJGTamQ!TU8BnfX!7l#_t8IFp<2y??ddjNv8n63$jAl_xXH082 zaeK&%yK--^$4^CTOQtmXo?rlovFFzS&Q^f~7=^*}SoyiEdLBfMbW+$t=A$t)vJyi;r%z-HjJ|3BS>{1;jQt&`+G z4=_fidyA&N*-M*Y_C=Axl==eD?}(e6#otG9o6MP3bjbaqEZ|SC#}{am8LASJE;J1F z!Zb`m11mn@twEVNd!1`KA@#f9R>-&WKl9`%FFTO1)W@BT#>%Ze-2Mby08ur=9@8FB z#`c&T9J0FVH9i%LDwd<-VB|)YxPs$6d68Rri66!%R*XOJ!k=OnAk^hbGj(N{DRpB) zyDw+J=$G;M`oV)(m{9Jv*HEa{4Ih~#lm)=1{RJH(`p(fB?_8(}Pq8aQkJ2uKOKkmp z;ioJ?snQWBLNjF0JAiO-tv@1rD(|ZosEbT@za`PXq?cUDawi`xC`KzenKu?1{F;sV z;jRuIAMPRJuP1VpoRd2IQl%KlJjF_NN(U7+j+x!+8?{MpCvS%L9l$L3y|Mviw;pcI4H$ zCMMY<4nMR;s}FbwAg0A&@@M6p8;vzjQQ_vE<~TnG|Ccg+czcW5re=iy)KZ9{N(Imf{tumI3_owjnk?Ocxs(v$8z_1wQGFaDR)6Y;>)I>TWSTE)oU%hbAG ze_A<{(B9VO7b1T!dyr8Wq$gNbm3H1UmsVu`J*oP2-m2@FpDfS47Xj})v%Qg(Qi3wU z70wAF_;sW9^kM$1A#7-U09ukL7a?>YR8IiqCgxk`Zgzw~Ml;j)unAO;iCjJYW;~+M zZk}d*FAM>XnT7kZoitsS_jXCRKy$JRBT&+JIB=?4@>BvlQ3FmKCD1)j#jy0<1!`ax zR<2D;nYr>%{b_9b%I&_?3g_Dgu&{KbpG1tTy8p@_V)udYa={}+ADX1psaoTJk;8o} zbVXd{Y{7@F%=i1KpYZ{1SIZChre1sxW)wOM1bkE|v`}-qrVIP5y6bV2ddP8ak>!I& z7%JxqQ`MirF&!e~MMlwxfi>AWQTpld-C<}PPn{}B1KryEE3t|WN2e*HOHejy5PKEP zp1X3MVo~1FC6@9g$no{cpvw0uu-<-p0d!K0OY@VoNuGdL&Y$!yuLrs0nLNE>sy6*M zo~1z@o60U@Pry!O>=;)A8-Ptms#KEB)p{rvp%sGW(Ibjt`{u!(xn&pxdj|$f>5TZx z@pAsjwrWt8#B~z{@<;Er2ja*|yFau%U8@`Zk+u?kJH~dbN#GWu84~+t3Y1nRYd5~! z6F|82c0FbFy8Q{}o4&f}0jg5hlLtZE8Y5B`(6&3A+t4F1z#n9rW}%8|t_;v`O5~B< z-Nz!`5on&e9EgYD`*&-An8;0bqE)rHq7F zq^P4gS2ku~@JDJ^_`aKa_QD_Oe7IS?wqWG0yV!~2?3V<8e?jTUU+RJmVoDts4g|4V zjC~-jY5})0(qph<^T1G*-6S@qHXt1g4eOE(q~yiT3Ot=q-RY*k`rz@Fatu71`%D@YLg#VJPwI3qhZPevL9KZcrT=KOA@nFkA(xfIx!{!AR=}? zCnjO2*UtT8d+bAPiUEM?e5N3XJs}D7jCc|yw|1Fj+UHF2Q!plH{w4gcRk=5qT+k*n z#&YGKba;hVdCDi<%=)WL*MWYjU9*TyFxBwiyg=-z8V`pJ0av)`wt9iJj{Y8EWQsD! z_|`Xg1o08lz7MCnhgS4AwMzD{xduXYS8L$ec54dBu*bH-HWT#HEhxbBTak!;fYp|x zQPRF?k?Kru@rk#2&@aS3nbWbba+R$z1ub(^u&4ccSh$fO(WX!ft#o9(5V{VmUw0L$`o2ulL`<_6mE| zf4xQxA0)DABD7^bmHNLpG8K{rcN>LUfqgGgOty@qn}bN?@vSj}6qo2PiX&=&aLoXl ze;^q4-h8~}yOfPQEA-M7`7-)TZnX`Kse&TB5U(ivC$2xc{xfdSI(v|~PrPCLLs4%v zQn#;1f$xNmn0Q}EvxpF%MnMmDj;)wtqsfX`OZp7Nu_;2 z{%{mR2AGXPvvV%DDkJP}e*Fnf+>~GIR2sdn9JIqFhJ3xQYLA%Tc?dKq(@z$S|H5zY z@wgq~ij$K7ZN?OT*s0AwO;&eScTqP#K!Ba+Ag5e0{MmlZ&+dVXlmXNavt@kq~hXdj3kulWXbu-e%p7>LGKlhHtQ*A zzK&ja;}rQlDQQv#bRmtn70A7|%n*E+_(hw3+ip5!FDar-Hddvad)N~D#wPZTn?+eB}Ng!Lus?=MDpyY{jy3W*`?%Pj?mACc3!7e zd09KzS57l`=6)A*uiO`>pv2ha&t;ItJvvE0*~h|;abowIZ{>pfJm8Xa-2Pc#!TRf0 zN1Z{%WaM76)ZmP}`Wh?U+uqRKhkt;{P5=Dbrd^A7gp>r+{32ja@Kg_OKT{q@Y{P4N zBmtrC`k67k?|VBBMw^h^^v&sCUo^zgGXbtZZM(&%`-R1iW9#o92T7mxUs*M&-1!3M zLLr;?#Wz9)ssVe^nRkZQ$X0+S&m@24#`$@2N6yD4a?qu%<8Xwh9~BK0FN#oY=?#-q z^zO4Xdb9z9DsI}UJM4F?|6uOHp=4)^{;}xP|K*7L{r%ZmNUlNF9B}KWv?JB%5sQVY z4Y8UPIZLdJmKBGCpXTlyw!bI~wlJ11{RgeZQBpn1UM!#1&LzolsP0X)5NoJBPlf*s zyP+!9HuzLQinhjakyy?I7fkj5>^*0&^AThR@({N$k^T$%sWBs0Rj6yM7HFY5ZV|zG zwr$ihBj(}-1~?8Lpa3#Tgl@h$TG@2`Y8uY@1oe*LHz##%Vyttffq>hz}$ zO0i_Xdu}OVrWd@=KktS~icXA=wEAM%SQ<)OA_b6-L7{fR@8tr4qP0f6U)tekG;Zhn z-@e)Gjn1#O_Wn3da=Jpvw;OO<%;O>(5xrrMLJ!?sCaYxz9ei^1Gg|O)b2Lys+}4@{ zX4JMQslg?xp+^N~8NL)gjWg9`g=imGc~YRJ6WR#k8N%V{>Xg%c{TlkWNHjhUCs!6Y zU3KBzu2pb+C&yt53TL7S#Qo~U-`7JWRduC!3#zUV5aA6{{C=8cRcsau449nxasx@r zi(5LlqzNPCjhGyYq*M9oWg9L-!Mw6UPbv3s?|!t^koj6ISt!(ty)5ti+5JRWV(y5U zshk4mlP)!9XNGDEW)h!x9}C7vd`-Ne%uJ~hoi=-lvgo^HM;`$~kmd8j8d92;>i6L? z0sv7h&8UXDD0`0obJmt&WVlgoEH3)&6Nx+2QjPP#_^~=)YCBq}g68(2!0aH)O7HdO zu{;?%hetDWjuX!p26BZDQo-J_tSHZH@LFumxM%B6l!V(jrNaLg3qWLtxuMYuQBCsFp2mK6I3KbIxeRkGWgqB1rAd=P2&kBC@`csccnJ}-{JDNypbcu$SzUd;@h zMMnjpLrtYr|D)^b0b98kVo5!hTea7S7a<;v*O}k7;hF25NOtLPyq3g2E~*7F)weXn zpkg?`<4<2_)s;PF!3T-CIN zRDOUPaT-YdT#X#RZ6?|BI@J1Glh$<$I+t=j#sWMM?>GI9dR@#$lQG zIV|1LS(8kFoCb0BuZ_>SL>h#K4c;ZsY_ck|FeH_GsGQbiV@)Rj3~ecHDG9m$wpGn8 z5{1jf@*ozMMZ_oy-aQlcEo>#~1d09 zGY~UY@_DS%{`D^$UOXJcpKLbm{ur#6zsHnRmxeO?^vfy_Z!ZxM5W5$qX&0cUy!it1 zyA2nD%15SZsy+8g_n!&xMUD1=C{`fzd9$6i2>0)YvX+a$IViY}zz?7JLQb;(uvh0N zz5b>}jQ4tWIKkI7fBn7+?F#+(2qf=q9P$!i+(yb8L%luHIG69T3s{ofycrO~lf;tT z_IfC|@`%~Gx=@~&ZP>Rq$0tlKD=^*FB#5t(Q!qdf;mbF7%klnLt=MBc-{?x#(PGc* zX>+mF6HD1z*@f3t#1pds!(g zVfJQefho%c(=*onwXu;y!BsrV-$ms~%E%zM0n5RA)3aH&=i5?c|K?v6Oj4oXY=_1WEAW{48DE?q+>Un@4(Ra)8?GJ_M_)@3NsunWFuS*EPKxc@&jgo9L)%ayV( zp)C`${>5PY(D>Lgj9MUDSV<_dzC5c_I-WbpZgbUSpc<402(qVpT!B`ttu>j$NqQCe zLxiO#rkt53V;(M6LzX_g6&ivkg`pobV}X}XdK|B37zHv#spI`z0?uh<_;>E3 zN}Pd>hm(o8had6_=aOfgo}?FZp1jYz4mj<+_ElKATIdXpDD-B#&)^7@MadEyd@>-% z-lj8hN5B?Tny$I_CoFjZRpu@+B=k!P2k3TU*DGFfe?kFa^st$qhs{r4A6y;(O%i^O13Yf9aDA4%{Z~f^$4xXQ zZNt~;{#>$fkE0yKgZrtH_uRN!s1e70=r0S$UG&?)P)sc!aHa9wE$t5thftDzsa|nK z#G*lE;-%9+>o%*yqEzx=JaMWe0E?_05o@mq5SdC|DY)bA#} zZ}on1EyxGcn|hXU20vb7Y}fW%YpM)wH(Zs1P@`12ERbYCL?0s0@sBn-ILV>bv~wAd z@7g0C+j1{YwME>O&ZSkTG*6y8s6S$&r{%!hl6?$MUQ@ zX7ZasUp!_e$-;P!$?26_a-nAqpO;JRSWx5YV96L{P^^oA&0<+;j zT-t}D&E`1tAmq9|t!x`UlUOsBQ^r*j1IGNg@e#Mmys4QUGT1rtljJ^Sa*r2Xss3wh zai%g#Z?4lnaEjeM;^n3mcV{y#4!VVEKO^-F7eUcZlJDH@JjDw4Wjt!4-YrSYNGTcqo56!5!g-z8sAoN<6UZSIQ^YeF~^cn z)eRSJw7tsaH4YPJxn4i~#<&r=GWJq;`bV$awSkJpC3MT+l*Z>}s{f)!RFQJSRRDBQ&kM6#m9%t@G9-pY~W{KmI&Jp=a5O4gNc6D-h)uDTMS^P z6t|GAz$vRHB#hL5d8o3089H?rqsL9EksHR7tIHp{p&yg+3tLw4FY=p60uL1*GM-28 zemqL57Z#zNUy7K$k}wf-!W~2yME<2?-?Jb|B8|lMzL!`e4=Q}=B}NY-m$*Cyr5l=n z2OD;)!I5B(-<5FbUxQ8j`auzU?`E^Tfw-%3bLI83U*vSR0y5M;XBil;fn-#TN4a|o zD_*e8@n3wGVBrS#*_KB*O-f&pFZVB)ds5WVtB4ZZ1+!m9sJ{xL`)HMawjlGV<$V7m z3rI0@mcg0e{u^2R?~7f4VTjdYE+&#Vo3Y)oJ@#u@h`I@P&s5jA#8S^0q2N_u)K*UW z%b0ATzfh(Tlx+_{gq0tRLR4v??!MUHIGIUWukG9x0~4K7cImm+#qki-LkW54)oWlk~p)lh&WOnKgw zjdo)dgq=7G7@h6C7qlX46O^&|rMUo{@-Jv%mucp}|i7&rGE4x-@E^3Oi| zRvBd$)icgeE5qq#N+eP&ZCXp1fJ5sc_8`k_I2xC*9Cj#n^<{4bIdr7n>`rgxvD0;~ z(;o?$?0R!z_I^<&^3n$g&ghA)Ej$eErIvP(5VjA9fmFK=`~ni=uc1w@LAz9gyd8z4 zRq7$H>_wnQTQ%>=ACakX9QJRoB4o2qFdc)J&5vFSba<|<)ClLcqAq2GTtQonDE4nX zm@?0~@w*5;kygV3V8Mdr&NR`W+#e5g(hfVn^qA}eJipHfw$fanPzyk*ImP&SiD}EM za^K{@wfP=g@`3z0ms8MWBfb&8Cw-;PCuG;GIxCX`?@x2f5%=zub;6H z-v%@$g{-crjZ`?p`*ls}LM& z$e=ovaJtwc;3rkw>s{XOP@|zC4EDjf-Yy)PpHyC1R9`@rmS~uDjQ|_R-cRawpw`I{ z+(8ulqx>4N*W#4CTCp7hZly>QM7bBsF4(^mYx1vn(86~6OX`b#z=Vf!m{jCWu(g&^ zyj!9|I&LVYSQc=Vlsp@!j}QRwy3+FkCB|;f znRx+Jj$-VXl2m<817k&TQl-T5E4YwwBCt@L_{ekZEga$+v-kC?I5>DWnwo&(i4o6_3rhPM@!!lHo25{#E{us?1_pkm5I|a>8qGIm=Sh!;H@dq#9;Rm%zu2BPo045m4&qdJ2P}a+TOYP5f+yF5Y97?^ z$%D4H-eJP>IHr#_nu2W*fKrj&7-xEc_bEMl^JbU$TNt?bg)qF^9<yuO$C@?#? z(z=tE1#pjCVvpVqPnOFn+bc)!=R*Fr>lKQ0NKi_?7=#KQF^q)G^ zR#n71UkQ#YPqK6p|1i(HPP_ke|MoQL&mdxe*J}`H@uURx@cHT|ajbZbuSpqyd;WCI zjMJH&W6wTw3#-ciR`EL5>B|iR%A`i+919O#+K<*a9<$1rPQRVSTlj})z_q;q;Ozs- z)Dw9ZDFV^4p`ds*Q4=n1`3*_u!|`7(ISk&3Z8P{<0~xWfpfu}%&1Cmi+7j@0%jcSBFz6S zBb$1vTF!1j>}L0(Ls<2B=kb0Qg%KE_A{zD-da-F(;rQ)~@3zhz%Gj|)xmXY7%8B`3 z`(Yz(q4zSkwKsh_R@wgC9eiw~_lK`RB96l0{gMe&GwANAz2WYFd*ej=fZ%lGU)nlI zaqZxM$;Zu5CZJ`*y7k#4dd|AFa1Gc>(hT9_!kQej=q_KyM>k>+?Y5x5@ssiVpLJ&?=s3j`s|7wl^JUZDk8v2)|(+i7*H(*}EScvJHs!@4drCuaGn2FkWs( zwSZmi1fTT~ITHYI&<+VMu!GWPKaRzei(PA|FdP&O>q{q-{xcyh#cS_(bmOAsO6zcS zIX*EKEFp4jhufI0kO#)JQ@gqB8U#d!enXcd*m-%Q7vgvk^V^^5$YBl8@60Zdma8yCN zC@5Z~KW7zIZjnIq?rfjiU0v?_(n;)|?7T#%{c9SzAVjCC=HfKM+6AVga@#cI1ewTq zysU^3W@{LdzRuTZJ80@w@?okojos}&%jQ$jzJ@im|HU<6!I>yF0`?H2 zKUH3|*R)Nv%jfhfXjFXc6jmv z=+h*X-Vsk=X3%%~%)+4By8DpS6|^y?;8;aGJsHhZk0!(v);|~qZW!;jXOa$cyFy7x{!-hy*4S$DIxDsg6V`ep1_&@YTk1@dVyDe#AKdk_xmd`jxkz9x=!NpkzEAZ zw;)g8Gj_?xxiZ7>LiN2w9N1E3sIiI1Nn3`i#@SohQxY>bniICWHg~g4h!SH($C{z& zprkX;xVIpqP#bd`s+v~~h|EczefJ*fFYtYohIYLn=4K)< zw=0N0dH7Kt3&rDm*^uI%#;;^%()yQ5E;cf|K%yGD({Dw1WDKeT{~LdSR{DXVvxdkg zGt=?!jZ;TQL9H1;lEC*~aN^JmDs;3YmXkPGZcn>3nJVe8@mq*QrHwg|Z{Qe>fFI>rY#JnSv60+_(dop5UmSFJLHEMKsx2Fzlesc6^HV(6AAd zK5~pxa~@>1q{@D!i6A;WKe!#DQxseRlhkN%2E+^8`|XoD_zvVj>}iZ8v{9%zYW*Wn zCq7Z&B@0H|oaURVXq{_uq^-1nYbiL2{T9CbEs1iQcrf(y2W2Vpuy=b_4fd*rd+qJ zsQ`n-jE6>Znq>JefeY7-{0X#_Nu2Uj)lRJOk4PRUVsNb3kpho7&&LH;(k|fyLh>3q z>pj;-fx}OuZy^`eWC=$Xk}bHbwbBq#4bD4OpxjijoJ-C!Ov>RBeYS^C)%Y$iFwOv+ z2Sd-5sjmafF_3tR)HtmJl{#O`m?(Ku@ka#l!nq{^Em6L|T|Ufq*m3%JIF{t3QIkI?Elg z949=|8uLlF#pw=$e-Sy@pOW^!??DFe%+y5fkp;iANlzQc+m{9ud-A70HJVsMq_bW; zdN1zjBnqW0mb65F=X!#xhS!M+bMd$kLqLgP*HI@wdZE~SVlTKZXrI0Z9rM2;?oD?g z7Lxu@T--_(dE6c~k^pzs(xow#m-oK{dr){$X7+0u@8+AWu~J|6S==0TV)P4(o!{L* zFHs#ka}_~?ZF^I3Kzl_(wQ^lWB<<3}0i&iTT9=Ydeu(;+iubBQLOxwYR2wenFhqt% zFa7brSIz&(TCKahc_-Mh020fXvQrT+fU(E^{F}KcX$HTk2Q177g&^;vYXmlJK&F!T zgyPOSxK)7N*2SOiOGMyyOu+af9%Dl&`AvyY5wY@b9@D;@hoL~FcrM6a;AV83^6BO+ zoJ#E0?f%<=-xb-BqvOb5;8uvhKerSMz)S1N!&?rx-!b3!RM+kiCc5`54!bB|Ge;Zz z?05*PZe`SiD|Gm}3C%g9%G?jK^V!R&!VW+XGJKSB;fQJ!o!u@&YW?Vqte5vnxUw1% z9jYQzRfXIR+jv~|pd#101JE6N=^<6pTvu>)EfMCdXndv4`fu^Q)JC(u9d!pl9El$= z?7RObOslJBw$Y!?gWU(MDmKFoC*O6CrKyXb3L?2MU^&aT zN%aNWhqnJP)Vem;zB1AN)~@VFM1(3s-`U#Tjek>j>gSziE6jg#c@g^km%pt$-{eJk zo#6W-AQaY?+#XM;x%`%4a5B#oC1Z?hTI?FrqHbS*>s_8oh|bktNX2{2VNm(kC zh~1r`*RgGxy0kNrbCVeV7TZ<{9PXJsjk6@~2C|8Cw|vl}w%8(gFyxfQSBvQqF}%2X z_u86AsD$(Y{{jH$FpvS-08k*|16MA$*v@la*8K}u=QmLdz{KAFQ`@*F<#P_hSA3j1CGcsde>(tLO-` zjMR>>IG*9W)}bE6pGe5K;PL3o*;GE=9AjbgSjuGVRTA@x-*DVkdw2<2!E)+aLLe5L93>|>U@bb2shs;wb z&rx<0lCu45INilLW+G#Zz!-&|J3x+q+W2B%W8Bkqoz zp1TPeP`Mfm%M)t33TKwbj)pgfl4zrjA?OO!UJC3qBxFxz@RJy zu0;x5*vnXkSM3!&N)a-Uh@Ze)H;KazdYA{mM)dkS88bQse#fND8GXi}TbXQ$5Fg8| z{;|js8Z@C(Ocle8+hiL7=9souBayv#=19{k_m%tYH~)ILFog9a-wi(-_deh2a=jsH@QFc^ekoey3qFaK`13qbHBng7wG8@%x^tg3F;j@$>*75E^@Tc& z@`sgTa(vKyZZl;%OYZn_PZvT?ubwiAVsswLb3!~Nzk=X6dn2*oC%}5^@(0l=bEJ<) zY7eQP#WS~QS9wIkd%rIySc0#2{t#IauyGO>U<5j}tK%u4-2e4pq)j6ruswzMAd>(c zR1U-qr_EwpHe91dr}MbtX1z@yV=AjCz+&o79gY_;x0Tis+_4&}AmhX>o`CYvWA(*D zcFM&v2!MB@e>?wlgxAd|j;ctC!j^l=q|hYn*M@qHsVOdSX4;5?$`jun@%dLZA!LZG z^9%jKMXLa(8yHlE>yHQF?x`&V!h7b@C1{i8!fcJhr>-T;C znKkQPbLKv0pWgeru6=rSr8x=xU~!HXJ*l%iO3%Yg#t5Q9GxC`(Z&CK!eNbbD@QZDG zv38r9q!d3*KNg}#D(?mg72awqAkzm(xL&ICkZ^IUjJ2Z|-7Z=qFKkkPxcoZfZ0J*U z*!wqLht8pmrK64&Kw|Mr7bn}og=vZ?XgRJ*vc#`R;uK`vgebWy`gt6~zn^0bWTUkg z<9xLw8?7w}fwQ?>5?(GPS<8RHrrvY=KD0?zT`_<=`aB|~#jeB|^|0rv=P`0PK85vv zz5oqwfxQ+n7K@MKnXL-$&z8(ZcfP(B$5>GN;L2v|X8y@)^ttbpRTmv}u~K*-r1-wu zesap<_<@0!KBW1eg2^*lv^Vn2;)?nmT3-{=WbLPH%T)NqmLUTKfA-FG%}0fWR$vPs zCNImsy9!~w&NsqQ*wOVoJ*$*CdGUvaRC0OP0_EbClX|L>(`nEXT^#v4$iK%662^a9 zI>8Ewp8jUE^UrirdY@+4F2~luj#FglJ@iRq7$jG5A{oQw@A}kV)G218Y(n(__9-Uw z*d7Po-jXtA-#vdcWD1T)Voo^8npFE*v)xN1kUNZFB;S+bP)C-L7v7y%e@t6+{lyf0 z;AJH3^ff)^(C$YEOpReJRgt&%EVEDCL>X?bdlMcjU>8ys?{De9y|f75Fv~sY4%a1} z{6n3RwqHISeutUyowKOcLgLb`9#ze^ty~O*(&njvg9L@-Qdd}y%$k`MUg`V5yH1~) z#&WbWGGc{;EC=Zwhfqo9} zN`&rJrHs|ECr7ODd7^I!I1?FuQ2YC>`woMfSZ2uqo;o(d@=g{nCecitQFbG+2yOX` z8)mT5Wf#WQub?SXR)?@V>zH*PYdBNb;_%;2A@I2>Q2oUqnQ@(kNkhpQHucIWN)>%hHztlyNH!C9!d zgCmCBkgZ(tr?zQHo06uMx8r=M)64xUVs+#~#EmCAy7VeDE$)|wATsb&uAZ*U;vt-d zJE@&y>am0NAe2p^*AG+SG!S%RNy3WtN-vd%xq2!1e9d29^6D3pkg?1a39I5mDSVu# zYXy~3g>|LE_A*b$=WNaU_YY$3+;|C&!vLP6shS6J*^Kl&-BBLdtY&0qm#G<8m^7CpAI@| zS-JQ(w=`yxN#c9grRe&9eKxM#|&;wuwXV3z4HK?B7vv3nk*=b)lD56l&E zy5D-Q`4C5MzC4s9j~(3zr={JgjxA0WxV8 z+A?8g{)%la?zBOt=#$atUeB`{hX@sdYF=_JG1f@?f2VQ^Tt+UDUviW~c*O714jNeO z%)-V8rV)&I;SwHu&+OObldkn3Y9R(asjq})TO{s@8{F=dz9B{0x>vMD+73bUQ~6cVDz0>?nb{s?t8ga)Xo&A7;MZB z%lI{R$mK{WkAvu;TTbzY)l#P7yw$)y#$ZV|>d@>UO+TCpcK&TU&d|1fJ1s_a-2wzR z3qkW)LA4$inl#xKJG&pU7FKGtt36i_+-Vyz4b%5=FUzVw$@{&j*9D(m`8$oUwqNiO z3dcDZP^u(>Utp&F9;iSQ%ln&Vd8o5nX;4nwzPmK94k0zSl#;NDpv>)YBnf_HK`9$> z-*lfgySpNI9s?FF@!O`;^h(RV_ReCB+rs= zKGG=b5iv{u)7~4)lijLw%L$d}KGgZ}66bigv_Zp2FUY$4$63eLhZgOW90qa0f^IGRi4=!L(nf>_bW1<`gFJ@Y7($XXrdFs;MbWJ#m-$|mnCpNqP=5dwAB#asc z8#C&b!^f3d=bacXN6H&UA@<_%bM%L;J{+_U?ca$t3ylc*}gFKSTtWj@?rcMG53X!tsjpAu~o z0%X+4WqdB$nyjbd7hd;)Q?lE_Pq%D@F2+BEua|R7gzI^6wQDH(;j{OC+8rHuAD;1K z_q_QXe35q>Sqv_{=p!JqJN94_QxNteGP; z7(Ift@zBe==T56`xXK?aT>{nZ^72V)9H=cfFx|StKQa66Ep4h6ynH{#xv`mK%|@0O zqFlc8XZjCmLso&exTwq>k!wlEWEmRTK~sLe!4#WTPvIf!`6hz4>;091Hu3vA1O;0< zY$~D6&Gy_zh~=6(*J|$xw4M94T8a$p2{A#qt@hpM*OB^D;ial&mraq3JO$M{JC7Yb zf!_IPNA)wW1|AJ}cyrAdB33o-x0ui7W2&5fjY3bzLmYE|In3(!I*CiGki*{GRLJKDnk?-~4~W@=bQ zkb8H`^gfINFCfT!UK4#=x|bNyvi=qD6A*b&AmYM=Z5mU_f*r@BjegW!D39?B+QTwa z5j0ZUgfE{bSTWTYRL`Zwu?b*E4{(j^^rf%SlRJxVr!YL3+tJ+5DDa<_3XO0)f0a~c z|9pePV>r6hr12P@&){$RCEAYo#XQ+wsL=9f$Y*XvAA!8PVD>tLS8|Yk>0TWkD5$-Eye_^<8+^VGtc1o5kFA71#*qMzjiZMy6k(MTDzYlyw9L0!k_EB53vp1Ord(u>wiVd#r*4F(omrSP-@ z``b-=Kj(mI;0|iDU@;?^5U|TkJCO*-ofhI>@9^VdawfQ5bjhGD?(;3(|0J@n2rC5wt zuyX0{xPEW-Iv;XCu0>qQ-@?+01NjUty*JEz8*|m0fv+2y$;1B7YqJNdnFCn zfYol#U0@T+M^YpT_1n;9@OK5O}tw%$yRScl(2hr4{o zXY_qCF(&nPpxG3mYQkT3`Z{IPDb5=*Y3!YH@ER?Za991Gqo1O+<7!^+;5+#ohKmy-Sb(a=xZdYJ z+)C-24{zxGo?4f^{d8N)dYz2YedWiMSHo2)oL@*0I(~heG5Ie|mNaXz0sQ75ZqhbG zY-2V&kpdt&oxk;}%OUP=n{Vs+UM!fdogLBaz)>8q`a)U5iTqwkk0C}Pg_gwVhD(IJ zURLHXyWr9NN|dfmC&^Xc#p~4KLYIC~go4?H^Jr|U!k_UU;5cAo8Xy4Hik)J1^;Y4n z-d6h9lD!|TW(XX6&Gy#axW-G1UX{C8v1B3wGytpIJ0*v{d&dxWD^#zt`!IrdN+~!W zW}|^Q5U1wOD`Pb=A^O}s3(|ni3|8E%Yq}B25yrrnG==Gb)NrB_b1Xh zw4x`)o-2qwn6{s}v=x@4omoQj*ht4g#haiGbkiVbKl4@@oCaq4H%s*5H-v8}kJ=9_ zi2|-W2zn{CP|gp3D)&d9hJt{Q{`wH6n{z9{IhGvldYl9j^)X zP6LY{fOc1`Ef1oYPg2_X=UVIqLH}8;8ZudhxU9mztZL;F8RO`4q$!Y1Cl7ey2nmb) zwb&YVT{L5Jy=S|835U#w^ir?LLvO_z6CN_O@g-$!Ax~dB&H01xqKFCw$tE zqwQ+t7=dZzH3bp3bI~($TZ|DIrP9VE@*6*T92Ok`;)ROuX7 zP9X5_O-LE=`I0nOxJMV8!S<$qi_IG!a+JY!a7{0p$C zY69=ryjIlYc1D9f|0R!vbl~n5!*~ll!Tu!=W&%EkVCHi(C(*)Q zU!GRfu_OY&4OtCOEQ1!hE;;@(^WwwfRt=liT_)Jai2cG6&^=%y+Q*Q<<4u{RVa^)S z)a~VOH9X`V$wRgQrdXj73nl9#2Td@P&`f^R+l~|Ek-C|O7LCdid6x0Jp6srlyhps0 z?cCvJm0ZxW=7)Z(aqTOOYml#Hy{YNM{I-*Lf6=iAIolc*A>N338&pp4Ymr@Mt=m_^ z@F75y{XLj!oSmMC4-$MLM7!zuM0%&Qu97ITI)S0q=Ia z6M+L#*EX1~4+o0z1z>EzWjVZLqzwjDUy%v3Mx_g%{0@I*TasH3sSUx_Wwx?VQv zDx&VS&<%-~N4W$xJuYv0W3~pKfn)jJG^a76pH7xp`#pm1MYPY^JJ*^X9^k%24-QKK z2@mVhp>vb66)s+z;DqUb*$iWMIA7E4gMXR*wpXG*0_HlDp3A86tx?=0G5zXtAL^n; zHql<~>u-Q=lMSoVA%3l7FO2_M8%j}actJ8}zsfVKv~oLUDuW#)d$@LEXFiYLR`d`@ z{QRBnKS7szFevsa)y2{Lqg1@p0*rB^vL6Dg9sHIuED4gbu6ZxqFY-F#<4L@H@#!1V z4+tq5X>lR*CdOdTpW`fB;lVEKrT7&>q~&l@m0gc~qc;|H{E~Hz{`D1(j~-n4>Q&Pzn_^_!|JEwb|1Z_WhZ<3?D8+S|OZBMTOf~(%@pjGk3hH?(^~Gr{3zu;rQ*OuiAa;Nug}_LHK=2TeBX*dDMGJ$9&#VurB=UArEQDlX|
c?nbhtNx`9lI1AMfN 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 VDGK81s$yEDDwTjI|9^^t{|}rsLWKYT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..358cb25e205d53a8d265be38fab49174932119e2 GIT binary patch literal 9587 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

|9K|Um!ATP+<9r!*&mAKX4!(Xx@@rnbJF#+y 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 dNkl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +