From 1e6ab386cbf48a6f65761302dd619c4a006a2310 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 13:15:07 +0200 Subject: [PATCH] launcher: add avalonia packages, settings, locale, and progress hook Adds Avalonia 11 + CommunityToolkit.Mvvm package references (no UI code yet), a flat-key Loc resource loader with embedded cs.json/en.json, the LauncherSettings JSON store with dev-mode-gated manifest URL override, and an optional IProgress bytesProgress hook on BlobDownloader so the upcoming GUI can drive a progress bar from per-blob byte counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Config/LauncherSettings.cs | 93 +++++++++++++++++++ src/Metin2Launcher/Localization/Loc.cs | 70 ++++++++++++++ src/Metin2Launcher/Localization/cs.json | 32 +++++++ src/Metin2Launcher/Localization/en.json | 32 +++++++ src/Metin2Launcher/Metin2Launcher.csproj | 13 ++- src/Metin2Launcher/Transfer/BlobDownloader.cs | 28 +++++- src/Metin2Launcher/app.manifest | 10 ++ .../LauncherSettingsTests.cs | 70 ++++++++++++++ tests/Metin2Launcher.Tests/LocTests.cs | 42 +++++++++ 9 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 src/Metin2Launcher/Config/LauncherSettings.cs create mode 100644 src/Metin2Launcher/Localization/Loc.cs create mode 100644 src/Metin2Launcher/Localization/cs.json create mode 100644 src/Metin2Launcher/Localization/en.json create mode 100644 src/Metin2Launcher/app.manifest create mode 100644 tests/Metin2Launcher.Tests/LauncherSettingsTests.cs create mode 100644 tests/Metin2Launcher.Tests/LocTests.cs diff --git a/src/Metin2Launcher/Config/LauncherSettings.cs b/src/Metin2Launcher/Config/LauncherSettings.cs new file mode 100644 index 0000000..0b86672 --- /dev/null +++ b/src/Metin2Launcher/Config/LauncherSettings.cs @@ -0,0 +1,93 @@ +using System.IO; +using System.Text.Json; +using Metin2Launcher.Logging; + +namespace Metin2Launcher.Config; + +/// +/// User-editable settings persisted to .updates/launcher-settings.json. +/// Only honored when DevMode == true for the URL override (defense in depth: a +/// tampered local file alone cannot redirect updates). +/// +public sealed class LauncherSettings +{ + public string Locale { get; set; } = "cs"; + public bool DevMode { get; set; } = false; + public string? ManifestUrlOverride { get; set; } + + private static readonly JsonSerializerOptions _opts = new() + { + WriteIndented = true, + }; + + public static LauncherSettings Load(string path) + { + try + { + if (!File.Exists(path)) + return new LauncherSettings(); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new LauncherSettings(); + } + catch (Exception ex) + { + Log.Warn("failed to read launcher settings, using defaults: " + ex.Message); + return new LauncherSettings(); + } + } + + public void Save(string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var json = JsonSerializer.Serialize(this, _opts); + File.WriteAllText(path, json); + } + catch (Exception ex) + { + Log.Warn("failed to save launcher settings: " + ex.Message); + } + } + + /// + /// Effective manifest URL respecting dev-mode gating. + /// + public string EffectiveManifestUrl() + { + if (DevMode && !string.IsNullOrWhiteSpace(ManifestUrlOverride)) + return ManifestUrlOverride!; + return LauncherConfig.ManifestUrl; + } + + /// + /// Derives signature URL from the (possibly overridden) manifest URL. + /// + public string EffectiveSignatureUrl() + { + var m = EffectiveManifestUrl(); + if (m == LauncherConfig.ManifestUrl) + return LauncherConfig.SignatureUrl; + return m + ".sig"; + } + + /// + /// Derives the blob base URL from the (possibly overridden) manifest URL. + /// Same host, sibling /files path. + /// + public string EffectiveBlobBaseUrl() + { + var m = EffectiveManifestUrl(); + if (m == LauncherConfig.ManifestUrl) + return LauncherConfig.BlobBaseUrl; + try + { + var u = new Uri(m); + return $"{u.Scheme}://{u.Authority}/files"; + } + catch + { + return LauncherConfig.BlobBaseUrl; + } + } +} diff --git a/src/Metin2Launcher/Localization/Loc.cs b/src/Metin2Launcher/Localization/Loc.cs new file mode 100644 index 0000000..1a5c69c --- /dev/null +++ b/src/Metin2Launcher/Localization/Loc.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.Json; + +namespace Metin2Launcher.Localization; + +/// +/// Tiny flat key-value localization loader. Resources are embedded JSON dictionaries. +/// Lookup falls back: requested locale -> en -> the key itself. +/// +public static class Loc +{ + private static readonly Dictionary> _bundles = new(); + private static string _current = "cs"; + + public static event Action? Changed; + + public static string Current => _current; + + public static IReadOnlyList Available => new[] { "cs", "en" }; + + static Loc() + { + Load("cs"); + Load("en"); + } + + private static void Load(string locale) + { + var asm = typeof(Loc).Assembly; + var resName = $"Metin2Launcher.Localization.{locale}.json"; + using var s = asm.GetManifestResourceStream(resName); + if (s is null) + { + _bundles[locale] = new Dictionary(); + return; + } + using var sr = new StreamReader(s); + var json = sr.ReadToEnd(); + var dict = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + _bundles[locale] = dict; + } + + public static void SetLocale(string locale) + { + if (!_bundles.ContainsKey(locale)) + locale = "en"; + if (_current == locale) return; + _current = locale; + Changed?.Invoke(); + } + + public static string Get(string key) + { + if (_bundles.TryGetValue(_current, out var d) && d.TryGetValue(key, out var v)) + return v; + if (_bundles.TryGetValue("en", out var en) && en.TryGetValue(key, out var ev)) + return ev; + return key; + } + + public static string Get(string key, params object?[] args) + { + var fmt = Get(key); + try { return string.Format(fmt, args); } + catch { return fmt; } + } +} diff --git a/src/Metin2Launcher/Localization/cs.json b/src/Metin2Launcher/Localization/cs.json new file mode 100644 index 0000000..af40928 --- /dev/null +++ b/src/Metin2Launcher/Localization/cs.json @@ -0,0 +1,32 @@ +{ + "window.title": "Metin2 Launcher", + "banner.title": "METIN2 LAUNCHER", + "banner.version": "v{0}", + "button.play": "Hrát", + "button.exit": "Konec", + "button.settings": "Nastavení", + "button.close": "Zavřít", + "button.ok": "OK", + "button.cancel": "Zrušit", + "status.idle": "Připraveno", + "status.fetchingManifest": "Kontrola aktualizací…", + "status.verifyingSignature": "Ověřování podpisu vydání…", + "status.diffing": "Porovnávání lokálních souborů…", + "status.downloading": "Stahování aktualizací…", + "status.applying": "Instalace aktualizací…", + "status.upToDate": "Klient je aktuální", + "status.offlineFallback": "Server aktualizací nedostupný, používá se lokální klient", + "status.signatureFailed": "Aktualizace odmítnuta: neplatný podpis manifestu", + "status.launching": "Spouštění Metin2…", + "detail.fileProgress": "{0} ({1} z {2})", + "news.title": "Novinky", + "news.empty": "Žádné novinky.", + "news.previousHeader": "Předchozí verze {0}", + "settings.title": "Nastavení", + "settings.language": "Jazyk", + "settings.language.cs": "Čeština", + "settings.language.en": "English", + "settings.manifestUrl": "URL manifestu serveru", + "settings.devMode": "Vývojářský režim: povolit přepsání", + "settings.note": "Přepsání URL je ignorováno bez vývojářského režimu." +} diff --git a/src/Metin2Launcher/Localization/en.json b/src/Metin2Launcher/Localization/en.json new file mode 100644 index 0000000..42b428d --- /dev/null +++ b/src/Metin2Launcher/Localization/en.json @@ -0,0 +1,32 @@ +{ + "window.title": "Metin2 Launcher", + "banner.title": "METIN2 LAUNCHER", + "banner.version": "v{0}", + "button.play": "Play", + "button.exit": "Exit", + "button.settings": "Settings", + "button.close": "Close", + "button.ok": "OK", + "button.cancel": "Cancel", + "status.idle": "Idle", + "status.fetchingManifest": "Checking for updates…", + "status.verifyingSignature": "Verifying release signature…", + "status.diffing": "Comparing local files…", + "status.downloading": "Downloading updates…", + "status.applying": "Installing updates…", + "status.upToDate": "Up to date", + "status.offlineFallback": "Update server unreachable, using local client", + "status.signatureFailed": "Update refused: manifest signature invalid", + "status.launching": "Starting Metin2…", + "detail.fileProgress": "{0} ({1} of {2})", + "news.title": "News", + "news.empty": "No news.", + "news.previousHeader": "Previous version {0}", + "settings.title": "Settings", + "settings.language": "Language", + "settings.language.cs": "Čeština", + "settings.language.en": "English", + "settings.manifestUrl": "Server manifest URL", + "settings.devMode": "Dev mode: allow override", + "settings.note": "Override is ignored unless dev mode is enabled." +} diff --git a/src/Metin2Launcher/Metin2Launcher.csproj b/src/Metin2Launcher/Metin2Launcher.csproj index 83d150d..867253b 100644 --- a/src/Metin2Launcher/Metin2Launcher.csproj +++ b/src/Metin2Launcher/Metin2Launcher.csproj @@ -7,12 +7,23 @@ enable Metin2Launcher Metin2Launcher - true + app.manifest + true + + + + + + + + + + diff --git a/src/Metin2Launcher/Transfer/BlobDownloader.cs b/src/Metin2Launcher/Transfer/BlobDownloader.cs index 385ee3e..a4d83d7 100644 --- a/src/Metin2Launcher/Transfer/BlobDownloader.cs +++ b/src/Metin2Launcher/Transfer/BlobDownloader.cs @@ -41,7 +41,8 @@ public sealed class BlobDownloader string sha256, long expectedSize, string destinationPath, - CancellationToken ct) + CancellationToken ct, + IProgress? bytesProgress = null) { var url = BlobUrl(_baseUrl, sha256); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); @@ -52,7 +53,7 @@ public sealed class BlobDownloader ct.ThrowIfCancellationRequested(); try { - await DownloadOnceAsync(url, expectedSize, destinationPath, ct).ConfigureAwait(false); + await DownloadOnceAsync(url, expectedSize, destinationPath, ct, bytesProgress).ConfigureAwait(false); var actual = FileHasher.HashFileOrNull(destinationPath); if (actual == sha256) return; @@ -83,7 +84,7 @@ public sealed class BlobDownloader $"blob {sha256} failed after {_maxRetries} attempts", lastError); } - private async Task DownloadOnceAsync(string url, long expectedSize, string destinationPath, CancellationToken ct) + private async Task DownloadOnceAsync(string url, long expectedSize, string destinationPath, CancellationToken ct, IProgress? bytesProgress) { long have = File.Exists(destinationPath) ? new FileInfo(destinationPath).Length : 0; if (have > expectedSize) @@ -95,8 +96,11 @@ public sealed class BlobDownloader if (have == expectedSize) { // Full size already on disk — the caller's hash check decides validity. + bytesProgress?.Report(expectedSize); return; } + if (have > 0) + bytesProgress?.Report(have); using var req = new HttpRequestMessage(HttpMethod.Get, url); if (have > 0) @@ -119,7 +123,23 @@ public sealed class BlobDownloader var mode = have > 0 ? FileMode.Append : FileMode.Create; await using var fs = new FileStream(destinationPath, mode, FileAccess.Write, FileShare.None, 1024 * 1024); await using var net = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - await net.CopyToAsync(fs, 1024 * 1024, ct).ConfigureAwait(false); + + if (bytesProgress is null) + { + await net.CopyToAsync(fs, 1024 * 1024, ct).ConfigureAwait(false); + } + else + { + var buf = new byte[1024 * 1024]; + long total = have; + int read; + while ((read = await net.ReadAsync(buf, ct).ConfigureAwait(false)) > 0) + { + await fs.WriteAsync(buf.AsMemory(0, read), ct).ConfigureAwait(false); + total += read; + bytesProgress.Report(total); + } + } } private static void TryDelete(string path) diff --git a/src/Metin2Launcher/app.manifest b/src/Metin2Launcher/app.manifest new file mode 100644 index 0000000..0b739a5 --- /dev/null +++ b/src/Metin2Launcher/app.manifest @@ -0,0 +1,10 @@ + + + + + + true + PerMonitorV2 + + + diff --git a/tests/Metin2Launcher.Tests/LauncherSettingsTests.cs b/tests/Metin2Launcher.Tests/LauncherSettingsTests.cs new file mode 100644 index 0000000..c12d9ea --- /dev/null +++ b/tests/Metin2Launcher.Tests/LauncherSettingsTests.cs @@ -0,0 +1,70 @@ +using Metin2Launcher.Config; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class LauncherSettingsTests : IDisposable +{ + private readonly string _tmp; + + public LauncherSettingsTests() + { + _tmp = Path.Combine(Path.GetTempPath(), "metin-settings-" + Guid.NewGuid()); + Directory.CreateDirectory(_tmp); + } + + public void Dispose() + { + try { Directory.Delete(_tmp, recursive: true); } catch { } + } + + [Fact] + public void Defaults_when_file_missing() + { + var path = Path.Combine(_tmp, "missing.json"); + var s = LauncherSettings.Load(path); + Assert.Equal("cs", s.Locale); + Assert.False(s.DevMode); + Assert.Null(s.ManifestUrlOverride); + } + + [Fact] + public void Save_then_load_round_trip() + { + var path = Path.Combine(_tmp, "s.json"); + var s = new LauncherSettings { Locale = "en", DevMode = true, ManifestUrlOverride = "https://example.test/m.json" }; + s.Save(path); + + var loaded = LauncherSettings.Load(path); + Assert.Equal("en", loaded.Locale); + Assert.True(loaded.DevMode); + Assert.Equal("https://example.test/m.json", loaded.ManifestUrlOverride); + } + + [Fact] + public void Override_ignored_when_devmode_off() + { + var s = new LauncherSettings { DevMode = false, ManifestUrlOverride = "https://evil.test/m.json" }; + Assert.Equal(LauncherConfig.ManifestUrl, s.EffectiveManifestUrl()); + Assert.Equal(LauncherConfig.SignatureUrl, s.EffectiveSignatureUrl()); + Assert.Equal(LauncherConfig.BlobBaseUrl, s.EffectiveBlobBaseUrl()); + } + + [Fact] + public void Override_used_when_devmode_on() + { + var s = new LauncherSettings { DevMode = true, ManifestUrlOverride = "https://example.test/m.json" }; + Assert.Equal("https://example.test/m.json", s.EffectiveManifestUrl()); + Assert.Equal("https://example.test/m.json.sig", s.EffectiveSignatureUrl()); + Assert.Equal("https://example.test/files", s.EffectiveBlobBaseUrl()); + } + + [Fact] + public void Corrupt_file_falls_back_to_defaults() + { + var path = Path.Combine(_tmp, "bad.json"); + File.WriteAllText(path, "{not json"); + var s = LauncherSettings.Load(path); + Assert.Equal("cs", s.Locale); + } +} diff --git a/tests/Metin2Launcher.Tests/LocTests.cs b/tests/Metin2Launcher.Tests/LocTests.cs new file mode 100644 index 0000000..7154335 --- /dev/null +++ b/tests/Metin2Launcher.Tests/LocTests.cs @@ -0,0 +1,42 @@ +using Metin2Launcher.Localization; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class LocTests +{ + [Fact] + public void Returns_value_for_known_key_in_cs() + { + Loc.SetLocale("cs"); + Assert.Equal("Hrát", Loc.Get("button.play")); + } + + [Fact] + public void Returns_value_for_known_key_in_en() + { + Loc.SetLocale("en"); + Assert.Equal("Play", Loc.Get("button.play")); + } + + [Fact] + public void Falls_back_to_key_for_unknown() + { + Loc.SetLocale("en"); + Assert.Equal("nope.does.not.exist", Loc.Get("nope.does.not.exist")); + } + + [Fact] + public void Format_substitutes_args() + { + Loc.SetLocale("en"); + Assert.Equal("Previous version 0.1.0", Loc.Get("news.previousHeader", "0.1.0")); + } + + [Fact] + public void Unknown_locale_falls_back_to_en() + { + Loc.SetLocale("xx"); + Assert.Equal("Play", Loc.Get("button.play")); + } +}