From 9cd5f2b153cbae80fbe6c63d00d987154ebcd94b Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 21:01:20 +0100 Subject: [PATCH] Launcher 0.3.2: Updates aus Gitea-Releases, Server-Karte entfernt Neueste Mod/Launcher-Version und Neuigkeiten kommen von git.mbm.wtf (bso-release.json mit Signatur), die Website ist nur noch Reserve (mit -dev zuerst). SPIELEN auf der Startseite startet ohne Server. Co-Authored-By: Claude Opus 5.5 --- BSO.Launcher.csproj | 6 +-- CHANGELOG.md | 4 ++ MainForm.cs | 6 ++- README.md | 13 ++++-- Services/Api.cs | 11 ++++- Services/GiteaReleases.cs | 85 +++++++++++++++++++++++++++++++++++++++ release.ps1 | 66 ++++++++++++++++-------------- ui/app.js | 16 +------- ui/i18n.js | 4 +- 9 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 Services/GiteaReleases.cs diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj index 601f513..941e7a8 100644 --- a/BSO.Launcher.csproj +++ b/BSO.Launcher.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -8,7 +8,7 @@ enable BSO.Launcher BSO-Launcher - 0.3.1 + 0.3.2 MBM.wtf BSO Launcher BSO Launcher - Bus Simulator Online @@ -32,7 +32,7 @@ - diff --git a/CHANGELOG.md b/CHANGELOG.md index 821f171..ab78b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ 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. +## 0.3.2 +- Updates kommen direkt aus den Releases auf git.mbm.wtf (Website nur noch als Reserve) +- Startseite: Server-Karte entfernt – zusammen gespielt wird über Freunde & Lobby + ## 0.3.1 - Setup-Assistent: Sprache, Installationsordner, Desktop-/Startmenü-Verknüpfung wählen, Fortschritt, Fertig-Seite - Bei jedem Start fragt der Launcher, ob ein neues Update (Launcher oder Mod) installiert werden soll diff --git a/MainForm.cs b/MainForm.cs index 0c52d9d..163289d 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -311,6 +311,7 @@ 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"); @@ -442,8 +443,9 @@ public sealed class MainForm : Form } else { - Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); - LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); + // Ohne Lobby: alleine fahren (Mehrspieler laeuft ueber Freunde & Lobby) + Game.WriteModConfig(folder, settings.Username, settings.Server, false, ticket); + LauncherLog.Info("Spiel gestartet (ohne Lobby)"); } Game.Launch(); if (!settings.KeepOpen) diff --git a/README.md b/README.md index e54ee30..61c23ec 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,14 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett ## Bauen und veröffentlichen ```powershell -dotnet publish -c Release -o publish # eine .exe, ohne vorinstalliertes .NET -.\release.ps1 -Product launcher -Version 0.1.0 -File publish\BSO-Launcher.exe -Changelog "..." +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 ``` +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. + ## Gegen die lokale Test-Website (`-dev`) ```powershell @@ -51,8 +55,9 @@ Lokale Website starten: im BSO-Website-Repo `dotnet run --urls http://localhost: `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 jedem BSO-Repo) signiert damit `BSO-RELEASE|produkt|version|sha256` und lädt hoch – braucht -`BSO_RELEASE_TOKEN` (= `RELEASE_TOKEN` der Website) und optional `BSO_MASTER_URL`. +`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. **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 647fd5d..d43dfca 100644 --- a/Services/Api.cs +++ b/Services/Api.cs @@ -75,7 +75,16 @@ 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); - public static async Task Latest(string product) + /// 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) { 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 new file mode 100644 index 0000000..15a781b --- /dev/null +++ b/Services/GiteaReleases.cs @@ -0,0 +1,85 @@ +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/release.ps1 b/release.ps1 index cd7ce65..9e53edb 100644 --- a/release.ps1 +++ b/release.ps1 @@ -1,13 +1,15 @@ -# Signiert ein Release und laedt es auf die BSO-Website (wie release.ps1 bei MSCO). -# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."] -# Laeuft auch automatisch bei "dotnet publish -p:BsoRelease=true" (Launcher und Server, siehe .csproj). +# 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. -# - Ist -File ein Ordner (Server: publish\), wird er ohne .pdb-Dateien gezippt. -# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN der Website), -# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf). Beide auch als Windows-Benutzervariable. -# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel. +# - 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. param( - [Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product, + [Parameter(Mandatory)][ValidateSet("mod", "launcher")][string]$Product, [Parameter(Mandatory)][string]$Version, [Parameter(Mandatory)][string]$File, [string]$Changelog = "" @@ -22,12 +24,14 @@ function Setting([string]$name) { if (-not $v) { $v = [Environment]::GetEnvironmentVariable($name, "User") } return $v } -$master = ((Setting "BSO_MASTER_URL"), "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/') -$token = Setting "BSO_RELEASE_TOKEN" -if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return } +$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" # Changelog aus CHANGELOG.md: alles unter "## " bis zur naechsten "## "-Zeile if (-not $Changelog) { @@ -44,15 +48,8 @@ if (-not $Changelog) { if ($Changelog) { Write-Host "Changelog aus CHANGELOG.md ($Version)" } else { Write-Host "Kein Changelog fuer $Version in CHANGELOG.md" } } -# Ordner (dotnet publish) -> zip $path = (Resolve-Path $File).Path -if (Test-Path $path -PathType Container) { - $zip = Join-Path $env:TEMP "BSO-$Product-$Version.zip" - Remove-Item $zip -ErrorAction SilentlyContinue - Compress-Archive -Path (Get-ChildItem $path -File | Where-Object Extension -ne ".pdb").FullName -DestinationPath $zip - $path = $zip -} - +$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, @@ -62,18 +59,27 @@ $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 $http = New-Object System.Net.Http.HttpClient $http.Timeout = [TimeSpan]::FromMinutes(10) -$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])), "`"$($pair[0])`"") -} -$content = New-Object System.Net.Http.ByteArrayContent(, $data) -$form.Add($content, "`"file`"", "`"$(Split-Path $path -Leaf)`"") -$res = $http.PostAsync("$master/api/releases/upload", $form).Result +$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 "$Product $Version gibt es schon auf $master - Version in der .csproj erhoehen"; return } -if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" } -Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)" +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)" } +} +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)" diff --git a/ui/app.js b/ui/app.js index 4a94d9e..30f697a 100644 --- a/ui/app.js +++ b/ui/app.js @@ -183,7 +183,7 @@ async function renderHome(root) { })); const ready = s.game.found && s.dotnet && (s.mod.installed || s.mod.latest); - const line = s.mod.installed ? t("BSO {0} installiert · Server {1}", s.mod.installed, s.settings.server) : t("BSO wird beim ersten Start installiert"); + const line = s.mod.installed ? t("BSO {0} installiert · zusammen spielen unter Freunde & Lobby", s.mod.installed) : t("BSO wird beim ersten Start installiert"); const children = [ h("div", { class: "hero" }, @@ -201,7 +201,7 @@ async function renderHome(root) { if (!s.masterReachable) children.push(h("div", { class: "banner warn" }, h("span", {}, t("BSO-Server nicht erreichbar - Updates werden spaeter geprueft.")))); - children.push(h("div", { class: "cards" }, gameCard(s), modCard(s), dotnetCard(s), serverCard(s))); + children.push(h("div", { class: "cards" }, gameCard(s), modCard(s), dotnetCard(s))); root.replaceChildren(...children); } @@ -246,18 +246,6 @@ function dotnetCard(s) { h("div", { class: "row" }, b)); } -function serverCard(s) { - const input = h("input", { value: s.settings.server, placeholder: "host:7777", spellcheck: "false" }); - const auto = h("input", { type: "checkbox" }); - auto.checked = s.settings.autoConnect; - const save = () => call("setSettings", { server: input.value.trim(), autoConnect: auto.checked }).then(() => toast(t("Gespeichert"))); - input.addEventListener("change", save); - auto.addEventListener("change", save); - return card("Server", "ok", t("Mitspielen"), - h("label", {}, h("span", {}, t("Adresse")), input), - h("label", { class: "switch" }, h("span", {}, t("Beim Start verbinden"), h("small", {}, t("sonst im Spiel mit F8"))), auto)); -} - // ---------------------------------------------------------------- Freunde & Lobby // Die Lobby liegt auf der Website, ihr Server laeuft im BSO-Relay - keiner hostet, keiner braucht eine Portfreigabe. diff --git a/ui/i18n.js b/ui/i18n.js index af270a3..2823e66 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -16,7 +16,7 @@ const EN = { "Spielen": "Play", "Freunde": "Friends", "Neuigkeiten": "News", "Einstellungen": "Settings", "Laden …": "Loading …", // Spielen "SPIELEN": "PLAY", "LAEUFT": "RUNNING", "Bus-Simulator 2012 startet …": "Bus-Simulator 2012 is starting …", - "BSO {0} installiert · Server {1}": "BSO {0} installed · Server {1}", "BSO wird beim ersten Start installiert": "BSO will be installed on first start", + "BSO {0} installiert · zusammen spielen unter Freunde & Lobby": "BSO {0} installed · play together under Friends & lobby", "BSO wird beim ersten Start installiert": "BSO will be installed on first start", "Multiplayer fuer Bus-Simulator 2012": "Multiplayer for Bus-Simulator 2012", "Vor dem Start fehlt noch etwas - siehe unten": "Something is still missing - see below", "Jetzt aktualisieren": "Update now", "Launcher startet neu …": "Launcher is restarting …", "Launcher {0}": "Launcher {0}", " ist da.": " is available.", @@ -28,7 +28,7 @@ const EN = { "Neu herunterladen": "Download again", "BSO {0} installiert": "BSO {0} installed", "Installiert": "Installed", "Die Mod laeuft mit .NET 10 (x64).": "The mod runs on .NET 10 (x64).", "Herunterladen": "Download", "Fehlt": "Missing", "BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren.": "BSO needs the .NET 10 Runtime (x64) - install \".NET Runtime\" for Windows x64.", - "Mitspielen": "Play together", "Adresse": "Address", "Gespeichert": "Saved", "Beim Start verbinden": "Connect on start", "sonst im Spiel mit F8": "otherwise in game with F8", + "Gespeichert": "Saved", // Freunde & Lobby "Freunde & Lobby": "Friends & lobby", "{0} lädt dich in die Lobby ein": "{0} invites you to their lobby", " lädt dich in die Lobby ein": " invites you to their lobby", "{0} möchte dein Freund sein": "{0} wants to be your friend", "Beitreten": "Join", "Ablehnen": "Decline", "Lobby": "Lobby",