From 3a8516334a448ec863c678b54c1fc5902568d4b1 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 02:31:25 +0800 Subject: [PATCH] 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. --- .cursor/skills/.gitkeep | 1 + Directory.Packages.props | 1 + LanMountainDesktop/LanMountainDesktop.csproj | 1 + .../Platform/Windows/ChromePatchState.cs | 6 + .../Platform/Windows/PatcherEntrance.cs | 12 ++ .../AppWindowInitializeAppWindowPatcher.cs | 25 +++ .../Win32WindowManagerConstructorPatcher.cs | 21 +++ LanMountainDesktop/Program.cs | 45 +++++ .../ViewModels/SettingsViewModels.cs | 8 +- LanMountainDesktop/Views/SettingsWindow.axaml | 171 ++++++++++-------- .../Views/SettingsWindow.axaml.cs | 74 ++++---- _b.txt | 13 ++ 12 files changed, 266 insertions(+), 112 deletions(-) create mode 100644 .cursor/skills/.gitkeep create mode 100644 LanMountainDesktop/Platform/Windows/ChromePatchState.cs create mode 100644 LanMountainDesktop/Platform/Windows/PatcherEntrance.cs create mode 100644 LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs create mode 100644 LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs create mode 100644 _b.txt diff --git a/.cursor/skills/.gitkeep b/.cursor/skills/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.cursor/skills/.gitkeep @@ -0,0 +1 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index aa56451..f43665b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 4af574e..464bcdd 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -56,6 +56,7 @@ + diff --git a/LanMountainDesktop/Platform/Windows/ChromePatchState.cs b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs new file mode 100644 index 0000000..7ee09a6 --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Platform.Windows; + +internal static class ChromePatchState +{ + public static bool UseSystemChrome { get; set; } +} diff --git a/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs new file mode 100644 index 0000000..6cecf3d --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs @@ -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); + } +} diff --git a/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs new file mode 100644 index 0000000..0941f4e --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs @@ -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); + } +} diff --git a/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs new file mode 100644 index 0000000..e67b3f8 --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs @@ -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; + } +} diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 1e1a50d..f911de9 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -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(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 diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index e09021f..ff8255a 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -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; + /// 用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。 + [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) diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index d95e0b2..9ca9764 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -1,26 +1,26 @@ - + - + 960 - + - + - + + - - - - - - + + + + + + - + PointerPressed="OnTitleBarDragZonePointerPressed" /> - + + + + + + + + + @@ -182,4 +195,4 @@ - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 0608187..83f1e66 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -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()) + var allItems = RootNavigationView.MenuItems.OfType() + .Concat(RootNavigationView.FooterMenuItems.OfType()); + + 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; } diff --git a/_b.txt b/_b.txt new file mode 100644 index 0000000..9ecc1c2 --- /dev/null +++ b/_b.txt @@ -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