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:
93
src/Metin2Launcher/Config/LauncherSettings.cs
Normal file
93
src/Metin2Launcher/Config/LauncherSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/Metin2Launcher/Localization/Loc.cs
Normal file
70
src/Metin2Launcher/Localization/Loc.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
32
src/Metin2Launcher/Localization/cs.json
Normal file
32
src/Metin2Launcher/Localization/cs.json
Normal 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."
|
||||
}
|
||||
32
src/Metin2Launcher/Localization/en.json
Normal file
32
src/Metin2Launcher/Localization/en.json
Normal 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."
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
src/Metin2Launcher/app.manifest
Normal file
10
src/Metin2Launcher/app.manifest
Normal 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>
|
||||
70
tests/Metin2Launcher.Tests/LauncherSettingsTests.cs
Normal file
70
tests/Metin2Launcher.Tests/LauncherSettingsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
tests/Metin2Launcher.Tests/LocTests.cs
Normal file
42
tests/Metin2Launcher.Tests/LocTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user