diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs index 1237b74..ad298a4 100644 --- a/LanMountainDesktop.Launcher/Services/Commands.cs +++ b/LanMountainDesktop.Launcher/Services/Commands.cs @@ -166,7 +166,10 @@ internal static class Commands return Path.GetFullPath(configured); } - var baseDir = AppContext.BaseDirectory; + var launcherDir = Path.GetDirectoryName(Environment.ProcessPath); + var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir) + ? launcherDir + : AppContext.BaseDirectory); // 发布版结构:Launcher 和 app-* 目录在同一目录 // 检查当前目录是否有 app-* 子目录(发布版) diff --git a/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs new file mode 100644 index 0000000..2b7d7d7 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs @@ -0,0 +1,199 @@ +using LanMountainDesktop.Shared.Contracts.Launcher; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed record HostLaunchPlan( + string HostPath, + string PackageRoot, + string WorkingDirectory, + IReadOnlyList Arguments, + IReadOnlyDictionary EnvironmentVariables, + AppVersionInfo VersionInfo); + +internal static class HostLaunchPlanBuilder +{ + private static readonly string[] LauncherOnlyOptions = + [ + "debug", "show-loading-details", "plugins-dir", "source", "result", + "app-root", + LauncherIpcConstants.LauncherPidEnvVar, + LauncherIpcConstants.PackageRootEnvVar, + LauncherIpcConstants.VersionEnvVar, + LauncherIpcConstants.CodenameEnvVar + ]; + + public static HostLaunchPlan Build( + CommandContext context, + DeploymentLocator deploymentLocator, + HostResolutionResult resolution) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(deploymentLocator); + ArgumentNullException.ThrowIfNull(resolution); + + if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath)) + { + throw new InvalidOperationException("Host path must be resolved before building a launch plan."); + } + + var hostPath = Path.GetFullPath(resolution.ResolvedHostPath); + var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource); + var versionInfo = deploymentLocator.GetVersionInfo(); + var arguments = BuildForwardedArguments(context, packageRoot, versionInfo); + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(), + [LauncherIpcConstants.PackageRootEnvVar] = packageRoot, + [LauncherIpcConstants.VersionEnvVar] = versionInfo.Version, + [LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename + }; + + return new HostLaunchPlan( + hostPath, + packageRoot, + Directory.Exists(packageRoot) + ? packageRoot + : Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory, + arguments, + environment, + versionInfo); + } + + public static string FormatArgumentsForLog(IReadOnlyList arguments) + { + return string.Join(" ", arguments.Select(QuoteArgument)); + } + + private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource) + { + var fullAppRoot = string.IsNullOrWhiteSpace(appRoot) + ? AppContext.BaseDirectory + : Path.GetFullPath(appRoot); + + var hostDirectory = Path.GetDirectoryName(hostPath); + if (hostDirectory is not null && + Directory.Exists(fullAppRoot) && + IsAppDeploymentDirectory(hostDirectory) && + IsParentOf(fullAppRoot, hostDirectory)) + { + return fullAppRoot; + } + + if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) || + string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) || + string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase)) + { + return fullAppRoot; + } + + return hostDirectory ?? fullAppRoot; + } + + private static IReadOnlyList BuildForwardedArguments( + CommandContext context, + string packageRoot, + AppVersionInfo versionInfo) + { + var arguments = new List(); + + for (var index = 0; index < context.RawArgs.Count; index++) + { + var arg = context.RawArgs[index]; + + if (index == 0 && + !arg.StartsWith("--", StringComparison.Ordinal) && + string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (index == 1 && + !arg.StartsWith("--", StringComparison.Ordinal) && + string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + var key = arg[2..]; + var equalsIndex = key.IndexOf('='); + if (equalsIndex >= 0) + { + key = key[..equalsIndex]; + } + + if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + if (equalsIndex < 0 && + index + 1 < context.RawArgs.Count && + !context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + index++; + } + + continue; + } + } + + arguments.Add(arg); + } + + arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}"); + arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}"); + arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}"); + arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}"); + + return arguments; + } + + private static bool IsAppDeploymentDirectory(string path) + { + var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path)); + return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsParentOf(string parent, string child) + { + var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return childPath.StartsWith( + parentPath + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + private static string QuoteArgument(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "\"\""; + } + + if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t')) + { + return value; + } + + var builder = new System.Text.StringBuilder(); + builder.Append('"'); + foreach (var ch in value) + { + if (ch == '"') + { + builder.Append("\\\""); + } + else + { + builder.Append(ch); + } + } + + builder.Append('"'); + return builder.ToString(); + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index e8f2591..1ed1499 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -11,21 +11,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 static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30); private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。"; private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。"; - private static readonly string[] LauncherOnlyOptions = - [ - "debug", "show-loading-details", "plugins-dir", "source", "result", - "app-root", - LauncherIpcConstants.LauncherPidEnvVar, - LauncherIpcConstants.PackageRootEnvVar, - LauncherIpcConstants.VersionEnvVar, - LauncherIpcConstants.CodenameEnvVar - ]; - private readonly CommandContext _context; private readonly DeploymentLocator _deploymentLocator; private readonly OobeStateService _oobeStateService; @@ -439,24 +429,6 @@ internal sealed class LauncherFlowCoordinator PublishCoordinatorStatus(hostProcessAliveOverride: true); } - var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false); - if (!connected) - { - Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications."); - } - else - { - 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); - } - Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) { return MergeDetails( @@ -475,11 +447,51 @@ internal sealed class LauncherFlowCoordinator recoveryActivationAttempted))); } + async Task RefreshShellStatusAsync(string waitingMessage) + { + if (!ipcClient.IsConnected) + { + return null; + } + + ipcConnected = true; + _startupAttemptRegistry.MarkOwnedIpcConnected(); + shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); + if (startupSuccessTracker.TryResolve(shellStatus, out var successState)) + { + return successState; + } + + if (shellStatus is { DesktopVisible: false }) + { + _startupAttemptRegistry.MarkOwnedWaitingForShell(waitingMessage); + } + + PublishCoordinatorStatus(hostProcessAliveOverride: true); + return null; + } + + var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(1200)).ConfigureAwait(false); + if (!connected) + { + Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications."); + } + else + { + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + successTcs.TrySetResult(shellSuccess); + } + } + var processExitTask = launchOutcome.Process.WaitForExitAsync(); var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; var softTimeoutAt = startedAt + StartupSoftTimeout; var hardTimeoutAt = startedAt + StartupHardTimeout; - var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); + var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2); + var nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1); var activationRetryAttempted = false; while (true) @@ -504,6 +516,29 @@ internal sealed class LauncherFlowCoordinator activationRetryAttempted = true; activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false); Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); + var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( + ipcClient, + startupSuccessTracker, + TimeSpan.FromSeconds(1)).ConfigureAwait(false); + if (activationRecovery is not null) + { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); + PublishCoordinatorStatus( + hostProcessAliveOverride: !launchOutcome.Process.HasExited, + completed: true, + succeeded: true); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: activationRecovery.Code, + message: activationRecovery.Message, + details: ComposeLaunchDetails( + !launchOutcome.Process.HasExited, + recoveryActivationAttempted: true)); + } + var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); if (retryOutcome is not null) { @@ -558,6 +593,28 @@ internal sealed class LauncherFlowCoordinator exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired) { activationRetryAttempted = true; + var activationRecovery = await TryRecoverActivationThroughExistingHostAsync( + ipcClient, + startupSuccessTracker, + TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activationRecovery is not null) + { + _startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message); + PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: activationRecovery.Code, + message: activationRecovery.Message, + details: MergeDetails( + ComposeLaunchDetails(hostProcessAlive: true, recoveryActivationAttempted: true), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["exitCode"] = exitCode.ToString() + })); + } + var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); if (retryOutcome is not null) { @@ -605,6 +662,21 @@ internal sealed class LauncherFlowCoordinator } var now = DateTimeOffset.UtcNow; + if (ipcConnected && + !launchOutcome.Process.HasExited && + now >= nextShellStatusPollAt) + { + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) + { + successTcs.TrySetResult(shellSuccess); + continue; + } + + nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1); + } + if (!ipcConnected && !launchOutcome.Process.HasExited && now >= nextReconnectAttemptAt) @@ -612,18 +684,16 @@ internal sealed class LauncherFlowCoordinator connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); if (connected) { - ipcConnected = true; - _startupAttemptRegistry.MarkOwnedIpcConnected(); - shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - if (shellStatus is { DesktopVisible: false }) + var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) { - _startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC reconnected; waiting for desktop shell."); + successTcs.TrySetResult(shellSuccess); + continue; } - - PublishCoordinatorStatus(hostProcessAliveOverride: true); } - nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); + nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2); } if (!softTimeoutShown && @@ -676,15 +746,21 @@ internal sealed class LauncherFlowCoordinator connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false); if (connected) { - ipcConnected = true; - _startupAttemptRegistry.MarkOwnedIpcConnected(); - shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); - if (shellStatus is { DesktopVisible: false }) + var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.") + .ConfigureAwait(false); + if (shellSuccess is not null) { - _startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell."); + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message); + PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: shellSuccess.Code, + message: shellSuccess.Message, + details: ComposeLaunchDetails(hostProcessAlive: true)); } - - PublishCoordinatorStatus(hostProcessAliveOverride: true); } } @@ -719,13 +795,44 @@ internal sealed class LauncherFlowCoordinator 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); + if (startupSuccessTracker.TryResolve(shellStatus, out var finalShellSuccess)) + { + _startupAttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message); + PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: true, + stage: "launch", + code: finalShellSuccess.Code, + message: finalShellSuccess.Message, + details: ComposeLaunchDetails( + hostProcessAlive: true, + recoveryActivationAttempted)); + } + + PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: false); + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: false, + stage: "launch", + code: "shell_not_ready", + message: "Host public IPC is connected, but the desktop shell did not create or show the main window in time.", + details: ComposeLaunchDetails( + hostProcessAlive: true, + recoveryActivationAttempted)); + } + + if (!connected && !launchOutcome.Process.HasExited) + { + windowsClosingByCoordinator = true; + _startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet."); + PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: true); 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.", + message: "Host process is still running; Launcher will not start another process while public IPC finishes startup.", details: ComposeLaunchDetails( hostProcessAlive: true, recoveryActivationAttempted)); @@ -739,7 +846,7 @@ internal sealed class LauncherFlowCoordinator success: false, stage: "launch", code: "desktop_not_visible", - message: "Host process started, but it never reached the required startup state within 120 seconds.", + message: $"Host process started, but it never reached the required startup state within {StartupHardTimeout.TotalSeconds:0} seconds.", details: ComposeLaunchDetails( !launchOutcome.Process.HasExited, recoveryActivationAttempted)); @@ -906,25 +1013,20 @@ internal sealed class LauncherFlowCoordinator bool forceDirectMode, string? retryTag) { - var hostPath = resolution.ResolvedHostPath!; + var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution); + var hostPath = plan.HostPath; if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { EnsureExecutable(hostPath); } - var hostWorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot(); - var versionInfo = _deploymentLocator.GetVersionInfo(); - var forwardedArguments = BuildForwardedArguments(versionInfo); - - var primaryMode = forceDirectMode || !OperatingSystem.IsWindows() - ? HostStartMode.Direct - : HostStartMode.ShellExecute; - var fallbackMode = primaryMode == HostStartMode.ShellExecute - ? HostStartMode.Direct + var primaryMode = HostStartMode.Direct; + var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows() + ? HostStartMode.ShellExecute : (HostStartMode?)null; - var firstAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, primaryMode, retryTag).ConfigureAwait(false); - if (firstAttempt.ProcessCreated && !firstAttempt.ExitedEarly && firstAttempt.Process is not null) + var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false); + if (firstAttempt.ProcessCreated && firstAttempt.Process is not null) { var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null); return HostLaunchOutcome.FromProcess( @@ -933,11 +1035,6 @@ internal sealed class LauncherFlowCoordinator firstDetails); } - if (firstAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded) - { - return BuildOutcomeFromAttempt(resolution, firstAttempt, null); - } - if (fallbackMode is null) { return BuildOutcomeFromAttempt(resolution, firstAttempt, null); @@ -947,8 +1044,8 @@ internal sealed class LauncherFlowCoordinator $"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " + $"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? ""}'."); - var secondAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, fallbackMode.Value, retryTag).ConfigureAwait(false); - if (secondAttempt.ProcessCreated && !secondAttempt.ExitedEarly && secondAttempt.Process is not null) + var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false); + if (secondAttempt.ProcessCreated && secondAttempt.Process is not null) { var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null); return HostLaunchOutcome.FromProcess( @@ -1014,113 +1111,57 @@ internal sealed class LauncherFlowCoordinator } private async Task StartHostProcessAsync( - string hostPath, - string hostWorkingDirectory, - string arguments, - AppVersionInfo versionInfo, + HostLaunchPlan plan, HostStartMode startMode, string? retryTag) { var startInfo = new ProcessStartInfo { - FileName = hostPath, - WorkingDirectory = hostWorkingDirectory, - Arguments = arguments, + FileName = plan.HostPath, + WorkingDirectory = plan.WorkingDirectory, UseShellExecute = startMode == HostStartMode.ShellExecute }; if (startMode == HostStartMode.Direct) { - startInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(); - startInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] = _deploymentLocator.GetAppRoot(); - startInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version; - startInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename; + foreach (var argument in plan.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (var pair in plan.EnvironmentVariables) + { + startInfo.EnvironmentVariables[pair.Key] = pair.Value; + } + } + else + { + startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments); } try { var process = Process.Start(startInfo); Logger.Info( - $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? ""}'; Path='{hostPath}'; " + - $"WorkingDir='{hostWorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; Args='{startInfo.Arguments}'."); + $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? ""}'; Path='{plan.HostPath}'; " + + $"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " + + $"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'."); if (process is null) { - return HostStartAttempt.StartFailed(startMode, "process_start_returned_null"); + return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan); } - var exitTask = process.WaitForExitAsync(); - var completed = await Task.WhenAny(exitTask, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); - if (completed == exitTask) - { - return HostStartAttempt.EarlyExit(startMode, process, process.ExitCode); - } - - return HostStartAttempt.Started(startMode, process); + await Task.Yield(); + return HostStartAttempt.Started(startMode, process, plan); } catch (Exception ex) { Logger.Error($"Host start failed. Mode='{startMode}'.", ex); - return HostStartAttempt.StartFailed(startMode, ex.GetType().Name); + return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan); } } - private string BuildForwardedArguments(AppVersionInfo versionInfo) - { - var arguments = new System.Text.StringBuilder(); - - for (var index = 0; index < _context.RawArgs.Count; index++) - { - var arg = _context.RawArgs[index]; - - if (arg == _context.Command || arg == _context.SubCommand) - { - continue; - } - - if (arg.StartsWith("--", StringComparison.Ordinal)) - { - var key = arg[2..]; - var equalsIndex = key.IndexOf('='); - if (equalsIndex >= 0) - { - key = key[..equalsIndex]; - } - - if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase)) - { - if (equalsIndex < 0 && - index + 1 < _context.RawArgs.Count && - !_context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal)) - { - index++; - } - - continue; - } - } - - if (arguments.Length > 0) - { - arguments.Append(' '); - } - - arguments.Append(QuoteArgument(arg)); - } - - if (arguments.Length > 0) - { - arguments.Append(' '); - } - - arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}"); - arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}"); - arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}"); - arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={QuoteArgument(versionInfo.Codename)}"); - - return arguments.ToString(); - } - private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync() { ErrorWindow? errorWindow = null; @@ -1333,6 +1374,9 @@ internal sealed class LauncherFlowCoordinator details["startMode"] = firstAttempt.StartMode.ToString(); details["processCreated"] = firstAttempt.ProcessCreated.ToString(); details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty; + details["packageRoot"] = firstAttempt.PackageRoot ?? string.Empty; + details["workingDirectory"] = firstAttempt.WorkingDirectory ?? string.Empty; + details["arguments"] = firstAttempt.Arguments ?? string.Empty; details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty; details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty; } @@ -1342,6 +1386,9 @@ internal sealed class LauncherFlowCoordinator details["fallbackStartMode"] = secondAttempt.StartMode.ToString(); details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString(); details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty; + details["fallbackPackageRoot"] = secondAttempt.PackageRoot ?? string.Empty; + details["fallbackWorkingDirectory"] = secondAttempt.WorkingDirectory ?? string.Empty; + details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty; details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty; details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty; } @@ -1362,36 +1409,6 @@ internal sealed class LauncherFlowCoordinator return merged; } - private static string QuoteArgument(string value) - { - if (string.IsNullOrEmpty(value)) - { - return "\"\""; - } - - if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t')) - { - return value; - } - - var builder = new System.Text.StringBuilder(); - builder.Append('"'); - foreach (var ch in value) - { - if (ch == '"') - { - builder.Append("\\\""); - } - else - { - builder.Append(ch); - } - } - - builder.Append('"'); - return builder.ToString(); - } - private static void EnsureExecutable(string path) { if (OperatingSystem.IsWindows()) @@ -1419,15 +1436,23 @@ internal sealed class LauncherFlowCoordinator return true; } - var connectTask = ipcClient.ConnectAsync(); - var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != connectTask) + try { + var connectTask = ipcClient.ConnectAsync(); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != connectTask) + { + return false; + } + + await connectTask.ConfigureAwait(false); + return ipcClient.IsConnected; + } + catch (Exception ex) + { + Logger.Warn($"Public IPC connect failed: {ex.Message}"); return false; } - - await connectTask.ConfigureAwait(false); - return true; } private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context) @@ -1468,6 +1493,35 @@ internal sealed class LauncherFlowCoordinator } } + private static async Task TryRecoverActivationThroughExistingHostAsync( + LanMountainDesktopIpcClient ipcClient, + StartupSuccessTracker startupSuccessTracker, + TimeSpan timeout) + { + var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false); + if (activation is null) + { + return null; + } + + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) + { + return shellSuccess; + } + + if (activation.Accepted) + { + return startupSuccessTracker.BuildRecoverySuccessState(); + } + + return IsRecoverableActivationFailure(activation) + ? new StartupSuccessState( + StartupStage.Ready, + "startup_pending", + activation.Message) + : null; + } + private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation) { if (activation.Accepted) @@ -1511,10 +1565,10 @@ internal sealed class LauncherFlowCoordinator try { var shellProxy = ipcClient.CreateProxy(); - var activationAccepted = await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false); - if (!activationAccepted) + var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess)) { - return null; + return shellSuccess; } var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); @@ -1523,7 +1577,7 @@ internal sealed class LauncherFlowCoordinator return await successTask.ConfigureAwait(false); } - if (!hostProcess.HasExited) + if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation))) { return startupSuccessTracker.BuildRecoverySuccessState(); } @@ -1642,18 +1696,48 @@ internal sealed class LauncherFlowCoordinator Process? Process, bool ExitedEarly, int? ExitCode, - string? FailureReason) + string? FailureReason, + string? PackageRoot, + string? WorkingDirectory, + string? Arguments) { public int? ProcessId => Process?.Id; - public static HostStartAttempt Started(HostStartMode startMode, Process process) => - new(startMode, true, process, false, null, null); + public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) => + new( + startMode, + true, + process, + false, + null, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); - public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode) => - new(startMode, true, process, true, exitCode, null); + public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) => + new( + startMode, + true, + process, + true, + exitCode, + null, + plan.PackageRoot, + plan.WorkingDirectory, + HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); - public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason) => - new(startMode, false, null, false, null, failureReason); + public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) => + new( + startMode, + false, + null, + false, + null, + failureReason, + plan?.PackageRoot, + plan?.WorkingDirectory, + plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)); } private sealed record HostLaunchOutcome( @@ -1715,6 +1799,13 @@ internal sealed class LauncherFlowCoordinator : "Desktop recovered in a visible state."); return true; + case StartupStage.Ready: + successState = new StartupSuccessState( + stage, + _policy == LaunchSuccessPolicy.Foreground ? "ready" : "background_ready", + "Desktop reported that startup is ready."); + return true; + case StartupStage.TrayReady: _trayReady = true; break; @@ -1746,6 +1837,26 @@ internal sealed class LauncherFlowCoordinator return false; } + public bool TryResolve(PublicShellStatus? status, out StartupSuccessState successState) + { + if (status is not null && + (status.DesktopVisible || status.MainWindowVisible || status.MainWindowOpened)) + { + successState = new StartupSuccessState( + status.DesktopVisible || status.MainWindowVisible + ? StartupStage.DesktopVisible + : StartupStage.Ready, + _policy == LaunchSuccessPolicy.Foreground ? "ok" : "background_ready", + status.DesktopVisible || status.MainWindowVisible + ? "Desktop shell is visible and ready." + : "Desktop shell window has opened."); + return true; + } + + successState = default!; + return false; + } + public StartupSuccessState BuildRecoverySuccessState() { return _policy switch diff --git a/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs b/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs new file mode 100644 index 0000000..38a4ae1 --- /dev/null +++ b/LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs @@ -0,0 +1,100 @@ +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.Contracts.Launcher; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class HostLaunchPlanBuilderTests : IDisposable +{ + private readonly string _testRoot; + + public HostLaunchPlanBuilderTests() + { + _testRoot = Path.Combine( + Path.GetTempPath(), + "LanMountainDesktop.HostLaunchPlanTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testRoot); + } + + [Fact] + public void Build_UsesPackageRootAsWorkingDirectory_ForPublishedDeployment() + { + var packageRoot = Path.Combine(_testRoot, "package-root"); + var deployment = CreateDeployment(packageRoot, "app-0.8.5.7"); + var resultPath = Path.Combine(_testRoot, "launcher-result.json"); + var context = CommandContext.FromArgs( + [ + "launch", + "--app-root", packageRoot, + "--result", resultPath, + "--launch-source", "postinstall", + "--custom-host-arg", "custom-value" + ]); + var locator = new DeploymentLocator(packageRoot); + var resolution = locator.ResolveHostExecutable(context); + + var plan = HostLaunchPlanBuilder.Build(context, locator, resolution); + + Assert.Equal(Path.GetFullPath(packageRoot), plan.PackageRoot); + Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory); + Assert.Equal(Path.Combine(deployment, GetExecutableName()), plan.HostPath); + Assert.Contains("--launch-source", plan.Arguments); + Assert.Contains("postinstall", plan.Arguments); + Assert.Contains("--custom-host-arg", plan.Arguments); + Assert.Contains("custom-value", plan.Arguments); + Assert.DoesNotContain("--app-root", plan.Arguments); + Assert.DoesNotContain(packageRoot, plan.Arguments); + Assert.DoesNotContain("--result", plan.Arguments); + Assert.DoesNotContain(resultPath, plan.Arguments); + Assert.Contains($"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}", plan.Arguments); + } + + [Fact] + public void Build_KeepsPathsWithSpacesAsSingleArgumentListTokens() + { + var packageRoot = Path.Combine(_testRoot, "package root with spaces"); + CreateDeployment(packageRoot, "app-0.8.5.7"); + var context = CommandContext.FromArgs(["launch", "--app-root", packageRoot]); + var locator = new DeploymentLocator(packageRoot); + var resolution = locator.ResolveHostExecutable(context); + + var plan = HostLaunchPlanBuilder.Build(context, locator, resolution); + + var packageRootArgument = $"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}"; + Assert.Contains(packageRootArgument, plan.Arguments); + Assert.Equal(Path.GetFullPath(packageRoot), plan.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar]); + Assert.DoesNotContain(plan.Arguments, argument => argument.StartsWith("\"", StringComparison.Ordinal)); + Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory); + } + + private static string CreateDeployment(string packageRoot, string deploymentName) + { + var deployment = Path.Combine(packageRoot, deploymentName); + Directory.CreateDirectory(deployment); + File.WriteAllText(Path.Combine(deployment, GetExecutableName()), string.Empty); + File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty); + File.WriteAllText( + Path.Combine(deployment, "version.json"), + """ + {"Version":"0.8.5.7","Codename":"Administrate"} + """); + return deployment; + } + + private static string GetExecutableName() + { + return OperatingSystem.IsWindows() + ? "LanMountainDesktop.exe" + : "LanMountainDesktop"; + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } +} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 574ecec..766011e 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -85,6 +85,7 @@ public partial class App : Application private LoadingStateReporter? _loadingStateReporter; private bool _singleInstanceReleased; private int _forcedExitScheduled; + private volatile bool _desktopShellInitializationStarted; private bool _mainWindowOpened; private bool _trayInitialized; private readonly object _launcherProgressLock = new(); @@ -184,6 +185,7 @@ public partial class App : Application RegisterUiUnhandledExceptionGuard(); LinuxDesktopEntryInstaller.EnsureInstalled(); InitializePublicIpc(); + CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow); _ = InitializeLauncherIpcAsync(); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); @@ -324,6 +326,7 @@ public partial class App : Application private void InitializeDesktopShell() { + _desktopShellInitializationStarted = true; _desktopShellHost ??= new DesktopShellHost( InitializePluginRuntime, InitializeTrayIcon, @@ -801,10 +804,16 @@ public partial class App : Application Resources["AppFontFamily"] = fontFamily; } - private void ActivateMainWindow() + internal void ActivateMainWindow() { AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}."); + if (!_desktopShellInitializationStarted && _mainWindow is null) + { + AppLogger.Info("SingleInstance", "Activation acknowledged while desktop shell is still initializing."); + return; + } + try { var restored = Dispatcher.UIThread.CheckAccess() @@ -815,7 +824,8 @@ public partial class App : Application if (!restored) { - throw new InvalidOperationException("Main window restore failed in activation callback."); + AppLogger.Warn("SingleInstance", "Activation callback could not restore the main window yet."); + return; } AppLogger.Info("SingleInstance", "Activation callback completed successfully."); @@ -823,7 +833,6 @@ public partial class App : Application catch (Exception ex) { AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex); - throw; } } @@ -1758,6 +1767,15 @@ public partial class App : Application internal PublicShellActivationResult TryActivateMainWindowWithStatusFromExternalIpc(string source) { + if (!_desktopShellInitializationStarted && _mainWindow is null) + { + return new PublicShellActivationResult( + false, + "startup_pending", + "Desktop process is running, but the shell has not started yet.", + GetPublicShellStatus()); + } + var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source); var status = GetPublicShellStatus(); if (restored) @@ -1770,12 +1788,17 @@ public partial class App : Application 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."; + var code = status.PublicIpcReady && (!status.MainWindowCreated || !status.MainWindowOpened) + ? "startup_pending" + : status.PublicIpcReady && !status.DesktopVisible + ? "shell_not_ready" + : "activation_failed"; + var message = code switch + { + "startup_pending" => "Desktop process is running, but the shell is still creating the main window.", + "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); } diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 826ef12..66b5be6 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -77,6 +77,16 @@ public sealed class Program StartupRenderMode = renderMode; AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); App.CurrentSingleInstanceService = singleInstance; + singleInstance.StartActivationListener(() => + { + if (Avalonia.Application.Current is App app) + { + app.ActivateMainWindow(); + return; + } + + AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready."); + }); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); AppLogger.Info("Startup", "Application exited normally."); } diff --git a/LanMountainDesktop/Services/SingleInstanceService.cs b/LanMountainDesktop/Services/SingleInstanceService.cs index 48c6913..8ae504f 100644 --- a/LanMountainDesktop/Services/SingleInstanceService.cs +++ b/LanMountainDesktop/Services/SingleInstanceService.cs @@ -16,6 +16,7 @@ public sealed class SingleInstanceService : IDisposable private readonly Mutex _mutex; private readonly string _pipeName; private readonly CancellationTokenSource _listenCts = new(); + private readonly ManualResetEventSlim _listenerReady = new(false); private bool _ownsMutex; private bool _disposed; private Task? _listenTask; @@ -64,6 +65,7 @@ public sealed class SingleInstanceService : IDisposable "SingleInstance", $"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}."); _listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token)); + _listenerReady.Wait(TimeSpan.FromMilliseconds(500)); } public bool TryNotifyPrimaryInstance(TimeSpan timeout) @@ -142,6 +144,7 @@ public sealed class SingleInstanceService : IDisposable } _listenCts.Dispose(); + _listenerReady.Dispose(); if (_ownsMutex) { try @@ -170,6 +173,7 @@ public sealed class SingleInstanceService : IDisposable PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + _listenerReady.Set(); await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); var buffer = new byte[1]; var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);