Files
MrMahoandClaude Opus 5.5 c7a30e7fa3 Auto-Update wie beim MSCO Launcher
Beim Start installiert sich ein neuer Launcher sofort selbst (Fortschrittsfenster, Neustart), neue
BSO-Versionen kommen nach dem Anmelden - beides abschaltbar. Einstellungen: Nach Updates suchen /
Installieren und neu starten. Updates wieder nur von der Website; dotnet publish laedt hoch (release.ps1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:19:48 +01:00

470 lines
23 KiB
C#

using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace BSO.Launcher;
/// <summary>
/// Fenster mit WebView2 (wie beim MSCO Launcher): die Oberflaeche (ui/*.html/css/js, in der .exe eingebettet) laeuft unter
/// https://app.bso/ und spricht ueber Nachrichten { id, cmd, args } mit dem Launcher; Antworten { id, ok, result | error },
/// Ereignisse { event, ... } (z.B. Download-Fortschritt).
/// </summary>
public sealed class MainForm : Form
{
const string Host = "app.bso";
readonly WebView2 web = new() { Dock = DockStyle.Fill, DefaultBackgroundColor = Color.FromArgb(0x0b, 0x0d, 0x12) };
readonly Settings settings = Settings.Load();
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "playLobby", "installLauncherUpdate", "uninstall" };
readonly HashSet<string> running = new();
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby", "checkLauncherUpdate" };
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<RECT>(m.LParam);
base.WndProc(ref m);
var after = Marshal.PtrToStructure<RECT>(m.LParam);
after.Top = before.Top;
if (WindowState == FormWindowState.Maximized)
{
// Maximiert ragt das Fenster um den Rahmen ueber den Bildschirm hinaus
uint dpi = GetDpiForWindow(Handle);
after.Top += GetSystemMetricsForDpi(33, dpi) + GetSystemMetricsForDpi(92, dpi); // SM_CYFRAME + SM_CXPADDEDBORDER
}
Marshal.StructureToPtr(after, m.LParam, false);
m.Result = IntPtr.Zero;
return;
}
base.WndProc(ref m);
}
void WindowCommand(string action)
{
switch (action)
{
case "drag":
ReleaseCapture();
SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, IntPtr.Zero);
break;
case "minimize":
WindowState = FormWindowState.Minimized;
break;
case "maximize":
WindowState = WindowState == FormWindowState.Maximized ? FormWindowState.Normal : FormWindowState.Maximized;
break;
case "close":
Close();
break;
}
}
async Task InitWeb()
{
try
{
var env = await CoreWebView2Environment.CreateAsync(null, Path.Combine(Settings.Folder, "WebView2"));
await web.EnsureCoreWebView2Async(env);
}
catch (Exception e)
{
var answer = MessageBox.Show(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<object?> HandleCommand(string cmd, JsonElement args)
{
switch (cmd)
{
case "window":
WindowCommand(Arg(args, "action"));
return null;
case "init":
return new
{
maximized = WindowState == FormWindowState.Maximized,
version = SelfUpdate.CurrentVersion,
username = settings.Username,
loggedIn = settings.Token != null,
master = Api.Base,
language = Lang.Current,
autoUpdateLauncher = settings.AutoUpdateLauncher,
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 "checkLauncherUpdate":
{
var latest = await Api.Latest("launcher");
return new { current = SelfUpdate.CurrentVersion, latest = latest?.version,
available = latest != null && SelfUpdate.IsNewer(latest.version, SelfUpdate.CurrentVersion) };
}
case "installLauncherUpdate":
{
var latest = await Api.Latest("launcher") ?? throw new Exception("Kein Launcher-Update gefunden");
LauncherLog.Info("Launcher wird aktualisiert auf " + latest.version);
string version = await SelfUpdate.Install(latest, p => Progress("launcher", p, "Launcher-Update wird geladen …"));
updateInstalled = true;
LauncherLog.Info("Launcher " + version + " installiert - Neustart");
// Beenden, damit die neue Version starten kann. Haengt das Schliessen (WebView2 o.ae.), nach 5 s hart
// beenden - sonst bleibt die alte Version offen und die neue wartet vergeblich
BeginInvoke(() => Application.Exit());
_ = Task.Delay(5000).ContinueWith(_ => Environment.Exit(0));
return new { version, restarting = true };
}
// --- 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.AutoUpdateLauncher = BoolArg(args, "autoUpdateLauncher") ?? settings.AutoUpdateLauncher;
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<object> Status()
{
string? folder = Game.FindFolder(settings);
var modTask = Api.Latest("mod");
var launcherTask = Api.Latest("launcher");
await Task.WhenAll(modTask, launcherTask);
Api.Release? mod = modTask.Result, launcher = launcherTask.Result;
string? installed = folder != null ? Game.ModVersion(folder) : null;
return new
{
game = new { found = folder != null, folder, running = Game.IsRunning() },
mod = new
{
installed,
latest = mod?.version,
changelog = mod?.changelog,
updateAvailable = mod != null && (installed == null || SelfUpdate.IsNewer(mod.version, installed))
},
dotnet = Game.DotnetInstalled(),
launcherUpdate = launcher != null && SelfUpdate.IsNewer(launcher.version, SelfUpdate.CurrentVersion) ? launcher.version : null,
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 }
};
}
/// <summary>Anfrage an die Website mit dem Konto-Token. Abgelaufene Anmeldung -> zurueck zum Anmelden.</summary>
async Task<JsonElement> 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<object> InstallMod()
{
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
var latest = await Api.Latest("mod") ?? throw new Exception("Noch keine BSO-Version auf dem Server");
byte[] zip = await Api.Download(latest, p => Progress("mod", p, $"BSO {latest.version} wird geladen …"));
Progress("mod", 1, "Installieren …");
Game.InstallMod(folder, zip);
return new { version = latest.version };
}
/// <summary>Spiel starten. lobby: auf den Server der eigenen Lobby (laeuft im BSO-Relay), sonst auf settings.Server.</summary>
async Task<object> 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
{
// Ohne Lobby: alleine fahren (Mehrspieler laeuft ueber Freunde & Lobby)
Game.WriteModConfig(folder, settings.Username, settings.Server, false, ticket);
LauncherLog.Info("Spiel gestartet (ohne Lobby)");
}
Game.Launch();
if (!settings.KeepOpen)
{
await Task.Delay(2500);
BeginInvoke(Close);
}
return new { started = true };
}
}