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:
BIN
src/Metin2Launcher/Assets/Branding/morion2-logo.png
Normal file
BIN
src/Metin2Launcher/Assets/Branding/morion2-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 351 KiB |
BIN
src/Metin2Launcher/Assets/Branding/social-discord.png
Normal file
BIN
src/Metin2Launcher/Assets/Branding/social-discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
src/Metin2Launcher/Assets/Branding/social-facebook.png
Normal file
BIN
src/Metin2Launcher/Assets/Branding/social-facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src/Metin2Launcher/Assets/Branding/social-instagram.png
Normal file
BIN
src/Metin2Launcher/Assets/Branding/social-instagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
src/Metin2Launcher/Assets/Branding/social-youtube.png
Normal file
BIN
src/Metin2Launcher/Assets/Branding/social-youtube.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user