Add Windows system chrome patchers (Harmony)

Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file.
This commit is contained in:
lincube
2026-05-04 02:31:25 +08:00
parent 458494d131
commit 3a8516334a
12 changed files with 266 additions and 112 deletions

1
.cursor/skills/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -16,6 +16,7 @@
<PackageVersion Include="Downloader" Version="5.4.0" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />

View File

@@ -56,6 +56,7 @@
<PackageReference Include="Downloader" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="Lib.Harmony.Thin" />
<PackageReference Include="Material.Avalonia" />
<PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" />

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Platform.Windows;
internal static class ChromePatchState
{
public static bool UseSystemChrome { get; set; }
}

View File

@@ -0,0 +1,12 @@
using HarmonyLib;
namespace LanMountainDesktop.Platform.Windows;
internal static class PatcherEntrance
{
public static void InstallPatchers()
{
var harmony = new Harmony("dev.lanmountain.desktop.patchers");
harmony.PatchAll(typeof(PatcherEntrance).Assembly);
}
}

View File

@@ -0,0 +1,25 @@
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using FluentAvalonia.UI.Windowing;
using LanMountainDesktop.Platform.Windows;
using HarmonyLib;
namespace LanMountainDesktop.Platform.Windows.Patches;
[HarmonyPatch(typeof(FAAppWindow), "InitializeAppWindow")]
internal class AppWindowInitializeAppWindowPatcher
{
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_PseudoClasses")]
private static extern IPseudoClasses GetPseudoClasses(StyledElement window);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_IsWindows")]
private static extern void SetIsWindowsProperty(FAAppWindow window, bool v);
static void Postfix(FAAppWindow __instance)
{
if (!ChromePatchState.UseSystemChrome) return;
GetPseudoClasses(__instance).Remove(":windows");
SetIsWindowsProperty(__instance, false);
}
}

View File

@@ -0,0 +1,21 @@
using FluentAvalonia.UI.Windowing;
using HarmonyLib;
using LanMountainDesktop.Platform.Windows;
namespace LanMountainDesktop.Platform.Windows.Patches;
[HarmonyPatch]
internal class Win32WindowManagerConstructorPatcher
{
[HarmonyTargetMethod]
static System.Reflection.MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("FluentAvalonia.UI.Windowing.Win32WindowManager");
return AccessTools.Constructor(type!, [typeof(FAAppWindow)]);
}
static bool Prefix(FAAppWindow window)
{
return !ChromePatchState.UseSystemChrome;
}
}

View File

@@ -87,6 +87,8 @@ public sealed class Program
AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready.");
});
LoadChromePatchState();
InstallChromePatchersIfNeeded();
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -198,6 +200,49 @@ public sealed class Program
}
}
private static void LoadChromePatchState()
{
try
{
var snapshot = HostSettingsFacadeProvider.GetOrCreate()
.Settings
.LoadSnapshot<AppSettingsSnapshot>(LanMountainDesktop.PluginSdk.SettingsScope.App);
if (OperatingSystem.IsWindows())
{
LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = snapshot.UseSystemChrome;
}
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to load chrome patch state. Falling back to FA chrome.", ex);
}
}
private static void InstallChromePatchersIfNeeded()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture;
if (arch != System.Runtime.InteropServices.Architecture.X64 &&
arch != System.Runtime.InteropServices.Architecture.X86)
{
return;
}
try
{
LanMountainDesktop.Platform.Windows.PatcherEntrance.InstallPatchers();
AppLogger.Info("Startup", $"Chrome patchers installed. UseSystemChrome={LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome}.");
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to install chrome patchers.", ex);
}
}
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
{
try

View File

@@ -31,12 +31,14 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase
{
_localizationService = new();
_languageCode = "zh-CN";
IsWindowsOs = OperatingSystem.IsWindows();
}
public SettingsWindowViewModel(LocalizationService localizationService, string languageCode)
{
_localizationService = localizationService;
_languageCode = languageCode;
IsWindowsOs = OperatingSystem.IsWindows();
}
private string L(string key) => _localizationService.GetString(_languageCode, key, key);
@@ -86,6 +88,10 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase
[ObservableProperty]
private bool _isDrawerOpen;
/// <summary>用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。</summary>
[ObservableProperty]
private bool _isWindowsOs;
public SettingsWindowViewModel Initialize()
{
RefreshLanguage(_languageCode);
@@ -855,7 +861,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
return;
}
PersistCurrentState(restartRequired: false);
PersistCurrentState(restartRequired: true);
}
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)

View File

@@ -1,26 +1,26 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.SettingsWindow"
x:DataType="vm:SettingsWindowViewModel"
Width="1120"
Height="760"
MinWidth="560"
MinHeight="480"
CanResize="True"
WindowStartupLocation="Manual"
WindowDecorations="BorderOnly"
FontFamily="{DynamicResource AppFontFamily}"
Background="Transparent"
Title="{Binding Title}">
<faWindowing:FAAppWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:faWindowing="using:FluentAvalonia.UI.Windowing"
x:Class="LanMountainDesktop.Views.SettingsWindow"
x:DataType="vm:SettingsWindowViewModel"
Width="1120"
Height="760"
MinWidth="560"
MinHeight="480"
CanResize="True"
WindowStartupLocation="Manual"
FontFamily="{DynamicResource AppFontFamily}"
Background="Transparent"
Title="{Binding Title}">
<Window.Resources>
<faWindowing:FAAppWindow.Resources>
<x:Double x:Key="SettingsContainerMaxWidth">960</x:Double>
</Window.Resources>
</faWindowing:FAAppWindow.Resources>
<Window.Styles>
<faWindowing:FAAppWindow.Styles>
<Style Selector="Grid.page-title-container">
<Setter Property="Margin" Value="0,16,0,0" />
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
@@ -34,94 +34,107 @@
<Style Selector="TextBlock.page-title-text:narrow">
<Setter Property="FontSize" Value="24" />
</Style>
</Window.Styles>
</faWindowing:FAAppWindow.Styles>
<Grid x:Name="RootGrid"
Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<!-- 顶栏布局对齐 SecRandom折叠/品牌/标题)+ 中(透明拖窗区)+ 右(重启 / Windows caption 占位) -->
<Border x:Name="WindowTitleBarHost"
Height="48"
Padding="12,0,12,0"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
Background="Transparent"
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
BorderThickness="0,0,0,1"
PointerPressed="OnWindowTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto"
ColumnSpacing="8"
VerticalAlignment="Center">
<Button x:Name="TogglePaneButton"
Classes="pane-toggle-button"
Click="OnTogglePaneButtonClick">
<Grid>
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
Icon="Navigation"
IconVariant="Regular"
FontSize="16" />
</Grid>
</Button>
<fi:FluentIcon x:Name="WindowBrandIcon"
Grid.Column="1"
Margin="6,0,2,0"
Icon="Settings"
IconVariant="Filled"
IsHitTestVisible="False"
VerticalAlignment="Center" />
<StackPanel Grid.Column="2"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto"
VerticalAlignment="Stretch">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Margin="8,0,8,0"
Spacing="8"
VerticalAlignment="Center">
<Button x:Name="TogglePaneButton"
Classes="pane-toggle-button"
Margin="-7,-8,-8,-8"
MinWidth="40"
Width="48"
VerticalAlignment="Center"
Spacing="10">
IsVisible="{Binding !#RootNavigationView.IsPaneToggleButtonVisible}"
Click="OnTogglePaneButtonClick">
<Grid>
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
Icon="Navigation"
IconVariant="Regular"
FontSize="16" />
</Grid>
</Button>
<fi:FluentIcon x:Name="WindowBrandIcon"
Margin="0,0,0,0"
Icon="Settings"
IconVariant="Filled"
IsHitTestVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="WindowTitleTextBlock"
FontSize="12"
FontWeight="SemiBold"
Margin="8,0,0,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
Text="{Binding Title}" />
</StackPanel>
<Button x:Name="RestartNowButton"
Grid.Column="3"
Padding="10,6"
Margin="0,0,4,0"
<Border x:Name="TitleBarDragZone"
Grid.Column="1"
Background="Transparent"
IsVisible="{Binding IsRestartRequested}"
Click="OnRestartNowClick">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon x:Name="RestartButtonIcon"
Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="RestartButtonTextBlock"
Text="{Binding RestartButtonText}" />
</StackPanel>
</Button>
PointerPressed="OnTitleBarDragZonePointerPressed" />
<Button x:Name="CloseWindowButton"
Grid.Column="4"
Width="40"
Height="32"
Padding="0"
Background="Transparent"
BorderThickness="0"
Click="OnCloseWindowClick">
<fi:FluentIcon x:Name="CloseWindowButtonIcon"
Icon="Dismiss"
IconVariant="Regular" />
</Button>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center"
Margin="0,0,8,0">
<Button x:Name="RestartNowButton"
Padding="10,6"
Margin="0,-8,0,-8"
VerticalAlignment="Center"
Background="Transparent"
IsVisible="{Binding IsRestartRequested}"
Click="OnRestartNowClick">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon x:Name="RestartButtonIcon"
Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="RestartButtonTextBlock"
Text="{Binding RestartButtonText}" />
</StackPanel>
</Button>
<Border Width="140"
Background="Transparent"
IsHitTestVisible="False"
IsVisible="{Binding IsWindowsOs}" />
</StackPanel>
</Grid>
</Border>
<ui:FANavigationView x:Name="RootNavigationView"
Grid.Row="1"
Margin="0,8,0,0"
Margin="0"
Background="Transparent"
PaneDisplayMode="Auto"
OpenPaneLength="283"
IsSettingsVisible="False"
IsPaneToggleButtonVisible="False"
IsBackButtonVisible="False"
SelectionChanged="OnNavigationSelectionChanged">
<ui:FANavigationView.Styles>
<Style Selector="ui|FANavigationView#RootNavigationView:minimal">
<Setter Property="IsPaneToggleButtonVisible" Value="False"/>
</Style>
</ui:FANavigationView.Styles>
<ui:FANavigationView.Resources>
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
@@ -182,4 +195,4 @@
</Grid>
</ui:FANavigationView>
</Grid>
</Window>
</faWindowing:FAAppWindow>

View File

@@ -5,9 +5,10 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform;
using Avalonia.Media;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
@@ -16,7 +17,7 @@ using Symbol = FluentIcons.Common.Symbol;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow : Window, ISettingsPageHostContext
public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
{
private const double BaseSettingsContainerWidth = 960d;
private const double MinSettingsContentWidth = 320d;
@@ -56,7 +57,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
_hostApplicationLifecycle = hostApplicationLifecycle;
DataContext = ViewModel;
InitializeComponent();
Icon = _appLogoService.CreateWindowIcon();
SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon());
ApplyChromeMode(useSystemChrome);
if (RootNavigationView is not null)
@@ -75,6 +76,14 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
private void OnLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
TitleBar.Height = 48;
TitleBar.ExtendsContentIntoTitleBar = true;
// SecRandom MainWindow标题栏按钮悬停/按下/非活动色,与系统 caption 更一致
TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0);
TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0);
TitleBar.ButtonInactiveForegroundColor = Colors.Gray;
SyncPendingRestartState();
SyncTitleText();
UpdateChromeMetrics();
@@ -160,11 +169,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
{
_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
ExtendClientAreaToDecorationsHint = true;
WindowDecorations = WindowDecorations.Full;
if (_useSystemChrome)
{
ExtendClientAreaToDecorationsHint = true;
WindowDecorations = WindowDecorations.Full;
if (WindowTitleBarHost is { })
{
WindowTitleBarHost.IsVisible = false;
@@ -172,9 +181,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
return;
}
WindowDecorations = WindowDecorations.BorderOnly;
ExtendClientAreaToDecorationsHint = true;
if (WindowTitleBarHost is { })
{
WindowTitleBarHost.IsVisible = true;
@@ -195,21 +201,32 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
}
RootNavigationView.MenuItems.Clear();
RootNavigationView.FooterMenuItems.Clear();
SettingsPageCategory? previousCategory = null;
foreach (var page in ViewModel.Pages)
{
var item = new FANavigationViewItem
{
Content = page.Title,
Tag = page.PageId,
IconSource = CreateSettingsIconSource(MapIcon(page.IconKey))
};
if (page.Category == SettingsPageCategory.About ||
page.Category == SettingsPageCategory.Dev)
{
RootNavigationView.FooterMenuItems.Add(item);
continue;
}
if (previousCategory is not null && previousCategory != page.Category)
{
RootNavigationView.MenuItems.Add(new FANavigationViewItemSeparator());
}
RootNavigationView.MenuItems.Add(new FANavigationViewItem
{
Content = page.Title,
Tag = page.PageId,
IconSource = CreateSettingsIconSource(MapIcon(page.IconKey))
});
RootNavigationView.MenuItems.Add(item);
previousCategory = page.Category;
}
@@ -293,7 +310,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
return;
}
foreach (var item in RootNavigationView.MenuItems.OfType<FANavigationViewItem>())
var allItems = RootNavigationView.MenuItems.OfType<FANavigationViewItem>()
.Concat(RootNavigationView.FooterMenuItems.OfType<FANavigationViewItem>());
foreach (var item in allItems)
{
if (string.Equals(item.Tag as string, pageId, StringComparison.OrdinalIgnoreCase))
{
@@ -494,7 +514,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId);
}
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
@@ -518,13 +538,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
RequestResponsiveLayoutRefresh();
}
private void OnCloseWindowClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_ = sender;
_ = e;
Close();
}
private void OnRootNavigationViewPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
_ = sender;
@@ -573,6 +586,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
{
return;
}
TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen
? FluentIcons.Common.Icon.LineHorizontal3
: FluentIcons.Common.Icon.Navigation;
}
private void UpdateChromeMetrics()
@@ -594,8 +611,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
RestartNowButton is null ||
RestartButtonIcon is null ||
RestartButtonTextBlock is null ||
CloseWindowButton is null ||
CloseWindowButtonIcon is null ||
DrawerTitleTextBlock is null ||
RootNavigationView is null)
{
@@ -606,7 +621,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
var height = Bounds.Height > 1 ? Bounds.Height : Math.Max(Height, MinHeight);
var layoutScale = Math.Clamp(Math.Min(width / 1120d, height / 760d), 0.90, 1.18);
var titleBarHeight = Math.Clamp(48d * layoutScale, 44d, 58d);
const double titleBarHeight = 48d;
var titleBarButtonWidth = Math.Clamp(40d * layoutScale, 36d, 48d);
var titleBarButtonHeight = Math.Clamp(32d * layoutScale, 30d, 38d);
var titleFontSize = Math.Clamp(12d * layoutScale, 11d, 14d);
@@ -618,7 +633,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
ExtendClientAreaTitleBarHeightHint = titleBarHeight;
WindowTitleBarHost.Height = titleBarHeight;
WindowTitleBarHost.Padding = new Thickness(chromePadding, 0, chromePadding, 0);
TogglePaneButton.Width = titleBarButtonWidth;
TogglePaneButton.Height = titleBarButtonHeight;
@@ -636,10 +650,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
RestartButtonIcon.FontSize = titleBarIconSize;
RestartButtonTextBlock.FontSize = titleFontSize;
CloseWindowButton.Width = titleBarButtonWidth;
CloseWindowButton.Height = titleBarButtonHeight;
CloseWindowButtonIcon.FontSize = titleBarIconSize;
DrawerTitleTextBlock.FontSize = drawerTitleFontSize;
}

13
_b.txt Normal file
View File

@@ -0,0 +1,13 @@
正在确定要还原的项目…
已还原 D:\github\LanMountainDesktop\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj (用时 265 毫秒)。
已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj (用时 597 毫秒)。
已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj (用时 264 毫秒)。
C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj]
生成失败。
C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj]
0 个警告
1 个错误
已用时间 00:00:07.94