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", "playLobby", "installLauncherUpdate", "uninstall" }; readonly HashSet running = new(); static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby" }; 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; Lang.Set(settings.Language); LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet" + (Api.Dev ? " (-dev)" : "") + ", Website " + Api.Base); if (Api.Base != Api.BaseUrl) Text += " (DEV)"; 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(Lang.T("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 = Lang.T(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 = Lang.T(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, language = Lang.Current, dev = Api.Dev || Api.Base != Api.BaseUrl }; // --- 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 "playLobby": // Ersteller: SPIELEN schaltet den Lobby-Server mit ein (der Relay faehrt ihn beim ersten Spieler hoch) if (BoolArg(args, "startServer") == true) await Site("POST", "/api/lobby/start"); return await Launch(lobby: true); // --- Freunde und Lobby (Website, mit dem Konto-Token) case "friends": return await Site("GET", "/api/friends"); case "friendRequest": return await Site("POST", "/api/friends/request", new { username = Arg(args, "username").Trim() }); case "friendAccept": return await Site("POST", "/api/friends/accept", new { id = Arg(args, "id") }); case "friendRemove": return await Site("POST", "/api/friends/remove", new { id = Arg(args, "id") }); case "lobby": return await Site("GET", "/api/lobby"); case "lobbyCreate": return await Site("POST", "/api/lobby/create"); case "lobbyInvite": return await Site("POST", "/api/lobby/invite", new { accountId = Arg(args, "accountId") }); case "lobbyJoin": return await Site("POST", "/api/lobby/join", new { lobbyId = Arg(args, "lobbyId") }); case "lobbyDecline": return await Site("POST", "/api/lobby/decline", new { lobbyId = Arg(args, "lobbyId") }); case "lobbyLeave": return await Site("POST", "/api/lobby/leave"); case "lobbyKick": return await Site("POST", "/api/lobby/kick", new { accountId = Arg(args, "accountId") }); case "lobbyStart": return await Site("POST", "/api/lobby/start"); case "lobbyStop": return await Site("POST", "/api/lobby/stop"); 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; if (Arg(args, "language") is "de" or "en") { settings.Language = Arg(args, "language"); Lang.Set(settings.Language); } settings.Save(); return null; case "chooseGameFolder": { using var dialog = new FolderBrowserDialog { Description = Lang.T("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, launcherChangelog = launcher?.changelog, launcherVersion = SelfUpdate.CurrentVersion, masterReachable = mod != null || launcher != null, settings = new { server = settings.Server, autoConnect = settings.AutoConnect, keepOpen = settings.KeepOpen, autoUpdateMod = settings.AutoUpdateMod } }; } /// Anfrage an die Website mit dem Konto-Token. Abgelaufene Anmeldung -> zurueck zum Anmelden. async Task Site(string method, string path, object? body = null) { string token = settings.Token ?? throw new Exception("Bitte anmelden"); var r = method == "GET" ? await Api.Get(path, token) : await Api.Post(path, token, body); if (r.Status == 401) { LoggedOut(); throw new Exception("Anmeldung abgelaufen - bitte neu anmelden"); } if (!r.Ok) throw new Exception(r.Error ?? "Fehlgeschlagen"); return r.Json; } void LoggedOut() { settings.Token = null; settings.Save(); Emit(new { @event = "loggedOut" }); } 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 }; } /// Spiel starten. lobby: auf den Server der eigenen Lobby (laeuft im BSO-Relay), sonst auf settings.Server. async Task Launch(bool lobby = false) { 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"); } // Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Relay pruefen es). Fuer die Lobby gilt es fuer deren Server. var t = await Site("POST", "/api/auth/ticket", lobby ? new { lobby = true } : null); string ticket = t.GetProperty("ticket").GetString()!; if (lobby) { string lobbyId = t.GetProperty("lobby").GetString()!, label = t.GetProperty("lobbyName").GetString() ?? "Lobby"; string relay = (await Site("GET", "/api/config")).GetProperty("relay").GetString()!; Game.WriteModConfig(folder, settings.Username, "relay:" + lobbyId, true, ticket, label, relay); LauncherLog.Info($"Spiel gestartet ({label} ueber {relay})"); } else { Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); } Game.Launch(); if (!settings.KeepOpen) { await Task.Delay(2500); BeginInvoke(Close); } return new { started = true }; } }