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