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",