mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.动画优化与更新界面
This commit is contained in:
42
.trae/specs/main-window-desktop-layer/design.md
Normal file
42
.trae/specs/main-window-desktop-layer/design.md
Normal 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.
|
||||||
20
.trae/specs/main-window-desktop-layer/requirements.md
Normal file
20
.trae/specs/main-window-desktop-layer/requirements.md
Normal 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.
|
||||||
10
.trae/specs/main-window-desktop-layer/tasks.md
Normal file
10
.trae/specs/main-window-desktop-layer/tasks.md
Normal 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.
|
||||||
25
.trae/specs/update-settings-fluent-controls/spec.md
Normal file
25
.trae/specs/update-settings-fluent-controls/spec.md
Normal 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`.
|
||||||
38
LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs
Normal file
38
LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}'.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
272
LanMountainDesktop/Services/MainWindowDesktopLayerService.cs
Normal file
272
LanMountainDesktop/Services/MainWindowDesktopLayerService.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -44,7 +44,21 @@
|
|||||||
<ui:FAFontIconSource Glyph="󰆨" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
<ui:FAFontIconSource Glyph="󰆨" 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="󱯠" 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="󰙇"
|
||||||
|
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="󰔄"
|
<ui:FAFontIconSource Glyph="󰔄"
|
||||||
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="󰤈"
|
<ui:FAFontIconSource Glyph="󰤈"
|
||||||
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="󰭎"
|
<ui:FAFontIconSource Glyph="󰭎"
|
||||||
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="󰣨"
|
<ui:FAFontIconSource Glyph="󰣨"
|
||||||
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="󰔄"
|
||||||
|
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="󰅨"
|
<ui:FAFontIconSource Glyph="󰅨"
|
||||||
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" />
|
||||||
|
|||||||
@@ -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
144
scripts/analyze_commits.ps1
Normal 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
206
scripts/analyze_commits.py
Normal 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())
|
||||||
177
scripts/generate_commit_reports.py
Normal file
177
scripts/generate_commit_reports.py
Normal 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']} <{commit['email']}> |
|
||||||
|
| 提交时间 | {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()
|
||||||
Reference in New Issue
Block a user