diff --git a/.trae/specs/main-window-desktop-layer/design.md b/.trae/specs/main-window-desktop-layer/design.md new file mode 100644 index 0000000..1e8f321 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/design.md @@ -0,0 +1,42 @@ +# Main Window Desktop Layer Design + +## Window Roles + +Lan Mountain Desktop now has three separate window-layer roles: + +- `MainDesktopWindow`: the normal desktop host window. With `EnableMainWindowDesktopLayer`, this window is moved to the desktop layer so it does not cover ordinary apps. +- `FusedDesktopSurface`: fused desktop component windows such as `DesktopWidgetWindow` and `TransparentOverlayWindow`. These continue to use `IWindowBottomMostService` and their existing click-through region service. +- `AirApp`: independent Air APP windows. These are ordinary app windows and do not use desktop-layer services or global `Topmost` promotion. + +## Service Boundary + +`IMainWindowDesktopLayerService` is dedicated to the main window only. It does not reuse fused desktop passthrough services because the main window must stay interactive. + +Windows behavior: + +- Save original parent, style, and extended style before enabling. +- Try to attach the main window to the desktop icon host. +- If that host is not found, use `HWND_BOTTOM`. +- On disable, restore the saved parent and styles as best effort. + +Non-Windows behavior: + +- Keep a null implementation. +- Log that the platform is unsupported. + +## Settings Flow + +The developer settings page owns confirmation UX for conflicts: + +- Fused desktop toggle and main-window desktop-layer toggle are one-way bound. +- Toggle click handlers ask for confirmation before saving conflicting states. +- The view model writes both keys together so runtime listeners receive a coherent change set. + +## Runtime Flow + +Main-window restore paths call `ActivateOrRefreshMainWindowLayer`. + +- If `EnableMainWindowDesktopLayer` is enabled, the app refreshes the desktop-layer attachment and hides the taskbar entry. +- If disabled, the app restores ordinary activation behavior, including the existing temporary foreground promotion. + +Settings changes call both fused desktop and main-window desktop-layer runtime application paths so switching modes is immediate. diff --git a/.trae/specs/main-window-desktop-layer/requirements.md b/.trae/specs/main-window-desktop-layer/requirements.md new file mode 100644 index 0000000..d18a030 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/requirements.md @@ -0,0 +1,20 @@ +# Main Window Desktop Layer + +## Requirements + +- Add a developer option named `EnableMainWindowDesktopLayer`. +- When enabled, the main Lan Mountain desktop window behaves like a desktop-surface window: ordinary application windows can stay above it. +- The feature is implemented as desktop-layer or bottom placement, not as `Topmost`. +- The option is mutually exclusive with `EnableFusedDesktop`. +- Enabling main-window desktop layer while fused desktop is enabled must ask for confirmation, then disable fused desktop on confirm or roll back on cancel. +- Enabling fused desktop while main-window desktop layer is enabled must ask for confirmation, then disable main-window desktop layer on confirm or roll back on cancel. +- Air APP windows remain ordinary application windows and must not be attached to the desktop layer. +- On Windows, the main window should attach to the desktop icon host when available and fall back to `HWND_BOTTOM` when unavailable. +- On non-Windows platforms, the setting may exist but the layer service is a no-op and must not throw. + +## Acceptance + +- Opening another app above Lan Mountain Desktop keeps that app visible when main-window desktop layer is enabled. +- Restoring the main window from tray keeps the desktop-layer behavior and does not perform a temporary `Topmost` promotion. +- Turning the option off restores normal main-window behavior as far as possible. +- Fused desktop component windows keep their existing bottom-most behavior and remain isolated from the main-window service. diff --git a/.trae/specs/main-window-desktop-layer/tasks.md b/.trae/specs/main-window-desktop-layer/tasks.md new file mode 100644 index 0000000..3380d54 --- /dev/null +++ b/.trae/specs/main-window-desktop-layer/tasks.md @@ -0,0 +1,10 @@ +# Main Window Desktop Layer Tasks + +- [x] Add `EnableMainWindowDesktopLayer` to app settings with a disabled default. +- [x] Add developer settings UI and localization strings. +- [x] Add confirmation flow for mutual exclusion with fused desktop. +- [x] Add a dedicated main-window desktop-layer service. +- [x] Wire main-window creation, restore, tray fallback, settings changes, and shutdown cleanup to the service. +- [x] Keep Air APP windows outside this layer service. +- [x] Add static regression tests for settings, restore paths, and service boundaries. +- [ ] Perform manual Windows z-order validation with real apps. diff --git a/.trae/specs/update-settings-fluent-controls/spec.md b/.trae/specs/update-settings-fluent-controls/spec.md new file mode 100644 index 0000000..5ae7589 --- /dev/null +++ b/.trae/specs/update-settings-fluent-controls/spec.md @@ -0,0 +1,25 @@ +# Update Settings Fluent Controls + +## Goal + +Make the Settings > Update page the single user-facing control surface for the host update flow. + +## Requirements + +- The page uses Fluent Avalonia settings controls for update status, release facts, update behavior, and transfer controls. +- Users can choose update channel, download source, update mode, and download thread count. +- Update mode options are: + - Manual: do not automatically download or install. + - Silent Download: check and download in the background, then wait for user installation confirmation. + - Silent Install: check and download in the background, then apply when the app exits. +- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall. +- The page displays whether the current payload is an incremental update or reinstall/full installer. +- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery. +- Existing PloNDS/FileMap incremental update and Launcher rollback ownership remain unchanged. + +## Acceptance + +- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel. +- `UpdateSettingsState` persists forced reinstall alongside other update preferences. +- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply. +- Build succeeds for `LanMountainDesktop.slnx`. diff --git a/LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs b/LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs new file mode 100644 index 0000000..f6a8bc1 --- /dev/null +++ b/LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using LanMountainDesktop.DesktopEditing; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class DesktopEditOverlayPresenterTests +{ + [Fact] + public void CompositionOffsetHelperFallsBackWhenVisualIsUnavailable() + { + var service = new CompositionVisualAnimationService(_ => null); + var target = new Border(); + + var result = service.TrySetOffset(target, new Point(12, 34)); + + Assert.False(result); + Assert.False(service.TrySetOpacity(target, 0.5)); + Assert.False(service.TrySetUniformScale(target, 1.05)); + } + + [Fact] + public void PreviewRectUsesCanvasPlacementWhenCompositionIsUnavailable() + { + var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null)); + var root = Assert.IsType(presenter.Root); + + presenter.SetPreviewRect(new Rect(12, 34, 180, 120)); + + var ghost = root.Children.OfType().Single(); + Assert.Equal(12, Canvas.GetLeft(ghost)); + Assert.Equal(34, Canvas.GetTop(ghost)); + Assert.Equal(180, ghost.Width); + Assert.Equal(120, ghost.Height); + } +} diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs index 2a73f03..639f64e 100644 --- a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs +++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs @@ -64,6 +64,63 @@ public sealed class WindowLayerIsolationTests Assert.Contains("window.RefreshDesktopLayer()", source); } + [Fact] + public void MainWindowDesktopLayerService_DoesNotUseFusedDesktopPassthroughBoundary() + { + var source = ReadRepositoryFile("LanMountainDesktop", "Services", "MainWindowDesktopLayerService.cs"); + + Assert.Contains("IMainWindowDesktopLayerService", source); + Assert.Contains("SetParent", source); + Assert.Contains("HWND_BOTTOM", source); + Assert.DoesNotContain("WindowBottomMostServiceFactory", source); + Assert.DoesNotContain("IRegionPassthroughService", source); + Assert.DoesNotContain("SetInteractiveRegions", source); + Assert.DoesNotContain("HTTRANSPARENT", source); + Assert.DoesNotContain("WS_EX_NOACTIVATE", source); + } + + [Fact] + public void MainWindowRestorePaths_UseDesktopLayerAwareActivation() + { + var source = ReadRepositoryFile("LanMountainDesktop", "App.axaml.cs"); + var restoreSource = ExtractMethodSource(source, "RestoreOrCreateMainWindowCore"); + var trayFallbackSource = ExtractMethodSource(source, "RecoverFromTrayUnavailable"); + var applyLayerSource = ExtractMethodSource(source, "ApplyMainWindowDesktopLayerRuntimeState"); + + Assert.Contains("ActivateOrRefreshMainWindowLayer(mainWindow", restoreSource); + Assert.DoesNotContain("Topmost = true", restoreSource); + + Assert.Contains("ActivateOrRefreshMainWindowLayer(mainWindow", trayFallbackSource); + Assert.DoesNotContain("Topmost = true", trayFallbackSource); + + Assert.Contains("FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown()", applyLayerSource); + } + + [Fact] + public void AppSettingsSnapshot_MainWindowDesktopLayerDefaultsToDisabled() + { + Assert.False(new LanMountainDesktop.Models.AppSettingsSnapshot().EnableMainWindowDesktopLayer); + } + + [Fact] + public void DeveloperSettings_DefinesMutuallyExclusiveDesktopLayerToggles() + { + var viewModelSource = ReadRepositoryFile("LanMountainDesktop", "ViewModels", "SettingsViewModels.cs"); + var pageSource = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsPages", "DevSettingsPage.axaml.cs"); + var xamlSource = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsPages", "DevSettingsPage.axaml"); + + Assert.Contains("EnableMainWindowDesktopLayer", viewModelSource); + Assert.Contains("ApplyFusedDesktopPreference", viewModelSource); + Assert.Contains("ApplyMainWindowDesktopLayerPreference", viewModelSource); + Assert.Contains("nameof(AppSettingsSnapshot.EnableFusedDesktop)", viewModelSource); + Assert.Contains("nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer)", viewModelSource); + + Assert.Contains("ConfirmDesktopLayerSwitchAsync", pageSource); + Assert.Contains("OnFusedDesktopToggleChanged", xamlSource); + Assert.Contains("OnMainWindowDesktopLayerToggleChanged", xamlSource); + Assert.Contains("Mode=OneWay", xamlSource); + } + private static string ReadRepositoryFile(params string[] segments) { var directory = new DirectoryInfo(AppContext.BaseDirectory); @@ -85,4 +142,37 @@ public sealed class WindowLayerIsolationTests throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'."); } + + private static string ExtractMethodSource(string source, string methodName) + { + var methodIndex = source.IndexOf($"private bool {methodName}(", StringComparison.Ordinal); + if (methodIndex < 0) + { + methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal); + } + + Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'."); + + var braceIndex = source.IndexOf('{', methodIndex); + Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'."); + + var depth = 0; + for (var i = braceIndex; i < source.Length; i++) + { + if (source[i] == '{') + { + depth++; + } + else if (source[i] == '}') + { + depth--; + if (depth == 0) + { + return source.Substring(methodIndex, i - methodIndex + 1); + } + } + } + + throw new InvalidOperationException($"Could not extract method '{methodName}'."); + } } diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 3a45522..ec4b1b6 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -57,6 +57,7 @@ public partial class App : Application private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); private readonly HostShutdownGate _shutdownGate = new(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); + private readonly IMainWindowDesktopLayerService _mainWindowDesktopLayerService = MainWindowDesktopLayerServiceFactory.GetOrCreate(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow; private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal"; @@ -897,7 +898,7 @@ public partial class App : Application var mainWindow = GetOrCreateMainWindow(desktop, source); mainWindow.PrepareEnterAnimation(); - mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar(); + mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar() && !IsMainWindowDesktopLayerEnabled(); if (!mainWindow.IsVisible) { @@ -917,9 +918,7 @@ public partial class App : Application mainWindow.WindowState = WindowState.FullScreen; } - mainWindow.Activate(); - mainWindow.Topmost = true; - mainWindow.Topmost = false; + ActivateOrRefreshMainWindowLayer(mainWindow, $"Restore:{source}"); Dispatcher.UIThread.Post(() => { @@ -1113,9 +1112,19 @@ public partial class App : Application if (fusedDesktopChanged) { + ApplyFusedDesktopRuntimeState(); RefreshFusedDesktopMenuItemVisibility(); } + var mainWindowDesktopLayerChanged = + refreshAll || + changedKeys.Contains(nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer), StringComparer.OrdinalIgnoreCase); + + if (mainWindowDesktopLayerChanged) + { + ApplyMainWindowDesktopLayerRuntimeState("SettingsChanged"); + } + var showInTaskbarChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparer.OrdinalIgnoreCase); @@ -1313,7 +1322,7 @@ public partial class App : Application var mainWindow = new MainWindow { DataContext = new MainWindowViewModel(), - ShowInTaskbar = ShouldShowMainWindowInTaskbar() + ShowInTaskbar = ShouldShowMainWindowInTaskbar() && !IsMainWindowDesktopLayerEnabled() }; _mainWindowOpened = false; @@ -1351,6 +1360,7 @@ public partial class App : Application { mainWindow.Opened -= OnMainWindowOpened; _mainWindowOpened = true; + ApplyMainWindowDesktopLayerRuntimeState("MainWindowOpened"); _loadingStateManager?.CompleteItem("system.init", "System initialization completed."); if (TryApplyStartupPresentation(mainWindow)) @@ -1428,6 +1438,7 @@ public partial class App : Application if (_mainWindow is not null) { + _mainWindowDesktopLayerService.Disable(_mainWindow); _mainWindow.Closing -= OnMainWindowClosing; _mainWindow.Closed -= OnMainWindowClosed; _mainWindow.PropertyChanged -= OnMainWindowPropertyChanged; @@ -1473,6 +1484,7 @@ public partial class App : Application mainWindow.Closing -= OnMainWindowClosing; mainWindow.Closed -= OnMainWindowClosed; mainWindow.PropertyChanged -= OnMainWindowPropertyChanged; + _mainWindowDesktopLayerService.Disable(mainWindow); if (ReferenceEquals(_mainWindow, mainWindow)) { @@ -1614,16 +1626,83 @@ public partial class App : Application mainWindow.WindowState = WindowState.FullScreen; } - mainWindow.Activate(); - mainWindow.Topmost = true; - mainWindow.Topmost = false; + ActivateOrRefreshMainWindowLayer(mainWindow, $"TrayFallbackForeground:{source}"); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}"); ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable."); } private bool ShouldShowMainWindowInTaskbar() { - return _settingsFacade.Settings.LoadSnapshot(SettingsScope.App).ShowInTaskbar; + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + return snapshot.ShowInTaskbar && !snapshot.EnableMainWindowDesktopLayer; + } + + private bool IsMainWindowDesktopLayerEnabled() + { + return _settingsFacade.Settings.LoadSnapshot(SettingsScope.App).EnableMainWindowDesktopLayer; + } + + private void ActivateOrRefreshMainWindowLayer(MainWindow mainWindow, string source) + { + if (IsMainWindowDesktopLayerEnabled()) + { + mainWindow.ShowInTaskbar = false; + _mainWindowDesktopLayerService.EnableOrRefresh(mainWindow); + AppLogger.Info("DesktopShell", $"Main window kept on desktop layer. Source='{source}'."); + return; + } + + _mainWindowDesktopLayerService.Disable(mainWindow); + mainWindow.Activate(); + mainWindow.Topmost = true; + mainWindow.Topmost = false; + } + + private void ApplyMainWindowDesktopLayerRuntimeState(string source) + { + if (_mainWindow is null) + { + return; + } + + if (IsMainWindowDesktopLayerEnabled()) + { + ExitFusedDesktopEditModeFromUi(closeLibrary: true); + FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); + _mainWindow.ShowInTaskbar = false; + _mainWindowDesktopLayerService.EnableOrRefresh(_mainWindow); + AppLogger.Info("DesktopShell", $"Main window desktop layer enabled. Source='{source}'."); + return; + } + + _mainWindowDesktopLayerService.Disable(_mainWindow); + _mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar(); + AppLogger.Info("DesktopShell", $"Main window desktop layer disabled. Source='{source}'."); + } + + private void ApplyFusedDesktopRuntimeState() + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + try + { + if (snapshot.EnableFusedDesktop) + { + if (_mainWindow is not null) + { + _mainWindowDesktopLayerService.Disable(_mainWindow); + } + + FusedDesktopManagerServiceFactory.GetOrCreate().Initialize(); + return; + } + + ExitFusedDesktopEditModeFromUi(closeLibrary: true); + FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to apply fused desktop runtime state.", ex); + } } private bool EnsureTaskbarEntry(string source) diff --git a/LanMountainDesktop/DesktopEditing/CompositionVisualAnimationService.cs b/LanMountainDesktop/DesktopEditing/CompositionVisualAnimationService.cs new file mode 100644 index 0000000..907485b --- /dev/null +++ b/LanMountainDesktop/DesktopEditing/CompositionVisualAnimationService.cs @@ -0,0 +1,82 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Rendering.Composition; + +namespace LanMountainDesktop.DesktopEditing; + +internal sealed class CompositionVisualAnimationService +{ + private readonly Func _getVisual; + + public CompositionVisualAnimationService() + : this(ElementComposition.GetElementVisual) + { + } + + internal CompositionVisualAnimationService(Func getVisual) + { + _getVisual = getVisual; + } + + public bool TrySetOffset(Control target, Point offset) + { + return TryApply(target, visual => + { + visual.StopAnimation(nameof(visual.Offset)); + visual.Offset = visual.Offset with + { + X = offset.X, + Y = offset.Y + }; + }); + } + + public bool TrySetOpacity(Control target, double opacity) + { + return TryApply(target, visual => + { + visual.StopAnimation(nameof(visual.Opacity)); + visual.Opacity = (float)Math.Clamp(opacity, 0, 1); + }); + } + + public bool TrySetUniformScale(Control target, double scale) + { + return TryApply(target, visual => + { + var clampedScale = Math.Clamp(scale, 0.01, 64); + visual.StopAnimation(nameof(visual.Scale)); + visual.Scale = visual.Scale with + { + X = clampedScale, + Y = clampedScale, + Z = 1 + }; + }); + } + + public bool TryResetOffset(Control target) + { + return TrySetOffset(target, new Point()); + } + + private bool TryApply(Control target, Action apply) + { + try + { + var visual = _getVisual(target); + if (visual is null) + { + return false; + } + + apply(visual); + return true; + } + catch + { + return false; + } + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs index cd488b6..1bbd8c7 100644 --- a/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs +++ b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs @@ -27,11 +27,14 @@ internal sealed class DesktopEditOverlayPresenter private readonly DesktopEditGhostView _ghostView; private readonly Border _candidateOutline; private readonly ScaleTransform _candidateScale = new(1, 1); + private readonly CompositionVisualAnimationService _visualAnimationService; private Rect? _previewRect; private Rect? _candidateRect; private bool _isInvalid; private bool _isVisible; + private bool _ghostUsesCompositionOffset; + private bool _candidateUsesCompositionOffset; private int _dismissVersion; private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF")); @@ -40,7 +43,14 @@ internal sealed class DesktopEditOverlayPresenter private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30")); public DesktopEditOverlayPresenter() + : this(new CompositionVisualAnimationService()) { + } + + internal DesktopEditOverlayPresenter(CompositionVisualAnimationService visualAnimationService) + { + _visualAnimationService = visualAnimationService; + _ghostView = new DesktopEditGhostView { IsHitTestVisible = false, @@ -276,8 +286,7 @@ internal sealed class DesktopEditOverlayPresenter var rect = _previewRect.Value; _ghostView.Width = Math.Max(1, rect.Width); _ghostView.Height = Math.Max(1, rect.Height); - Canvas.SetLeft(_ghostView, rect.X); - Canvas.SetTop(_ghostView, rect.Y); + SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y), ref _ghostUsesCompositionOffset); _ghostView.UpdatePreviewMetrics(rect.Width, rect.Height); } @@ -294,8 +303,7 @@ internal sealed class DesktopEditOverlayPresenter _candidateOutline.IsVisible = true; _candidateOutline.Width = Math.Max(1, rect.Width); _candidateOutline.Height = Math.Max(1, rect.Height); - Canvas.SetLeft(_candidateOutline, rect.X); - Canvas.SetTop(_candidateOutline, rect.Y); + SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y), ref _candidateUsesCompositionOffset); var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26); _candidateOutline.CornerRadius = new CornerRadius(cornerRadius); @@ -325,6 +333,26 @@ internal sealed class DesktopEditOverlayPresenter return new Rect(rect.X, rect.Y, width, height); } + private void SetOverlayOffset(Control target, Point position, ref bool usesCompositionOffset) + { + if (_visualAnimationService.TrySetOffset(target, position)) + { + Canvas.SetLeft(target, 0); + Canvas.SetTop(target, 0); + usesCompositionOffset = true; + return; + } + + if (usesCompositionOffset) + { + _visualAnimationService.TryResetOffset(target); + usesCompositionOffset = false; + } + + Canvas.SetLeft(target, position.X); + Canvas.SetTop(target, position.Y); + } + private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) => new() { diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 5121bb6..ad8d804 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -802,6 +802,13 @@ "settings.dev.three_finger_description": "Enable desktop page switching gestures when the current platform supports them.", "settings.dev.fused_header": "Fused desktop experience", "settings.dev.fused_description": "Enable the fused desktop shell and its related experimental entry points.", + "settings.dev.main_window_desktop_layer_header": "Prevent covering other apps", + "settings.dev.main_window_desktop_layer_description": "Keep the main desktop window on the desktop layer so ordinary app windows can stay above it.", + "settings.dev.desktop_layer_conflict_title": "Switch desktop layer mode?", + "settings.dev.desktop_layer_conflict_enable_main": "Main desktop layer mode and fused desktop cannot run at the same time. Enabling this option will turn off fused desktop.", + "settings.dev.desktop_layer_conflict_enable_fused": "Fused desktop and main desktop layer mode cannot run at the same time. Enabling fused desktop will turn off main desktop layer mode.", + "settings.dev.desktop_layer_conflict_confirm": "Switch", + "settings.dev.desktop_layer_conflict_cancel": "Cancel", "settings.dev.plugin_path_header": "Development plugin path", "settings.dev.plugin_path_description": "Load a local plugin output directory for iterative debugging without packaging.", "settings.dev.plugin_path_placeholder": "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 9a5ebdf..d77c20d 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -741,6 +741,13 @@ "settings.dev.three_finger_description": "在当前平台支持时,启用手势在桌面分页间切换。", "settings.dev.fused_header": "融合桌面体验", "settings.dev.fused_description": "启用融合桌面壳及相关实验入口。", + "settings.dev.main_window_desktop_layer_header": "防遮挡其它应用窗口", + "settings.dev.main_window_desktop_layer_description": "让主桌面窗口保持在桌面层,使普通应用窗口可以显示在它上方。", + "settings.dev.desktop_layer_conflict_title": "切换桌面层模式?", + "settings.dev.desktop_layer_conflict_enable_main": "主桌面桌面层模式不能和融合桌面同时运行。开启此选项将关闭融合桌面。", + "settings.dev.desktop_layer_conflict_enable_fused": "融合桌面不能和主桌面桌面层模式同时运行。开启融合桌面将关闭主桌面桌面层模式。", + "settings.dev.desktop_layer_conflict_confirm": "切换", + "settings.dev.desktop_layer_conflict_cancel": "取消", "settings.dev.plugin_path_header": "开发插件路径", "settings.dev.plugin_path_description": "加载本地插件输出目录以便免打包迭代调试。", "settings.dev.plugin_path_placeholder": "例如:C:\\path\\to\\plugin\\bin\\Debug\\net10.0", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 9863343..ee7af8c 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -99,6 +99,8 @@ public sealed class AppSettingsSnapshot public int UpdateDownloadThreads { get; set; } = 4; + public bool ForceUpdateReinstall { get; set; } + public string? PendingUpdateInstallerPath { get; set; } public string? PendingUpdateVersion { get; set; } @@ -182,6 +184,8 @@ public sealed class AppSettingsSnapshot public bool EnableFusedDesktop { get; set; } = false; + public bool EnableMainWindowDesktopLayer { get; set; } = false; + public List DisabledPluginIds { get; set; } = []; public bool IsDevModeEnabled { get; set; } diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index 1162004..8177e32 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -20,6 +20,7 @@ public interface IFusedDesktopManagerService void EnterEditMode(); void ExitEditMode(); void ReloadWidgets(); + void Shutdown(); } /// @@ -158,6 +159,18 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService } } + public void Shutdown() + { + _isEditMode = false; + foreach (var window in _widgetWindows.Values) + { + window.Close(); + } + + _widgetWindows.Clear(); + AppLogger.Info("FusedDesktop", "Fused desktop manager shut down."); + } + private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement) { EnsureRegistries(); diff --git a/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs b/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs new file mode 100644 index 0000000..5ace9ca --- /dev/null +++ b/LanMountainDesktop/Services/MainWindowDesktopLayerService.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Avalonia.Controls; + +namespace LanMountainDesktop.Services; + +public interface IMainWindowDesktopLayerService +{ + bool IsSupported { get; } + void EnableOrRefresh(Window window); + void Disable(Window window); +} + +public static class MainWindowDesktopLayerServiceFactory +{ + private static readonly object Gate = new(); + private static IMainWindowDesktopLayerService? _instance; + + public static IMainWindowDesktopLayerService GetOrCreate() + { + lock (Gate) + { + return _instance ??= OperatingSystem.IsWindows() + ? new WindowsMainWindowDesktopLayerService() + : new NullMainWindowDesktopLayerService(); + } + } +} + +internal sealed class WindowsMainWindowDesktopLayerService : IMainWindowDesktopLayerService +{ + private const int GWL_STYLE = -16; + private const int GWL_EXSTYLE = -20; + + private const long WS_CHILD = 0x40000000L; + private const long WS_POPUP = 0x80000000L; + private const long WS_CAPTION = 0x00C00000L; + private const long WS_THICKFRAME = 0x00040000L; + private const long WS_MINIMIZEBOX = 0x00020000L; + private const long WS_MAXIMIZEBOX = 0x00010000L; + private const long WS_SYSMENU = 0x00080000L; + + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_SHOWWINDOW = 0x0040; + private const uint SWP_FRAMECHANGED = 0x0020; + + private static readonly IntPtr HWND_TOP = IntPtr.Zero; + private static readonly IntPtr HWND_BOTTOM = new(1); + + private readonly object _gate = new(); + private readonly Dictionary _restoreStates = []; + + public bool IsSupported => true; + + public void EnableOrRefresh(Window window) + { + ArgumentNullException.ThrowIfNull(window); + + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) + { + window.Opened -= OnDeferredOpened; + window.Opened += OnDeferredOpened; + return; + } + + EnableOrRefresh(handle); + } + + public void Disable(Window window) + { + ArgumentNullException.ThrowIfNull(window); + window.Opened -= OnDeferredOpened; + + var handle = GetWindowHandle(window); + if (handle == IntPtr.Zero) + { + return; + } + + WindowRestoreState? restoreState; + lock (_gate) + { + if (!_restoreStates.Remove(handle, out restoreState)) + { + return; + } + } + + try + { + _ = SetParent(handle, restoreState.Parent); + SetWindowLongPtr(handle, GWL_STYLE, restoreState.Style); + SetWindowLongPtr(handle, GWL_EXSTYLE, restoreState.ExStyle); + _ = SetWindowPos( + handle, + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_FRAMECHANGED | SWP_SHOWWINDOW); + AppLogger.Info("MainWindowDesktopLayer", $"Disabled desktop layer. Window={handle}."); + } + catch (Exception ex) + { + AppLogger.Warn("MainWindowDesktopLayer", $"Failed to disable desktop layer. Window={handle}.", ex); + } + } + + private void OnDeferredOpened(object? sender, EventArgs e) + { + if (sender is not Window window) + { + return; + } + + window.Opened -= OnDeferredOpened; + EnableOrRefresh(window); + } + + private void EnableOrRefresh(IntPtr handle) + { + if (handle == IntPtr.Zero || !IsWindow(handle)) + { + return; + } + + SaveRestoreStateIfNeeded(handle); + var desktopHost = ResolveDesktopIconHost(); + if (desktopHost != IntPtr.Zero && IsWindow(desktopHost)) + { + ApplyDesktopChildStyle(handle); + if (GetParent(handle) != desktopHost) + { + _ = SetParent(handle, desktopHost); + } + + _ = SetWindowPos( + handle, + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_FRAMECHANGED | SWP_SHOWWINDOW); + AppLogger.Info("MainWindowDesktopLayer", $"Enabled desktop layer. Window={handle}; Host={desktopHost}."); + return; + } + + _ = SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW); + AppLogger.Warn("MainWindowDesktopLayer", $"Desktop icon host not found. Falling back to HWND_BOTTOM. Window={handle}."); + } + + private void SaveRestoreStateIfNeeded(IntPtr handle) + { + lock (_gate) + { + if (_restoreStates.ContainsKey(handle)) + { + return; + } + + _restoreStates[handle] = new WindowRestoreState( + GetParent(handle), + GetWindowLongPtr(handle, GWL_STYLE), + GetWindowLongPtr(handle, GWL_EXSTYLE)); + } + } + + private static void ApplyDesktopChildStyle(IntPtr handle) + { + var style = GetWindowLongPtr(handle, GWL_STYLE).ToInt64(); + style |= WS_CHILD; + style &= ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); + SetWindowLongPtr(handle, GWL_STYLE, new IntPtr(style)); + } + + private static IntPtr ResolveDesktopIconHost() + { + var topLevelWindows = new List(); + EnumWindows((handle, _) => + { + topLevelWindows.Add(handle); + return true; + }, IntPtr.Zero); + + foreach (var topLevelWindow in topLevelWindows) + { + var worker = FindWindowEx(topLevelWindow, IntPtr.Zero, "WorkerW", null); + if (worker == IntPtr.Zero) + { + continue; + } + + var defView = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null); + if (defView != IntPtr.Zero) + { + return defView; + } + } + + foreach (var topLevelWindow in topLevelWindows) + { + var defView = FindWindowEx(topLevelWindow, IntPtr.Zero, "SHELLDLL_DefView", null); + if (defView != IntPtr.Zero) + { + return defView; + } + } + + return IntPtr.Zero; + } + + private static IntPtr GetWindowHandle(Window window) + { + try + { + return window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + } + catch + { + return IntPtr.Zero; + } + } + + private sealed record WindowRestoreState(IntPtr Parent, IntPtr Style, IntPtr ExStyle); + + private delegate bool EnumWindowsProc(IntPtr handle, IntPtr lParam); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint flags); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); + + [DllImport("user32.dll")] + private static extern IntPtr GetParent(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr FindWindowEx(IntPtr hParent, IntPtr hChildAfter, string? lpszClass, string? lpszWindow); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); +} + +internal sealed class NullMainWindowDesktopLayerService : IMainWindowDesktopLayerService +{ + public bool IsSupported => false; + + public void EnableOrRefresh(Window window) + { + AppLogger.Info("MainWindowDesktopLayer", "Desktop layer requested on an unsupported platform."); + } + + public void Disable(Window window) + { + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 4374912..7e97752 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -89,6 +89,7 @@ public sealed record UpdateSettingsState( string UpdateMode, string UpdateDownloadSource, int UpdateDownloadThreads, + bool ForceUpdateReinstall, bool UseGhProxyMirror, string? PendingUpdateInstallerPath, string? PendingUpdateVersion, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 3136585..e35d9b1 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -802,6 +802,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode), UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource), UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads), + snapshot.ForceUpdateReinstall, snapshot.UseGhProxyMirror, snapshot.PendingUpdateInstallerPath, snapshot.PendingUpdateVersion, @@ -824,6 +825,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource); snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); + snapshot.ForceUpdateReinstall = state.ForceUpdateReinstall; snapshot.UseGhProxyMirror = state.UseGhProxyMirror; snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath) ? null @@ -850,6 +852,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl nameof(AppSettingsSnapshot.UpdateMode), nameof(AppSettingsSnapshot.UpdateDownloadSource), nameof(AppSettingsSnapshot.UpdateDownloadThreads), + nameof(AppSettingsSnapshot.ForceUpdateReinstall), nameof(AppSettingsSnapshot.UseGhProxyMirror), nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), nameof(AppSettingsSnapshot.PendingUpdateVersion), diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs index 32ef908..9c704ab 100644 --- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -129,11 +129,27 @@ public sealed class UpdateOrchestrator : IDisposable UpdateManifest? manifest; try { - manifest = await _manifestProvider.GetLatestAsync( - channel, - LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(), - currentVersion, - operationToken); + var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(); + manifest = settings.ForceUpdateReinstall + ? await _manifestProvider.GetByVersionAsync( + currentVersionText, + channel, + platform, + operationToken) + : await _manifestProvider.GetLatestAsync( + channel, + platform, + currentVersion, + operationToken); + + if (manifest is null && settings.ForceUpdateReinstall) + { + manifest = await _manifestProvider.GetLatestAsync( + channel, + platform, + currentVersion, + operationToken); + } } catch (OperationCanceledException) { @@ -525,7 +541,13 @@ public sealed class UpdateOrchestrator : IDisposable try { - await CheckAsync(ct); + var report = await CheckAsync(ct); + if (!report.IsUpdateAvailable || !CurrentPhase.CanDownload()) + { + return; + } + + await DownloadAsync(ct); } catch (OperationCanceledException) { diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index 6877ca5..303a2da 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -10,6 +10,8 @@ public static class UpdateSettingsValues public const string ModeManual = "manual"; public const string ModeDownloadThenConfirm = "download_then_confirm"; public const string ModeSilentOnExit = "silent_on_exit"; + public const string ModeSilentDownload = ModeDownloadThenConfirm; + public const string ModeSilentInstall = ModeSilentOnExit; // NOTE: keep constant name for compatibility with existing call sites. public const string DownloadSourcePlonds = "plonds-api"; diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 3130ccc..e3c5081 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -2416,6 +2416,9 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase [ObservableProperty] private bool _enableFusedDesktop; + [ObservableProperty] + private bool _enableMainWindowDesktopLayer; + [ObservableProperty] private string _infoBarTitle = string.Empty; @@ -2440,6 +2443,27 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _fusedDescription = string.Empty; + [ObservableProperty] + private string _mainWindowDesktopLayerHeader = string.Empty; + + [ObservableProperty] + private string _mainWindowDesktopLayerDescription = string.Empty; + + [ObservableProperty] + private string _desktopLayerConflictTitle = string.Empty; + + [ObservableProperty] + private string _desktopLayerConflictEnableMainMessage = string.Empty; + + [ObservableProperty] + private string _desktopLayerConflictEnableFusedMessage = string.Empty; + + [ObservableProperty] + private string _desktopLayerConflictConfirmText = string.Empty; + + [ObservableProperty] + private string _desktopLayerConflictCancelText = string.Empty; + [ObservableProperty] private string _pluginPathHeader = string.Empty; @@ -2486,6 +2510,13 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase ThreeFingerDescription = L("settings.dev.three_finger_description", "Enable desktop page switching gestures when supported."); FusedHeader = L("settings.dev.fused_header", "Fused desktop experience"); FusedDescription = L("settings.dev.fused_description", "Enable the fused desktop shell and experimental entry points."); + MainWindowDesktopLayerHeader = L("settings.dev.main_window_desktop_layer_header", "Prevent covering other apps"); + MainWindowDesktopLayerDescription = L("settings.dev.main_window_desktop_layer_description", "Keep the main desktop window on the desktop layer so ordinary app windows can stay above it."); + DesktopLayerConflictTitle = L("settings.dev.desktop_layer_conflict_title", "Switch desktop layer mode?"); + DesktopLayerConflictEnableMainMessage = L("settings.dev.desktop_layer_conflict_enable_main", "Main desktop layer mode and fused desktop cannot run at the same time. Enabling this option will turn off fused desktop."); + DesktopLayerConflictEnableFusedMessage = L("settings.dev.desktop_layer_conflict_enable_fused", "Fused desktop and main desktop layer mode cannot run at the same time. Enabling fused desktop will turn off main desktop layer mode."); + DesktopLayerConflictConfirmText = L("settings.dev.desktop_layer_conflict_confirm", "Switch"); + DesktopLayerConflictCancelText = L("settings.dev.desktop_layer_conflict_cancel", "Cancel"); PluginPathHeader = L("settings.dev.plugin_path_header", "Development plugin path"); PluginPathDescription = L("settings.dev.plugin_path_description", "Load a local plugin output directory without packaging."); PluginPathPlaceholder = L("settings.dev.plugin_path_placeholder", "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0"); @@ -2527,6 +2558,12 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase SaveField(nameof(AppSettingsSnapshot.EnableFusedDesktop), value); } + partial void OnEnableMainWindowDesktopLayerChanged(bool value) + { + if (_isInitializing) return; + SaveField(nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer), value); + } + private void LoadSettings() { var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); @@ -2534,6 +2571,7 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase DevPluginPath = snapshot.DevPluginPath ?? string.Empty; EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; EnableFusedDesktop = snapshot.EnableFusedDesktop; + EnableMainWindowDesktopLayer = snapshot.EnableMainWindowDesktopLayer; } private void OnSettingsChanged(object? sender, SettingsChangedEvent e) @@ -2555,6 +2593,7 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; EnableFusedDesktop = snapshot.EnableFusedDesktop; + EnableMainWindowDesktopLayer = snapshot.EnableMainWindowDesktopLayer; } finally { @@ -2573,4 +2612,51 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]); } + + public void ApplyFusedDesktopPreference(bool enabled, bool disableMainWindowDesktopLayer) + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + snapshot.EnableFusedDesktop = enabled; + if (enabled && disableMainWindowDesktopLayer) + { + snapshot.EnableMainWindowDesktopLayer = false; + } + + SaveDesktopLayerPreferences(snapshot); + } + + public void ApplyMainWindowDesktopLayerPreference(bool enabled, bool disableFusedDesktop) + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + snapshot.EnableMainWindowDesktopLayer = enabled; + if (enabled && disableFusedDesktop) + { + snapshot.EnableFusedDesktop = false; + } + + SaveDesktopLayerPreferences(snapshot); + } + + private void SaveDesktopLayerPreferences(AppSettingsSnapshot snapshot) + { + _settingsFacade.Settings.SaveSnapshot( + SettingsScope.App, + snapshot, + changedKeys: + [ + nameof(AppSettingsSnapshot.EnableFusedDesktop), + nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer) + ]); + + _isInitializing = true; + try + { + EnableFusedDesktop = snapshot.EnableFusedDesktop; + EnableMainWindowDesktopLayer = snapshot.EnableMainWindowDesktopLayer; + } + finally + { + _isInitializing = false; + } + } } diff --git a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs index 86463b8..4cf8012 100644 --- a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs +++ b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs @@ -63,9 +63,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [ObservableProperty] private string _lastCheckedLabel = string.Empty; [ObservableProperty] private string _updateTypeLabel = string.Empty; [ObservableProperty] private string _channelLabel = string.Empty; + [ObservableProperty] private string _channelDescription = string.Empty; [ObservableProperty] private string _sourceLabel = string.Empty; + [ObservableProperty] private string _sourceDescription = string.Empty; [ObservableProperty] private string _modeLabel = string.Empty; + [ObservableProperty] private string _modeDescription = string.Empty; [ObservableProperty] private string _downloadThreadsLabel = string.Empty; + [ObservableProperty] private string _downloadThreadsDescription = string.Empty; + [ObservableProperty] private string _forceReinstallLabel = string.Empty; + [ObservableProperty] private string _forceReinstallDescription = string.Empty; + [ObservableProperty] private string _resumeSupportLabel = string.Empty; + [ObservableProperty] private string _resumeSupportDescription = string.Empty; + [ObservableProperty] private string _transferControlsTitle = string.Empty; + [ObservableProperty] private string _transferControlsDescription = string.Empty; [ObservableProperty] private string _updateAvailableBadgeText = string.Empty; [ObservableProperty] private string _pausedBadgeText = string.Empty; @@ -86,10 +96,11 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable [ObservableProperty] private string _updateTypeText = string.Empty; [ObservableProperty] private bool _isUpdateAvailable; [ObservableProperty] private bool _isDeltaUpdate; + [ObservableProperty] private bool _forceReinstall; [ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; [ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc; - [ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; + [ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeSilentDownload; [ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads; [ObservableProperty] private SelectionOption? _selectedChannel; @@ -183,6 +194,16 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable SavePreferenceState(); } + partial void OnForceReinstallChanged(bool value) + { + SavePreferenceState(); + UpdateTypeText = value + ? L("settings.update.type_reinstall", "Reinstall") + : (IsDeltaUpdate + ? L("settings.update.type_delta", "Incremental Update") + : UpdateTypeText); + } + [RelayCommand(CanExecute = nameof(CanCheck))] private async Task CheckAsync() { @@ -332,6 +353,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable SelectedUpdateSourceValue = state.UpdateDownloadSource; SelectedUpdateModeValue = state.UpdateMode; DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); + ForceReinstall = state.ForceUpdateReinstall; SyncComboBoxSelections(); } @@ -369,9 +391,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked"); UpdateTypeLabel = L("settings.update.update_type_label", "Update Type"); ChannelLabel = L("settings.update.channel_label", "Update Channel"); + ChannelDescription = L("settings.update.channel_description", "Choose Stable for regular releases or Preview for earlier builds."); SourceLabel = L("settings.update.source_label", "Download Source"); + SourceDescription = L("settings.update.source_description", "Select the manifest and installer source used by the update workflow."); ModeLabel = L("settings.update.mode_label", "Update Mode"); + ModeDescription = L("settings.update.mode_description", "Manual never downloads or installs automatically. Silent Download downloads in the background. Silent Install downloads in the background and applies on exit."); DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); + DownloadThreadsDescription = L("settings.update.download_threads_description", "Select how many parallel threads are used for update downloads. Paused downloads can be resumed later."); + ForceReinstallLabel = L("settings.update.force_reinstall_label", "Force Reinstall"); + ForceReinstallDescription = L("settings.update.force_reinstall_description", "Download the full payload for the selected version and mark this run as a reinstall instead of an incremental update."); + ResumeSupportLabel = L("settings.update.resume_support_label", "Resume Support"); + ResumeSupportDescription = L("settings.update.resume_support_description", "Downloads keep partial files and package metadata, so Pause and Resume continue from the previous state when the server supports it."); + TransferControlsTitle = L("settings.update.transfer_controls_title", "Transfer Controls"); + TransferControlsDescription = L("settings.update.transfer_controls_description", "Pause a running download, resume it from the saved state, or cancel and clear pending update artifacts."); UpdateAvailableBadgeText = L("settings.update.badge_available", "Update available"); PausedBadgeText = L("settings.update.badge_paused", "Paused"); @@ -423,9 +455,9 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable { return [ - new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual")), - new(UpdateSettingsValues.ModeDownloadThenConfirm, L("settings.update.mode_confirm", "Download then Confirm")), - new(UpdateSettingsValues.ModeSilentOnExit, L("settings.update.mode_silent", "Silent on Exit")) + new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual: no automatic download or install")), + new(UpdateSettingsValues.ModeSilentDownload, L("settings.update.mode_silent_download", "Silent Download")), + new(UpdateSettingsValues.ModeSilentInstall, L("settings.update.mode_silent_install", "Silent Install")) ]; } @@ -437,7 +469,8 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable UpdateChannel = SelectedUpdateChannelValue, UpdateDownloadSource = SelectedUpdateSourceValue, UpdateMode = SelectedUpdateModeValue, - UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)) + UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)), + ForceUpdateReinstall = ForceReinstall }); } @@ -541,12 +574,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable => L("settings.update.status_canceled", "Update canceled."); private string GetUpdateTypeText(UpdatePayloadKind? payloadKind) - => payloadKind switch + { + if (ForceReinstall) + { + return L("settings.update.type_reinstall", "Reinstall"); + } + + return payloadKind switch { UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy => L("settings.update.type_delta", "Incremental Update"), - UpdatePayloadKind.FullInstaller => L("settings.update.type_full", "Full Installer"), + UpdatePayloadKind.FullInstaller => L("settings.update.type_reinstall", "Reinstall"), _ => string.Empty }; + } private string L(string key, string fallback) => _localizationService.GetString(_languageCode, key, fallback); diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index c322e5c..3aec7d2 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -96,6 +96,7 @@ public partial class MainWindow : Window string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.ForceUpdateReinstall), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) || @@ -681,6 +682,7 @@ public partial class MainWindow : Window UpdateMode = latestUpdateState.UpdateMode, UpdateDownloadSource = latestUpdateState.UpdateDownloadSource, UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads, + ForceUpdateReinstall = latestUpdateState.ForceUpdateReinstall, UseGhProxyMirror = latestUpdateState.UseGhProxyMirror, PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath, PendingUpdateVersion = latestUpdateState.PendingUpdateVersion, diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml index 302e1cd..0954d83 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml @@ -44,7 +44,21 @@ - + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs index 65fc06c..f697688 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml.cs @@ -1,3 +1,7 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.ViewModels; @@ -14,6 +18,9 @@ namespace LanMountainDesktop.Views.SettingsPages; DescriptionLocalizationKey = "settings.dev.description")] public partial class DevSettingsPage : SettingsPageBase { + private bool _isReady; + private bool _syncingToggles; + public DevSettingsPage() : this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate())) { @@ -24,7 +31,92 @@ public partial class DevSettingsPage : SettingsPageBase ViewModel = viewModel; DataContext = ViewModel; InitializeComponent(); + _isReady = true; } public DevSettingsPageViewModel ViewModel { get; } + + private async void OnFusedDesktopToggleChanged(object? sender, RoutedEventArgs e) + { + if (!_isReady || _syncingToggles || sender is not ToggleSwitch toggle) + { + return; + } + + var requested = toggle.IsChecked == true; + if (!requested) + { + ViewModel.ApplyFusedDesktopPreference(enabled: false, disableMainWindowDesktopLayer: false); + SyncTogglesFromViewModel(); + return; + } + + if (ViewModel.EnableMainWindowDesktopLayer && + !await ConfirmDesktopLayerSwitchAsync(ViewModel.DesktopLayerConflictEnableFusedMessage).ConfigureAwait(true)) + { + SyncTogglesFromViewModel(); + return; + } + + ViewModel.ApplyFusedDesktopPreference(enabled: true, disableMainWindowDesktopLayer: true); + SyncTogglesFromViewModel(); + } + + private async void OnMainWindowDesktopLayerToggleChanged(object? sender, RoutedEventArgs e) + { + if (!_isReady || _syncingToggles || sender is not ToggleSwitch toggle) + { + return; + } + + var requested = toggle.IsChecked == true; + if (!requested) + { + ViewModel.ApplyMainWindowDesktopLayerPreference(enabled: false, disableFusedDesktop: false); + SyncTogglesFromViewModel(); + return; + } + + if (ViewModel.EnableFusedDesktop && + !await ConfirmDesktopLayerSwitchAsync(ViewModel.DesktopLayerConflictEnableMainMessage).ConfigureAwait(true)) + { + SyncTogglesFromViewModel(); + return; + } + + ViewModel.ApplyMainWindowDesktopLayerPreference(enabled: true, disableFusedDesktop: true); + SyncTogglesFromViewModel(); + } + + private async Task ConfirmDesktopLayerSwitchAsync(string message) + { + var dialog = new FAContentDialog + { + Title = ViewModel.DesktopLayerConflictTitle, + Content = message, + PrimaryButtonText = ViewModel.DesktopLayerConflictConfirmText, + CloseButtonText = ViewModel.DesktopLayerConflictCancelText, + DefaultButton = FAContentDialogButton.Close + }; + + var owner = this.FindAncestorOfType(); + var result = owner is not null + ? await dialog.ShowAsync(owner) + : await dialog.ShowAsync(); + return result == FAContentDialogResult.Primary; + } + + private void SyncTogglesFromViewModel() + { + _syncingToggles = true; + try + { + FusedDesktopToggle.IsChecked = ViewModel.EnableFusedDesktop; + MainWindowDesktopLayerToggle.IsChecked = ViewModel.EnableMainWindowDesktopLayer; + } + finally + { + _syncingToggles = false; + } + } } diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml index 48192c9..36a9f39 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml @@ -89,49 +89,40 @@ + + + + + + + Spacing="8">