launcher: scaffold avalonia main window, view models, and settings dialog

Adds the Avalonia App, MainWindow (900x560 fixed, dark-red banner, left
status/progress column, right news panel) and a modal SettingsDialog with
language + dev-mode override. MainWindowViewModel binds to the orchestrator
through IProgress, throttles UI updates to ~10 Hz via a DispatcherTimer
flush, drives state-specific status text and Play-button gating, fetches
previous-manifest notes best-effort, and forces a red status banner with
Play disabled on a SignatureFailed result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 13:15:44 +02:00
parent d4b9f56cb3
commit e28b243100
8 changed files with 548 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Metin2Launcher.UI.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

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

View File

@@ -0,0 +1,104 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Metin2Launcher.UI.MainWindow"
Width="900" Height="560"
CanResize="False"
WindowStartupLocation="CenterScreen"
SystemDecorations="Full"
Title="{Binding WindowTitle}">
<Grid RowDefinitions="100,*">
<!-- Banner -->
<Border Grid.Row="0" Background="#8B0000">
<Grid>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding BannerTitle}"
FontSize="32" FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding BannerVersion}"
FontSize="12"
Foreground="#FFD7D7"
HorizontalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,8,12,0"
Spacing="6">
<Button Content="⚙" Width="32" Height="32"
Foreground="White" Background="Transparent"
BorderBrush="#FFFFFF44" BorderThickness="1"
ToolTip.Tip="{Binding SettingsTooltip}"
Command="{Binding OpenSettingsCommand}"/>
<Button Content="✕" Width="32" Height="32"
Foreground="White" Background="Transparent"
BorderBrush="#FFFFFF44" BorderThickness="1"
ToolTip.Tip="{Binding CloseTooltip}"
Command="{Binding ExitCommand}"/>
</StackPanel>
</Grid>
</Border>
<!-- Body -->
<Grid Grid.Row="1" ColumnDefinitions="300,*">
<!-- Left -->
<Border Grid.Column="0" Padding="20" Background="#F4F4F4">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding StatusText}"
FontSize="18" FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{Binding StatusForeground}"/>
<ProgressBar Grid.Row="1"
Margin="0,16,0,4"
Height="18"
Minimum="0" Maximum="100"
Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsIndeterminate}"/>
<TextBlock Grid.Row="2"
Text="{Binding ProgressPercentText}"
FontSize="12"
Foreground="#555"/>
<TextBlock Grid.Row="3"
Margin="0,12,0,0"
Text="{Binding DetailText}"
FontSize="12"
TextWrapping="Wrap"
Foreground="#444"/>
<StackPanel Grid.Row="4"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Left">
<Button Content="{Binding PlayLabel}"
Width="110" Height="38"
FontSize="16" FontWeight="SemiBold"
Background="#8B0000" Foreground="White"
IsEnabled="{Binding CanPlay}"
Command="{Binding PlayCommand}"/>
<Button Content="{Binding ExitLabel}"
Width="110" Height="38"
FontSize="14"
Command="{Binding ExitCommand}"/>
</StackPanel>
</Grid>
</Border>
<!-- Right -->
<Border Grid.Column="1" Padding="20" Background="White">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="{Binding NewsTitle}"
FontSize="16" FontWeight="SemiBold"
Margin="0,0,0,8"/>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding NewsText}"
TextWrapping="Wrap"
FontSize="13"
Foreground="#222"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Grid>
</Window>

View File

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

View File

@@ -0,0 +1,40 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Metin2Launcher.UI.SettingsDialog"
Width="460" Height="280"
CanResize="False"
WindowStartupLocation="CenterOwner"
Title="{Binding Title}">
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0" Text="{Binding LanguageLabel}" FontWeight="SemiBold"/>
<ComboBox Grid.Row="1"
Margin="0,4,0,12"
Width="200" HorizontalAlignment="Left"
ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}"
DisplayMemberBinding="{Binding Display}"/>
<TextBlock Grid.Row="2" Text="{Binding ManifestUrlLabel}" FontWeight="SemiBold"/>
<TextBox Grid.Row="3"
Margin="0,4,0,8"
Text="{Binding ManifestUrl}"
IsEnabled="{Binding DevMode}"/>
<CheckBox Grid.Row="4"
Content="{Binding DevModeLabel}"
IsChecked="{Binding DevMode}"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="5"
Text="{Binding NoteLabel}"
FontSize="11" Foreground="#666"
TextWrapping="Wrap"/>
<StackPanel Grid.Row="6"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,12,0,0">
<Button Content="{Binding OkLabel}" Width="90" Click="OnOk"/>
<Button Content="{Binding CancelLabel}" Width="90" Click="OnCancel"/>
</StackPanel>
</Grid>
</Window>

View File

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

View File

@@ -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<UpdateProgress>(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<Manifest.Manifest>(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<bool>(_window);
if (!ok) return;
dialogVm.ApplyTo(_settings);
_settings.Save(_settingsPath);
Loc.SetLocale(_settings.Locale);
}
}

View File

@@ -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<LanguageOption> 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<LanguageOption>
{
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;
}
}