Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
d4901e436f Add launcher debug settings, recovery & version fixes
Introduce a persistent LauncherDebugSettingsStore and wire it into ErrorWindow and SplashWindow so dev-mode and custom host path can be saved/loaded. Harden DeploymentLocator/FlexibleHostLocator to safely normalize and validate saved debug paths and log warnings for malformed values. Add a WaitingForShell startup state and recoverable-activation logic across App and LauncherFlowCoordinator (with registry updates) so Launcher can attach to an in-progress desktop shell rather than failing. Clean up ErrorDebugWindow UI/flow (WasAccepted flag, localization fixes, event wiring) and improve splash version population. Improve AppVersionProvider to trim surrounding quotes, robustly parse version.json via JsonDocument and read string properties; add unit tests for AppVersionProvider, DeploymentLocator and LauncherDebugSettingsStore. Also quote Exec commands in the csproj and harden scripts/Generate-VersionFile.ps1 (argument normalization, LiteralPath, error handling).
2026-04-23 19:04:39 +08:00
lincube
2d9391f930 Add HostShutdownGate and shutdown handling
Introduce HostShutdownGate to serialize and record the first host shutdown request (Restart preferred over later Exit). Add tests (HostShutdownGateTests) and a tray-menu spec describing shutdown requirements. Integrate the gate into App: expose IsShutdownInProgress, ignore tray/settings/component-library actions during shutdown, reuse/track the fused component library window, ensure edit-mode exit on failures, and close the library during shutdown. Add TrySubmitShutdown to commit shutdown intent, schedule forced termination, perform exit cleanup, and invoke desktop lifetime shutdown. Update HostApplicationLifecycleService to use the new TrySubmitShutdown flow for Exit/Restart. Harden DesktopTrayService.Dispose to clear icons and dispose the tray icon safely. These changes ensure irreversible shutdown commits, prevent UI reopening during shutdown, preserve restart intent, and avoid duplicate or conflicting shutdown actions.
2026-04-23 14:18:09 +08:00
22 changed files with 1086 additions and 223 deletions

View File

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

View File

@@ -120,7 +120,23 @@ public partial class App : Application
private static SplashWindow CreateSplashWindow()
{
var preferences = StartupVisualPreferencesResolver.Resolve();
return new SplashWindow(preferences.Mode);
var window = new SplashWindow(preferences.Mode);
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
@@ -318,12 +334,16 @@ public partial class App : Application
{
reporter?.Report("activation", response.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = response.Accepted,
Success = success,
Stage = "launch",
Code = response.Code,
Message = response.Message,
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: response.Message,
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
};
}
@@ -334,12 +354,19 @@ public partial class App : Application
{
reporter?.Report("activation", activation.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = activation.Accepted,
Success = success,
Stage = "launch",
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
Message = activation.Message,
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: activation.Message,
Details = BuildCoordinatorResultDetails(null, activation)
};
}
@@ -370,6 +397,18 @@ public partial class App : Application
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = activation.Accepted,
@@ -419,6 +458,32 @@ public partial class App : Application
};
}
private static bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)

View File

@@ -9,7 +9,8 @@ internal enum StartupAttemptState
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed
Failed,
WaitingForShell
}
internal sealed class StartupAttemptRecord

View File

@@ -204,12 +204,16 @@ internal sealed class DeploymentLocator
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
var fullSavedPath = Path.GetFullPath(savedCustomPath);
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
}
@@ -229,6 +233,21 @@ internal sealed class DeploymentLocator
return null;
}
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
{
try
{
fullSavedPath = Path.GetFullPath(savedPath);
return true;
}
catch (Exception ex)
{
fullSavedPath = string.Empty;
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
return false;
}
}
private static string? FindBestDeploymentHost(
string root,
string executable,
@@ -303,9 +322,17 @@ internal sealed class DeploymentLocator
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
return savedCustomPath;
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
var devPath = ScanDevelopmentPaths(executable);

View File

@@ -560,6 +560,11 @@ namespace LanMountainDesktop.Launcher.Services;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}

View File

@@ -0,0 +1,124 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
internal static class LauncherDebugSettingsStore
{
private const string DevModeFileName = "dev-mode.flag";
private const string CustomHostPathFileName = "custom-host-path.txt";
private const string LegacyDevModeFileName = "devmode.config";
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
internal static string? ConfigBaseDirectoryOverride { get; set; }
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
public static LauncherDebugSettings Load()
{
return new LauncherDebugSettings(
LoadDevModeState(),
LoadCustomHostPath());
}
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
public static void Save(LauncherDebugSettings settings)
{
try
{
Directory.CreateDirectory(ConfigBaseDirectory);
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
}
catch (Exception ex)
{
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
}
}
public static void SaveDevModeState(bool enabled)
{
var current = Load();
Save(current with { DevModeEnabled = enabled });
}
public static void SaveCustomHostPath(string? customHostPath)
{
var current = Load();
Save(current with { CustomHostPath = customHostPath });
}
private static bool LoadDevModeState()
{
var newValue = TryReadText(GetPath(DevModeFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return TryParseDevMode(newValue);
}
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
}
private static string? LoadCustomHostPath()
{
var newValue = TryReadText(GetPath(CustomHostPathFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return newValue.Trim();
}
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
}
private static bool TryParseDevMode(string value)
{
var normalized = value.Trim();
return normalized == "1" ||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
}
private static string? TryReadText(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
return null;
}
}
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
private static string ResolveConfigBaseDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
}
catch
{
}
try
{
return Path.Combine(AppContext.BaseDirectory, ".launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
}

View File

@@ -228,6 +228,7 @@ internal sealed class LauncherFlowCoordinator
{
ipcConnected = true;
shellStatus = existingActivation.Status;
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
lastStage = existingActivation.Accepted
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
@@ -236,6 +237,10 @@ internal sealed class LauncherFlowCoordinator
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
else if (recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
@@ -244,14 +249,20 @@ internal sealed class LauncherFlowCoordinator
PublishCoordinatorStatus(
hostProcessAliveOverride: true,
completed: true,
succeeded: existingActivation.Accepted);
succeeded: existingActivation.Accepted || recoverableActivationFailure);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: existingActivation.Accepted,
success: existingActivation.Accepted || recoverableActivationFailure,
stage: "launch",
code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
message: existingActivation.Message,
code: existingActivation.Accepted
? "existing_host_activated"
: recoverableActivationFailure
? "existing_host_startup_pending"
: "existing_host_activation_failed",
message: recoverableActivationFailure
? "Existing desktop process is still starting; Launcher will not start another process."
: existingActivation.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -438,6 +449,11 @@ internal sealed class LauncherFlowCoordinator
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
if (shellStatus is { DesktopVisible: false })
{
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
}
PublishCoordinatorStatus(hostProcessAliveOverride: true);
}
@@ -464,6 +480,7 @@ internal sealed class LauncherFlowCoordinator
var softTimeoutAt = startedAt + StartupSoftTimeout;
var hardTimeoutAt = startedAt + StartupHardTimeout;
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
var activationRetryAttempted = false;
while (true)
{
@@ -482,10 +499,35 @@ internal sealed class LauncherFlowCoordinator
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
}
if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason))
if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
{
activationRetryAttempted = true;
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
if (retryOutcome is not null)
{
windowsClosingByCoordinator = true;
if (retryOutcome.Success)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
PublishCoordinatorStatus(
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
completed: true,
succeeded: true);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
completed: true,
succeeded: false);
}
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(retryOutcome, ComposeLaunchDetails(!launchOutcome.Process.HasExited, recoveryActivationAttempted: true));
}
}
if (processExitTask.IsCompleted)
@@ -512,6 +554,36 @@ internal sealed class LauncherFlowCoordinator
}));
}
if (!activationRetryAttempted &&
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
{
activationRetryAttempted = true;
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
if (retryOutcome is not null)
{
if (retryOutcome.Success)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
}
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(
retryOutcome,
MergeDetails(
ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
}
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
@@ -543,6 +615,11 @@ internal sealed class LauncherFlowCoordinator
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
if (shellStatus is { DesktopVisible: false })
{
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC reconnected; waiting for desktop shell.");
}
PublishCoordinatorStatus(hostProcessAliveOverride: true);
}
@@ -602,6 +679,11 @@ internal sealed class LauncherFlowCoordinator
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
if (shellStatus is { DesktopVisible: false })
{
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
}
PublishCoordinatorStatus(hostProcessAliveOverride: true);
}
}
@@ -632,6 +714,23 @@ internal sealed class LauncherFlowCoordinator
}
}
if (connected && !launchOutcome.Process.HasExited)
{
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: false);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: "startup_pending",
message: "Host process is still running; Launcher will not start another process while the desktop shell finishes startup.",
details: ComposeLaunchDetails(
hostProcessAlive: true,
recoveryActivationAttempted));
}
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
@@ -1369,6 +1468,25 @@ internal sealed class LauncherFlowCoordinator
}
}
private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{
if (activation.Accepted)
{
return false;
}
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return activation.Status.PublicIpcReady &&
(!activation.Status.MainWindowOpened ||
!activation.Status.DesktopVisible ||
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{

View File

@@ -309,6 +309,19 @@ internal sealed class StartupAttemptRegistry
});
}
public void MarkOwnedWaitingForShell(string? message)
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.WaitingForShell;
}
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedDetachedWaiting()
{
UpdateOwned(record =>
@@ -423,7 +436,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsAttachable(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
@@ -433,7 +450,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
@@ -448,7 +469,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsCoordinatorLive(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}

View File

@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
private bool _isInitialized;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public bool WasAccepted { get; private set; }
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
: this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized) return;
if (_isInitialized)
{
return;
}
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
private void InitializeComponents()
{
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsCheckedChanged += (s, e) =>
devModeToggle.IsCheckedChanged += (_, _) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
if (this.FindControl<Button>("OkButton") is { } okButton)
{
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
okButton.Click += (_, _) =>
{
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
WasAccepted = true;
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
else
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
cancelButton.Click += (_, _) => Close();
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null) return;
if (storageProvider is null)
{
return;
}
var options = new FilePickerOpenOptions
{
Title = "选择阑山桌面主程序",
Title = "Select LanMountainDesktop host executable",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
FileTypeFilter =
[
new FilePickerFileType("Executable")
{
Patterns = OperatingSystem.IsWindows()
? new[] { "*.exe" }
: new[] { "*" }
? ["*.exe"]
: ["*"]
}
}
]
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count > 0)
if (result.Count <= 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
return;
}
_selectedHostPath = result[0].Path.LocalPath;
UpdatePathDisplay(_selectedHostPath);
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
}
}
}

View File

@@ -185,17 +185,23 @@ public partial class ErrorWindow : Window
debugWindow.Closed += (_, _) =>
{
if (!debugWindow.WasAccepted)
{
_isDebugMode = false;
_iconClickCount = 0;
return;
}
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
{
ScanDevPaths();
SaveCustomHostPathInternal(_customHostPath);
}
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
_isDebugMode = false;
_iconClickCount = 0;
};
@@ -285,74 +291,17 @@ public partial class ErrorWindow : Window
private static string GetConfigBaseDirectory()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
return Path.Combine(AppContext.BaseDirectory, ".launcher");
return LauncherDebugSettingsStore.ConfigBaseDirectory;
}
private static string GetDevModePath() => Path.Combine(GetConfigBaseDirectory(), "dev-mode.flag");
private static string GetCustomHostPathFile() => Path.Combine(GetConfigBaseDirectory(), "custom-host-path.txt");
private static bool LoadDevModeStateInternal()
{
try
{
return File.Exists(GetDevModePath()) &&
bool.TryParse(File.ReadAllText(GetDevModePath()).Trim(), out var enabled) &&
enabled;
}
catch
{
return false;
}
}
private static void SaveDevModeStateInternal(bool enabled)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetDevModePath(), enabled.ToString());
}
catch
{
}
return LauncherDebugSettingsStore.IsDevModeEnabled();
}
private static string? LoadCustomHostPathInternal()
{
try
{
var pathFile = GetCustomHostPathFile();
if (!File.Exists(pathFile))
{
return null;
}
var savedPath = File.ReadAllText(pathFile).Trim();
return string.IsNullOrWhiteSpace(savedPath) ? null : savedPath;
}
catch
{
return null;
}
}
private static void SaveCustomHostPathInternal(string? customHostPath)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetCustomHostPathFile(), customHostPath ?? string.Empty);
}
catch
{
}
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
}
}

View File

@@ -261,6 +261,13 @@ public partial class SplashWindow : Window, ISplashStageReporter
debugWindow.Closed += (_, _) =>
{
if (debugWindow.WasAccepted)
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
debugWindow.IsDevModeEnabled,
debugWindow.SelectedHostPath));
}
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};

View File

@@ -108,7 +108,9 @@ public static class AppVersionProvider
return fallback;
}
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
var normalized = TrimSurroundingQuotes(rawValue)
.Split('+', 2, StringSplitOptions.TrimEntries)[0]
.Trim();
return string.IsNullOrWhiteSpace(normalized)
? fallback
: normalized;
@@ -116,9 +118,10 @@ public static class AppVersionProvider
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
{
return string.IsNullOrWhiteSpace(rawValue)
var normalized = TrimSurroundingQuotes(rawValue);
return string.IsNullOrWhiteSpace(normalized)
? fallback
: rawValue.Trim();
: normalized;
}
private static AppVersionInfo OverrideMissingParts(
@@ -158,17 +161,24 @@ public static class AppVersionProvider
try
{
var json = File.ReadAllText(versionFilePath);
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
var version = ReadStringProperty(root, nameof(AppVersionInfo.Version));
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
var codename = ReadStringProperty(root, nameof(AppVersionInfo.Codename));
info = new AppVersionInfo
{
Version = NormalizeVersionText(parsedInfo.Version),
Codename = NormalizeCodename(parsedInfo.Codename)
Version = NormalizeVersionText(version),
Codename = NormalizeCodename(codename)
};
return true;
}
@@ -359,4 +369,43 @@ public static class AppVersionProvider
return null;
}
}
private static string? ReadStringProperty(JsonElement root, string propertyName)
{
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
return property.Value.GetString();
}
}
return null;
}
private static string TrimSurroundingQuotes(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
var normalized = rawValue.Trim();
while (normalized.Length >= 2)
{
var first = normalized[0];
var last = normalized[^1];
if ((first == '\'' && last == '\'') ||
(first == '"' && last == '"'))
{
normalized = normalized[1..^1].Trim();
continue;
}
break;
}
return normalized;
}
}

View File

@@ -0,0 +1,83 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AppVersionProviderTests
{
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonExists_UsesVersionFile()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7", """
{"Version":"0.8.5.7","Codename":"Administrate"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
Assert.Equal("Administrate", info.Codename);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonIsMissing_FallsBackToDeploymentDirectory()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonContainsQuotedValues_NormalizesValues()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-1.2.3", """
{"Version":"'1.2.3'","Codename":"'Administrate'"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("1.2.3", info.Version);
Assert.Equal("Administrate", info.Codename);
}
private sealed class TemporaryPackage : IDisposable
{
private TemporaryPackage(string root)
{
Root = root;
}
public string Root { get; }
public static TemporaryPackage Create()
{
var root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.VersionTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return new TemporaryPackage(root);
}
public void CreateDeployment(string name, string? versionJson = null)
{
var deployment = Path.Combine(Root, name);
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, "LanMountainDesktop.exe"), string.Empty);
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
if (versionJson is not null)
{
File.WriteAllText(Path.Combine(deployment, "version.json"), versionJson);
}
}
public void Dispose()
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,43 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class DeploymentLocatorTests : IDisposable
{
private readonly string _appRoot;
private readonly string _configRoot;
public DeploymentLocatorTests()
{
var testRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DeploymentLocatorTests", Guid.NewGuid().ToString("N"));
_appRoot = Path.Combine(testRoot, "app-root");
_configRoot = Path.Combine(testRoot, "config");
Directory.CreateDirectory(_appRoot);
Directory.CreateDirectory(_configRoot);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _configRoot;
}
[Fact]
public void ResolveHostExecutable_WhenSavedDebugPathIsMalformed_DoesNotThrow()
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, "bad\0path"));
var locator = new DeploymentLocator(_appRoot);
var result = locator.ResolveHostExecutable(CommandContext.FromArgs(["launch", "--debug"]));
Assert.NotEqual("debug_saved_custom_path", result.ResolutionSource);
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
var testRoot = Directory.GetParent(_appRoot)?.FullName;
if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot))
{
Directory.Delete(testRoot, recursive: true);
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,50 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class LauncherDebugSettingsStoreTests : IDisposable
{
private readonly string _tempDirectory;
public LauncherDebugSettingsStoreTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DebugSettingsTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDirectory);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _tempDirectory;
}
[Fact]
public void Load_WhenOnlyLegacyFilesExist_ReadsLegacySettings()
{
var customPath = Path.Combine(_tempDirectory, "legacy-host.exe");
File.WriteAllText(Path.Combine(_tempDirectory, "devmode.config"), "1");
File.WriteAllText(Path.Combine(_tempDirectory, "custom-host-path.config"), customPath);
var settings = LauncherDebugSettingsStore.Load();
Assert.True(settings.DevModeEnabled);
Assert.Equal(customPath, settings.CustomHostPath);
}
[Fact]
public void Save_WritesNewSettingsFiles()
{
var customPath = Path.Combine(_tempDirectory, "host.exe");
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, customPath));
Assert.Equal("True", File.ReadAllText(Path.Combine(_tempDirectory, "dev-mode.flag")).Trim());
Assert.Equal(customPath, File.ReadAllText(Path.Combine(_tempDirectory, "custom-host-path.txt")).Trim());
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
}

View File

@@ -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;
@@ -1585,9 +1760,23 @@ public partial class App : Application
{
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
var status = GetPublicShellStatus();
return restored
? new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status)
: new PublicShellActivationResult(false, "activation_failed", "Desktop window activation failed.", status);
if (restored)
{
return new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status);
}
if (IsShutdownInProgress)
{
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
}
var code = status.PublicIpcReady && (!status.MainWindowOpened || !status.DesktopVisible)
? "shell_not_ready"
: "activation_failed";
var message = code == "shell_not_ready"
? "Desktop process is running, but the shell is not ready for activation yet."
: "Desktop window activation failed.";
return new PublicShellActivationResult(false, code, message, status);
}
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)

View File

@@ -90,8 +90,8 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
<!-- 发布时也生成版本信息文件 -->
@@ -101,7 +101,7 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
</Project>

View File

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

View File

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

View 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);
}
}
}

View File

@@ -1,15 +1,47 @@
# 生成版本信息文件
param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$Codename = "Administrate"
)
$ErrorActionPreference = "Stop"
function Normalize-ArgumentValue {
param(
[Parameter(Mandatory=$true)]
[AllowEmptyString()]
[string]$Value
)
$trimmed = $Value.Trim()
if ($trimmed.Length -ge 2) {
$first = $trimmed[0]
$last = $trimmed[$trimmed.Length - 1]
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $trimmed.Substring(1, $trimmed.Length - 2).Trim()
}
}
return $trimmed
}
$OutputPath = Normalize-ArgumentValue -Value $OutputPath
$Version = Normalize-ArgumentValue -Value $Version
$Codename = Normalize-ArgumentValue -Value $Codename
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
throw "OutputPath is required."
}
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Version is required."
}
$versionInfo = @{
Version = $Version
Codename = $Codename
@@ -18,11 +50,15 @@ $versionInfo = @{
$json = $versionInfo | ConvertTo-Json -Compress
$dir = Split-Path -Parent $OutputPath
if (!(Test-Path $dir)) {
if ([string]::IsNullOrWhiteSpace($dir)) {
throw "OutputPath must include a directory: $OutputPath"
}
if (!(Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
Write-Host " Version: $Version" -ForegroundColor Gray
Write-Host " Codename: $Codename" -ForegroundColor Gray