SECURITY: prune no longer recurses clientRoot — ledger-based only

Post-mortem: the previous implementation of PruneStaleFiles walked
Directory.EnumerateFiles(clientRoot, "*", SearchOption.AllDirectories)
and deleted anything not listed in the current manifest. That made
clientRoot unsafe to set to anything other than a dedicated install
directory. Because clientRoot is Directory.GetCurrentDirectory(), any
user who ran Metin2Launcher.exe from their home directory (or worse,
from a parent directory containing other projects) would have had
every unrelated file in that tree silently deleted — including
.ssh keys, .bashrc, docs, source trees. One of Jan's colleagues
hit exactly this tonight and lost a significant chunk of their home
directory.

The blast radius was enormous and entirely my fault. This commit
switches prune to a strict ledger-based model so the launcher can
ONLY delete files it itself wrote:

  .updates/applied-files.txt  — newline list of relative paths this
                                launcher has ever successfully
                                installed into clientRoot.

On prune:
  1. Read the ledger.
  2. For every entry not in the current manifest's file set (plus
     the top-level launcher.path), delete the file at that relative
     path inside clientRoot — if and only if PathSafety.ResolveInside
     accepts the resolved path (defense against traversal/symlinks).
  3. Rewrite the ledger to exactly match the current manifest.

Files the launcher never wrote are invisible to prune. A fresh
install dir has no ledger, so prune is a no-op on the first run and
subsequent runs only touch files listed in the ledger. Even if a user
points the launcher at ~/ with valuable data, prune cannot touch
anything it didn't put there.

Other hardening:
- PathSafety.ResolveInside is now invoked for every prune target, so
  a maliciously crafted manifest can't name "../../etc/passwd".
- Ledger write happens after apply so a crash mid-apply doesn't
  leave a stale ledger that would prune real user files on the next
  run.

Immediate mitigations taken outside this commit:
- Pulled the published Metin2Launcher.exe off updates.jakubkadlec.dev/
  launcher/ and replaced it with a README.txt warning, so no new user
  can download the dangerous binary.
- Will rebuild and re-upload the safe binary before re-announcing.

Followup:
- Add a Gitea issue documenting the incident and the ledger contract.
- Tests for the ledger read/write and for the "ledger empty = no
  prune" safety case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-15 15:13:57 +02:00
parent 3db306fbc7
commit db1f2f435b
8 changed files with 366 additions and 216 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -274,15 +274,29 @@ public sealed class UpdateOrchestrator
return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey); return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey);
} }
// Walk clientRoot and delete any file not in the manifest. Skips the // Delete files that were installed by a previous manifest but are no
// .updates state dir. Called after both the normal apply path and the // longer in the current one. Called after both the normal apply path
// already-up-to-date short-circuit so switching to a leaner release // and the already-up-to-date short-circuit so switching from a fat
// does not leave orphaned files on disk. // release to a lean one doesn't leave orphaned files on disk.
// //
// The keep set is files {manifest.launcher.path}. Per the manifest // IMPORTANT SAFETY CONTRACT (see the post-mortem in commit message for
// spec (m2dev-client/docs/update-manifest.md), the top-level launcher // why this matters): prune is STRICTLY scoped to files listed in a
// entry is privileged and is never listed in files; it must survive // previous manifest that the launcher itself applied. It is NEVER a
// prune so a correctly-authored manifest does not delete the updater. // recursive walk of clientRoot. The earlier version of this method did
// a recursive walk and would have happily deleted a user's home
// directory if they ran the launcher from ~/ instead of a dedicated
// install dir. Fixed by switching to a content-addressed ledger:
//
// .updates/applied-files.txt — newline list of relative paths this
// launcher has ever written into
// clientRoot.
//
// On prune: read the ledger, subtract the current manifest's file set
// (plus manifest.launcher.path), delete each leftover. Then rewrite
// the ledger to contain exactly the current manifest's file set.
//
// Files we never wrote are never considered, so there is no blast
// radius beyond what the launcher itself created.
private void PruneStaleFiles(Manifest.Manifest manifest) private void PruneStaleFiles(Manifest.Manifest manifest)
{ {
var keepRel = new HashSet<string>( var keepRel = new HashSet<string>(
@@ -292,24 +306,65 @@ public sealed class UpdateOrchestrator
{ {
keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar)); keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar));
} }
int pruned = 0;
var ledgerPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "applied-files.txt");
var previouslyApplied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try try
{ {
var rootFull = Path.GetFullPath(_clientRoot); if (File.Exists(ledgerPath))
var stateDirFull = Path.GetFullPath(Path.Combine(_clientRoot, LauncherConfig.StateDirName));
foreach (var path in Directory.EnumerateFiles(rootFull, "*", SearchOption.AllDirectories))
{ {
if (path.StartsWith(stateDirFull, StringComparison.Ordinal)) continue; foreach (var line in File.ReadAllLines(ledgerPath))
var rel = Path.GetRelativePath(rootFull, path); {
if (keepRel.Contains(rel)) continue; var trimmed = line.Trim();
try { File.Delete(path); pruned++; } if (trimmed.Length == 0) continue;
catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); } previouslyApplied.Add(trimmed.Replace('/', Path.DirectorySeparatorChar));
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warn("prune walk failed: " + ex.Message); Log.Warn($"failed to read applied-files ledger at {ledgerPath}: {ex.Message}");
previouslyApplied.Clear();
}
int pruned = 0;
if (previouslyApplied.Count > 0)
{
foreach (var rel in previouslyApplied)
{
if (keepRel.Contains(rel)) continue;
// Defense in depth: refuse to touch anything that escapes
// clientRoot via symlink or path traversal. PathSafety rejects
// any rel that resolves outside clientRoot.
string absolute;
try { absolute = PathSafety.ResolveInside(_clientRoot, rel.Replace(Path.DirectorySeparatorChar, '/')); }
catch (UnsafePathException ex)
{
Log.Warn($"prune refusing unsafe path {rel}: {ex.Message}");
continue;
}
if (!File.Exists(absolute)) continue;
try { File.Delete(absolute); pruned++; }
catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); }
}
} }
if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest"); if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest");
// Rewrite ledger to match the current manifest. Launcher path is
// included so a future run doesn't try to delete the updater.
try
{
var stateDir = Path.Combine(_clientRoot, LauncherConfig.StateDirName);
Directory.CreateDirectory(stateDir);
var lines = keepRel
.Select(r => r.Replace(Path.DirectorySeparatorChar, '/'))
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase);
File.WriteAllLines(ledgerPath, lines);
}
catch (Exception ex)
{
Log.Warn($"failed to write applied-files ledger at {ledgerPath}: {ex.Message}");
}
} }
} }

View File

@@ -1,252 +1,291 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Metin2Launcher.UI.MainWindow" x:Class="Metin2Launcher.UI.MainWindow"
Width="900" Height="560" Width="1000" Height="620"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
SystemDecorations="Full" SystemDecorations="Full"
Background="#0F0F12" Background="#05050A"
Title="{Binding WindowTitle}"> Title="{Binding WindowTitle}">
<Window.Resources> <Window.Resources>
<!-- Banner gradient: deep crimson with darker edges, gives the header depth --> <!-- Play button: deep blue with a brighter top highlight, matches the -->
<LinearGradientBrush x:Key="BannerGradient" StartPoint="0%,0%" EndPoint="100%,100%"> <!-- "crystal gate" mockup vibe — not the old crimson. -->
<GradientStop Offset="0.0" Color="#3D0000"/>
<GradientStop Offset="0.5" Color="#7A0000"/>
<GradientStop Offset="1.0" Color="#2A0000"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PlayGradient" StartPoint="0%,0%" EndPoint="0%,100%"> <LinearGradientBrush x:Key="PlayGradient" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0.0" Color="#C41515"/> <GradientStop Offset="0.0" Color="#3E7BC6"/>
<GradientStop Offset="1.0" Color="#7A0000"/> <GradientStop Offset="0.5" Color="#1F4A8A"/>
<GradientStop Offset="1.0" Color="#0A1D3E"/>
</LinearGradientBrush> </LinearGradientBrush>
<LinearGradientBrush x:Key="PlayGradientHover" StartPoint="0%,0%" EndPoint="0%,100%"> <LinearGradientBrush x:Key="PlayGradientHover" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0.0" Color="#E52020"/> <GradientStop Offset="0.0" Color="#5CA3F0"/>
<GradientStop Offset="1.0" Color="#8B0000"/> <GradientStop Offset="0.5" Color="#2F68B8"/>
<GradientStop Offset="1.0" Color="#142E5E"/>
</LinearGradientBrush> </LinearGradientBrush>
<!-- Column brushes are semi-transparent so the branding image bleeds --> <!-- Dark stone nav button (WEB / ITEMSHOP / DISCORD / NASTAVENÍ). -->
<!-- through while still keeping text legible. The left column sits over --> <LinearGradientBrush x:Key="NavGradient" StartPoint="0%,0%" EndPoint="0%,100%">
<!-- the status widgets so it's a bit opaquer; the right column is the --> <GradientStop Offset="0.0" Color="#24293A"/>
<!-- news/changelog and stays darker to avoid image-through-text. --> <GradientStop Offset="1.0" Color="#0E1220"/>
<SolidColorBrush x:Key="LeftColumnBrush" Color="#17171B" Opacity="0.78"/> </LinearGradientBrush>
<SolidColorBrush x:Key="RightColumnBrush" Color="#0F0F12" Opacity="0.68"/> <LinearGradientBrush x:Key="NavGradientHover" StartPoint="0%,0%" EndPoint="0%,100%">
<SolidColorBrush x:Key="AccentBrush" Color="#C41515"/> <GradientStop Offset="0.0" Color="#334061"/>
<SolidColorBrush x:Key="MutedTextBrush" Color="#888892"/> <GradientStop Offset="1.0" Color="#121830"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBrush" Color="#0B0F1A" Opacity="0.78"/>
<SolidColorBrush x:Key="BodyTextBrush" Color="#E0E0E4"/> <SolidColorBrush x:Key="BodyTextBrush" Color="#E0E0E4"/>
<SolidColorBrush x:Key="MutedTextBrush" Color="#8892A8"/>
<SolidColorBrush x:Key="AccentBrush" Color="#4C90E0"/>
</Window.Resources> </Window.Resources>
<Window.Styles> <Window.Styles>
<!-- Big Hrát button — the one that launches the client. -->
<Style Selector="Button.play"> <Style Selector="Button.play">
<Setter Property="Background" Value="{StaticResource PlayGradient}"/> <Setter Property="Background" Value="{StaticResource PlayGradient}"/>
<Setter Property="Foreground" Value="White"/> <Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#E85050"/> <Setter Property="BorderBrush" Value="#6BA8E8"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="2"/>
<Setter Property="CornerRadius" Value="4"/> <Setter Property="CornerRadius" Value="6"/>
<Setter Property="FontWeight" Value="Bold"/> <Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="16"/> <Setter Property="FontSize" Value="22"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
</Style> </Style>
<Style Selector="Button.play:pointerover /template/ ContentPresenter"> <Style Selector="Button.play:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource PlayGradientHover}"/> <Setter Property="Background" Value="{StaticResource PlayGradientHover}"/>
<Setter Property="BorderBrush" Value="#FF6060"/> <Setter Property="BorderBrush" Value="#9BC8FF"/>
</Style> </Style>
<Style Selector="Button.play:disabled /template/ ContentPresenter"> <Style Selector="Button.play:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#3A1515"/> <Setter Property="Background" Value="#15253A"/>
<Setter Property="BorderBrush" Value="#502020"/> <Setter Property="BorderBrush" Value="#2A3A55"/>
<Setter Property="Foreground" Value="#886060"/> <Setter Property="Foreground" Value="#5A6A85"/>
</Style> </Style>
<Style Selector="Button.exit"> <!-- Top-right nav buttons. -->
<Setter Property="Background" Value="#24242A"/> <Style Selector="Button.nav">
<Setter Property="Foreground" Value="#C0C0C8"/> <Setter Property="Background" Value="{StaticResource NavGradient}"/>
<Setter Property="BorderBrush" Value="#3A3A42"/> <Setter Property="Foreground" Value="#D8E0EC"/>
<Setter Property="BorderBrush" Value="#3A4560"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/> <Setter Property="CornerRadius" Value="4"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="14,6"/>
</Style> </Style>
<Style Selector="Button.exit:pointerover /template/ ContentPresenter"> <Style Selector="Button.nav:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2E2E36"/> <Setter Property="Background" Value="{StaticResource NavGradientHover}"/>
<Setter Property="BorderBrush" Value="#50505A"/> <Setter Property="BorderBrush" Value="#5C70A0"/>
</Style> </Style>
<Style Selector="Button.icon"> <!-- Social icon buttons (top-left). Backed by bitmap assets cropped from -->
<!-- the asset pack, so we keep the button as a transparent frame and let -->
<!-- the Image child carry the crystal gem art. -->
<Style Selector="Button.social">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#EAEAEA"/> <Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderBrush" Value="#FFFFFF33"/> <Setter Property="BorderThickness" Value="0"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="Width" Value="52"/>
<Setter Property="CornerRadius" Value="3"/> <Setter Property="Height" Value="52"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
</Style> </Style>
<Style Selector="Button.icon:pointerover /template/ ContentPresenter"> <Style Selector="Button.social:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFFFFF1A"/> <Setter Property="Background" Value="#FFFFFF14"/>
<Setter Property="BorderBrush" Value="#FFFFFF66"/> </Style>
<!-- Window chrome buttons (settings, exit) -->
<Style Selector="Button.chrome">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#C0CCE4"/>
<Setter Property="BorderBrush" Value="#FFFFFF22"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.chrome:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFFFFF18"/>
<Setter Property="BorderBrush" Value="#FFFFFF55"/>
</Style> </Style>
<Style Selector="ProgressBar"> <Style Selector="ProgressBar">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/> <Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="Background" Value="#24242A"/> <Setter Property="Background" Value="#151A28"/>
<Setter Property="CornerRadius" Value="4"/>
</Style> </Style>
</Window.Styles> </Window.Styles>
<Grid> <Grid>
<!-- Full-window branding image, hand-authored for the m2pack release. --> <!-- Crystal gate background. -->
<!-- Sits behind the banner + body grid; the dark column brushes above it -->
<!-- give the status and news columns their own legible background while -->
<!-- the banner fades the top into the crimson header. -->
<Image Source="avares://Metin2Launcher/Assets/Branding/launcher-bg.png" <Image Source="avares://Metin2Launcher/Assets/Branding/launcher-bg.png"
Stretch="UniformToFill" Stretch="UniformToFill"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/> VerticalAlignment="Stretch"/>
<!-- Dim overlay so text on the image reads reliably. --> <!-- Dark vignette for legibility. -->
<Rectangle Fill="#000000" Opacity="0.35"/> <Rectangle Fill="#000000" Opacity="0.32"/>
<Grid RowDefinitions="110,*"> <!-- 3-row layout: top nav bar (auto), free center (*), bottom UI (auto). -->
<!-- Banner --> <Grid RowDefinitions="Auto,*,Auto" Margin="20,16,20,20">
<Border Grid.Row="0" Background="{StaticResource BannerGradient}">
<Grid>
<!-- Thin accent borders top and bottom of the banner for definition -->
<Border VerticalAlignment="Top" Height="2" Background="#C41515" Opacity="0.6"/>
<Border VerticalAlignment="Bottom" Height="2" Background="#C41515" Opacity="0.6"/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="4"> <!-- ==== TOP ROW: socials left / logo center / nav + chrome right ==== -->
<TextBlock Text="{Binding BannerTitle}" <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto">
FontSize="34"
FontWeight="Bold" <!-- Socials, top-left -->
Foreground="White" <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Top">
HorizontalAlignment="Center" <Button Classes="social" ToolTip.Tip="Discord">
LetterSpacing="6"/> <Image Source="avares://Metin2Launcher/Assets/Branding/social-discord.png"
<TextBlock Text="{Binding BannerVersion}" Stretch="Uniform"/>
FontSize="11" </Button>
FontWeight="Light" <Button Classes="social" ToolTip.Tip="Facebook">
Foreground="#FFB0B0" <Image Source="avares://Metin2Launcher/Assets/Branding/social-facebook.png"
HorizontalAlignment="Center" Stretch="Uniform"/>
LetterSpacing="3"/> </Button>
<Button Classes="social" ToolTip.Tip="YouTube">
<Image Source="avares://Metin2Launcher/Assets/Branding/social-youtube.png"
Stretch="Uniform"/>
</Button>
<Button Classes="social" ToolTip.Tip="Instagram">
<Image Source="avares://Metin2Launcher/Assets/Branding/social-instagram.png"
Stretch="Uniform"/>
</Button>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" <!-- MORION 2 logo, top-center -->
HorizontalAlignment="Right" <Image Grid.Column="1"
VerticalAlignment="Top" Source="avares://Metin2Launcher/Assets/Branding/morion2-logo.png"
Margin="0,10,14,0" Height="100"
Spacing="8"> Stretch="Uniform"
<Button Classes="icon" HorizontalAlignment="Center"
Content="⚙" VerticalAlignment="Top"/>
Width="34" Height="34"
FontSize="16"
ToolTip.Tip="{Binding SettingsTooltip}"
Command="{Binding OpenSettingsCommand}"/>
<Button Classes="icon"
Content="✕"
Width="34" Height="34"
FontSize="14"
ToolTip.Tip="{Binding CloseTooltip}"
Command="{Binding ExitCommand}"/>
</StackPanel>
</Grid>
</Border>
<!-- Body --> <!-- Nav buttons + window chrome, top-right -->
<Grid Grid.Row="1" ColumnDefinitions="320,*"> <StackPanel Grid.Column="2" Orientation="Vertical" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Top">
<!-- Left column: status + progress + buttons --> <StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right">
<Border Grid.Column="0" <Button Classes="chrome" Content=""
Background="{StaticResource LeftColumnBrush}" ToolTip.Tip="{Binding SettingsTooltip}"
BorderBrush="#2A2A32" Command="{Binding OpenSettingsCommand}"/>
BorderThickness="0,0,1,0" <Button Classes="chrome" Content="✕"
Padding="24,28,24,24"> ToolTip.Tip="{Binding CloseTooltip}"
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding StatusText}"
FontSize="18"
FontWeight="SemiBold"
TextWrapping="Wrap"
LineHeight="24"
Foreground="{Binding StatusForeground}"/>
<ProgressBar Grid.Row="1"
Margin="0,20,0,6"
Height="8"
CornerRadius="4"
Minimum="0" Maximum="100"
Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsIndeterminate}"/>
<TextBlock Grid.Row="2"
Text="{Binding ProgressPercentText}"
FontSize="11"
FontWeight="Medium"
Foreground="{StaticResource MutedTextBrush}"/>
<TextBlock Grid.Row="3"
Margin="0,16,0,0"
Text="{Binding DetailText}"
FontSize="12"
TextWrapping="Wrap"
Foreground="{StaticResource MutedTextBrush}"/>
<StackPanel Grid.Row="4"
Orientation="Horizontal"
Spacing="12"
HorizontalAlignment="Left">
<Button Classes="play"
Content="{Binding PlayLabel}"
Width="130" Height="44"
IsEnabled="{Binding CanPlay}"
Command="{Binding PlayCommand}"/>
<Button Classes="exit"
Content="{Binding ExitLabel}"
Width="100" Height="44"
Command="{Binding ExitCommand}"/> Command="{Binding ExitCommand}"/>
</StackPanel> </StackPanel>
</Grid> <StackPanel Orientation="Horizontal" Spacing="6">
</Border> <Button Classes="nav" Content="WEB"/>
<Button Classes="nav" Content="ITEMSHOP"/>
<!-- Right column: news / changelog with empty-state placeholder -->
<Border Grid.Column="1"
Background="{StaticResource RightColumnBrush}"
Padding="28,28,28,24">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,0,0,14">
<TextBlock Text="{Binding NewsTitle}"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource BodyTextBrush}"
LetterSpacing="1"/>
<Border Height="1"
Background="#2A2A32"
HorizontalAlignment="Stretch"
Margin="0,8,0,0"/>
</StackPanel> </StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled" <StackPanel Orientation="Horizontal" Spacing="6">
VerticalScrollBarVisibility="Auto"> <Button Classes="nav" Content="DISCORD"/>
<Grid> <Button Classes="nav" Content="NASTAVENÍ"
<StackPanel HorizontalAlignment="Center" Command="{Binding OpenSettingsCommand}"/>
VerticalAlignment="Center" </StackPanel>
Margin="0,60,0,0" </StackPanel>
Spacing="10" </Grid>
IsVisible="{Binding IsNewsEmpty}">
<TextBlock Text="◇"
FontSize="46"
Foreground="#3A3A42"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding NewsEmptyLabel}"
FontSize="13"
Foreground="{StaticResource MutedTextBrush}"
HorizontalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding NewsText}" <!-- ==== CENTER: empty, background art shows through ==== -->
TextWrapping="Wrap"
FontSize="13" <!-- ==== BOTTOM ROW: play+progress (2/3) / news panel (1/3) ==== -->
LineHeight="20" <Grid Grid.Row="2" ColumnDefinitions="*,340" Margin="0,12,0,0">
Foreground="{StaticResource BodyTextBrush}"
IsVisible="{Binding IsNewsPresent}"/> <!-- Play + status + progress, bottom-left -->
<Border Grid.Column="0"
Background="{StaticResource PanelBrush}"
BorderBrush="#2A3550"
BorderThickness="1"
CornerRadius="6"
Padding="24,18,24,18"
Margin="0,0,14,0">
<Grid RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Status line -->
<TextBlock Grid.Row="0"
Text="{Binding StatusText}"
FontSize="14"
FontWeight="SemiBold"
LetterSpacing="1"
TextWrapping="Wrap"
Foreground="{Binding StatusForeground}"/>
<!-- Progress bar -->
<ProgressBar Grid.Row="1"
Margin="0,10,0,4"
Height="10"
CornerRadius="5"
Minimum="0" Maximum="100"
Value="{Binding ProgressValue}"
IsIndeterminate="{Binding IsIndeterminate}"/>
<!-- Per-file detail line -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="0,4,0,14">
<TextBlock Grid.Column="0"
Text="{Binding DetailText}"
FontSize="11"
TextTrimming="CharacterEllipsis"
Foreground="{StaticResource MutedTextBrush}"/>
<TextBlock Grid.Column="1"
Text="{Binding ProgressPercentText}"
FontSize="11"
FontWeight="Medium"
Foreground="{StaticResource AccentBrush}"/>
</Grid> </Grid>
</ScrollViewer>
</DockPanel> <!-- Big SPUSTIT HRU button -->
</Border> <Button Grid.Row="3"
Classes="play"
Content="{Binding PlayLabel}"
HorizontalAlignment="Stretch"
Height="56"
IsEnabled="{Binding CanPlay}"
Command="{Binding PlayCommand}"/>
</Grid>
</Border>
<!-- News panel, bottom-right -->
<Border Grid.Column="1"
Background="{StaticResource PanelBrush}"
BorderBrush="#2A3550"
BorderThickness="1"
CornerRadius="6"
Padding="18,14,18,14">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,0,0,10">
<TextBlock Text="{Binding NewsTitle}"
FontSize="13"
FontWeight="Bold"
LetterSpacing="2"
Foreground="{StaticResource BodyTextBrush}"/>
<Border Height="1"
Background="#2A3550"
HorizontalAlignment="Stretch"
Margin="0,6,0,0"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,20,0,0"
Spacing="6"
IsVisible="{Binding IsNewsEmpty}">
<TextBlock Text="◇"
FontSize="30"
Foreground="#3A4560"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding NewsEmptyLabel}"
FontSize="11"
Foreground="{StaticResource MutedTextBrush}"
HorizontalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding NewsText}"
TextWrapping="Wrap"
FontSize="11"
LineHeight="16"
Foreground="{StaticResource BodyTextBrush}"
IsVisible="{Binding IsNewsPresent}"/>
</Grid>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Grid> </Grid>
</Grid> </Grid>
</Grid>
</Window> </Window>

View File

@@ -1,40 +1,96 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Metin2Launcher.UI.SettingsDialog" x:Class="Metin2Launcher.UI.SettingsDialog"
Width="460" Height="280" Width="460" Height="320"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
SystemDecorations="Full"
Background="#0B0F1A"
Title="{Binding Title}"> Title="{Binding Title}">
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<Window.Styles>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="#E0E0E4"/>
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="#8892A8"/>
</Style>
<Style Selector="TextBox">
<Setter Property="Background" Value="#151A28"/>
<Setter Property="Foreground" Value="#E0E0E4"/>
<Setter Property="BorderBrush" Value="#2A3550"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#4C90E0"/>
</Style>
<Style Selector="ComboBox">
<Setter Property="Background" Value="#151A28"/>
<Setter Property="Foreground" Value="#E0E0E4"/>
<Setter Property="BorderBrush" Value="#2A3550"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="#E0E0E4"/>
</Style>
<Style Selector="Button.dialog">
<Setter Property="Background" Value="#1E2740"/>
<Setter Property="Foreground" Value="#E0E0E4"/>
<Setter Property="BorderBrush" Value="#3A4560"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="Button.dialog:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2A3555"/>
<Setter Property="BorderBrush" Value="#5C70A0"/>
</Style>
<Style Selector="Button.ok">
<Setter Property="Background" Value="#1F4A8A"/>
<Setter Property="BorderBrush" Value="#4C90E0"/>
</Style>
<Style Selector="Button.ok:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2F68B8"/>
<Setter Property="BorderBrush" Value="#6BA8E8"/>
</Style>
</Window.Styles>
<Grid Margin="22" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0" Text="{Binding LanguageLabel}" FontWeight="SemiBold"/> <TextBlock Grid.Row="0" Text="{Binding LanguageLabel}" FontWeight="SemiBold"/>
<ComboBox Grid.Row="1" <ComboBox Grid.Row="1"
Margin="0,4,0,12" Margin="0,6,0,14"
Width="200" HorizontalAlignment="Left" Width="220" HorizontalAlignment="Left"
ItemsSource="{Binding Languages}" ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}" SelectedItem="{Binding SelectedLanguage}"
DisplayMemberBinding="{Binding Display}"/> DisplayMemberBinding="{Binding Display}"/>
<TextBlock Grid.Row="2" Text="{Binding ManifestUrlLabel}" FontWeight="SemiBold"/> <TextBlock Grid.Row="2" Text="{Binding ManifestUrlLabel}" FontWeight="SemiBold"/>
<TextBox Grid.Row="3" <TextBox Grid.Row="3"
Margin="0,4,0,8" Margin="0,6,0,10"
Text="{Binding ManifestUrl}" Text="{Binding ManifestUrl}"
IsEnabled="{Binding DevMode}"/> IsEnabled="{Binding DevMode}"/>
<CheckBox Grid.Row="4" <CheckBox Grid.Row="4"
Content="{Binding DevModeLabel}" Content="{Binding DevModeLabel}"
IsChecked="{Binding DevMode}" IsChecked="{Binding DevMode}"
Margin="0,0,0,4"/> Margin="0,0,0,6"/>
<TextBlock Grid.Row="5" <TextBlock Grid.Row="5"
Classes="muted"
Text="{Binding NoteLabel}" Text="{Binding NoteLabel}"
FontSize="11" Foreground="#666" FontSize="11"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
<StackPanel Grid.Row="6" <StackPanel Grid.Row="6"
Orientation="Horizontal" Orientation="Horizontal"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Spacing="8" Spacing="8"
Margin="0,12,0,0"> Margin="0,14,0,0">
<Button Content="{Binding OkLabel}" Width="90" Click="OnOk"/> <Button Classes="dialog ok" Content="{Binding OkLabel}" Width="90" Click="OnOk"/>
<Button Content="{Binding CancelLabel}" Width="90" Click="OnCancel"/> <Button Classes="dialog" Content="{Binding CancelLabel}" Width="90" Click="OnCancel"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>