From c7a30e7fa336bfcae5d7dd0e650a920f9f3d9fb9 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 21:19:48 +0100 Subject: [PATCH] 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 --- BSO.Launcher.csproj | 8 +- CHANGELOG.md | 6 +- MainForm.cs | 21 +++- README.md | 27 ++--- Services/Api.cs | 12 +-- Services/GiteaReleases.cs | 85 --------------- Services/Settings.cs | 2 + release.ps1 | 222 +++++++++++++++++++++++++++----------- ui/app.css | 7 +- ui/app.js | 106 +++++++++++------- ui/i18n.js | 13 ++- 11 files changed, 284 insertions(+), 225 deletions(-) delete mode 100644 Services/GiteaReleases.cs diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj index 941e7a8..8db1bf6 100644 --- a/BSO.Launcher.csproj +++ b/BSO.Launcher.csproj @@ -32,10 +32,10 @@ - - - + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index ab78b64..d4315ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # Changelog – BSO Launcher Neue Version: hier einen Abschnitt `## ` anlegen, `` in `BSO.Launcher.csproj` erhöhen, dann -`dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher. +`dotnet publish -c Release -o publish` – lädt die Version hoch; der Abschnitt wird der Changelog auf der Website und im Launcher. ## 0.3.2 -- Updates kommen direkt aus den Releases auf git.mbm.wtf (Website nur noch als Reserve) +- Auto-Update wie beim MSCO Launcher: ein neuer Launcher installiert sich beim Start sofort selbst und startet neu, + neue BSO-Versionen kommen beim Start (beides in den Einstellungen abschaltbar) +- Einstellungen: Nach Updates suchen - Startseite: Server-Karte entfernt – zusammen gespielt wird über Freunde & Lobby ## 0.3.1 diff --git a/MainForm.cs b/MainForm.cs index 163289d..d22145a 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -21,7 +21,7 @@ public sealed class MainForm : Form /// 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" }; + static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby", "checkLauncherUpdate" }; bool updateInstalled; static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -248,6 +248,7 @@ public sealed class MainForm : Form loggedIn = settings.Token != null, master = Api.Base, language = Lang.Current, + autoUpdateLauncher = settings.AutoUpdateLauncher, dev = Api.Dev || Api.Base != Api.BaseUrl }; @@ -311,19 +312,28 @@ public sealed class MainForm : Form case "lobbyStop": return await Site("POST", "/api/lobby/stop"); case "releases": { - if (!Api.TestWebsite && await GiteaReleases.List() is { Count: > 0 } fromGit) return fromGit; 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 aktualisiert auf " + version); - BeginInvoke(Close); // die neue Version startet gerade - return new { version }; + 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 @@ -333,6 +343,7 @@ public sealed class MainForm : Form 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; diff --git a/README.md b/README.md index 61c23ec..9773c2e 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett oder eine neuere Version installiert, bietet es Starten an. `--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:** Bei jedem Start fragt der Launcher, ob ein neues Update (Launcher oder Mod) installiert werden soll; - dazu neueste Mod-Version vor jedem Spielstart (abschaltbar). +- **Auto-Update (wie MSCO):** Beim Start installiert sich ein neuer Launcher sofort selbst und startet neu; neue Mod- + Versionen kommen beim Start und vor jedem Spielstart (beides abschaltbar). Einstellungen: *Nach Updates suchen*. 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). - **Freunde & Lobby:** Freunde per Benutzername hinzufügen (online-Anzeige), Lobby erstellen, einladen - SPIELEN des Erstellers startet den Lobby-Server. @@ -29,13 +29,12 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett ## Bauen und veröffentlichen ```powershell -dotnet publish -c Release -o publish -p:BsoRelease=true # bauen + als Gitea-Release veroeffentlichen -.\release.ps1 -Product launcher -Version 0.3.2 -File publish\BSO-Launcher.exe # oder von Hand +dotnet publish -c Release -o publish # bauen - und die neue Version gleich auf https://bso.mbm.wtf laden ``` -Updates kommen aus den **Gitea-Releases** auf git.mbm.wtf (Mod: `MrMaho/BSO-Mod`, Launcher: `MrMaho/BSO-Launcher`, beide -öffentlich). Jedes Release hat die Datei und `bso-release.json` (Version, SHA-256, Signatur). Ist Gitea nicht erreichbar, -fragt der Launcher die Website; mit `-dev` umgekehrt. +Wie bei MSCO: `dotnet publish` ruft `release.ps1` auf – signiert die .exe und lädt sie mit Changelog (Abschnitt +`## ` aus `CHANGELOG.md`) auf die Website. Gibt es die Version schon, passiert nichts; also Version in der +`.csproj` erhöhen und bauen. `BSO_NO_RELEASE=1` baut nur. Der Launcher holt seine Updates von der Website. ## Gegen die lokale Test-Website (`-dev`) @@ -53,11 +52,15 @@ Lokale Website starten: im BSO-Website-Repo `dotnet run --urls http://localhost: ## 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 BSO-Mod und BSO-Launcher) signiert damit `BSO-RELEASE|produkt|version|sha256` und legt das -Gitea-Release an – braucht `BSO_GITEA_TOKEN` (Gitea: Einstellungen → Anwendungen → Token mit *repository: Lesen und -Schreiben*), gern als Windows-Benutzervariable. +Geheimnisse liegen DPAPI-verschlüsselt unter `%USERPROFILE%\.bso\` (wie `.msco` bei MSCO): + +| | Datei | Einrichten | +|---|---|---| +| Release-Token (`RELEASE_TOKEN` der Website) | `release-token.dpapi` | `release.ps1 -SaveToken` (oder `BSO_RELEASE_TOKEN`) | +| Signier-Schlüssel (RSA 3072) | `release-signing-key.dpapi` | `release.ps1 -ImportSigningKey ` | + +Sicherung: `release.ps1 -ExportSigningKey ` (AES-256, eigenes Passwort). Ein ganz neues Paar nur im Notfall: +`new-release-key.ps1` (trägt den öffentlichen Teil in `Services\ReleaseSignature.cs` ein). **Den Schlüssel sichern:** Mit einem neuen Schlüssel erkennen bereits verteilte Launcher keine Updates mehr. diff --git a/Services/Api.cs b/Services/Api.cs index d43dfca..2f9b521 100644 --- a/Services/Api.cs +++ b/Services/Api.cs @@ -75,16 +75,8 @@ public static class Api // --- Freunde und Lobby (alles mit dem Konto-Token) public static Task Get(string path, string token) => Send(HttpMethod.Get, path, null, token); public static Task Post(string path, string token, object? body = null) => Send(HttpMethod.Post, path, body ?? new { }, token); - /// Gegen eine Test-Website (-dev, BSO_LAUNCHER_API)? Dann kommen Releases zuerst von dort. - public static bool TestWebsite => Base != BaseUrl.TrimEnd('/'); - - /// Neueste Version: vom Git-Server (Gitea-Releases), sonst von der Website - mit -dev umgekehrt. - public static async Task Latest(string product) => - TestWebsite - ? await WebsiteLatest(product) ?? await GiteaReleases.Latest(product) - : await GiteaReleases.Latest(product) ?? await WebsiteLatest(product); - - static async Task WebsiteLatest(string product) + /// Neueste Version von mod/launcher auf der Website (wie beim MSCO Keymaster) - oder null. + 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; diff --git a/Services/GiteaReleases.cs b/Services/GiteaReleases.cs deleted file mode 100644 index 15a781b..0000000 --- a/Services/GiteaReleases.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text.Json; - -namespace BSO.Launcher; - -/// -/// Releases direkt vom Git-Server (git.mbm.wtf, Gitea): Mod aus BSO-Mod, Launcher aus BSO-Launcher (beide oeffentlich). -/// Jedes Release hat die Datei und bso-release.json (Version, SHA-256, Signatur aus release.ps1) - installiert wird weiter -/// nur, was mit dem BSO-Release-Schluessel signiert ist, egal woher die Datei kommt. -/// -public static class GiteaReleases -{ - public const string ApiBase = "https://git.mbm.wtf/api/v1/repos/MrMaho/"; - public const string ManifestName = "bso-release.json"; - - static string Repo(string product) => product == "launcher" ? "BSO-Launcher" : "BSO-Mod"; - - sealed record Manifest(string product, string version, string sha256, long size, string file, string signature); - - /// Version aus dem Tag ("v0.5.0" -> "0.5.0") oder null. - static string? TagVersion(JsonElement release) - { - string tag = release.GetProperty("tag_name").GetString() ?? ""; - string v = tag.TrimStart('v', 'V'); - return Version.TryParse(v, out _) ? v : null; - } - - static bool Published(JsonElement release) => - !release.GetProperty("draft").GetBoolean() && !release.GetProperty("prerelease").GetBoolean(); - - static async Task Fetch(string product) - { - using var doc = JsonDocument.Parse(await Api.Http.GetStringAsync(ApiBase + Repo(product) + "/releases?limit=20")); - return doc.RootElement.EnumerateArray().Where(Published).Select(r => r.Clone()).ToArray(); - } - - /// Neueste Version von mod/launcher - null, wenn es keine gibt oder Gitea nicht erreichbar ist. - public static async Task Latest(string product) - { - try - { - var newest = (await Fetch(product)) - .Select(r => (release: r, version: TagVersion(r))) - .Where(x => x.version != null) - .OrderByDescending(x => Version.Parse(x.version!)) - .FirstOrDefault(); - if (newest.version == null) return null; - - var assets = newest.release.GetProperty("assets").EnumerateArray() - .ToDictionary(a => a.GetProperty("name").GetString() ?? "", a => a.GetProperty("browser_download_url").GetString() ?? ""); - if (!assets.TryGetValue(ManifestName, out string? manifestUrl)) return null; - var m = JsonSerializer.Deserialize(await Api.Http.GetStringAsync(manifestUrl)); - if (m == null || m.product != product || !assets.TryGetValue(m.file, out string? fileUrl)) return null; - return new Api.Release(product, m.version, m.sha256, m.size, newest.release.GetProperty("body").GetString() ?? "", - newest.release.GetProperty("published_at").GetString() ?? "", m.file, fileUrl, m.signature); - } - catch (Exception e) - { - LauncherLog.Error($"Gitea-Release {product}: {e.Message}"); - return null; - } - } - - /// Neuigkeiten: Releases von Mod und Launcher, neueste zuerst. null = Gitea nicht erreichbar. - public static async Task?> List() - { - var items = new List<(DateTimeOffset date, object item)>(); - int reached = 0; - foreach (string product in new[] { "mod", "launcher" }) - { - try - { - foreach (var r in await Fetch(product)) - { - if (TagVersion(r) is not { } version) continue; - string date = r.GetProperty("published_at").GetString() ?? ""; - items.Add((DateTimeOffset.TryParse(date, out var d) ? d : default, - new { product, version, changelog = r.GetProperty("body").GetString() ?? "", date })); - } - reached++; - } - catch (Exception e) { LauncherLog.Error($"Gitea-Releases {product}: {e.Message}"); } - } - return reached == 0 ? null : items.OrderByDescending(x => x.date).Select(x => x.item).ToList(); - } -} diff --git a/Services/Settings.cs b/Services/Settings.cs index 70b5b17..76e4434 100644 --- a/Services/Settings.cs +++ b/Services/Settings.cs @@ -23,6 +23,8 @@ public sealed class Settings public bool AutoConnect { get; set; } = true; public bool KeepOpen { get; set; } public bool AutoUpdateMod { get; set; } = true; + /// Beim Start eine neue Launcher-Version gleich installieren und neu starten (wie beim MSCO Launcher). + public bool AutoUpdateLauncher { get; set; } = true; /// "de" oder "en" - null = nach der Windows-Sprache (Lang.Default). public string? Language { get; set; } diff --git a/release.ps1 b/release.ps1 index 9e53edb..2629eb1 100644 --- a/release.ps1 +++ b/release.ps1 @@ -1,41 +1,143 @@ -# Signiert ein Release und veroeffentlicht es als Gitea-Release auf git.mbm.wtf (Launcher und Website holen es von dort). -# .\release.ps1 -Product mod|launcher -Version 0.5.0 -File dist\BSO-Mod.zip [-Changelog "..."] -# Laeuft auch automatisch bei "dotnet publish -p:BsoRelease=true" (Launcher) bzw. "build.ps1 -Release" (Mod). -# - Repo: mod -> MrMaho/BSO-Mod, launcher -> MrMaho/BSO-Launcher; Tag v (wird auf dem Standard-Branch angelegt). -# - Ohne -Changelog: der Abschnitt "## " aus CHANGELOG.md neben diesem Skript. -# - Dateien im Release: die Datei (Launcher als BSO-Launcher-Setup.exe) und bso-release.json mit Version, SHA-256 und -# Signatur. Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten -# oeffentlichen Schluessel, egal woher die Datei kommt. -# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1) und BSO_GITEA_TOKEN (Gitea: Einstellungen -> Anwendungen -# -> Token mit "repository: Lesen und Schreiben"), auch als Windows-Benutzervariable. Optional BSO_GITEA_URL. +# Neue Version automatisch auf die BSO-Website hochladen (wie release.ps1 bei MSCO -> Keymaster). +# Laeuft nach dem Release-Build von selbst (Launcher: dotnet publish -c Release, Mod: build.ps1) - oder von Hand: +# powershell -ExecutionPolicy Bypass -File release.ps1 -Product mod|launcher -File [-Version 0.5.1] [-Changelog "..."] +# +# Braucht den Release-Token der Website (RELEASE_TOKEN in deren .env) und den privaten Signier-Schluessel. Beide liegen +# verschluesselt beim Entwickler unter %USERPROFILE%\.bso\*.dpapi (Windows DPAPI - nur dein Windows-Konto kann sie lesen). +# Einrichten: "-SaveToken" (Token), Schluessel auf einem weiteren Rechner mit "-ImportSigningKey " aus der +# Sicherung ("-ExportSigningKey ", mit eigenem Passwort) uebernehmen. Neues Paar: BSO-Launcher\new-release-key.ps1. +# BSO_RELEASE_TOKEN (Umgebungs- oder Benutzervariable) ersetzt den gespeicherten Token, BSO_MASTER_URL die Website +# (Standard https://bso.mbm.wtf), BSO_NO_RELEASE=1 schaltet Hochladen ab. +# +# Signatur: jede Datei wird signiert (RSA-3072, SHA-256, "BSO-RELEASE|produkt|version|sha256") - der Launcher +# installiert nur, was zu seinem eingebauten oeffentlichen Schluessel passt. +# Gibt es die Version auf der Website schon, passiert nichts - also einfach die Version erhoehen und bauen. +# Fehler brechen den Build nicht ab (Meldung, Exit 0). param( - [Parameter(Mandatory)][ValidateSet("mod", "launcher")][string]$Product, - [Parameter(Mandatory)][string]$Version, - [Parameter(Mandatory)][string]$File, - [string]$Changelog = "" + [ValidateSet("mod", "launcher")][string]$Product, + [string]$File, + [string]$Version = "", + [string]$Changelog = "", + [switch]$SaveToken, + [string]$ExportSigningKey = "", + [string]$ImportSigningKey = "" ) $ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path Add-Type -AssemblyName System.Security Add-Type -AssemblyName System.Net.Http -# Aus der Umgebung - sonst die Windows-Benutzervariable (z.B. wenn Visual Studio vor dem Setzen gestartet wurde) -function Setting([string]$name) { - $v = [Environment]::GetEnvironmentVariable($name) - if (-not $v) { $v = [Environment]::GetEnvironmentVariable($name, "User") } - return $v -} -$gitea = ((Setting "BSO_GITEA_URL"), "https://git.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/') -$token = Setting "BSO_GITEA_TOKEN" -if (-not $token) { Write-Host "BSO_GITEA_TOKEN nicht gesetzt - kein Release angelegt"; 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" } -$Version = $Version.Split('+')[0] -$repo = @{ mod = "BSO-Mod"; launcher = "BSO-Launcher" }[$Product] -$api = "$gitea/api/v1/repos/MrMaho/$repo" +# ---------------------------------------------------------------- Ablage der Geheimnisse +# Nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App) - ein normales Terminal saehe die Dateien nicht +$secretDir = Join-Path $env:USERPROFILE ".bso" +$tokenFile = Join-Path $secretDir "release-token.dpapi" +$keyFile = Join-Path $secretDir "release-signing-key.dpapi" +$oldKeyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi" # fruehere Ablage (new-release-key.ps1) -# Changelog aus CHANGELOG.md: alles unter "## " bis zur naechsten "## "-Zeile +function Read-Secret([string]$file) { + if (-not (Test-Path -LiteralPath $file)) { return $null } + return [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser) +} + +function Write-Secret([string]$file, [byte[]]$data) { + New-Item -ItemType Directory -Force -Path $secretDir | Out-Null + [IO.File]::WriteAllBytes($file, [Security.Cryptography.ProtectedData]::Protect($data, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)) +} + +function Read-SigningKey { + $k = Read-Secret $keyFile + if ($null -eq $k) { $k = Read-Secret $oldKeyFile } + return $k +} + +function Get-Pbkdf2Key([string]$password, [byte[]]$salt) { + $kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($password, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256) + return $kdf.GetBytes(32) +} + +function Get-BackupPassword([string]$prompt) { + if ($env:BSO_BACKUP_PASSWORD) { return $env:BSO_BACKUP_PASSWORD } + $secure = Read-Host $prompt -AsSecureString + return (New-Object System.Net.NetworkCredential('', $secure)).Password +} + +# ---------------------------------------------------------------- Einrichten / Sichern + +if ($SaveToken) { + $plain = $env:BSO_RELEASE_TOKEN + if (-not $plain) { + $secure = Read-Host "Release-Token" -AsSecureString + $plain = (New-Object System.Net.NetworkCredential('', $secure)).Password + } + if (-not $plain -or $plain.Length -lt 24) { Write-Host "Token zu kurz - nichts gespeichert"; exit 1 } + Write-Secret $tokenFile ([Text.Encoding]::UTF8.GetBytes($plain)) + Write-Host "Release-Token verschluesselt gespeichert: $tokenFile" + exit 0 +} + +# Sicherung des privaten Schluessels mit eigenem Passwort (AES-256, PBKDF2) - z.B. auf dem NAS / USB-Stick +if ($ExportSigningKey) { + $private = Read-SigningKey + if ($null -eq $private) { Write-Host "Kein Signier-Schluessel ($keyFile)"; exit 1 } + $pw = Get-BackupPassword "Passwort fuer die Sicherung" + if ($pw.Length -lt 12) { Write-Host "Mindestens 12 Zeichen"; exit 1 } + if ((Get-BackupPassword "Passwort wiederholen") -ne $pw) { Write-Host "Die Passwoerter sind nicht gleich - nichts geschrieben"; exit 1 } + $salt = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt) + $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $pw $salt; $aes.GenerateIV() + $enc = $aes.CreateEncryptor().TransformFinalBlock($private, 0, $private.Length) + $out = "BSO-SIGNING-KEY-BACKUP v1`n" + [Convert]::ToBase64String($salt) + "`n" + [Convert]::ToBase64String($aes.IV) + "`n" + [Convert]::ToBase64String($enc) + [IO.File]::WriteAllText($ExportSigningKey, $out) + # Gegenprobe: Datei lesen und mit dem Passwort wieder entschluesseln + $lines = [IO.File]::ReadAllText($ExportSigningKey).Split("`n") + $aes2 = [Security.Cryptography.Aes]::Create(); $aes2.Key = Get-Pbkdf2Key $pw ([Convert]::FromBase64String($lines[1])); $aes2.IV = [Convert]::FromBase64String($lines[2]) + $check = [Convert]::FromBase64String($lines[3]); $check = $aes2.CreateDecryptor().TransformFinalBlock($check, 0, $check.Length) + if ([Convert]::ToBase64String($check) -ne [Convert]::ToBase64String($private)) { Write-Host "Gegenprobe FEHLGESCHLAGEN - die Sicherung ist unbrauchbar"; exit 1 } + Write-Host "Sicherung geschrieben und geprueft: $ExportSigningKey - Datei und Passwort gut aufheben" + exit 0 +} + +if ($ImportSigningKey) { + if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Nicht gefunden: $ImportSigningKey"; exit 1 } + $lines = [IO.File]::ReadAllText($ImportSigningKey).Split("`n") + if ($lines.Count -lt 4 -or $lines[0].Trim() -ne "BSO-SIGNING-KEY-BACKUP v1") { Write-Host "Das ist keine BSO-Schluessel-Sicherung"; exit 1 } + $pw = Get-BackupPassword "Passwort der Sicherung" + try { + $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $pw ([Convert]::FromBase64String($lines[1])); $aes.IV = [Convert]::FromBase64String($lines[2]) + $enc = [Convert]::FromBase64String($lines[3].Trim()) + $private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length) + [Security.Cryptography.RSA]::Create().FromXmlString([Text.Encoding]::UTF8.GetString($private)) + } catch { Write-Host "Falsches Passwort oder beschaedigte Sicherung"; exit 1 } + if ($null -ne (Read-Secret $keyFile)) { Write-Host "Es gibt schon einen Signier-Schluessel ($keyFile) - nicht ersetzt"; exit 1 } + Write-Secret $keyFile $private + Write-Host "Signier-Schluessel uebernommen: $keyFile" + exit 0 +} + +# ---------------------------------------------------------------- Hochladen + +if (-not $Product -or -not $File) { Write-Host "Aufruf: release.ps1 -Product mod|launcher -File | -SaveToken | -ExportSigningKey | -ImportSigningKey "; exit 1 } +if ($env:BSO_NO_RELEASE -eq "1") { Write-Host "BSO release: BSO_NO_RELEASE=1 - nicht hochgeladen."; exit 0 } + +$token = $env:BSO_RELEASE_TOKEN +if (-not $token) { $token = [Environment]::GetEnvironmentVariable("BSO_RELEASE_TOKEN", "User") } # z.B. Visual Studio vor dem Setzen gestartet +if (-not $token) { + try { $stored = Read-Secret $tokenFile; if ($null -ne $stored) { $token = [Text.Encoding]::UTF8.GetString($stored) } } + catch { Write-Host "BSO release: gespeicherter Token nicht lesbar (anderer Benutzer/PC?) - nicht hochgeladen."; exit 0 } +} +if (-not $token) { Write-Host "BSO release: kein Release-Token ($tokenFile oder BSO_RELEASE_TOKEN) - einrichten mit 'release.ps1 -SaveToken'. Nicht hochgeladen."; exit 0 } +$master = $env:BSO_MASTER_URL +if (-not $master) { $master = [Environment]::GetEnvironmentVariable("BSO_MASTER_URL", "User") } +if (-not $master) { $master = "https://bso.mbm.wtf" } +$master = $master.TrimEnd('/') + +if (-not (Test-Path $File)) { Write-Host "BSO release: $File nicht gefunden"; exit 1 } +if (-not $Version) { $Version = ((Get-Item $File).VersionInfo.ProductVersion -split '\+')[0] } +$Version = $Version.Split('+')[0] +if (-not $Version) { Write-Host "BSO release: Version unbekannt - -Version angeben"; exit 1 } + +# Changelog: Abschnitt "## " aus CHANGELOG.md neben diesem Skript if (-not $Changelog) { - $md = Join-Path $PSScriptRoot "CHANGELOG.md" + $md = Join-Path $here "CHANGELOG.md" if (Test-Path $md) { $lines = [IO.File]::ReadAllLines($md) $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l -match "^##\s+v?$([regex]::Escape($Version))(\s|$)" }) @@ -45,41 +147,37 @@ if (-not $Changelog) { $Changelog = ($section -join "`n").Trim() } } - if ($Changelog) { Write-Host "Changelog aus CHANGELOG.md ($Version)" } else { Write-Host "Kein Changelog fuer $Version in CHANGELOG.md" } } -$path = (Resolve-Path $File).Path -$fileName = if ($Product -eq "launcher") { "BSO-Launcher-Setup.exe" } else { Split-Path $path -Leaf } -$data = [IO.File]::ReadAllBytes($path) -$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)) -$manifest = [ordered]@{ product = $Product; version = $Version; sha256 = $sha; size = $data.Length; file = $fileName; signature = $signature } | - ConvertTo-Json -Compress +# Signieren: passt nur zu genau dieser Datei, diesem Produkt und dieser Version +$data = [IO.File]::ReadAllBytes((Resolve-Path $File)) +$sha = ([Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join "" +try { $private = Read-SigningKey } +catch { Write-Host "BSO release: Signier-Schluessel nicht lesbar (anderer Benutzer/PC?) - nicht hochgeladen"; exit 0 } +if ($null -eq $private) { Write-Host "BSO release: kein Signier-Schluessel ($keyFile) - mit 'release.ps1 -ImportSigningKey ' uebernehmen. Nicht hochgeladen."; exit 0 } +try { + $rsa = [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, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)) + $private = $null +} catch { Write-Host "BSO release: Signieren fehlgeschlagen ($($_.Exception.Message)) - nicht hochgeladen"; exit 0 } $http = New-Object System.Net.Http.HttpClient $http.Timeout = [TimeSpan]::FromMinutes(10) -$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("token", $token) - -# Release anlegen (Tag v) -$json = @{ tag_name = "v$Version"; name = "$Product $Version"; body = $Changelog; draft = $false; prerelease = $false } | ConvertTo-Json -Compress -$res = $http.PostAsync("$api/releases", (New-Object System.Net.Http.StringContent($json, [Text.Encoding]::UTF8, "application/json"))).Result -$body = $res.Content.ReadAsStringAsync().Result -if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$repo v$Version gibt es schon - Version erhoehen"; return } -if (-not $res.IsSuccessStatusCode) { throw "Release anlegen fehlgeschlagen ($([int]$res.StatusCode)): $body" } -$id = ($body | ConvertFrom-Json).id - -function Upload([string]$name, [byte[]]$bytes) { - $form = New-Object System.Net.Http.MultipartFormDataContent - $form.Add((New-Object System.Net.Http.ByteArrayContent(, $bytes)), "`"attachment`"", "`"$name`"") - $r = $http.PostAsync("$api/releases/$id/assets?name=$([Uri]::EscapeDataString($name))", $form).Result - if (-not $r.IsSuccessStatusCode) { throw "Hochladen von $name fehlgeschlagen ($([int]$r.StatusCode)): $($r.Content.ReadAsStringAsync().Result)" } +$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], [Text.Encoding]::UTF8)), "`"$($pair[0])`"") } -Upload $fileName $data -Upload "bso-release.json" ([Text.Encoding]::UTF8.GetBytes($manifest)) -Write-Host "$Product $Version veroeffentlicht: $gitea/MrMaho/$repo/releases/tag/v$Version (sha256 $sha)" +$form.Add((New-Object System.Net.Http.ByteArrayContent(, $data)), "`"file`"", "`"$(Split-Path $File -Leaf)`"") +try { + $res = $http.PostAsync("$master/api/releases/upload", $form).GetAwaiter().GetResult() + $body = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult() +} catch { Write-Host "BSO release: $master nicht erreichbar ($($_.Exception.Message)) - nicht hochgeladen"; exit 0 } +switch ([int]$res.StatusCode) { + 200 { Write-Host "BSO release: $Product $Version hochgeladen nach $master" } + 409 { Write-Host "BSO release: $Product $Version gibt es auf $master schon - zum Veroeffentlichen die Version erhoehen" } + default { Write-Host "BSO release: Hochladen fehlgeschlagen (HTTP $([int]$res.StatusCode)): $body" } +} +exit 0 diff --git a/ui/app.css b/ui/app.css index ad04252..7ec0d28 100644 --- a/ui/app.css +++ b/ui/app.css @@ -178,14 +178,15 @@ nav button.on { background: var(--panel-2); color: var(--text); } 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; } -/* Update-Abfrage beim Start */ +/* Launcher-Update beim Start (wie beim MSCO Launcher) */ .modal-back { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: rgba(5, 7, 10, .72); } .modal { width: min(520px, 100%); max-height: calc(100vh - 48px); overflow: auto; display: grid; gap: 14px; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 24px; box-shadow: 0 24px 64px rgba(0, 0, 0, .5); } .modal h2 { margin: 0; } .modal p { margin: 0; } -.update-item { display: grid; gap: 6px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; } -.update-item pre { margin: 0; max-height: 160px; overflow: auto; white-space: pre-wrap; font: inherit; font-size: 13px; color: #c4ccd8; } +.update-box { text-align: center; justify-items: center; width: min(420px, 100%); } +.update-box img { width: 72px; height: 72px; } +.update-box .progress { width: 100%; text-align: left; } /* ---------------------------------------------------------------- Einstellungen */ .settings { display: grid; gap: 14px; } diff --git a/ui/app.js b/ui/app.js index 30f697a..438bb5c 100644 --- a/ui/app.js +++ b/ui/app.js @@ -141,6 +141,7 @@ function showApp(username) { $("meName").textContent = username; $("meAvatar").textContent = (username || "?").slice(0, 1).toUpperCase(); showPage("home"); + autoUpdateMod(); } // ---------------------------------------------------------------- Seiten @@ -419,9 +420,28 @@ async function renderSettings(root) { uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall"))); const langBox = h("div", { class: "lang-switch", "data-lang-switch": true }); + // Launcher-Update von Hand (wie beim MSCO Launcher: "Nach Updates suchen" -> "Installieren und neu starten") + const launcherBox = h("div", {}); + const check = h("button", { class: "btn small", type: "button" }, t("Nach Updates suchen")); + const install = h("button", { class: "btn small primary", type: "button" }, t("Installieren und neu starten")); + install.addEventListener("click", () => busy(install, async () => { + progressBox = h("div", { class: "progress" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div"))); + launcherBox.append(progressBox); + try { await call("installLauncherUpdate"); install.textContent = t("Neustart …"); install.disabled = true; } + catch (e) { progressBox.remove(); throw e; } + })); + check.addEventListener("click", () => busy(check, async () => { + const u = await call("checkLauncherUpdate"); + launcherBox.replaceChildren(u.available + ? h("div", { class: "row" }, h("b", {}, t("Launcher {0} ist da", u.latest)), install) + : h("span", { class: "muted small" }, u.latest ? t("Der Launcher ist aktuell") : t("Keine Launcher-Version auf dem Server"))); + })); + root.replaceChildren(h("h1", {}, t("Einstellungen")), h("div", { class: "settings" }, h("div", { class: "card" }, - toggle(t("BSO automatisch aktualisieren"), t("Vor jedem Start die neueste Mod-Version installieren"), "autoUpdateMod", s?.settings.autoUpdateMod ?? true), + toggle(t("Launcher automatisch aktualisieren"), t("Beim Start wird eine neue Launcher-Version gleich installiert (er startet von selbst neu)"), + "autoUpdateLauncher", init.autoUpdateLauncher ?? true), + toggle(t("BSO automatisch aktualisieren"), t("Neue BSO-Versionen beim Start des Launchers und vor jedem Spielstart installieren"), "autoUpdateMod", s?.settings.autoUpdateMod ?? true), toggle(t("Launcher offen lassen"), t("Sonst schliesst er sich nach dem Spielstart"), "keepOpen", s?.settings.keepOpen ?? false), h("div", { class: "switch" }, h("span", {}, t("Sprache")), langBox)), h("div", { class: "card" }, @@ -431,50 +451,56 @@ async function renderSettings(root) { h("div", { class: "card" }, h("h3", {}, "Launcher"), h("span", { class: "muted small" }, t("Version {0} · Server {1}", init.version, init.master)), - h("div", { class: "row" }, log, uninstall)))); + launcherBox, + h("div", { class: "row" }, check, log, uninstall)))); renderLangSwitch(setLang); } -// ---------------------------------------------------------------- Update-Abfrage +// ---------------------------------------------------------------- Updates (wie beim MSCO Launcher) -/** Bei jedem Start: gibt es einen neuen Launcher (oder eine neue Mod)? Dann fragen - "Später" fragt beim naechsten Start wieder. */ -async function askForUpdates() { +/** + * Beim Start: gibt es einen neueren Launcher, gleich installieren und neu starten (Einstellung "Launcher automatisch + * aktualisieren"). Hoechstens 8 s auf die Pruefung warten - ohne Netz startet der Launcher normal. + */ +async function startupUpdate(init) { + let u; + try { + u = await Promise.race([call("checkLauncherUpdate"), new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 8000))]); + } catch { return false; } + if (!u || !u.available) return false; + if (init.autoUpdateLauncher === false) { toast(t("Launcher {0} ist da – unter Einstellungen installieren", u.latest)); return false; } + + const progress = h("div", { class: "progress" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div"))); + const text = h("p", { class: "muted" }, t("Launcher wird auf {0} aktualisiert …", u.latest)); + const overlay = h("div", { class: "modal-back" }, h("div", { class: "modal update-box" }, + h("img", { src: "logo.svg", alt: "" }), + h("h2", {}, t("Launcher {0} ist da", u.latest)), + text, progress, + h("small", { class: "muted" }, t("Der Launcher startet danach von selbst neu.")))); + document.body.append(overlay); + progressBox = progress; + try { + await call("installLauncherUpdate"); + text.textContent = t("Neustart …"); + return true; + } catch (e) { + overlay.remove(); + progressBox = null; + toast(t("Launcher-Update fehlgeschlagen: {0}", e.message), true); + return false; + } +} + +/** Nach dem Anmelden: eine neue BSO-Version gleich installieren (Einstellung "BSO automatisch aktualisieren"). */ +async function autoUpdateMod() { let s; try { s = await call("status"); } catch { return; } - const mod = s.mod.installed && s.mod.updateAvailable && !s.game.running ? s.mod : null; - if (!s.launcherUpdate && !mod) return; - - const item = (name, from, to, changelog) => h("div", { class: "update-item" }, - h("div", {}, h("b", {}, name + " " + to), h("span", { class: "muted small" }, " " + t("installiert: {0}", from))), - h("pre", {}, changelog || t("Keine Beschreibung"))); - const progress = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div"))); - const later = h("button", { class: "btn", type: "button" }, t("Später")); - const now = h("button", { class: "btn primary", type: "button" }, t("Jetzt aktualisieren")); - const box = h("div", { class: "modal-back" }, h("div", { class: "modal", role: "dialog" }, - h("h2", {}, t("Update verfügbar")), - h("p", { class: "muted" }, t("Beim Start gefunden – jetzt aktualisieren?")), - s.launcherUpdate ? item("Launcher", s.launcherVersion, s.launcherUpdate, s.launcherChangelog) : null, - mod ? item(t("BSO-Mod"), mod.installed, mod.latest, mod.changelog) : null, - progress, - h("div", { class: "row", style: "justify-content:flex-end" }, later, now))); - document.body.append(box); - now.focus(); - - later.addEventListener("click", () => box.remove()); - now.addEventListener("click", async () => { - now.disabled = later.disabled = true; - progressBox = progress; - try { - if (mod) await call("installMod"); - if (s.launcherUpdate) { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); return; } // beendet sich gleich - box.remove(); - toast(t("BSO {0} installiert", mod.latest)); - if (!$("app").classList.contains("hidden")) showPage(page); - } catch (e) { - toast(e.message, true); - now.disabled = later.disabled = false; - } - }); + if (!s.settings.autoUpdateMod || !s.game.found || s.game.running || !s.mod.updateAvailable || !s.mod.latest) return; + try { + const r = await call("installMod"); + toast(t("BSO {0} installiert", r.version)); + if (page === "home") showPage("home"); + } catch (e) { toast(e.message, true); } } // ---------------------------------------------------------------- Start @@ -494,7 +520,7 @@ async function askForUpdates() { $("meVersion").textContent += " · DEV"; } $("authServer").textContent = t("Server: {0}", init.master.replace(/^https?:\/\//, "")); - askForUpdates(); // auch vor dem Anmelden + if (await startupUpdate(init)) return; // neue Version startet gleich if (!init.loggedIn) { showAuth(); return; } const me = await call("me"); if (me.loggedIn) showApp(me.username); diff --git a/ui/i18n.js b/ui/i18n.js index 2823e66..12b375a 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -45,8 +45,17 @@ const EN = { "Freund entfernen": "Remove friend", "{0} als Freund entfernen?": "Remove {0} as a friend?", "eingeladen": "invited", "Einladen": "Invite", "in der Lobby": "in the lobby", "Gesendet": "Sent", "wartet …": "pending …", "Zurückziehen": "Withdraw", // Neuigkeiten - "Update verfügbar": "Update available", "Beim Start gefunden – jetzt aktualisieren?": "Found at startup – update now?", - "installiert: {0}": "installed: {0}", "Später": "Later", "BSO-Mod": "BSO mod", + "Launcher {0} ist da – unter Einstellungen installieren": "Launcher {0} is available – install it in the settings", + "Launcher wird auf {0} aktualisiert …": "Updating the launcher to {0} …", "Launcher {0} ist da": "Launcher {0} is available", + "Der Launcher startet danach von selbst neu.": "The launcher restarts by itself.", "Neustart …": "Restarting …", + "Launcher-Update fehlgeschlagen: {0}": "Launcher update failed: {0}", "Nach Updates suchen": "Check for updates", + "Installieren und neu starten": "Install and restart", "Der Launcher ist aktuell": "The launcher is up to date", + "Keine Launcher-Version auf dem Server": "No launcher version on the server", + "Launcher automatisch aktualisieren": "Update the launcher automatically", + "Beim Start wird eine neue Launcher-Version gleich installiert (er startet von selbst neu)": + "When the launcher starts, a new launcher version is installed right away (it restarts by itself)", + "Neue BSO-Versionen beim Start des Launchers und vor jedem Spielstart installieren": + "Installs new BSO versions when the launcher starts and before every game start", "Keine Beschreibung": "No description", "Noch keine Versionen veroeffentlicht.": "No versions published yet.", // Einstellungen "Spielordner waehlen": "Choose game folder", "Spielordner gespeichert": "Game folder saved", "Protokoll-Ordner oeffnen": "Open log folder",