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<long> 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) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 13:15:07 +02:00
parent a134e7548f
commit 1e6ab386cb
9 changed files with 385 additions and 5 deletions

View File

@@ -0,0 +1,93 @@
using System.IO;
using System.Text.Json;
using Metin2Launcher.Logging;
namespace Metin2Launcher.Config;
/// <summary>
/// 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).
/// </summary>
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<LauncherSettings>(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);
}
}
/// <summary>
/// Effective manifest URL respecting dev-mode gating.
/// </summary>
public string EffectiveManifestUrl()
{
if (DevMode && !string.IsNullOrWhiteSpace(ManifestUrlOverride))
return ManifestUrlOverride!;
return LauncherConfig.ManifestUrl;
}
/// <summary>
/// Derives signature URL from the (possibly overridden) manifest URL.
/// </summary>
public string EffectiveSignatureUrl()
{
var m = EffectiveManifestUrl();
if (m == LauncherConfig.ManifestUrl)
return LauncherConfig.SignatureUrl;
return m + ".sig";
}
/// <summary>
/// Derives the blob base URL from the (possibly overridden) manifest URL.
/// Same host, sibling /files path.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
namespace Metin2Launcher.Localization;
/// <summary>
/// Tiny flat key-value localization loader. Resources are embedded JSON dictionaries.
/// Lookup falls back: requested locale -> en -> the key itself.
/// </summary>
public static class Loc
{
private static readonly Dictionary<string, Dictionary<string, string>> _bundles = new();
private static string _current = "cs";
public static event Action? Changed;
public static string Current => _current;
public static IReadOnlyList<string> 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<string, string>();
return;
}
using var sr = new StreamReader(s);
var json = sr.ReadToEnd();
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new Dictionary<string, string>();
_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; }
}
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -7,12 +7,23 @@
<Nullable>enable</Nullable>
<RootNamespace>Metin2Launcher</RootNamespace>
<AssemblyName>Metin2Launcher</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<ApplicationManifest>app.manifest</ApplicationManifest>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
<PackageReference Include="Velopack" Version="0.0.1053" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Localization\cs.json" />
<EmbeddedResource Include="Localization\en.json" />
</ItemGroup>
</Project>

View File

@@ -41,7 +41,8 @@ public sealed class BlobDownloader
string sha256,
long expectedSize,
string destinationPath,
CancellationToken ct)
CancellationToken ct,
IProgress<long>? 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<long>? 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)

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.1.0.0" name="Metin2Launcher.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -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);
}
}

View File

@@ -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"));
}
}