feat.动画优化与更新界面

This commit is contained in:
lincube
2026-05-17 19:36:07 +08:00
parent a5abda62dc
commit 9404a0b347
29 changed files with 1607 additions and 71 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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<Canvas>(presenter.Root);
presenter.SetPreviewRect(new Rect(12, 34, 180, 120));
var ghost = root.Children.OfType<DesktopEditGhostView>().Single();
Assert.Equal(12, Canvas.GetLeft(ghost));
Assert.Equal(34, Canvas.GetTop(ghost));
Assert.Equal(180, ghost.Width);
Assert.Equal(120, ghost.Height);
}
}

View File

@@ -64,6 +64,63 @@ public sealed class WindowLayerIsolationTests
Assert.Contains("window.RefreshDesktopLayer()", source); 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) private static string ReadRepositoryFile(params string[] segments)
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); 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)}'."); 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}'.");
}
} }

View File

@@ -57,6 +57,7 @@ public partial class App : Application
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly HostShutdownGate _shutdownGate = new(); private readonly HostShutdownGate _shutdownGate = new();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly IMainWindowDesktopLayerService _mainWindowDesktopLayerService = MainWindowDesktopLayerServiceFactory.GetOrCreate();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow; private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal"; private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal";
@@ -897,7 +898,7 @@ public partial class App : Application
var mainWindow = GetOrCreateMainWindow(desktop, source); var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation(); mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar(); mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar() && !IsMainWindowDesktopLayerEnabled();
if (!mainWindow.IsVisible) if (!mainWindow.IsVisible)
{ {
@@ -917,9 +918,7 @@ public partial class App : Application
mainWindow.WindowState = WindowState.FullScreen; mainWindow.WindowState = WindowState.FullScreen;
} }
mainWindow.Activate(); ActivateOrRefreshMainWindowLayer(mainWindow, $"Restore:{source}");
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
@@ -1113,9 +1112,19 @@ public partial class App : Application
if (fusedDesktopChanged) if (fusedDesktopChanged)
{ {
ApplyFusedDesktopRuntimeState();
RefreshFusedDesktopMenuItemVisibility(); RefreshFusedDesktopMenuItemVisibility();
} }
var mainWindowDesktopLayerChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer), StringComparer.OrdinalIgnoreCase);
if (mainWindowDesktopLayerChanged)
{
ApplyMainWindowDesktopLayerRuntimeState("SettingsChanged");
}
var showInTaskbarChanged = var showInTaskbarChanged =
refreshAll || refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparer.OrdinalIgnoreCase); changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparer.OrdinalIgnoreCase);
@@ -1313,7 +1322,7 @@ public partial class App : Application
var mainWindow = new MainWindow var mainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel(),
ShowInTaskbar = ShouldShowMainWindowInTaskbar() ShowInTaskbar = ShouldShowMainWindowInTaskbar() && !IsMainWindowDesktopLayerEnabled()
}; };
_mainWindowOpened = false; _mainWindowOpened = false;
@@ -1351,6 +1360,7 @@ public partial class App : Application
{ {
mainWindow.Opened -= OnMainWindowOpened; mainWindow.Opened -= OnMainWindowOpened;
_mainWindowOpened = true; _mainWindowOpened = true;
ApplyMainWindowDesktopLayerRuntimeState("MainWindowOpened");
_loadingStateManager?.CompleteItem("system.init", "System initialization completed."); _loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
if (TryApplyStartupPresentation(mainWindow)) if (TryApplyStartupPresentation(mainWindow))
@@ -1428,6 +1438,7 @@ public partial class App : Application
if (_mainWindow is not null) if (_mainWindow is not null)
{ {
_mainWindowDesktopLayerService.Disable(_mainWindow);
_mainWindow.Closing -= OnMainWindowClosing; _mainWindow.Closing -= OnMainWindowClosing;
_mainWindow.Closed -= OnMainWindowClosed; _mainWindow.Closed -= OnMainWindowClosed;
_mainWindow.PropertyChanged -= OnMainWindowPropertyChanged; _mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
@@ -1473,6 +1484,7 @@ public partial class App : Application
mainWindow.Closing -= OnMainWindowClosing; mainWindow.Closing -= OnMainWindowClosing;
mainWindow.Closed -= OnMainWindowClosed; mainWindow.Closed -= OnMainWindowClosed;
mainWindow.PropertyChanged -= OnMainWindowPropertyChanged; mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
_mainWindowDesktopLayerService.Disable(mainWindow);
if (ReferenceEquals(_mainWindow, mainWindow)) if (ReferenceEquals(_mainWindow, mainWindow))
{ {
@@ -1614,16 +1626,83 @@ public partial class App : Application
mainWindow.WindowState = WindowState.FullScreen; mainWindow.WindowState = WindowState.FullScreen;
} }
mainWindow.Activate(); ActivateOrRefreshMainWindowLayer(mainWindow, $"TrayFallbackForeground:{source}");
mainWindow.Topmost = true;
mainWindow.Topmost = false;
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}"); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable."); ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
} }
private bool ShouldShowMainWindowInTaskbar() private bool ShouldShowMainWindowInTaskbar()
{ {
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar; var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return snapshot.ShowInTaskbar && !snapshot.EnableMainWindowDesktopLayer;
}
private bool IsMainWindowDesktopLayerEnabled()
{
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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) private bool EnsureTaskbarEntry(string source)

View File

@@ -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<Visual, CompositionVisual?> _getVisual;
public CompositionVisualAnimationService()
: this(ElementComposition.GetElementVisual)
{
}
internal CompositionVisualAnimationService(Func<Visual, CompositionVisual?> 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<CompositionVisual> apply)
{
try
{
var visual = _getVisual(target);
if (visual is null)
{
return false;
}
apply(visual);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -27,11 +27,14 @@ internal sealed class DesktopEditOverlayPresenter
private readonly DesktopEditGhostView _ghostView; private readonly DesktopEditGhostView _ghostView;
private readonly Border _candidateOutline; private readonly Border _candidateOutline;
private readonly ScaleTransform _candidateScale = new(1, 1); private readonly ScaleTransform _candidateScale = new(1, 1);
private readonly CompositionVisualAnimationService _visualAnimationService;
private Rect? _previewRect; private Rect? _previewRect;
private Rect? _candidateRect; private Rect? _candidateRect;
private bool _isInvalid; private bool _isInvalid;
private bool _isVisible; private bool _isVisible;
private bool _ghostUsesCompositionOffset;
private bool _candidateUsesCompositionOffset;
private int _dismissVersion; private int _dismissVersion;
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF")); private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
@@ -40,7 +43,14 @@ internal sealed class DesktopEditOverlayPresenter
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30")); private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
public DesktopEditOverlayPresenter() public DesktopEditOverlayPresenter()
: this(new CompositionVisualAnimationService())
{ {
}
internal DesktopEditOverlayPresenter(CompositionVisualAnimationService visualAnimationService)
{
_visualAnimationService = visualAnimationService;
_ghostView = new DesktopEditGhostView _ghostView = new DesktopEditGhostView
{ {
IsHitTestVisible = false, IsHitTestVisible = false,
@@ -276,8 +286,7 @@ internal sealed class DesktopEditOverlayPresenter
var rect = _previewRect.Value; var rect = _previewRect.Value;
_ghostView.Width = Math.Max(1, rect.Width); _ghostView.Width = Math.Max(1, rect.Width);
_ghostView.Height = Math.Max(1, rect.Height); _ghostView.Height = Math.Max(1, rect.Height);
Canvas.SetLeft(_ghostView, rect.X); SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y), ref _ghostUsesCompositionOffset);
Canvas.SetTop(_ghostView, rect.Y);
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height); _ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
} }
@@ -294,8 +303,7 @@ internal sealed class DesktopEditOverlayPresenter
_candidateOutline.IsVisible = true; _candidateOutline.IsVisible = true;
_candidateOutline.Width = Math.Max(1, rect.Width); _candidateOutline.Width = Math.Max(1, rect.Width);
_candidateOutline.Height = Math.Max(1, rect.Height); _candidateOutline.Height = Math.Max(1, rect.Height);
Canvas.SetLeft(_candidateOutline, rect.X); SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y), ref _candidateUsesCompositionOffset);
Canvas.SetTop(_candidateOutline, rect.Y);
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26); var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius); _candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
@@ -325,6 +333,26 @@ internal sealed class DesktopEditOverlayPresenter
return new Rect(rect.X, rect.Y, width, height); 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) => private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
new() new()
{ {

View File

@@ -802,6 +802,13 @@
"settings.dev.three_finger_description": "Enable desktop page switching gestures when the current platform supports them.", "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_header": "Fused desktop experience",
"settings.dev.fused_description": "Enable the fused desktop shell and its related experimental entry points.", "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_header": "Development plugin path",
"settings.dev.plugin_path_description": "Load a local plugin output directory for iterative debugging without packaging.", "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", "settings.dev.plugin_path_placeholder": "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0",

View File

@@ -741,6 +741,13 @@
"settings.dev.three_finger_description": "在当前平台支持时,启用手势在桌面分页间切换。", "settings.dev.three_finger_description": "在当前平台支持时,启用手势在桌面分页间切换。",
"settings.dev.fused_header": "融合桌面体验", "settings.dev.fused_header": "融合桌面体验",
"settings.dev.fused_description": "启用融合桌面壳及相关实验入口。", "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_header": "开发插件路径",
"settings.dev.plugin_path_description": "加载本地插件输出目录以便免打包迭代调试。", "settings.dev.plugin_path_description": "加载本地插件输出目录以便免打包迭代调试。",
"settings.dev.plugin_path_placeholder": "例如C:\\path\\to\\plugin\\bin\\Debug\\net10.0", "settings.dev.plugin_path_placeholder": "例如C:\\path\\to\\plugin\\bin\\Debug\\net10.0",

View File

@@ -99,6 +99,8 @@ public sealed class AppSettingsSnapshot
public int UpdateDownloadThreads { get; set; } = 4; public int UpdateDownloadThreads { get; set; } = 4;
public bool ForceUpdateReinstall { get; set; }
public string? PendingUpdateInstallerPath { get; set; } public string? PendingUpdateInstallerPath { get; set; }
public string? PendingUpdateVersion { get; set; } public string? PendingUpdateVersion { get; set; }
@@ -182,6 +184,8 @@ public sealed class AppSettingsSnapshot
public bool EnableFusedDesktop { get; set; } = false; public bool EnableFusedDesktop { get; set; } = false;
public bool EnableMainWindowDesktopLayer { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = []; public List<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; } public bool IsDevModeEnabled { get; set; }

View File

@@ -20,6 +20,7 @@ public interface IFusedDesktopManagerService
void EnterEditMode(); void EnterEditMode();
void ExitEditMode(); void ExitEditMode();
void ReloadWidgets(); void ReloadWidgets();
void Shutdown();
} }
/// <summary> /// <summary>
@@ -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) private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement)
{ {
EnsureRegistries(); EnsureRegistries();

View File

@@ -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<IntPtr, WindowRestoreState> _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<IntPtr>();
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)
{
}
}

View File

@@ -89,6 +89,7 @@ public sealed record UpdateSettingsState(
string UpdateMode, string UpdateMode,
string UpdateDownloadSource, string UpdateDownloadSource,
int UpdateDownloadThreads, int UpdateDownloadThreads,
bool ForceUpdateReinstall,
bool UseGhProxyMirror, bool UseGhProxyMirror,
string? PendingUpdateInstallerPath, string? PendingUpdateInstallerPath,
string? PendingUpdateVersion, string? PendingUpdateVersion,

View File

@@ -802,6 +802,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode), UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource), UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads), UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
snapshot.ForceUpdateReinstall,
snapshot.UseGhProxyMirror, snapshot.UseGhProxyMirror,
snapshot.PendingUpdateInstallerPath, snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion, snapshot.PendingUpdateVersion,
@@ -824,6 +825,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource); snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
snapshot.ForceUpdateReinstall = state.ForceUpdateReinstall;
snapshot.UseGhProxyMirror = state.UseGhProxyMirror; snapshot.UseGhProxyMirror = state.UseGhProxyMirror;
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath) snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
? null ? null
@@ -850,6 +852,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.UpdateMode), nameof(AppSettingsSnapshot.UpdateMode),
nameof(AppSettingsSnapshot.UpdateDownloadSource), nameof(AppSettingsSnapshot.UpdateDownloadSource),
nameof(AppSettingsSnapshot.UpdateDownloadThreads), nameof(AppSettingsSnapshot.UpdateDownloadThreads),
nameof(AppSettingsSnapshot.ForceUpdateReinstall),
nameof(AppSettingsSnapshot.UseGhProxyMirror), nameof(AppSettingsSnapshot.UseGhProxyMirror),
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion), nameof(AppSettingsSnapshot.PendingUpdateVersion),

View File

@@ -129,11 +129,27 @@ public sealed class UpdateOrchestrator : IDisposable
UpdateManifest? manifest; UpdateManifest? manifest;
try try
{ {
manifest = await _manifestProvider.GetLatestAsync( var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform();
manifest = settings.ForceUpdateReinstall
? await _manifestProvider.GetByVersionAsync(
currentVersionText,
channel, channel,
LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(), platform,
operationToken)
: await _manifestProvider.GetLatestAsync(
channel,
platform,
currentVersion, currentVersion,
operationToken); operationToken);
if (manifest is null && settings.ForceUpdateReinstall)
{
manifest = await _manifestProvider.GetLatestAsync(
channel,
platform,
currentVersion,
operationToken);
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -525,7 +541,13 @@ public sealed class UpdateOrchestrator : IDisposable
try try
{ {
await CheckAsync(ct); var report = await CheckAsync(ct);
if (!report.IsUpdateAvailable || !CurrentPhase.CanDownload())
{
return;
}
await DownloadAsync(ct);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@@ -10,6 +10,8 @@ public static class UpdateSettingsValues
public const string ModeManual = "manual"; public const string ModeManual = "manual";
public const string ModeDownloadThenConfirm = "download_then_confirm"; public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit"; 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. // NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "plonds-api"; public const string DownloadSourcePlonds = "plonds-api";

View File

@@ -2416,6 +2416,9 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _enableFusedDesktop; private bool _enableFusedDesktop;
[ObservableProperty]
private bool _enableMainWindowDesktopLayer;
[ObservableProperty] [ObservableProperty]
private string _infoBarTitle = string.Empty; private string _infoBarTitle = string.Empty;
@@ -2440,6 +2443,27 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _fusedDescription = string.Empty; 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] [ObservableProperty]
private string _pluginPathHeader = string.Empty; 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."); ThreeFingerDescription = L("settings.dev.three_finger_description", "Enable desktop page switching gestures when supported.");
FusedHeader = L("settings.dev.fused_header", "Fused desktop experience"); FusedHeader = L("settings.dev.fused_header", "Fused desktop experience");
FusedDescription = L("settings.dev.fused_description", "Enable the fused desktop shell and experimental entry points."); 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"); 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."); 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"); 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); SaveField(nameof(AppSettingsSnapshot.EnableFusedDesktop), value);
} }
partial void OnEnableMainWindowDesktopLayerChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer), value);
}
private void LoadSettings() private void LoadSettings()
{ {
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
@@ -2534,6 +2571,7 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
DevPluginPath = snapshot.DevPluginPath ?? string.Empty; DevPluginPath = snapshot.DevPluginPath ?? string.Empty;
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop; EnableFusedDesktop = snapshot.EnableFusedDesktop;
EnableMainWindowDesktopLayer = snapshot.EnableMainWindowDesktopLayer;
} }
private void OnSettingsChanged(object? sender, SettingsChangedEvent e) private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
@@ -2555,6 +2593,7 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop; EnableFusedDesktop = snapshot.EnableFusedDesktop;
EnableMainWindowDesktopLayer = snapshot.EnableMainWindowDesktopLayer;
} }
finally finally
{ {
@@ -2573,4 +2612,51 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]); _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
} }
public void ApplyFusedDesktopPreference(bool enabled, bool disableMainWindowDesktopLayer)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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;
}
}
} }

View File

@@ -63,9 +63,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string _lastCheckedLabel = string.Empty; [ObservableProperty] private string _lastCheckedLabel = string.Empty;
[ObservableProperty] private string _updateTypeLabel = string.Empty; [ObservableProperty] private string _updateTypeLabel = string.Empty;
[ObservableProperty] private string _channelLabel = string.Empty; [ObservableProperty] private string _channelLabel = string.Empty;
[ObservableProperty] private string _channelDescription = string.Empty;
[ObservableProperty] private string _sourceLabel = string.Empty; [ObservableProperty] private string _sourceLabel = string.Empty;
[ObservableProperty] private string _sourceDescription = string.Empty;
[ObservableProperty] private string _modeLabel = string.Empty; [ObservableProperty] private string _modeLabel = string.Empty;
[ObservableProperty] private string _modeDescription = string.Empty;
[ObservableProperty] private string _downloadThreadsLabel = 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 _updateAvailableBadgeText = string.Empty;
[ObservableProperty] private string _pausedBadgeText = 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 string _updateTypeText = string.Empty;
[ObservableProperty] private bool _isUpdateAvailable; [ObservableProperty] private bool _isUpdateAvailable;
[ObservableProperty] private bool _isDeltaUpdate; [ObservableProperty] private bool _isDeltaUpdate;
[ObservableProperty] private bool _forceReinstall;
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; [ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc; [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 double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
[ObservableProperty] private SelectionOption? _selectedChannel; [ObservableProperty] private SelectionOption? _selectedChannel;
@@ -183,6 +194,16 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
SavePreferenceState(); 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))] [RelayCommand(CanExecute = nameof(CanCheck))]
private async Task CheckAsync() private async Task CheckAsync()
{ {
@@ -332,6 +353,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
SelectedUpdateSourceValue = state.UpdateDownloadSource; SelectedUpdateSourceValue = state.UpdateDownloadSource;
SelectedUpdateModeValue = state.UpdateMode; SelectedUpdateModeValue = state.UpdateMode;
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads); DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
ForceReinstall = state.ForceUpdateReinstall;
SyncComboBoxSelections(); SyncComboBoxSelections();
} }
@@ -369,9 +391,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked"); LastCheckedLabel = L("settings.update.last_checked_label", "Last Checked");
UpdateTypeLabel = L("settings.update.update_type_label", "Update Type"); UpdateTypeLabel = L("settings.update.update_type_label", "Update Type");
ChannelLabel = L("settings.update.channel_label", "Update Channel"); 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"); 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"); 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"); 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"); UpdateAvailableBadgeText = L("settings.update.badge_available", "Update available");
PausedBadgeText = L("settings.update.badge_paused", "Paused"); PausedBadgeText = L("settings.update.badge_paused", "Paused");
@@ -423,9 +455,9 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
{ {
return return
[ [
new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual")), new(UpdateSettingsValues.ModeManual, L("settings.update.mode_manual", "Manual: no automatic download or install")),
new(UpdateSettingsValues.ModeDownloadThenConfirm, L("settings.update.mode_confirm", "Download then Confirm")), new(UpdateSettingsValues.ModeSilentDownload, L("settings.update.mode_silent_download", "Silent Download")),
new(UpdateSettingsValues.ModeSilentOnExit, L("settings.update.mode_silent", "Silent on Exit")) new(UpdateSettingsValues.ModeSilentInstall, L("settings.update.mode_silent_install", "Silent Install"))
]; ];
} }
@@ -437,7 +469,8 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
UpdateChannel = SelectedUpdateChannelValue, UpdateChannel = SelectedUpdateChannelValue,
UpdateDownloadSource = SelectedUpdateSourceValue, UpdateDownloadSource = SelectedUpdateSourceValue,
UpdateMode = SelectedUpdateModeValue, 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."); => L("settings.update.status_canceled", "Update canceled.");
private string GetUpdateTypeText(UpdatePayloadKind? payloadKind) 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.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 _ => string.Empty
}; };
}
private string L(string key, string fallback) private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback); => _localizationService.GetString(_languageCode, key, fallback);

View File

@@ -96,6 +96,7 @@ public partial class MainWindow : Window
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), 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.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
@@ -681,6 +682,7 @@ public partial class MainWindow : Window
UpdateMode = latestUpdateState.UpdateMode, UpdateMode = latestUpdateState.UpdateMode,
UpdateDownloadSource = latestUpdateState.UpdateDownloadSource, UpdateDownloadSource = latestUpdateState.UpdateDownloadSource,
UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads, UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads,
ForceUpdateReinstall = latestUpdateState.ForceUpdateReinstall,
UseGhProxyMirror = latestUpdateState.UseGhProxyMirror, UseGhProxyMirror = latestUpdateState.UseGhProxyMirror,
PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath, PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath,
PendingUpdateVersion = latestUpdateState.PendingUpdateVersion, PendingUpdateVersion = latestUpdateState.PendingUpdateVersion,

View File

@@ -44,7 +44,21 @@
<ui:FAFontIconSource Glyph="&#xF01A8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> <ui:FAFontIconSource Glyph="&#xF01A8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource> </ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer> <ui:FASettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableFusedDesktop}" /> <ToggleSwitch x:Name="FusedDesktopToggle"
IsChecked="{Binding EnableFusedDesktop, Mode=OneWay}"
IsCheckedChanged="OnFusedDesktopToggleChanged" />
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding MainWindowDesktopLayerHeader}"
Description="{Binding MainWindowDesktopLayerDescription}">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1BE0;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ToggleSwitch x:Name="MainWindowDesktopLayerToggle"
IsChecked="{Binding EnableMainWindowDesktopLayer, Mode=OneWay}"
IsCheckedChanged="OnMainWindowDesktopLayerToggleChanged" />
</ui:FASettingsExpander.Footer> </ui:FASettingsExpander.Footer>
</ui:FASettingsExpander> </ui:FASettingsExpander>

View File

@@ -1,3 +1,7 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
@@ -14,6 +18,9 @@ namespace LanMountainDesktop.Views.SettingsPages;
DescriptionLocalizationKey = "settings.dev.description")] DescriptionLocalizationKey = "settings.dev.description")]
public partial class DevSettingsPage : SettingsPageBase public partial class DevSettingsPage : SettingsPageBase
{ {
private bool _isReady;
private bool _syncingToggles;
public DevSettingsPage() public DevSettingsPage()
: this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate())) : this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
{ {
@@ -24,7 +31,92 @@ public partial class DevSettingsPage : SettingsPageBase
ViewModel = viewModel; ViewModel = viewModel;
DataContext = ViewModel; DataContext = ViewModel;
InitializeComponent(); InitializeComponent();
_isReady = true;
} }
public DevSettingsPageViewModel ViewModel { get; } 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<bool> 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<Window>();
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;
}
}
} }

View File

@@ -89,49 +89,40 @@
</ui:FAInfoBar.IconSource> </ui:FAInfoBar.IconSource>
</ui:FAInfoBar> </ui:FAInfoBar>
<ui:FAInfoBar Title="{Binding ResumeSupportLabel}"
Message="{Binding ResumeSupportDescription}"
IsOpen="True"
IsClosable="False"
Severity="Informational">
<ui:FAInfoBar.IconSource>
<ui:FAFontIconSource Glyph="&#xF0647;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FAInfoBar.IconSource>
</ui:FAInfoBar>
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="8" Spacing="8">
IsVisible="{Binding CanDownload}">
<Button Classes="settings-accent-button" <Button Classes="settings-accent-button"
Content="{Binding DownloadButtonText}" Content="{Binding DownloadButtonText}"
Command="{Binding DownloadCommand}" /> Command="{Binding DownloadCommand}"
</StackPanel> IsVisible="{Binding CanDownload}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
IsVisible="{Binding CanInstall}">
<Button Classes="settings-accent-button" <Button Classes="settings-accent-button"
Content="{Binding InstallButtonText}" Content="{Binding InstallButtonText}"
Command="{Binding InstallCommand}" /> Command="{Binding InstallCommand}"
</StackPanel> IsVisible="{Binding CanInstall}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
IsVisible="{Binding CanPause}">
<Button Content="{Binding PauseButtonText}" <Button Content="{Binding PauseButtonText}"
Command="{Binding PauseCommand}" /> Command="{Binding PauseCommand}"
</StackPanel> IsVisible="{Binding CanPause}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
IsVisible="{Binding CanResume}">
<Button Classes="settings-accent-button" <Button Classes="settings-accent-button"
Content="{Binding ResumeButtonText}" Content="{Binding ResumeButtonText}"
Command="{Binding ResumeCommand}" /> Command="{Binding ResumeCommand}"
</StackPanel> IsVisible="{Binding CanResume}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
IsVisible="{Binding CanRollback}">
<Button Content="{Binding RollbackButtonText}" <Button Content="{Binding RollbackButtonText}"
Command="{Binding RollbackCommand}" /> Command="{Binding RollbackCommand}"
</StackPanel> IsVisible="{Binding CanRollback}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
IsVisible="{Binding CanCancel}">
<Button Content="{Binding CancelButtonText}" <Button Content="{Binding CancelButtonText}"
Command="{Binding CancelCommand}" /> Command="{Binding CancelCommand}"
IsVisible="{Binding CanCancel}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
@@ -211,7 +202,8 @@
<ui:FAFontIconSource Glyph="&#xF0504;" <ui:FAFontIconSource Glyph="&#xF0504;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource> </ui:FASettingsExpander.IconSource>
<ui:FASettingsExpanderItem Content="{Binding ChannelLabel}"> <ui:FASettingsExpanderItem Content="{Binding ChannelLabel}"
Description="{Binding ChannelDescription}">
<ui:FASettingsExpanderItem.IconSource> <ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0908;" <ui:FAFontIconSource Glyph="&#xF0908;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
@@ -219,11 +211,17 @@
<ui:FASettingsExpanderItem.Footer> <ui:FASettingsExpanderItem.Footer>
<ComboBox Width="220" <ComboBox Width="220"
ItemsSource="{Binding ChannelOptions}" ItemsSource="{Binding ChannelOptions}"
SelectedItem="{Binding SelectedChannel}" SelectedItem="{Binding SelectedChannel}">
DisplayMemberBinding="{Binding Label}" /> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpanderItem.Footer> </ui:FASettingsExpanderItem.Footer>
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
<ui:FASettingsExpanderItem Content="{Binding SourceLabel}"> <ui:FASettingsExpanderItem Content="{Binding SourceLabel}"
Description="{Binding SourceDescription}">
<ui:FASettingsExpanderItem.IconSource> <ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0B4E;" <ui:FAFontIconSource Glyph="&#xF0B4E;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
@@ -231,11 +229,17 @@
<ui:FASettingsExpanderItem.Footer> <ui:FASettingsExpanderItem.Footer>
<ComboBox Width="220" <ComboBox Width="220"
ItemsSource="{Binding SourceOptions}" ItemsSource="{Binding SourceOptions}"
SelectedItem="{Binding SelectedSource}" SelectedItem="{Binding SelectedSource}">
DisplayMemberBinding="{Binding Label}" /> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpanderItem.Footer> </ui:FASettingsExpanderItem.Footer>
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
<ui:FASettingsExpanderItem Content="{Binding ModeLabel}"> <ui:FASettingsExpanderItem Content="{Binding ModeLabel}"
Description="{Binding ModeDescription}">
<ui:FASettingsExpanderItem.IconSource> <ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF08E8;" <ui:FAFontIconSource Glyph="&#xF08E8;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
@@ -243,11 +247,28 @@
<ui:FASettingsExpanderItem.Footer> <ui:FASettingsExpanderItem.Footer>
<ComboBox Width="220" <ComboBox Width="220"
ItemsSource="{Binding ModeOptions}" ItemsSource="{Binding ModeOptions}"
SelectedItem="{Binding SelectedMode}" SelectedItem="{Binding SelectedMode}">
DisplayMemberBinding="{Binding Label}" /> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}"
TextWrapping="Wrap" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpanderItem.Footer> </ui:FASettingsExpanderItem.Footer>
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
<ui:FASettingsExpanderItem Content="{Binding DownloadThreadsLabel}"> <ui:FASettingsExpanderItem Content="{Binding ForceReinstallLabel}"
Description="{Binding ForceReinstallDescription}">
<ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0504;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpanderItem.IconSource>
<ui:FASettingsExpanderItem.Footer>
<ToggleSwitch IsChecked="{Binding ForceReinstall}" />
</ui:FASettingsExpanderItem.Footer>
</ui:FASettingsExpanderItem>
<ui:FASettingsExpanderItem Content="{Binding DownloadThreadsLabel}"
Description="{Binding DownloadThreadsDescription}">
<ui:FASettingsExpanderItem.IconSource> <ui:FASettingsExpanderItem.IconSource>
<ui:FAFontIconSource Glyph="&#xF0168;" <ui:FAFontIconSource Glyph="&#xF0168;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
@@ -258,7 +279,7 @@
VerticalAlignment="Center"> VerticalAlignment="Center">
<Slider Width="140" <Slider Width="140"
Minimum="1" Minimum="1"
Maximum="16" Maximum="128"
Value="{Binding DownloadThreadsSliderValue}" Value="{Binding DownloadThreadsSliderValue}"
TickFrequency="1" TickFrequency="1"
IsSnapToTickEnabled="True" /> IsSnapToTickEnabled="True" />

BIN
diff.txt Normal file

Binary file not shown.

View File

@@ -245,6 +245,15 @@ See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration
- On Windows, desktop-surface windows may attach to the desktop icon host through `IWindowBottomMostService`, or fall back to `HWND_BOTTOM`. - On Windows, desktop-surface windows may attach to the desktop icon host through `IWindowBottomMostService`, or fall back to `HWND_BOTTOM`.
- Fused desktop windows refresh their bottom-most layer after being opened, shown, or reloaded so they do not cover ordinary apps. - Fused desktop windows refresh their bottom-most layer after being opened, shown, or reloaded so they do not cover ordinary apps.
## Main Window Desktop Layer
- The main desktop host window has a separate developer option, `EnableMainWindowDesktopLayer`.
- This mode is mutually exclusive with fused desktop because fused desktop manages component windows while main-window desktop layer manages the host window itself.
- The main-window service is `IMainWindowDesktopLayerService`; it attaches only the main window to the desktop icon host on Windows and falls back to `HWND_BOTTOM`.
- The main-window service does not use fused desktop click-through region logic, so the main desktop window remains interactive.
- Main-window restore paths refresh the desktop-layer attachment instead of using temporary `Topmost` foreground promotion while this mode is enabled.
- Air APP windows remain ordinary application windows and are not handled by either desktop-layer service.
## Air APP Window Chrome ## Air APP Window Chrome
- `LanMountainDesktop.AirAppHost` owns Air APP window chrome through `AirAppWindowDescriptor`. - `LanMountainDesktop.AirAppHost` owns Air APP window chrome through `AirAppWindowDescriptor`.

144
scripts/analyze_commits.ps1 Normal file
View File

@@ -0,0 +1,144 @@
# Analyze Git commits from today and generate Markdown reports
param(
[string]$RepoPath = (Split-Path -Parent $PSScriptRoot)
)
Write-Host "Analyzing repository: $RepoPath"
# Create output directory
$outputDir = Join-Path (Join-Path $RepoPath "docs") "auto_commit_md"
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
Write-Host "Created directory: $outputDir"
} else {
Write-Host "Output directory: $outputDir"
}
Write-Host ""
# Get today's date range
$today = Get-Date
$todayStart = $today.Date.ToString("yyyy-MM-ddTHH:mm:ss")
$todayEnd = $today.Date.AddDays(1).AddSeconds(-1).ToString("yyyy-MM-ddTHH:mm:ss")
# Get commits from today
$commitsOutput = & git -C $RepoPath log --since="$todayStart" --until="$todayEnd" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso
if (-not $commitsOutput) {
Write-Host "No new commits today."
exit 0
}
$commits = @()
foreach ($line in $commitsOutput -split "`n") {
if (-not $line) { continue }
$parts = $line -split '\|', 5
if ($parts.Count -eq 5) {
$commits += @{
Hash = $parts[0]
AuthorName = $parts[1]
AuthorEmail = $parts[2]
Date = $parts[3]
Message = $parts[4]
}
}
}
Write-Host "Found $($commits.Count) commits today."
Write-Host ""
foreach ($commit in $commits) {
$shortHash = $commit.Hash.Substring(0, 7)
Write-Host "Processing commit: $shortHash - $($commit.Message)"
# Get commit details
$diffStat = & git -C $RepoPath show --stat $commit.Hash
$diffDetails = & git -C $RepoPath show $commit.Hash
# Parse statistics
$filesChanged = @()
$totalInsertions = 0
$totalDeletions = 0
foreach ($line in $diffStat -split "`n") {
if ($line -match '(\d+) insertion') {
$totalInsertions += [int]$matches[1]
}
if ($line -match '(\d+) deletion') {
$totalDeletions += [int]$matches[1]
}
if ($line -match '^\s*(.*?)\s*\|\s*(\d+)') {
$filesChanged += @{
File = $matches[1]
Lines = [int]$matches[2]
}
}
}
# Generate filename
$dateStr = $commit.Date.Split(' ')[0].Replace('-', '')
$filename = "$dateStr`_$shortHash.md"
$outputPath = Join-Path $outputDir $filename
# Generate Markdown content
$report = @"
# Commit Analysis Report - $shortHash
## Basic Information
| Item | Content |
|------|---------|
| Commit Hash | `` $($commit.Hash) `` |
| Author | $($commit.AuthorName) ($($commit.AuthorEmail)) |
| Commit Time | $($commit.Date) |
## Commit Message
$($commit.Message)
## Change Statistics
| Metric | Value |
|--------|-------|
| Files Changed | $($filesChanged.Count) |
| Lines Added | +$totalInsertions |
| Lines Removed | -$totalDeletions |
## Modified Files
"@
foreach ($file in $filesChanged) {
$report += "- $($file.File) ($($file.Lines) lines)`n"
}
$report += @"
## Detailed Changes
```diff
$diffDetails
```
## Code Review Checklist
> This section is auto-generated, manual review recommended
- [ ] Check for potential bugs
- [ ] Verify code follows project standards
- [ ] Test new functionality if applicable
- [ ] Check for security issues
---
*Report generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")*
"@
# Save file
[System.IO.File]::WriteAllText($outputPath, $report, [System.Text.Encoding]::UTF8)
Write-Host " Report saved: $outputPath"
}
Write-Host ""
Write-Host "Done!"

206
scripts/analyze_commits.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
分析当天 Git 提交并生成 Markdown 报告的脚本
"""
import os
import subprocess
import sys
from datetime import datetime
import re
def run_command(cmd, cwd=None):
"""运行命令并返回输出"""
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
encoding='utf-8',
errors='replace'
)
return result.stdout, result.stderr, result.returncode
except Exception as e:
return "", str(e), 1
def get_today_commits(repo_path):
"""获取当天的所有提交"""
today_start = datetime.now().strftime('%Y-%m-%dT00:00:00')
today_end = datetime.now().strftime('%Y-%m-%dT23:59:59')
cmd = f'git log --since="{today_start}" --until="{today_end}" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso'
stdout, stderr, code = run_command(cmd, cwd=repo_path)
if code != 0:
print(f"Error getting commits: {stderr}")
return []
commits = []
for line in stdout.strip().split('\n'):
if not line:
continue
parts = line.split('|', 4)
if len(parts) == 5:
commits.append({
'hash': parts[0],
'author_name': parts[1],
'author_email': parts[2],
'date': parts[3],
'message': parts[4]
})
return commits
def get_commit_diff(repo_path, commit_hash):
"""获取提交的详细 diff"""
cmd = f'git show --stat {commit_hash}'
stdout, _, _ = run_command(cmd, cwd=repo_path)
return stdout
def get_commit_details(repo_path, commit_hash):
"""获取提交的详细信息"""
cmd = f'git show {commit_hash}'
stdout, _, _ = run_command(cmd, cwd=repo_path)
return stdout
def parse_diff_stats(diff_stat):
"""解析 diff --stat 的输出"""
files_changed = []
total_insertions = 0
total_deletions = 0
lines = diff_stat.strip().split('\n')
for line in lines:
match = re.search(r'(\d+) insertion', line)
if match:
total_insertions += int(match.group(1))
match = re.search(r'(\d+) deletion', line)
if match:
total_deletions += int(match.group(1))
file_match = re.match(r'^\s*(.*?)\s*\|\s*(\d+)', line)
if file_match:
files_changed.append({
'file': file_match.group(1),
'lines': int(file_match.group(2))
})
return {
'files': files_changed,
'insertions': total_insertions,
'deletions': total_deletions
}
def generate_markdown_report(commit, diff_stat, diff_details):
"""生成 Markdown 报告"""
short_hash = commit['hash'][:7]
date_str = commit['date'].split(' ')[0].replace('-', '')
report = f"""# 提交分析报告 - {short_hash}
## 基本信息
| 项目 | 内容 |
|------|------|
| 提交哈希 | `{commit['hash']}` |
| 作者 | {commit['author_name']} ({commit['author_email']}) |
| 提交时间 | {commit['date']} |
## 提交信息
{commit['message']}
## 变更统计
| 指标 | 数值 |
|------|------|
| 修改文件数 | {len(diff_stat['files'])} |
| 新增行数 | +{diff_stat['insertions']} |
| 删除行数 | -{diff_stat['deletions']} |
## 修改文件列表
"""
for file_info in diff_stat['files']:
report += f"- {file_info['file']} ({file_info['lines']} 行)\n"
report += f"""
## 详细变更
```diff
{diff_details}
```
## 代码审查要点
> 此部分为自动生成,建议人工审查确认
- [ ] 检查是否有潜在的 bug
- [ ] 确认代码符合项目规范
- [ ] 验证是否有需要测试的新功能
- [ ] 检查是否有安全问题
---
*报告生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
return report, f"{date_str}_{short_hash}.md"
def main():
repo_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
output_dir = os.path.join(repo_path, 'docs', 'auto_commit_md')
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
print(f"分析仓库: {repo_path}")
print(f"输出目录: {output_dir}")
print()
# 获取当天的提交
commits = get_today_commits(repo_path)
if not commits:
print("今天没有新提交。")
return 0
print(f"找到 {len(commits)} 个今天的提交。")
print()
for commit in commits:
print(f"处理提交: {commit['hash'][:7]} - {commit['message']}")
# 获取提交详情
diff_stat = get_commit_diff(repo_path, commit['hash'])
diff_details = get_commit_details(repo_path, commit['hash'])
# 解析统计信息
stats = parse_diff_stats(diff_stat)
# 生成报告
report_content, filename = generate_markdown_report(commit, stats, diff_details)
# 保存文件
output_path = os.path.join(output_dir, filename)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(report_content)
print(f" 报告已保存: {output_path}")
print()
print("完成!")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
import subprocess
import os
import re
from datetime import datetime
from pathlib import Path
def run_git_command(cmd, cwd=None):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd)
if result.returncode != 0:
print(f"Git command failed: {cmd}")
print(f"Error: {result.stderr}")
return None
return result.stdout
def get_commits_since(since_date, until_date, cwd=None):
cmd = f'git log --since="{since_date}" --until="{until_date}" --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso'
output = run_git_command(cmd, cwd)
if not output:
return []
commits = []
for line in output.strip().split('\n'):
if line:
parts = line.split('|', 4)
commits.append({
'hash': parts[0],
'author': parts[1],
'email': parts[2],
'date': parts[3],
'message': parts[4]
})
return commits
def get_commit_diff(commit_hash, cwd=None):
cmd = f'git show {commit_hash}'
return run_git_command(cmd, cwd)
def get_commit_stat(commit_hash, cwd=None):
cmd = f'git show --stat {commit_hash}'
return run_git_command(cmd, cwd)
def analyze_diff(diff_text):
file_changes = []
current_file = None
changes = {'insertions': 0, 'deletions': 0, 'files': 0}
lines = diff_text.split('\n')
for line in lines:
if line.startswith('diff --git'):
if current_file:
file_changes.append(current_file)
filename = line.split(' ')[2][2:]
current_file = {'name': filename, 'insertions': 0, 'deletions': 0, 'hunks': []}
changes['files'] += 1
elif line.startswith('+') and not line.startswith('+++'):
if current_file:
current_file['insertions'] += 1
changes['insertions'] += 1
elif line.startswith('-') and not line.startswith('---'):
if current_file:
current_file['deletions'] += 1
changes['deletions'] += 1
if current_file:
file_changes.append(current_file)
return file_changes, changes
def generate_markdown_report(commit, diff_text, stat_text, output_dir):
file_changes, summary = analyze_diff(diff_text)
short_hash = commit['hash'][:7]
date_obj = datetime.fromisoformat(commit['date'].replace(' +0800', ''))
date_str = date_obj.strftime('%Y%m%d')
filename = f"{date_str}_{short_hash}.md"
filepath = Path(output_dir) / filename
markdown = f"""# Git 提交分析报告
## 基本信息
| 项目 | 内容 |
|------|------|
| 提交哈希 | `{commit['hash']}` |
| 短哈希 | `{short_hash}` |
| 作者 | {commit['author']} &lt;{commit['email']}&gt; |
| 提交时间 | {commit['date']} |
## 提交信息
{commit['message']}
## 变更统计
- **变更文件数**: {summary['files']}
- **新增行数**: +{summary['insertions']}
- **删除行数**: -{summary['deletions']}
## 详细变更
"""
if file_changes:
for fc in sorted(file_changes, key=lambda x: x['name']):
markdown += f"\n### `{fc['name']}`\n"
markdown += f"- 新增: +{fc['insertions']}\n"
markdown += f"- 删除: -{fc['deletions']}\n"
else:
markdown += "\n*无文件变更统计*\n"
markdown += "\n## 完整 Diff\n\n```diff\n"
markdown += diff_text
markdown += "\n```\n"
with open(filepath, 'w', encoding='utf-8') as f:
f.write(markdown)
print(f"Generated: {filepath}")
return filepath
def main():
repo_dir = Path(__file__).parent.parent
output_dir = repo_dir / 'docs' / 'auto_commit_md'
output_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now()
since_date = today.strftime('%Y-%m-%d 00:00:00')
until_date = today.strftime('%Y-%m-%d 23:59:59')
print(f"Fetching commits from {since_date} to {until_date}...")
commits = get_commits_since(since_date, until_date, str(repo_dir))
if not commits:
print("No commits found for today.")
print("\nLet's check the latest commits instead...")
cmd = 'git log -3 --pretty=format:"%H|%an|%ae|%ad|%s" --date=iso'
output = run_git_command(cmd, str(repo_dir))
if output:
commits = []
for line in output.strip().split('\n'):
if line:
parts = line.split('|', 4)
commits.append({
'hash': parts[0],
'author': parts[1],
'email': parts[2],
'date': parts[3],
'message': parts[4]
})
if commits:
print(f"\nFound {len(commits)} commit(s) to analyze:\n")
for commit in commits:
print(f" - {commit['hash'][:7]}: {commit['message']}")
print("\nGenerating reports...\n")
for commit in commits:
diff = get_commit_diff(commit['hash'], str(repo_dir))
stat = get_commit_stat(commit['hash'], str(repo_dir))
if diff:
generate_markdown_report(commit, diff, stat, str(output_dir))
print(f"\nDone! Reports saved to {output_dir}")
else:
print("No commits found.")
if __name__ == '__main__':
main()