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);
}
[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}'.");
}
}

View File

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

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 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()
{

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }

View File

@@ -20,6 +20,7 @@ public interface IFusedDesktopManagerService
void EnterEditMode();
void ExitEditMode();
void ReloadWidgets();
void Shutdown();
}
/// <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)
{
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 UpdateDownloadSource,
int UpdateDownloadThreads,
bool ForceUpdateReinstall,
bool UseGhProxyMirror,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,

View File

@@ -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),

View File

@@ -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)
{

View File

@@ -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";

View File

@@ -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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<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 _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);

View File

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

View File

@@ -44,7 +44,21 @@
<ui:FAFontIconSource Glyph="&#xF01A8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<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>

View File

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