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>
This commit is contained in:
2026-10-28 21:19:48 +01:00
co-authored by Claude Opus 5.5
parent 9cd5f2b153
commit c7a30e7fa3
11 changed files with 284 additions and 225 deletions
+4 -4
View File
@@ -32,10 +32,10 @@
<EmbeddedResource Include="ui\**\*" LogicalName="ui/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<!-- dotnet publish -c Release -o publish -p:BsoRelease=true: die neue Version signieren und als Gitea-Release veroeffentlichen
(release.ps1, Changelog aus CHANGELOG.md). Ohne -p:BsoRelease=true wird nur gebaut. -->
<Target Name="BsoRelease" AfterTargets="Publish" Condition="'$(BsoRelease)' == 'true'">
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\release.ps1&quot; -Product launcher -Version $(Version) -File &quot;$(PublishDir)BSO-Launcher.exe&quot;" />
<!-- dotnet publish: neue Version automatisch auf die Website laden (wie MSCO, nur mit Release-Token, siehe release.ps1).
Gibt es die Version schon, passiert nichts. BSO_NO_RELEASE=1 baut, ohne hochzuladen. -->
<Target Name="BsoRelease" AfterTargets="Publish">
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\release.ps1&quot; -Product launcher -Version $(Version) -File &quot;$([System.IO.Path]::GetFullPath('$(PublishDir)'))BSO-Launcher.exe&quot;" ContinueOnError="true" />
</Target>
</Project>
+4 -2
View File
@@ -1,10 +1,12 @@
# Changelog – BSO Launcher
Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` 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
+16 -5
View File
@@ -21,7 +21,7 @@ public sealed class MainForm : Form
/// <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" };
static readonly HashSet<string> 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;
+15 -12
View File
@@ -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
`## <Version>` 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>` |
Sicherung: `release.ps1 -ExportSigningKey <datei>` (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.
+2 -10
View File
@@ -75,16 +75,8 @@ public static class Api
// --- Freunde und Lobby (alles mit dem Konto-Token)
public static Task<Result> Get(string path, string token) => Send(HttpMethod.Get, path, null, token);
public static Task<Result> Post(string path, string token, object? body = null) => Send(HttpMethod.Post, path, body ?? new { }, token);
/// <summary>Gegen eine Test-Website (-dev, BSO_LAUNCHER_API)? Dann kommen Releases zuerst von dort.</summary>
public static bool TestWebsite => Base != BaseUrl.TrimEnd('/');
/// <summary>Neueste Version: vom Git-Server (Gitea-Releases), sonst von der Website - mit -dev umgekehrt.</summary>
public static async Task<Release?> Latest(string product) =>
TestWebsite
? await WebsiteLatest(product) ?? await GiteaReleases.Latest(product)
: await GiteaReleases.Latest(product) ?? await WebsiteLatest(product);
static async Task<Release?> WebsiteLatest(string product)
/// <summary>Neueste Version von mod/launcher auf der Website (wie beim MSCO Keymaster) - oder null.</summary>
public static async Task<Release?> Latest(string product)
{
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
if (!r.Ok) return null;
-85
View File
@@ -1,85 +0,0 @@
using System.Text.Json;
namespace BSO.Launcher;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Version aus dem Tag ("v0.5.0" -> "0.5.0") oder null.</summary>
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<JsonElement[]> 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();
}
/// <summary>Neueste Version von mod/launcher - null, wenn es keine gibt oder Gitea nicht erreichbar ist.</summary>
public static async Task<Api.Release?> 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<Manifest>(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;
}
}
/// <summary>Neuigkeiten: Releases von Mod und Launcher, neueste zuerst. null = Gitea nicht erreichbar.</summary>
public static async Task<List<object>?> 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();
}
}
+2
View File
@@ -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;
/// <summary>Beim Start eine neue Launcher-Version gleich installieren und neu starten (wie beim MSCO Launcher).</summary>
public bool AutoUpdateLauncher { get; set; } = true;
/// <summary>"de" oder "en" - null = nach der Windows-Sprache (Lang.Default).</summary>
public string? Language { get; set; }
+160 -62
View File
@@ -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<Version> (wird auf dem Standard-Branch angelegt).
# - Ohne -Changelog: der Abschnitt "## <Version>" 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 <datei> [-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 <backup>" aus der
# Sicherung ("-ExportSigningKey <datei>", 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 "## <Version>" 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 <datei> | -SaveToken | -ExportSigningKey <datei> | -ImportSigningKey <datei>"; 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 "## <Version>" 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 <sicherung>' 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<Version>)
$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
+4 -3
View File
@@ -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; }
+66 -40
View File
@@ -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);
+11 -2
View File
@@ -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",