diff --git a/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md new file mode 100644 index 0000000..3067a0f --- /dev/null +++ b/.trae/specs/launcher-shell-hardening/tray-menu-shutdown-addendum.md @@ -0,0 +1,17 @@ +# Tray Menu Shutdown Addendum + +## Requirements + +- Tray menu `Exit App` must commit an irreversible host shutdown request. +- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library. +- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline. +- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit. +- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it. +- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails. + +## Acceptance + +- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock. +- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart. +- Repeated tray clicks during shutdown are ignored and logged. +- Repeated component-library clicks focus the existing window instead of opening duplicates. diff --git a/LanMountainDesktop.Tests/HostShutdownGateTests.cs b/LanMountainDesktop.Tests/HostShutdownGateTests.cs new file mode 100644 index 0000000..2471847 --- /dev/null +++ b/LanMountainDesktop.Tests/HostShutdownGateTests.cs @@ -0,0 +1,48 @@ +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class HostShutdownGateTests +{ + [Fact] + public void Submit_WhenFirstExitRequest_AcceptsAndRecordsExit() + { + var gate = new HostShutdownGate(); + + var submission = gate.Submit(HostShutdownMode.Exit); + + Assert.True(submission.Accepted); + Assert.True(submission.IsFirstSubmission); + Assert.Equal(HostShutdownMode.Exit, submission.EffectiveMode); + Assert.True(gate.IsShutdownRequested); + Assert.Equal(HostShutdownMode.Exit, gate.EffectiveMode); + } + + [Fact] + public void Submit_WhenDuplicateSameMode_AcceptsButDoesNotExecuteAgain() + { + var gate = new HostShutdownGate(); + gate.Submit(HostShutdownMode.Exit); + + var duplicate = gate.Submit(HostShutdownMode.Exit); + + Assert.True(duplicate.Accepted); + Assert.False(duplicate.IsFirstSubmission); + Assert.Equal(HostShutdownMode.Exit, duplicate.EffectiveMode); + } + + [Fact] + public void Submit_WhenExitArrivesAfterRestart_DoesNotOverwriteRestart() + { + var gate = new HostShutdownGate(); + gate.Submit(HostShutdownMode.Restart); + + var conflictingExit = gate.Submit(HostShutdownMode.Exit); + + Assert.False(conflictingExit.Accepted); + Assert.False(conflictingExit.IsFirstSubmission); + Assert.Equal(HostShutdownMode.Restart, conflictingExit.EffectiveMode); + Assert.Equal(HostShutdownMode.Restart, gate.EffectiveMode); + } +} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index ba55861..33c79f8 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -56,6 +56,7 @@ public partial class App : Application private readonly LocalizationService _localizationService = new(); private readonly FontFamilyService _fontFamilyService = new(); private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); + private readonly HostShutdownGate _shutdownGate = new(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow; @@ -75,6 +76,7 @@ public partial class App : Application private PluginRuntimeService? _pluginRuntimeService; private MainWindow? _mainWindow; private TransparentOverlayWindow? _transparentOverlayWindow; + private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow; private bool _mainWindowClosed; private bool _uiUnhandledExceptionHooked; private DesktopShellHost? _desktopShellHost; @@ -107,6 +109,7 @@ public partial class App : Application public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle; internal ISettingsWindowService? SettingsWindowService => _settingsWindowService; internal INotificationService? NotificationService => _notificationService; + internal bool IsShutdownInProgress => _shutdownGate.IsShutdownRequested || _shutdownIntent != ShutdownIntent.None; internal RestartPresentationMode GetCurrentRestartPresentationMode() { return _desktopShellState switch @@ -119,6 +122,14 @@ public partial class App : Application internal void OpenIndependentSettingsModule(string source, string? pageTag = null) { + if (IsShutdownInProgress) + { + AppLogger.Info( + "SettingsFacade", + $"Settings open ignored because shutdown is in progress. Source='{source}'; PageTag='{pageTag ?? ""}'."); + return; + } + EnsureSettingsWindowService(); AppLogger.Info( "SettingsFacade", @@ -348,11 +359,23 @@ public partial class App : Application private void OnTrayShowDesktopClick(object? sender, EventArgs e) { + if (IsShutdownInProgress) + { + AppLogger.Info("DesktopShell", "Tray Open Desktop ignored because shutdown is in progress."); + return; + } + RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu"); } private void OnTrayRestartClick(object? sender, EventArgs e) { + if (IsShutdownInProgress) + { + AppLogger.Info("HostLifecycle", "Tray Restart ignored because shutdown is already in progress."); + return; + } + _ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest( Source: "TrayMenu", Reason: "User selected Restart App from the tray menu.")); @@ -362,6 +385,13 @@ public partial class App : Application { _ = sender; _ = e; + + if (IsShutdownInProgress) + { + AppLogger.Info("SettingsFacade", "Tray Settings ignored because shutdown is in progress."); + return; + } + OpenIndependentSettingsModule("TrayMenu"); } @@ -369,28 +399,52 @@ public partial class App : Application { _ = sender; _ = e; + + if (IsShutdownInProgress) + { + AppLogger.Info("FusedDesktop", "Tray Component Library ignored because shutdown is in progress."); + return; + } if (!OperatingSystem.IsWindows()) { AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows."); return; } - - FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode(); - - // 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず - EnsureTransparentOverlayWindow(); Dispatcher.UIThread.Post(() => { + if (IsShutdownInProgress) + { + AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress."); + return; + } + try { + if (_fusedComponentLibraryWindow is { } existingWindow) + { + if (!existingWindow.IsVisible) + { + existingWindow.Show(); + } + + existingWindow.Activate(); + return; + } + + var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate(); + fusedDesktopManager.EnterEditMode(); + + // 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず + EnsureTransparentOverlayWindow(); if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible) { _transparentOverlayWindow.Show(); } var window = new FusedDesktopComponentLibraryWindow(); + _fusedComponentLibraryWindow = window; if (_transparentOverlayWindow is not null) { @@ -406,7 +460,11 @@ public partial class App : Application } // 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢 - FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); + fusedDesktopManager.ExitEditMode(); + if (ReferenceEquals(_fusedComponentLibraryWindow, s)) + { + _fusedComponentLibraryWindow = null; + } }; window.Show(); @@ -415,6 +473,25 @@ public partial class App : Application catch (Exception ex) { AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex); + try + { + _transparentOverlayWindow?.SaveLayoutAndHide(); + } + catch (Exception overlayEx) + { + AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay after library open failure.", overlayEx); + } + + try + { + FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); + } + catch (Exception exitEx) + { + AppLogger.Warn("FusedDesktop", "Failed to exit edit mode after library open failure.", exitEx); + } + + _fusedComponentLibraryWindow = null; } }, DispatcherPriority.Send); } @@ -752,6 +829,12 @@ public partial class App : Application private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source) { + if (IsShutdownInProgress) + { + AppLogger.Info("DesktopShell", $"Restore ignored because shutdown is in progress. Source='{source}'."); + return; + } + Dispatcher.UIThread.Post(() => { _ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source); @@ -760,6 +843,12 @@ public partial class App : Application private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source) { + if (IsShutdownInProgress) + { + AppLogger.Info("DesktopShell", $"Restore skipped because shutdown is in progress. Source='{source}'."); + return false; + } + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) { AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'."); @@ -838,6 +927,62 @@ public partial class App : Application } } + internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request) + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + AppLogger.Warn( + "HostLifecycle", + $"Shutdown request ignored because desktop lifetime is unavailable. Mode='{mode}'; Source='{request?.Source ?? "Unknown"}'."); + return false; + } + + return Dispatcher.UIThread.CheckAccess() + ? TrySubmitShutdownCore(mode, request, desktop) + : Dispatcher.UIThread.InvokeAsync( + () => TrySubmitShutdownCore(mode, request, desktop), + DispatcherPriority.Send).GetAwaiter().GetResult(); + } + + private bool TrySubmitShutdownCore( + HostShutdownMode mode, + HostApplicationLifecycleRequest? request, + IClassicDesktopStyleApplicationLifetime desktop) + { + var source = request?.Source ?? "Unknown"; + var submission = _shutdownGate.Submit(mode); + if (!submission.IsFirstSubmission) + { + AppLogger.Warn( + "HostLifecycle", + $"Shutdown request ignored because shutdown is already in progress. Requested='{submission.RequestedMode}'; Effective='{submission.EffectiveMode}'; Source='{source}'."); + return submission.Accepted; + } + + _shutdownIntent = mode == HostShutdownMode.Restart + ? ShutdownIntent.RestartRequested + : ShutdownIntent.ExitRequested; + AppLogger.Info( + "DesktopShell", + $"Shutdown committed. Intent='{_shutdownIntent}'; Source='{source}'; Reason='{request?.Reason ?? string.Empty}'; CurrentShellState='{_desktopShellState}'."); + + ScheduleForcedProcessTermination($"ShutdownRequest:{source}"); + StopShellRecoveryWatchdog(); + PerformExitCleanup(); + ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}"); + + try + { + desktop.Shutdown(); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("HostLifecycle", $"Desktop lifetime shutdown failed. Source='{source}'.", ex); + return true; + } + } + internal void PrepareForShutdown(bool isRestart, string source) { void Mark() @@ -1123,6 +1268,30 @@ public partial class App : Application disposableRegistry.Dispose(); } + if (_fusedComponentLibraryWindow is not null) + { + try + { + _fusedComponentLibraryWindow.Close(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to close fused desktop component library during shutdown.", ex); + } + finally + { + _fusedComponentLibraryWindow = null; + try + { + FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); + } + catch (Exception ex) + { + AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex); + } + } + } + if (_transparentOverlayWindow is not null) { try @@ -1487,6 +1656,12 @@ public partial class App : Application private bool EnsureTaskbarEntry(string source) { + if (IsShutdownInProgress) + { + AppLogger.Info("DesktopShell", $"Taskbar repair skipped because shutdown is in progress. Source='{source}'."); + return false; + } + if (!ShouldShowMainWindowInTaskbar()) { return false; diff --git a/LanMountainDesktop/Services/DesktopTrayService.cs b/LanMountainDesktop/Services/DesktopTrayService.cs index d1ec7db..138df62 100644 --- a/LanMountainDesktop/Services/DesktopTrayService.cs +++ b/LanMountainDesktop/Services/DesktopTrayService.cs @@ -128,6 +128,27 @@ internal sealed class DesktopTrayService : IDisposable { } + try + { + TrayIcon.SetIcons(_application, []); + } + catch + { + } + + try + { + if (_trayIcon is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch + { + } + + _trayIcon = null; + SetState(TrayAvailabilityState.Unavailable, "Dispose"); } diff --git a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs index fd4d7fc..e8de9f2 100644 --- a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs +++ b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs @@ -23,23 +23,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle $"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'."); app = Application.Current as App; - if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime) { AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable."); return false; } - app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown"); - if (Dispatcher.UIThread.CheckAccess()) - { - desktop.Shutdown(); - } - else - { - Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send); - } - - return true; + return app.TrySubmitShutdown(HostShutdownMode.Exit, request); } catch (Exception ex) { @@ -55,6 +45,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle try { app = Application.Current as App; + if (app?.IsShutdownInProgress == true) + { + AppLogger.Warn( + "HostLifecycle", + $"Restart request ignored because shutdown is already in progress. Source='{request?.Source ?? "Unknown"}'."); + return false; + } if (HasPendingPluginUpgrades()) { @@ -123,10 +120,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}"); Process.Start(helperStartInfo); - - app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown"); - - return TryExit(request); + return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true; } private bool TryRestartDirectly(HostApplicationLifecycleRequest? request) @@ -143,8 +137,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle } Process.Start(startInfo); - app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown"); - var exitRequest = request is null + var shutdownRequest = request is null ? new HostApplicationLifecycleRequest(Reason: "Restart accepted.") : request with { @@ -153,7 +146,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle : request.Reason }; - return TryExit(exitRequest); + return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true; } private static string ResolveUpgradeHelperPath() diff --git a/LanMountainDesktop/Services/HostShutdownGate.cs b/LanMountainDesktop/Services/HostShutdownGate.cs new file mode 100644 index 0000000..0ec70c3 --- /dev/null +++ b/LanMountainDesktop/Services/HostShutdownGate.cs @@ -0,0 +1,65 @@ +namespace LanMountainDesktop.Services; + +internal enum HostShutdownMode +{ + Exit = 0, + Restart = 1 +} + +internal readonly record struct HostShutdownSubmission( + bool Accepted, + bool IsFirstSubmission, + HostShutdownMode EffectiveMode, + HostShutdownMode RequestedMode); + +internal sealed class HostShutdownGate +{ + private readonly object _gate = new(); + private bool _submitted; + private HostShutdownMode _mode; + + public bool IsShutdownRequested + { + get + { + lock (_gate) + { + return _submitted; + } + } + } + + public HostShutdownMode? EffectiveMode + { + get + { + lock (_gate) + { + return _submitted ? _mode : null; + } + } + } + + public HostShutdownSubmission Submit(HostShutdownMode requestedMode) + { + lock (_gate) + { + if (!_submitted) + { + _submitted = true; + _mode = requestedMode; + return new HostShutdownSubmission( + Accepted: true, + IsFirstSubmission: true, + EffectiveMode: requestedMode, + RequestedMode: requestedMode); + } + + return new HostShutdownSubmission( + Accepted: _mode == requestedMode, + IsFirstSubmission: false, + EffectiveMode: _mode, + RequestedMode: requestedMode); + } + } +}