mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9391f930 |
@@ -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.
|
||||
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "<default>"}'.");
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user