From 33591a0a6380c9628182ca26462d962f99f18717 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 23 Apr 2026 09:03:35 +0800 Subject: [PATCH] Add startup visual modes and attempt registry Implement startup visual behavior, de-duplicate startup attempts, and improve failure UX. Key changes: - Add spec and docs for startup visuals and timing contract (.trae/specs and docs/LAUNCHER_STARTUP_VISUALS.md). - Introduce StartupVisualPreferences contract and resolver; create SplashWindow via resolved mode. - Add StartupAttemptRecord model and a file-backed StartupAttemptRegistry to persist and coordinate in-progress startup attempts (attach/adopt, soft/hard timeouts, IPC/connect state, lifecycle updates). - Update LauncherFlowCoordinator to: adopt/attach to existing attempts, track IPC connection and soft/hard timeouts (30s/120s), show delayed UI state, attempt foreground recovery via public IPC, compose detailed launch result metadata, and mark registry states (soft timeout, detached waiting, succeeded, failed). - Add TryActivateExistingInstanceAsync to attempt activating an existing desktop via IPC. - Change failure flow: ShowFailureWindowAsync now returns user choice; ErrorWindow updated to present Activate/Wait/Open Logs/Exit semantics and new layouts/styles; improved button wiring and debug/dev mode handling. - Add UI and resource tweaks (ErrorWindow and SplashWindow changes), project asset link for nightly logo, and unit tests for StartupVisualPreferences. These changes prevent duplicate desktop processes during slow startups, provide clearer UX for delayed startups, and persist startup attempt state across Launcher invocations for safer recovery/attach behavior. --- .../startup-visuals-addendum.md | 29 + LanMountainDesktop.Launcher/App.axaml.cs | 154 +++- .../LanMountainDesktop.Launcher.csproj | 1 + .../Models/StartupAttemptRecord.cs | 46 ++ .../Services/LauncherFlowCoordinator.cs | 499 ++++++++++--- .../Services/StartupAttemptRegistry.cs | 313 ++++++++ .../Views/ErrorWindow.axaml | 122 ++- .../Views/ErrorWindow.axaml.cs | 697 +++++++----------- .../Views/SplashWindow.axaml | 133 ++-- .../Views/SplashWindow.axaml.cs | 435 ++++++----- .../Launcher/LoadingState.cs | 194 +---- .../Launcher/StartupVisualPreferences.cs | 91 +++ .../StartupVisualPreferencesTests.cs | 57 ++ LanMountainDesktop/App.axaml.cs | 10 +- .../Models/AppSettingsSnapshot.cs | 2 + .../ViewModels/SettingsViewModels.cs | 65 +- .../Views/MainWindow.SettingsHardCut.Stubs.cs | 2 + LanMountainDesktop/Views/MainWindow.axaml.cs | 186 ++++- .../SettingsPages/GeneralSettingsPage.axaml | 20 +- docs/LAUNCHER_STARTUP_VISUALS.md | 28 + 20 files changed, 2008 insertions(+), 1076 deletions(-) create mode 100644 .trae/specs/launcher-shell-hardening/startup-visuals-addendum.md create mode 100644 LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs create mode 100644 LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs create mode 100644 LanMountainDesktop.Shared.Contracts/Launcher/StartupVisualPreferences.cs create mode 100644 LanMountainDesktop.Tests/StartupVisualPreferencesTests.cs create mode 100644 docs/LAUNCHER_STARTUP_VISUALS.md diff --git a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md new file mode 100644 index 0000000..241e786 --- /dev/null +++ b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md @@ -0,0 +1,29 @@ +# Launcher Slow-Startup And Startup Visual Addendum + +## New startup timing contract + +- `30s` is a soft timeout, not a failure threshold. +- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process. +- `120s` is the hard timeout. +- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`. + +## Startup attempt de-duplication + +- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`. +- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again. +- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run. + +## Startup visual modes + +- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade. +- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`. +- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`. + +## UX safeguards + +- If the host process is still alive at failure time, the failure dialog must prefer: + - `Activate` + - `Wait` + - `Open Logs` + - `Exit` +- Retry is only valid when Launcher is not about to create a duplicate desktop process. diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index d18571e..c004f89 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -6,6 +6,9 @@ using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher; @@ -52,7 +55,7 @@ public partial class App : Application } else { - var splashWindow = new SplashWindow(); + var splashWindow = CreateSplashWindow(); splashWindow.Show(); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); } @@ -68,7 +71,7 @@ public partial class App : Application case "preview-splash": { Logger.Info("Preview command: splash."); - var splashWindow = new SplashWindow(); + var splashWindow = CreateSplashWindow(); splashWindow.SetDebugMode(true); splashWindow.Show(); _ = SimulateSplashPreviewAsync(desktop, splashWindow); @@ -112,6 +115,12 @@ public partial class App : Application } } + private static SplashWindow CreateSplashWindow() + { + var preferences = StartupVisualPreferencesResolver.Resolve(); + return new SplashWindow(preferences.Mode); + } + private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) { var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; @@ -172,49 +181,76 @@ public partial class App : Application SplashWindow splashWindow) { LauncherResult result; + SplashWindow? currentSplashWindow = splashWindow; + var appRoot = Commands.ResolveAppRoot(context); - try + while (true) { - var appRoot = Commands.ResolveAppRoot(context); - Logger.Info( - $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + - $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + - $"ResultPath='{context.GetOption("result") ?? ""}'."); - - var deploymentLocator = new DeploymentLocator(appRoot); - var coordinator = new LauncherFlowCoordinator( - context, - deploymentLocator, - new OobeStateService(appRoot), - new UpdateEngineService(deploymentLocator), - new PluginInstallerService()); - - result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Coordinator threw an unhandled exception.", ex); - result = new LauncherResult + try { - Success = false, - Stage = "launch", - Code = "exception", - Message = $"Launcher failed: {ex.Message}", - ErrorMessage = ex.ToString() - }; + Logger.Info( + $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"ResultPath='{context.GetOption("result") ?? ""}'."); + + var deploymentLocator = new DeploymentLocator(appRoot); + var coordinator = new LauncherFlowCoordinator( + context, + deploymentLocator, + new OobeStateService(appRoot), + new UpdateEngineService(deploymentLocator), + new PluginInstallerService()); + + result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Coordinator threw an unhandled exception.", ex); + result = new LauncherResult + { + Success = false, + Stage = "launch", + Code = "exception", + Message = $"Launcher failed: {ex.Message}", + ErrorMessage = ex.ToString() + }; + } + + if (result.Success || + result.Code == "host_not_found" || + (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && + !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) + { + break; + } + + var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); + if (failureAction == ErrorWindowResult.Exit) + { + break; + } + + if (failureAction == ErrorWindowResult.ActivateExisting && + await TryActivateExistingInstanceAsync().ConfigureAwait(false)) + { + result = new LauncherResult + { + Success = true, + Stage = "launch", + Code = "activation_requested", + Message = "Launcher activated the existing desktop instance.", + Details = result.Details + }; + break; + } + + currentSplashWindow = CreateSplashWindow(); + currentSplashWindow.Show(); } Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - if (!result.Success && - result.Code is not "host_not_found" && - (string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) || - string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) - { - await ShowFailureWindowAsync(result).ConfigureAwait(false); - } - Environment.ExitCode = result.Success ? 0 : 1; await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } @@ -238,15 +274,31 @@ public partial class App : Application } } - private static async Task ShowFailureWindowAsync(LauncherResult result) + private static async Task ShowFailureWindowAsync(LauncherResult result) { ErrorWindow? errorWindow = null; + var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && + bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && + hostProcessAliveValue; + var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var parsedPid) + ? parsedPid + : (int?)null; await Dispatcher.UIThread.InvokeAsync(() => { try { errorWindow = new ErrorWindow(); + if (hostProcessAlive) + { + errorWindow.ConfigureForRunningHostFailure(hostPid); + } + else + { + errorWindow.ConfigureForGenericFailure(allowRetry: true); + } + errorWindow.SetErrorMessage( $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); errorWindow.Show(); @@ -259,16 +311,38 @@ public partial class App : Application if (errorWindow is null) { - return; + return ErrorWindowResult.Exit; } try { - await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); } catch (Exception ex) { Logger.Error("Failure window closed unexpectedly.", ex); + return ErrorWindowResult.Exit; + } + } + + private static async Task TryActivateExistingInstanceAsync() + { + try + { + using var ipcClient = new LanMountainDesktopIpcClient(); + await ipcClient.ConnectAsync().ConfigureAwait(false); + if (!ipcClient.IsConnected) + { + return false; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); + return false; } } diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj index d22016c..de158af 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -35,6 +35,7 @@ + diff --git a/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs new file mode 100644 index 0000000..1105324 --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Models; + +internal enum StartupAttemptState +{ + Pending, + SoftTimeout, + DetachedWaiting, + Succeeded, + Failed +} + +internal sealed class StartupAttemptRecord +{ + [JsonPropertyName("attemptId")] + public string AttemptId { get; set; } = Guid.NewGuid().ToString("N"); + + [JsonPropertyName("hostPid")] + public int HostPid { get; set; } + + [JsonPropertyName("startedAtUtc")] + public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + [JsonPropertyName("updatedAtUtc")] + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + [JsonPropertyName("launchSource")] + public string LaunchSource { get; set; } = string.Empty; + + [JsonPropertyName("successPolicy")] + public string SuccessPolicy { get; set; } = string.Empty; + + [JsonPropertyName("lastObservedStage")] + public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing; + + [JsonPropertyName("lastObservedMessage")] + public string LastObservedMessage { get; set; } = string.Empty; + + [JsonPropertyName("ipcConnected")] + public bool IpcConnected { get; set; } + + [JsonPropertyName("state")] + public StartupAttemptState State { get; set; } = StartupAttemptState.Pending; +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 085e1ae..c1eb343 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -10,6 +10,11 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class LauncherFlowCoordinator { + private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120); + private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。"; + private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。"; + private static readonly string[] LauncherOnlyOptions = [ "debug", "show-loading-details", "plugins-dir", "source", "result", @@ -25,6 +30,7 @@ internal sealed class LauncherFlowCoordinator private readonly OobeStateService _oobeStateService; private readonly UpdateEngineService _updateEngine; private readonly PluginInstallerService _pluginInstallerService; + private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -39,6 +45,7 @@ internal sealed class LauncherFlowCoordinator _oobeStateService = oobeStateService; _updateEngine = updateEngine; _pluginInstallerService = pluginInstallerService; + _startupAttemptRegistry = new StartupAttemptRegistry(); _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; } @@ -66,6 +73,7 @@ internal sealed class LauncherFlowCoordinator window.Show(); return window; }); + var windowsClosingByCoordinator = false; var versionInfo = _deploymentLocator.GetVersionInfo(); splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename); var reporter = (ISplashStageReporter)splashWindow; @@ -85,8 +93,25 @@ internal sealed class LauncherFlowCoordinator var lastStage = StartupStage.Initializing; var lastStageMessage = "launcher-started"; var startupSuccessTracker = new StartupSuccessTracker(_context); + var activationFailureReason = string.Empty; + var ipcConnected = false; + var softTimeoutShown = false; + var attachedToExistingAttempt = false; + StartupAttemptRecord? trackedAttempt = null; var loadingState = new LoadingStateMessage(); + EventHandler? splashClosedHandler = null; + splashClosedHandler = (_, _) => + { + if (windowsClosingByCoordinator) + { + return; + } + + _startupAttemptRegistry.MarkOwnedDetachedWaiting(); + Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt."); + }; + splashWindow.Closed += splashClosedHandler; using var ipcClient = new LanMountainDesktopIpcClient(); ipcClient.RegisterNotifyHandler(IpcRoutedNotifyIds.LauncherStartupProgress, message => { @@ -94,8 +119,9 @@ internal sealed class LauncherFlowCoordinator { try { + ipcConnected = true; lastStage = message.Stage; - lastStageMessage = message.Message ?? string.Empty; + lastStageMessage = message.Message ?? message.Stage.ToString(); Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'."); loadingState = loadingState with @@ -108,6 +134,7 @@ internal sealed class LauncherFlowCoordinator reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString()); loadingDetailsWindow?.UpdateLoadingState(loadingState); + _startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true); if (startupSuccessTracker.TryResolve(message.Stage, out var successState)) { @@ -116,6 +143,7 @@ internal sealed class LauncherFlowCoordinator if (message.Stage == StartupStage.ActivationFailed) { + activationFailureReason = message.Message ?? "activation_failed"; activationFailedTcs.TrySetResult(message.Message ?? "activation_failed"); } } @@ -170,7 +198,90 @@ internal sealed class LauncherFlowCoordinator } reporter.Report("launch", "Launching desktop..."); - var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); + var launchOutcome = default(HostLaunchOutcome); + var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey); + if (attachableAttempt is not null && + _startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) && + TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess)) + { + trackedAttempt = attachableAttempt; + attachedToExistingAttempt = true; + ipcConnected = attachableAttempt.IpcConnected; + lastStage = attachableAttempt.LastObservedStage; + lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage) + ? "Attached to the existing startup attempt." + : attachableAttempt.LastObservedMessage; + reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage); + + if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState)) + { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: attachedSuccessState.Code, + message: attachedSuccessState.Message, + details: MergeDetails( + launcherContextDetails, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown: false, + recoveryActivationAttempted: false))); + } + + if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting) + { + softTimeoutShown = true; + reporter.Report("delayed", SoftTimeoutStatusMessage); + loadingState = BuildDelayedLoadingState( + loadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + trackedAttempt.StartedAtUtc); + loadingDetailsWindow?.UpdateLoadingState(loadingState); + } + + launchOutcome = HostLaunchOutcome.FromProcess( + attachedProcess!, + BuildResult( + true, + "launchHost", + "attached_attempt", + "Attached to an existing startup attempt.", + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)), + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: true, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)); + } + else + { + launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); + } + if (!launchOutcome.Result.Success) { return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails); @@ -189,7 +300,30 @@ internal sealed class LauncherFlowCoordinator stage: "launch", code: "host_start_failed", message: "Host launch did not create a process.", - details: MergeDetails(launcherContextDetails, launchOutcome.Details)); + details: MergeDetails( + launcherContextDetails, + MergeDetails( + launchOutcome.Details, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive: false, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted: false)))); + } + + if (!attachedToExistingAttempt) + { + trackedAttempt = _startupAttemptRegistry.StartOwnedAttempt( + launchOutcome.Process.Id, + _context.LaunchSource, + startupSuccessTracker.PolicyKey, + lastStage, + lastStageMessage); } var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false); @@ -197,68 +331,172 @@ internal sealed class LauncherFlowCoordinator { Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications."); } + else + { + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); + } + + Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) + { + return MergeDetails( + launcherContextDetails, + MergeDetails( + launchOutcome.Details, + BuildAttemptDetails( + trackedAttempt, + attachedToExistingAttempt, + ipcConnected, + hostProcessAlive, + lastStage, + lastStageMessage, + activationFailureReason, + softTimeoutShown, + recoveryActivationAttempted))); + } var processExitTask = launchOutcome.Process.WaitForExitAsync(); - var completedTask = await Task.WhenAny( - successTcs.Task, - activationFailedTcs.Task, - processExitTask, - Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false); + var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; + var softTimeoutAt = startedAt + StartupSoftTimeout; + var hardTimeoutAt = startedAt + StartupHardTimeout; + var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); - if (completedTask == successTcs.Task) + while (true) { - var successState = await successTcs.Task.ConfigureAwait(false); - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: true, - stage: "launch", - code: successState.Code, - message: successState.Message, - details: MergeDetails(launcherContextDetails, launchOutcome.Details)); - } - - if (completedTask == activationFailedTcs.Task) - { - Logger.Warn($"Activation failure received before desktop visibility. Reason='{await activationFailedTcs.Task.ConfigureAwait(false)}'."); - var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); - if (retryOutcome is not null) + if (successTcs.Task.IsCompleted) { + var successState = await successTcs.Task.ConfigureAwait(false); + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return WithAdditionalDetails(retryOutcome, launcherContextDetails); + return BuildResult( + success: true, + stage: "launch", + code: successState.Code, + message: successState.Message, + details: ComposeLaunchDetails(!launchOutcome.Process.HasExited)); } + + if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason)) + { + activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false); + Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); + } + + if (processExitTask.IsCompleted) + { + var exitCode = launchOutcome.Process.ExitCode; + Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); + + windowsClosingByCoordinator = true; + if (exitCode == HostExitCodes.SecondaryActivationSucceeded) + { + _startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance."); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: "activation_redirected", + message: "Host redirected activation to the existing desktop instance.", + details: MergeDetails( + ComposeLaunchDetails(hostProcessAlive: false), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["exitCode"] = exitCode.ToString() + })); + } + + _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: false, + stage: "launch", + code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired + ? "activation_failed" + : "host_exited_early", + message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired + ? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}." + : $"Host exited before the required startup state was reported. ExitCode={exitCode}.", + details: MergeDetails( + ComposeLaunchDetails(hostProcessAlive: false), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["exitCode"] = exitCode.ToString() + })); + } + + var now = DateTimeOffset.UtcNow; + if (!ipcConnected && + !launchOutcome.Process.HasExited && + now >= nextReconnectAttemptAt) + { + connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); + if (connected) + { + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); + } + + nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); + } + + if (!softTimeoutShown && + now >= softTimeoutAt && + (!launchOutcome.Process.HasExited || ipcConnected)) + { + softTimeoutShown = true; + _startupAttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage); + reporter.Report("delayed", SoftTimeoutStatusMessage); + loadingState = BuildDelayedLoadingState( + loadingState, + SoftTimeoutStatusMessage, + SoftTimeoutDetailsMessage, + trackedAttempt?.StartedAtUtc ?? startedAt); + loadingDetailsWindow?.UpdateLoadingState(loadingState); + } + + if (now >= hardTimeoutAt) + { + break; + } + + var nextCheckpointAt = hardTimeoutAt; + if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt) + { + nextCheckpointAt = softTimeoutAt; + } + + var delay = nextCheckpointAt - now; + if (delay > TimeSpan.FromSeconds(1)) + { + delay = TimeSpan.FromSeconds(1); + } + else if (delay < TimeSpan.FromMilliseconds(100)) + { + delay = TimeSpan.FromMilliseconds(100); + } + + await Task.WhenAny( + successTcs.Task, + activationFailedTcs.Task, + processExitTask, + Task.Delay(delay)).ConfigureAwait(false); } - if (completedTask == processExitTask) + var recoveryActivationAttempted = false; + if (!connected && !launchOutcome.Process.HasExited) { - var exitCode = launchOutcome.Process.ExitCode; - Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); - - if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired) + connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false); + if (connected) { - var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); - if (retryOutcome is not null) - { - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return WithAdditionalDetails(retryOutcome, launcherContextDetails); - } + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); } - - await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); - return BuildResult( - success: false, - stage: "launch", - code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early", - message: exitCode == HostExitCodes.SecondaryActivationSucceeded - ? "Host redirected activation to the existing desktop instance." - : $"Host exited before the required startup state was reported. ExitCode={exitCode}.", - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["exitCode"] = exitCode.ToString() - }))); } if (connected && !launchOutcome.Process.HasExited) { + recoveryActivationAttempted = true; var recoveryOutcome = await TryRecoverWithPublicActivationAsync( ipcClient, launchOutcome.Process, @@ -266,48 +504,57 @@ internal sealed class LauncherFlowCoordinator startupSuccessTracker).ConfigureAwait(false); if (recoveryOutcome is not null) { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: true, stage: "launch", code: recoveryOutcome.Code, message: recoveryOutcome.Message, - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["recoveryActivationAttempted"] = bool.TrueString - }))); + details: ComposeLaunchDetails( + !launchOutcome.Process.HasExited, + recoveryActivationAttempted: true)); } } + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: false, stage: "launch", code: "desktop_not_visible", - message: "Host process started, but it never reached the required startup state within 30 seconds.", - details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary - { - ["ipcStage"] = lastStage.ToString(), - ["ipcMessage"] = lastStageMessage - }))); + message: "Host process started, but it never reached the required startup state within 120 seconds.", + details: ComposeLaunchDetails( + !launchOutcome.Process.HasExited, + recoveryActivationAttempted)); } finally { - await Dispatcher.UIThread.InvokeAsync(() => + if (splashClosedHandler is not null) { - try + splashWindow.Closed -= splashClosedHandler; + } + + if (!windowsClosingByCoordinator) + { + await Dispatcher.UIThread.InvokeAsync(() => { - if (splashWindow.IsVisible && splashWindow.IsLoaded) + try { - splashWindow.Close(); - Logger.Info("Splash window closed in coordinator cleanup."); + if (splashWindow.IsVisible && splashWindow.IsLoaded) + { + splashWindow.Close(); + Logger.Info("Splash window closed in coordinator cleanup."); + } } - } - catch (Exception ex) - { - Logger.Error("Failed to close splash window during coordinator cleanup.", ex); - } - }); + catch (Exception ex) + { + Logger.Error("Failed to close splash window during coordinator cleanup.", ex); + } + }); + } } } catch (Exception ex) @@ -373,20 +620,17 @@ internal sealed class LauncherFlowCoordinator private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) { + try + { + await splashWindow.DismissAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failed to dismiss splash window.", ex); + } + await Dispatcher.UIThread.InvokeAsync(() => { - try - { - if (splashWindow.IsVisible && splashWindow.IsLoaded) - { - splashWindow.Close(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to close splash window.", ex); - } - try { if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) @@ -672,6 +916,7 @@ internal sealed class LauncherFlowCoordinator try { errorWindow = new ErrorWindow(); + errorWindow.ConfigureForHostNotFound(); errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); errorWindow.Show(); Logger.Warn("Host not found. Showing error window."); @@ -1000,6 +1245,94 @@ internal sealed class LauncherFlowCoordinator return null; } + private static LoadingStateMessage BuildDelayedLoadingState( + LoadingStateMessage loadingState, + string summaryMessage, + string detailMessage, + DateTimeOffset startedAtUtc) + { + var delayedItems = loadingState.ActiveItems + .Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + delayedItems.Insert(0, new LoadingItem + { + Id = "launcher-soft-timeout", + Type = LoadingItemType.System, + Name = "Startup still in progress", + Description = detailMessage, + State = LoadingState.Delayed, + ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1), + Message = detailMessage, + StartTime = startedAtUtc + }); + + return loadingState with + { + ActiveItems = delayedItems, + Message = summaryMessage, + Timestamp = DateTimeOffset.UtcNow, + TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count) + }; + } + + private static Dictionary BuildAttemptDetails( + StartupAttemptRecord? trackedAttempt, + bool attachedToExistingAttempt, + bool ipcConnected, + bool hostProcessAlive, + StartupStage lastStage, + string lastStageMessage, + string? activationFailureReason, + bool softTimeoutShown, + bool recoveryActivationAttempted) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["hostProcessAlive"] = hostProcessAlive.ToString(), + ["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(), + ["ipcConnected"] = ipcConnected.ToString(), + ["ipcStage"] = lastStage.ToString(), + ["ipcMessage"] = lastStageMessage, + ["activationFailureReason"] = activationFailureReason ?? string.Empty, + ["softTimeoutShown"] = softTimeoutShown.ToString(), + ["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString() + }; + + if (trackedAttempt is not null) + { + details["startupAttemptId"] = trackedAttempt.AttemptId; + details["startupAttemptState"] = trackedAttempt.State.ToString(); + details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O"); + details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O"); + details["successPolicy"] = trackedAttempt.SuccessPolicy; + details["hostPid"] = trackedAttempt.HostPid.ToString(); + } + + return details; + } + + private static bool TryGetLiveProcess(int processId, out Process? process) + { + process = null; + if (processId <= 0) + { + return false; + } + + try + { + process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + process?.Dispose(); + process = null; + return false; + } + } + private enum HostStartMode { ShellExecute, @@ -1048,6 +1381,8 @@ internal sealed class LauncherFlowCoordinator private bool _trayReady; private bool _backgroundReady; + public string PolicyKey => _policy.ToString(); + public StartupSuccessTracker(CommandContext context) { var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs); diff --git a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs new file mode 100644 index 0000000..ffd08d0 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs @@ -0,0 +1,313 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class StartupAttemptRegistry +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true + }; + + private readonly string _statePath; + private readonly string _mutexName; + private string? _ownedAttemptId; + + public StartupAttemptRegistry() + : this(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + ".launcher", + "state", + "startup-attempt.json")) + { + } + + internal StartupAttemptRegistry(string statePath) + { + _statePath = statePath; + _mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}"; + } + + public StartupAttemptRecord StartOwnedAttempt( + int hostPid, + string launchSource, + string successPolicy, + StartupStage stage, + string? message) + { + var record = new StartupAttemptRecord + { + AttemptId = Guid.NewGuid().ToString("N"), + HostPid = hostPid, + LaunchSource = launchSource, + SuccessPolicy = successPolicy, + LastObservedStage = stage, + LastObservedMessage = message ?? string.Empty, + StartedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + State = StartupAttemptState.Pending + }; + + ExecuteWithLock(() => + { + SaveUnsafe(record); + _ownedAttemptId = record.AttemptId; + }); + + return Clone(record); + } + + public bool AdoptAttempt(string attemptId) + { + if (string.IsNullOrWhiteSpace(attemptId)) + { + return false; + } + + var adopted = false; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal)) + { + return; + } + + if (!IsAttachable(record)) + { + return; + } + + _ownedAttemptId = record.AttemptId; + if (record.State == StartupAttemptState.DetachedWaiting) + { + record.State = StartupAttemptState.SoftTimeout; + } + + record.UpdatedAtUtc = DateTimeOffset.UtcNow; + SaveUnsafe(record); + adopted = true; + }); + + return adopted; + } + + public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy) + { + StartupAttemptRecord? result = null; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || + !IsAttachable(record) || + !string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) || + !string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + result = Clone(record); + }); + + return result; + } + + public void MarkOwnedIpcConnected() + { + UpdateOwned(record => record.IpcConnected = true); + } + + public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected) + { + UpdateOwned(record => + { + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? string.Empty; + if (ipcConnected) + { + record.IpcConnected = true; + } + }); + } + + public void MarkOwnedSoftTimeout(string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.SoftTimeout; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + public void MarkOwnedDetachedWaiting() + { + UpdateOwned(record => + { + if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout) + { + record.State = StartupAttemptState.DetachedWaiting; + } + }); + } + + public void MarkOwnedSucceeded(StartupStage stage, string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.Succeeded; + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + public void MarkOwnedFailed(StartupStage stage, string? message) + { + UpdateOwned(record => + { + record.State = StartupAttemptState.Failed; + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? record.LastObservedMessage; + }); + } + + private void UpdateOwned(Action update) + { + if (string.IsNullOrWhiteSpace(_ownedAttemptId)) + { + return; + } + + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal)) + { + return; + } + + update(record); + record.UpdatedAtUtc = DateTimeOffset.UtcNow; + SaveUnsafe(record); + }); + } + + private void ExecuteWithLock(Action action) + { + using var mutex = new Mutex(false, _mutexName); + var hasHandle = false; + try + { + try + { + hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2)); + } + catch (AbandonedMutexException) + { + hasHandle = true; + } + + if (!hasHandle) + { + return; + } + + action(); + } + finally + { + if (hasHandle) + { + mutex.ReleaseMutex(); + } + } + } + + private StartupAttemptRecord? LoadUnsafe() + { + if (!File.Exists(_statePath)) + { + return null; + } + + try + { + var json = File.ReadAllText(_statePath); + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch + { + return null; + } + } + + private void SaveUnsafe(StartupAttemptRecord record) + { + var directory = Path.GetDirectoryName(_statePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions)); + } + + private static bool IsAttachable(StartupAttemptRecord record) + { + if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)) + { + return false; + } + + return TryGetLiveProcess(record.HostPid, out _); + } + + private static bool TryGetLiveProcess(int processId, out Process? process) + { + process = null; + if (processId <= 0) + { + return false; + } + + try + { + process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + process?.Dispose(); + process = null; + return false; + } + } + + private static string ComputePathHash(string statePath) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant())); + return Convert.ToHexString(bytes[..8]); + } + + private static StartupAttemptRecord Clone(StartupAttemptRecord record) + { + return new StartupAttemptRecord + { + AttemptId = record.AttemptId, + HostPid = record.HostPid, + StartedAtUtc = record.StartedAtUtc, + UpdatedAtUtc = record.UpdatedAtUtc, + LaunchSource = record.LaunchSource, + SuccessPolicy = record.SuccessPolicy, + LastObservedStage = record.LastObservedStage, + LastObservedMessage = record.LastObservedMessage, + IpcConnected = record.IpcConnected, + State = record.State + }; + } +} diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml index 3e27087..3bfb78b 100644 --- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml @@ -3,102 +3,96 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" - xmlns:ui="using:FluentAvalonia.UI.Controls" mc:Ignorable="d" - d:DesignWidth="520" - d:DesignHeight="280" x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow" x:DataType="views:ErrorWindow" - Title="阑山桌面" - Width="520" - Height="280" + Title="LanMountain Desktop" + Width="560" + Height="320" CanResize="False" WindowStartupLocation="CenterScreen" - Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" + Background="#111318" TransparencyLevelHint="None" Icon="/Assets/logo.ico"> - - - - - + - + VerticalAlignment="Center" /> - - - - + + - - + Foreground="#F6F7FB" + TextWrapping="Wrap" /> + - - + LineHeight="22" /> + + LineHeight="20" /> - - + Padding="24,16" + Background="#171A21"> +