diff --git a/src/Metin2Launcher/UI/App.axaml b/src/Metin2Launcher/UI/App.axaml
new file mode 100644
index 0000000..48b44fe
--- /dev/null
+++ b/src/Metin2Launcher/UI/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/Metin2Launcher/UI/App.axaml.cs b/src/Metin2Launcher/UI/App.axaml.cs
new file mode 100644
index 0000000..5fde887
--- /dev/null
+++ b/src/Metin2Launcher/UI/App.axaml.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Metin2Launcher.UI.ViewModels;
+
+namespace Metin2Launcher.UI;
+
+public partial class App : Application
+{
+ public static MainWindowViewModel? MainViewModel { get; set; }
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var vm = MainViewModel ?? new MainWindowViewModel();
+ desktop.MainWindow = new MainWindow { DataContext = vm };
+ vm.AttachWindow(desktop.MainWindow);
+ desktop.MainWindow.Opened += async (_, _) => await vm.StartUpdateAsync();
+ }
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/Metin2Launcher/UI/MainWindow.axaml b/src/Metin2Launcher/UI/MainWindow.axaml
new file mode 100644
index 0000000..863927e
--- /dev/null
+++ b/src/Metin2Launcher/UI/MainWindow.axaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Metin2Launcher/UI/MainWindow.axaml.cs b/src/Metin2Launcher/UI/MainWindow.axaml.cs
new file mode 100644
index 0000000..1fe8a02
--- /dev/null
+++ b/src/Metin2Launcher/UI/MainWindow.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Metin2Launcher.UI;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/Metin2Launcher/UI/SettingsDialog.axaml b/src/Metin2Launcher/UI/SettingsDialog.axaml
new file mode 100644
index 0000000..1616b80
--- /dev/null
+++ b/src/Metin2Launcher/UI/SettingsDialog.axaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Metin2Launcher/UI/SettingsDialog.axaml.cs b/src/Metin2Launcher/UI/SettingsDialog.axaml.cs
new file mode 100644
index 0000000..37bdaf5
--- /dev/null
+++ b/src/Metin2Launcher/UI/SettingsDialog.axaml.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Metin2Launcher.UI;
+
+public partial class SettingsDialog : Window
+{
+ public SettingsDialog()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private void OnOk(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ Close(true);
+ }
+
+ private void OnCancel(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ Close(false);
+ }
+}
diff --git a/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..4e7db21
--- /dev/null
+++ b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,274 @@
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Media;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Metin2Launcher.Config;
+using Metin2Launcher.GameLaunch;
+using Metin2Launcher.Localization;
+using Metin2Launcher.Logging;
+using Metin2Launcher.Manifest;
+using Metin2Launcher.Orchestration;
+
+namespace Metin2Launcher.UI.ViewModels;
+
+public sealed partial class MainWindowViewModel : ObservableObject
+{
+ private readonly string _clientRoot;
+ private readonly string _stateDir;
+ private readonly string _settingsPath;
+ private readonly LauncherSettings _settings;
+ private readonly HttpClient _http;
+ private MainWindow? _window;
+
+ // Throttling
+ private UpdateProgress? _pendingProgress;
+ private readonly object _progressLock = new();
+ private DispatcherTimer? _flushTimer;
+
+ [ObservableProperty] private string _statusText = "";
+ [ObservableProperty] private string _detailText = "";
+ [ObservableProperty] private string _newsText = "";
+ [ObservableProperty] private double _progressValue;
+ [ObservableProperty] private bool _isIndeterminate;
+ [ObservableProperty] private bool _canPlay;
+ [ObservableProperty] private IBrush _statusForeground = Brushes.Black;
+ [ObservableProperty] private string _progressPercentText = "";
+
+ public string WindowTitle => Loc.Get("window.title");
+ public string BannerTitle => Loc.Get("banner.title");
+ public string BannerVersion => Loc.Get("banner.version", "0.1-dev");
+ public string PlayLabel => Loc.Get("button.play");
+ public string ExitLabel => Loc.Get("button.exit");
+ public string SettingsTooltip => Loc.Get("button.settings");
+ public string CloseTooltip => Loc.Get("button.close");
+ public string NewsTitle => Loc.Get("news.title");
+
+ private LauncherState _state = LauncherState.Idle;
+ private LoadedManifest? _lastManifest;
+ private bool _signatureBlocked;
+
+ public MainWindowViewModel()
+ {
+ _clientRoot = Directory.GetCurrentDirectory();
+ _stateDir = Path.Combine(_clientRoot, LauncherConfig.StateDirName);
+ Directory.CreateDirectory(_stateDir);
+ _settingsPath = Path.Combine(_stateDir, "launcher-settings.json");
+ _settings = LauncherSettings.Load(_settingsPath);
+ Loc.SetLocale(_settings.Locale);
+ Loc.Changed += OnLocaleChanged;
+
+ _http = new HttpClient { Timeout = LauncherConfig.BlobFetchTimeout };
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd("Metin2Launcher/0.1");
+
+ _statusText = Loc.Get("status.idle");
+ _newsText = "";
+ }
+
+ public void AttachWindow(Window w) => _window = w as MainWindow;
+
+ private void OnLocaleChanged()
+ {
+ OnPropertyChanged(nameof(WindowTitle));
+ OnPropertyChanged(nameof(BannerTitle));
+ OnPropertyChanged(nameof(BannerVersion));
+ OnPropertyChanged(nameof(PlayLabel));
+ OnPropertyChanged(nameof(ExitLabel));
+ OnPropertyChanged(nameof(SettingsTooltip));
+ OnPropertyChanged(nameof(CloseTooltip));
+ OnPropertyChanged(nameof(NewsTitle));
+ // Refresh status text from current state
+ ApplyState(_state);
+ }
+
+ public async Task StartUpdateAsync()
+ {
+ // ~10 Hz UI flush timer
+ _flushTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
+ _flushTimer.Tick += (_, _) => FlushProgress();
+ _flushTimer.Start();
+
+ var orchestrator = new UpdateOrchestrator(_clientRoot, _settings, _http);
+ var progress = new Progress(p =>
+ {
+ lock (_progressLock) { _pendingProgress = p; }
+ });
+
+ UpdateOrchestrator.Result? result = null;
+ try
+ {
+ using var cts = new CancellationTokenSource(LauncherConfig.TotalUpdateTimeout);
+ result = await Task.Run(() => orchestrator.RunAsync(progress, cts.Token));
+ }
+ catch (ManifestSignatureException)
+ {
+ _signatureBlocked = true;
+ ApplyState(LauncherState.SignatureFailed);
+ FlushProgress();
+ return;
+ }
+ catch (Exception ex)
+ {
+ Log.Error("update flow crashed, falling back to offline launch", ex);
+ ApplyState(LauncherState.OfflineFallback);
+ }
+
+ FlushProgress();
+
+ if (result is not null)
+ {
+ _lastManifest = result.Manifest;
+ ApplyState(result.FinalState);
+ await TryFetchNewsAsync(result.Manifest);
+ }
+
+ _flushTimer?.Stop();
+ }
+
+ private void FlushProgress()
+ {
+ UpdateProgress? p;
+ lock (_progressLock) { p = _pendingProgress; _pendingProgress = null; }
+ if (p is null) return;
+
+ if (p.Manifest is not null) _lastManifest = p.Manifest;
+ ApplyState(p.State);
+
+ if (p.Percent.HasValue)
+ {
+ IsIndeterminate = false;
+ ProgressValue = p.Percent.Value;
+ ProgressPercentText = $"{(int)p.Percent.Value}%";
+ }
+ else
+ {
+ IsIndeterminate = true;
+ ProgressPercentText = "";
+ }
+
+ if (!string.IsNullOrEmpty(p.Detail))
+ DetailText = p.Detail!;
+ }
+
+ private void ApplyState(LauncherState s)
+ {
+ _state = s;
+ StatusText = s switch
+ {
+ LauncherState.Idle => Loc.Get("status.idle"),
+ LauncherState.FetchingManifest => Loc.Get("status.fetchingManifest"),
+ LauncherState.VerifyingSignature => Loc.Get("status.verifyingSignature"),
+ LauncherState.Diffing => Loc.Get("status.diffing"),
+ LauncherState.Downloading => Loc.Get("status.downloading"),
+ LauncherState.Applying => Loc.Get("status.applying"),
+ LauncherState.UpToDate => Loc.Get("status.upToDate"),
+ LauncherState.OfflineFallback => Loc.Get("status.offlineFallback"),
+ LauncherState.SignatureFailed => Loc.Get("status.signatureFailed"),
+ LauncherState.Launching => Loc.Get("status.launching"),
+ _ => "",
+ };
+
+ StatusForeground = s == LauncherState.SignatureFailed ? Brushes.DarkRed : Brushes.Black;
+
+ var indeterminate = s is LauncherState.FetchingManifest
+ or LauncherState.VerifyingSignature
+ or LauncherState.Diffing;
+ IsIndeterminate = indeterminate;
+
+ if (s is LauncherState.UpToDate or LauncherState.OfflineFallback)
+ {
+ ProgressValue = 100;
+ ProgressPercentText = "100%";
+ }
+
+ CanPlay = !_signatureBlocked
+ && s is LauncherState.UpToDate
+ or LauncherState.OfflineFallback;
+ }
+
+ private async Task TryFetchNewsAsync(LoadedManifest? loaded)
+ {
+ if (loaded is null)
+ {
+ NewsText = Loc.Get("news.empty");
+ return;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"v{loaded.Manifest.Version} ({loaded.Manifest.CreatedAt})");
+ sb.AppendLine();
+ sb.AppendLine(string.IsNullOrWhiteSpace(loaded.Manifest.Notes) ? "—" : loaded.Manifest.Notes);
+
+ var prev = loaded.Manifest.Previous;
+ if (!string.IsNullOrEmpty(prev))
+ {
+ try
+ {
+ var manifestUrl = _settings.EffectiveManifestUrl();
+ var baseUri = new Uri(manifestUrl);
+ var prevUrl = new Uri(baseUri, $"manifests/{prev}.json").ToString();
+ var json = await _http.GetStringAsync(prevUrl);
+ var prevManifest = System.Text.Json.JsonSerializer.Deserialize(json);
+ if (prevManifest is not null && !string.IsNullOrWhiteSpace(prevManifest.Notes))
+ {
+ sb.AppendLine();
+ sb.AppendLine();
+ sb.AppendLine(Loc.Get("news.previousHeader", prev));
+ sb.AppendLine(prevManifest.Notes);
+ }
+ }
+ catch
+ {
+ // best effort
+ }
+ }
+
+ NewsText = sb.ToString();
+ }
+
+ [RelayCommand]
+ private void Exit()
+ {
+ if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
+ d.Shutdown(0);
+ else
+ Environment.Exit(0);
+ }
+
+ [RelayCommand]
+ private void Play()
+ {
+ if (!CanPlay) return;
+ ApplyState(LauncherState.Launching);
+ var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable);
+ if (ok)
+ {
+ if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
+ d.Shutdown(0);
+ }
+ else
+ {
+ // Re-enable so user can retry
+ CanPlay = true;
+ }
+ }
+
+ [RelayCommand]
+ private async Task OpenSettings()
+ {
+ if (_window is null) return;
+ var dialogVm = new SettingsDialogViewModel(_settings);
+ var dialog = new SettingsDialog { DataContext = dialogVm };
+ var ok = await dialog.ShowDialog(_window);
+ if (!ok) return;
+
+ dialogVm.ApplyTo(_settings);
+ _settings.Save(_settingsPath);
+ Loc.SetLocale(_settings.Locale);
+ }
+}
diff --git a/src/Metin2Launcher/UI/ViewModels/SettingsDialogViewModel.cs b/src/Metin2Launcher/UI/ViewModels/SettingsDialogViewModel.cs
new file mode 100644
index 0000000..c0d6d15
--- /dev/null
+++ b/src/Metin2Launcher/UI/ViewModels/SettingsDialogViewModel.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Metin2Launcher.Config;
+using Metin2Launcher.Localization;
+
+namespace Metin2Launcher.UI.ViewModels;
+
+public sealed class LanguageOption
+{
+ public string Code { get; }
+ public string Display { get; }
+ public LanguageOption(string code, string display) { Code = code; Display = display; }
+ public override string ToString() => Display;
+}
+
+public sealed partial class SettingsDialogViewModel : ObservableObject
+{
+ [ObservableProperty] private LanguageOption _selectedLanguage;
+ [ObservableProperty] private string _manifestUrl;
+ [ObservableProperty] private bool _devMode;
+
+ public List Languages { get; }
+
+ public string Title => Loc.Get("settings.title");
+ public string LanguageLabel => Loc.Get("settings.language");
+ public string ManifestUrlLabel => Loc.Get("settings.manifestUrl");
+ public string DevModeLabel => Loc.Get("settings.devMode");
+ public string NoteLabel => Loc.Get("settings.note");
+ public string OkLabel => Loc.Get("button.ok");
+ public string CancelLabel => Loc.Get("button.cancel");
+
+ public SettingsDialogViewModel(LauncherSettings settings)
+ {
+ Languages = new List
+ {
+ new("cs", Loc.Get("settings.language.cs")),
+ new("en", Loc.Get("settings.language.en")),
+ };
+ _selectedLanguage = Languages.Find(l => l.Code == settings.Locale) ?? Languages[0];
+ _manifestUrl = settings.ManifestUrlOverride ?? LauncherConfig.ManifestUrl;
+ _devMode = settings.DevMode;
+ }
+
+ public void ApplyTo(LauncherSettings settings)
+ {
+ settings.Locale = SelectedLanguage.Code;
+ settings.DevMode = DevMode;
+ settings.ManifestUrlOverride = ManifestUrl == LauncherConfig.ManifestUrl ? null : ManifestUrl;
+ }
+}