Files
LanMountainDesktop/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs

2034 lines
88 KiB
C#

using System.Diagnostics;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
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 string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine;
private readonly PluginInstallerService _pluginInstallerService;
private readonly StartupAttemptRegistry _startupAttemptRegistry;
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
private readonly DataLocationResolver _dataLocationResolver;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
public LauncherFlowCoordinator(
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstallerService,
StartupAttemptRegistry? startupAttemptRegistry = null,
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_pluginInstallerService = pluginInstallerService;
_startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry();
_coordinatorIpcServer = coordinatorIpcServer;
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
_oobeSteps =
[
new WelcomeOobeStep(_oobeStateService, _context),
new DataLocationOobeStep(_dataLocationResolver)
];
}
public static string ResolveSuccessPolicyKey(CommandContext context)
{
return new StartupSuccessTracker(context).PolicyKey;
}
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
{
try
{
_deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
var oobeDecision = _oobeStateService.Evaluate(_context);
var launcherContextDetails = BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot());
if (oobeDecision.ShouldShowOobe)
{
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
if (legacyInfo is not null)
{
var migrationResult = await ShowMigrationPromptAsync(legacyInfo).ConfigureAwait(false);
Logger.Info($"Migration prompt completed. Result='{migrationResult}'.");
}
}
var splashWindow = existingSplashWindow ?? await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
var windowsClosingByCoordinator = false;
var versionInfo = _deploymentLocator.GetVersionInfo();
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
var reporter = (ISplashStageReporter)splashWindow;
LoadingDetailsWindow? loadingDetailsWindow = null;
if (_context.IsDebugMode || _context.GetOption("show-loading-details") == "true")
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
loadingDetailsWindow = new LoadingDetailsWindow();
loadingDetailsWindow.Show();
});
}
var successTcs = new TaskCompletionSource<StartupSuccessState>(TaskCreationOptions.RunContinuationsAsynchronously);
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
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;
PublicShellStatus? shellStatus = null;
void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false)
{
if (_coordinatorIpcServer is null)
{
return;
}
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt;
var hostPid = trackedAttempt?.HostPid ?? 0;
var hostProcessAlive = hostProcessAliveOverride ??
(hostPid > 0 && TryGetLiveProcess(hostPid, out _));
var status = new LauncherCoordinatorStatus
{
AttemptId = trackedAttempt?.AttemptId ?? string.Empty,
CoordinatorPid = Environment.ProcessId,
HostPid = hostPid,
HostProcessAlive = hostProcessAlive,
LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource,
SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey,
LastObservedStage = lastStage,
LastObservedMessage = lastStageMessage,
PublicIpcConnected = ipcConnected,
State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(),
SoftTimeoutShown = softTimeoutShown,
Completed = completed,
Succeeded = succeeded,
ShellStatus = shellStatus,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
_coordinatorIpcServer.UpdateStatus(status);
_startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status);
}
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
PublishCoordinatorStatus();
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<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
ipcConnected = true;
lastStage = message.Stage;
lastStageMessage = message.Message ?? message.Stage.ToString();
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
loadingState = loadingState with
{
Stage = message.Stage,
OverallProgressPercent = message.ProgressPercent,
Message = message.Message,
Timestamp = DateTimeOffset.UtcNow
};
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
loadingDetailsWindow?.UpdateLoadingState(loadingState);
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
PublishCoordinatorStatus();
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
{
successTcs.TrySetResult(successState);
}
if (message.Stage == StartupStage.ActivationFailed)
{
activationFailureReason = message.Message ?? "activation_failed";
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
}
}
catch (Exception ex)
{
Logger.Error("IPC progress callback failed.", ex);
}
});
});
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
loadingState = message;
loadingDetailsWindow?.UpdateLoadingState(loadingState);
}
catch (Exception ex)
{
Logger.Error("IPC loading-state callback failed.", ex);
}
});
});
try
{
if (ShouldProbeExistingHostBeforeLaunch(_context))
{
var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
.ConfigureAwait(false);
if (IsExistingHostReadyForLauncherDecision(existingShellStatus))
{
ipcConnected = true;
shellStatus = existingShellStatus;
var decisionResult = await ApplyExistingHostBehaviorAsync(
ipcClient,
multiInstanceBehavior,
existingShellStatus!)
.ConfigureAwait(false);
shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
IsRecoverableActivationFailure(decisionResult.ActivationResult);
lastStage = decisionResult.Success || recoverableActivationFailure
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
lastStageMessage = decisionResult.Message;
if (decisionResult.Success || recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
}
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: decisionResult.Success,
stage: "launch",
code: decisionResult.Code,
message: decisionResult.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["publicIpcConnected"] = "true",
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
}));
}
}
reporter.Report("update", "Checking updates...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
reporter.Report("update", "Update failed, launching existing version...");
// Clean up corrupted update files to prevent repeated failures
try
{
_updateEngine.CleanupIncomingArtifacts();
}
catch (Exception ex)
{
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
}
// Continue to launch existing version instead of aborting
}
reporter.Report("plugins", "Applying plugin upgrades...");
var pluginsDir = _context.GetOption("plugins-dir") ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
{
Logger.Warn($"Plugin upgrade failed, continuing startup. Error='{queueResult.Message}'.");
reporter.Report("plugins", "Plugin upgrade failed, continuing...");
}
if (oobeDecision.ShouldShowOobe)
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
}
reporter.Report("launch", "Launching desktop...");
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);
PublishCoordinatorStatus(hostProcessAliveOverride: true);
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);
}
if (launchOutcome.ImmediateResult is not null)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(launchOutcome.ImmediateResult, launcherContextDetails);
}
if (launchOutcome.Process is null)
{
return BuildResult(
success: false,
stage: "launch",
code: "host_start_failed",
message: "Host launch did not create a process.",
details: MergeDetails(
launcherContextDetails,
MergeDetails(
launchOutcome.Details,
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive: false,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted: false))));
}
if (!attachedToExistingAttempt)
{
var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
? _startupAttemptRegistry.AssignOwnedHostProcess(
launchOutcome.Process.Id,
lastStage,
lastStageMessage)
: _startupAttemptRegistry.StartOwnedAttempt(
launchOutcome.Process.Id,
_context.LaunchSource,
startupSuccessTracker.PolicyKey,
lastStage,
lastStageMessage);
PublishCoordinatorStatus(hostProcessAliveOverride: true);
}
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
{
return MergeDetails(
launcherContextDetails,
MergeDetails(
launchOutcome.Details,
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted)));
}
async Task<StartupSuccessState?> 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.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
}
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(2);
var nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1);
var activationRetryAttempted = false;
while (true)
{
if (successTcs.Task.IsCompleted)
{
var successState = await successTcs.Task.ConfigureAwait(false);
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: successState.Code,
message: successState.Message,
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
}
if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
{
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));
}
Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
}
if (processExitTask.IsCompleted)
{
var exitCode = launchOutcome.Process.ExitCode;
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
windowsClosingByCoordinator = true;
if (IsSuccessfulActivationExitCode(exitCode))
{
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
}
if (!activationRetryAttempted &&
IsFailedActivationExitCode(exitCode))
{
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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
}
Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: false,
stage: "launch",
code: IsFailedActivationExitCode(exitCode)
? "activation_failed"
: "host_exited_early",
message: IsFailedActivationExitCode(exitCode)
? $"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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
}
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)
{
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
if (connected)
{
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
successTcs.TrySetResult(shellSuccess);
continue;
}
}
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
}
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);
PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited);
}
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);
}
var recoveryActivationAttempted = false;
if (!connected && !launchOutcome.Process.HasExited)
{
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
if (connected)
{
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
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));
}
}
}
if (connected && !launchOutcome.Process.HasExited)
{
recoveryActivationAttempted = true;
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
ipcClient,
launchOutcome.Process,
successTcs.Task,
startupSuccessTracker).ConfigureAwait(false);
if (recoveryOutcome is not null)
{
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: recoveryOutcome.Code,
message: recoveryOutcome.Message,
details: ComposeLaunchDetails(
!launchOutcome.Process.HasExited,
recoveryActivationAttempted: true));
}
}
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);
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 public IPC finishes startup.",
details: ComposeLaunchDetails(
hostProcessAlive: true,
recoveryActivationAttempted));
}
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
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 {StartupHardTimeout.TotalSeconds:0} seconds.",
details: ComposeLaunchDetails(
!launchOutcome.Process.HasExited,
recoveryActivationAttempted));
}
finally
{
if (splashClosedHandler is not null)
{
splashWindow.Closed -= splashClosedHandler;
}
if (!windowsClosingByCoordinator)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
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("Launcher coordinator failed.", ex);
return BuildResult(
success: false,
stage: "launch",
code: "exception",
message: ex.Message,
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
errorMessage: ex.ToString());
}
}
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
try
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
}
catch (Exception ex)
{
Logger.Error("Failed to dismiss splash window.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
{
loadingDetailsWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close loading details window.", ex);
}
});
}
private async Task<HostLaunchOutcome> LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null)
{
var resolution = _deploymentLocator.ResolveHostExecutable(_context);
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
{
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false);
if (errorResult == ErrorWindowResult.Retry)
{
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
{
return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
}
return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false);
}
return HostLaunchOutcome.FromResult(BuildResult(
success: false,
stage: "launchHost",
code: "host_not_found",
message: "LanMountainDesktop host executable was not found.",
details: BuildResolutionDetails(resolution, null, null, "resolve")));
}
return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false);
}
private Task<HostLaunchOutcome> LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag)
{
var resolution = new HostResolutionResult
{
Success = true,
ResolvedHostPath = Path.GetFullPath(hostPath),
ResolutionSource = "user_selected_path",
AppRoot = _deploymentLocator.GetAppRoot(),
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
SearchedPaths = [Path.GetFullPath(hostPath)]
};
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
}
internal static LauncherResult? ValidateDotNetRuntimePrerequisite(
HostLaunchPlan plan,
HostResolutionResult resolution,
DotNetRuntimeProbeOptions? probeOptions = null)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(resolution);
if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath))
{
return null;
}
var runtime = DotNetRuntimeProbe.Probe(probeOptions);
Logger.Info(
$"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " +
$"Architecture={runtime.Architecture}; Message='{runtime.Message}'.");
if (runtime.IsAvailable)
{
return null;
}
var details = BuildResolutionDetails(resolution, null, null, "runtime");
foreach (var pair in runtime.ToDetails())
{
details[pair.Key] = pair.Value;
}
return BuildResult(
success: false,
stage: "launchHost",
code: "dotnet_runtime_missing",
message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.",
details: details,
errorMessage: runtime.Message);
}
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
HostResolutionResult resolution,
bool forceDirectMode,
string? retryTag)
{
var dataRoot = _dataLocationResolver.ResolveDataRoot();
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution);
if (prerequisiteFailure is not null)
{
return HostLaunchOutcome.FromResult(prerequisiteFailure);
}
var hostPath = plan.HostPath;
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
EnsureExecutable(hostPath);
}
var primaryMode = HostStartMode.Direct;
var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
? HostStartMode.ShellExecute
: (HostStartMode?)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(
firstAttempt.Process,
BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails),
firstDetails);
}
if (fallbackMode is null)
{
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
}
Logger.Warn(
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
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(
secondAttempt.Process,
BuildResult(true, "launchHost", "ok", "Host launched.", details),
details);
}
return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt);
}
private static HostLaunchOutcome BuildOutcomeFromAttempt(
HostResolutionResult resolution,
HostStartAttempt finalAttempt,
HostStartAttempt? previousAttempt)
{
var details = BuildResolutionDetails(
resolution,
previousAttempt ?? finalAttempt,
previousAttempt is null ? null : finalAttempt,
!finalAttempt.ProcessCreated
? "start"
: finalAttempt.ExitCode is int finalExitCode && IsFailedActivationExitCode(finalExitCode)
? "activation"
: "early-exit");
if (!finalAttempt.ProcessCreated)
{
return HostLaunchOutcome.FromResult(BuildResult(
false,
"launchHost",
"host_start_failed",
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
details));
}
if (finalAttempt.ExitCode is not null && IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromImmediateResult(BuildResult(
true,
"launch",
"activation_redirected",
"Launcher activation was redirected to the existing desktop instance.",
details));
}
if (finalAttempt.ExitCode is not null && IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromResult(BuildResult(
false,
"launch",
"activation_failed",
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
details));
}
return HostLaunchOutcome.FromResult(BuildResult(
false,
"launchHost",
"host_exited_early",
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
details));
}
private async Task<HostStartAttempt> StartHostProcessAsync(
HostLaunchPlan plan,
HostStartMode startMode,
string? retryTag)
{
var startInfo = new ProcessStartInfo
{
FileName = plan.HostPath,
WorkingDirectory = plan.WorkingDirectory,
UseShellExecute = startMode == HostStartMode.ShellExecute
};
if (startMode == HostStartMode.Direct)
{
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 ?? "<none>"}'; 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", plan);
}
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, plan);
}
}
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
ErrorWindow? errorWindow = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
errorWindow.ConfigureForHostNotFound();
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
errorWindow.Show();
Logger.Warn("Host not found. Showing error window.");
}
catch (Exception ex)
{
Logger.Error("Failed to show host-not-found error window.", ex);
}
});
if (errorWindow is null)
{
return (ErrorWindowResult.Exit, null);
}
ErrorWindowResult result;
string? customPath;
try
{
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
customPath = errorWindow.GetCustomHostPath();
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
}
catch (Exception ex)
{
Logger.Error("Failed while waiting for host-not-found window result.", ex);
result = ErrorWindowResult.Exit;
customPath = null;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (errorWindow.IsVisible && errorWindow.IsLoaded)
{
errorWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close host-not-found error window.", ex);
}
});
return (result, customPath);
}
private async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
{
MigrationPromptWindow? migrationWindow = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
migrationWindow = new MigrationPromptWindow();
migrationWindow.SetLegacyInfo(legacyInfo);
migrationWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show migration prompt window.", ex);
}
});
if (migrationWindow is null)
{
return MigrationResult.Skipped;
}
MigrationResult result;
try
{
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failed while waiting for migration prompt result.", ex);
result = MigrationResult.Skipped;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
{
migrationWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close migration prompt window.", ex);
}
});
return result;
}
private static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
{
StartupStage.Initializing => "initializing",
StartupStage.LoadingSettings => "settings",
StartupStage.LoadingPlugins => "plugins",
StartupStage.TrayReady => "shell",
StartupStage.InitializingUI => "ui",
StartupStage.ShellInitialized => "shell",
StartupStage.BackgroundReady => "ready",
StartupStage.DesktopVisible => "ready",
StartupStage.ActivationRedirected => "activation",
StartupStage.ActivationFailed => "error",
StartupStage.Ready => "ready",
_ => "launch"
};
private static LauncherResult BuildResult(
bool success,
string stage,
string code,
string message,
Dictionary<string, string>? details = null,
string? errorMessage = null)
{
Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'.");
return new LauncherResult
{
Success = success,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = errorMessage,
Details = details ?? []
};
}
private static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details)
{
return new LauncherResult
{
Success = result.Success,
Stage = result.Stage,
Code = result.Code,
Message = result.Message,
CurrentVersion = result.CurrentVersion,
TargetVersion = result.TargetVersion,
RolledBackTo = result.RolledBackTo,
Details = MergeDetails(details, result.Details),
InstalledPackagePath = result.InstalledPackagePath,
ManifestId = result.ManifestId,
ManifestName = result.ManifestName,
ErrorMessage = result.ErrorMessage
};
}
private static Dictionary<string, string> BuildLauncherContextDetails(
CommandContext context,
OobeLaunchDecision oobeDecision,
string appRoot)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource,
["isGuiMode"] = context.IsGuiCommand.ToString(),
["isDebugMode"] = context.IsDebugMode.ToString(),
["isElevated"] = oobeDecision.IsElevated.ToString(),
["resolvedAppRoot"] = appRoot,
["oobeStatePath"] = oobeDecision.StatePath,
["oobeStateStatus"] = oobeDecision.Status.ToString(),
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
["oobeResultCode"] = oobeDecision.ResultCode,
["userSid"] = oobeDecision.UserSid ?? string.Empty,
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
["oobeStateError"] = oobeDecision.ErrorMessage
};
}
private static Dictionary<string, string> BuildResolutionDetails(
HostResolutionResult resolution,
HostStartAttempt? firstAttempt,
HostStartAttempt? secondAttempt,
string? failureStage)
{
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["resolvedAppRoot"] = resolution.AppRoot,
["explicitAppRoot"] = resolution.ExplicitAppRoot ?? string.Empty,
["resolvedHostPath"] = resolution.ResolvedHostPath ?? string.Empty,
["resolutionSource"] = resolution.ResolutionSource ?? string.Empty,
["devModeConfigIgnored"] = resolution.DevModeConfigIgnored.ToString(),
["searchedPaths"] = string.Join(" | ", resolution.SearchedPaths),
["failureStage"] = failureStage ?? string.Empty
};
if (firstAttempt is not null)
{
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;
}
if (secondAttempt is not null)
{
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;
}
return details;
}
private static Dictionary<string, string> MergeDetails(
Dictionary<string, string> left,
Dictionary<string, string> right)
{
var merged = new Dictionary<string, string>(left, StringComparer.OrdinalIgnoreCase);
foreach (var pair in right)
{
merged[pair.Key] = pair.Value;
}
return merged;
}
private static void EnsureExecutable(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
var mode = File.GetUnixFileMode(path);
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
File.SetUnixFileMode(path, mode);
}
catch
{
}
}
private static async Task<bool> TryConnectToPublicIpcAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
if (ipcClient.IsConnected)
{
return true;
}
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.Info($"Public IPC is not ready yet: {ex.Message}");
return false;
}
}
internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
{
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (context.IsPreviewCommand || context.IsMaintenanceCommand)
{
return false;
}
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
}
private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
{
try
{
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
}
catch (Exception ex)
{
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status)
{
return status is { PublicIpcReady: true, ProcessId: > 0 };
}
private static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
try
{
var connected = ipcClient.IsConnected ||
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
if (!connected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
return null;
}
}
private static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
LanMountainDesktopIpcClient ipcClient,
MultiInstanceLaunchBehavior behavior,
PublicShellStatus status)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return behavior switch
{
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated",
successMessage: "Launcher activated the existing desktop instance.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
shellProxy,
status).ConfigureAwait(false),
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
_ => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
};
}
catch (Exception ex)
{
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
return new ExistingHostBehaviorResult(
false,
"multi_instance_behavior_failed",
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
null);
}
}
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
IPublicShellControlService shellProxy,
bool showLauncherNotice,
string successCode,
string successMessage,
string failureCode)
{
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation);
if (showLauncherNotice && success)
{
var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
}
}
return new ExistingHostBehaviorResult(
success,
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
activation.Accepted ? successMessage : activation.Message,
activation);
}
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
IPublicShellControlService shellProxy)
{
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
return new ExistingHostBehaviorResult(
accepted,
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
accepted
? "Launcher requested the existing desktop instance to restart."
: "Launcher could not request restart from the existing desktop instance.",
null);
}
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
IPublicShellControlService shellProxy,
PublicShellStatus status)
{
var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
return await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated_from_prompt",
successMessage: "Launcher activated the existing desktop instance from the prompt.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
}
return new ExistingHostBehaviorResult(
true,
"existing_host_prompt_only",
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
null);
}
private static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
{
var prompt = new MultiInstancePromptWindow();
prompt.SetDetails(status.ProcessId, status.ShellState);
prompt.Show();
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
});
}
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
try
{
var connected = ipcClient.IsConnected ||
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
if (!connected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
return null;
}
}
private static async Task<StartupSuccessState?> 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;
}
internal 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));
}
internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
exitCode == HostExitCodes.SecondaryActivationSucceeded;
internal static bool IsFailedActivationExitCode(int exitCode) =>
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to query public shell status: {ex.Message}");
return null;
}
}
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
LanMountainDesktopIpcClient ipcClient,
Process hostProcess,
Task<StartupSuccessState> successTask,
StartupSuccessTracker startupSuccessTracker)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
{
return shellSuccess;
}
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
if (completedTask == successTask)
{
return await successTask.ConfigureAwait(false);
}
if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation)))
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
}
catch (Exception ex)
{
Logger.Warn($"Public activation recovery failed: {ex.Message}");
}
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<string, string> BuildAttemptDetails(
StartupAttemptRecord? trackedAttempt,
bool attachedToExistingAttempt,
bool ipcConnected,
bool hostProcessAlive,
StartupStage lastStage,
string lastStageMessage,
string? activationFailureReason,
bool softTimeoutShown,
bool recoveryActivationAttempted)
{
var details = new Dictionary<string, string>(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["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
details["successPolicy"] = trackedAttempt.SuccessPolicy;
details["hostPid"] = trackedAttempt.HostPid.ToString();
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
details["shellStatus"] = trackedAttempt.ShellStatus;
}
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,
Direct
}
private sealed record HostStartAttempt(
HostStartMode StartMode,
bool ProcessCreated,
Process? Process,
bool ExitedEarly,
int? ExitCode,
string? FailureReason,
string? PackageRoot,
string? WorkingDirectory,
string? Arguments)
{
public int? ProcessId => Process?.Id;
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, 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, 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 ExistingHostBehaviorResult(
bool Success,
string Code,
string Message,
PublicShellActivationResult? ActivationResult);
private sealed record HostLaunchOutcome(
LauncherResult Result,
Process? Process,
LauncherResult? ImmediateResult,
Dictionary<string, string> Details)
{
public static HostLaunchOutcome FromResult(LauncherResult result) =>
new(result, null, result.Success ? result : null, result.Details);
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
new(result, null, result, result.Details);
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
new(result, process, null, details);
}
private sealed class StartupSuccessTracker
{
private readonly LaunchSuccessPolicy _policy;
private bool _trayReady;
private bool _backgroundReady;
public string PolicyKey => _policy.ToString();
public StartupSuccessTracker(CommandContext context)
{
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
_policy = !isRestartLaunch
? LaunchSuccessPolicy.Foreground
: restartPresentation switch
{
RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray,
RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground,
_ => LaunchSuccessPolicy.Foreground
};
}
public bool TryResolve(StartupStage stage, out StartupSuccessState successState)
{
switch (stage)
{
case StartupStage.ActivationRedirected:
successState = new StartupSuccessState(
stage,
"activation_redirected",
"Launcher activation was redirected to the existing desktop instance.");
return true;
case StartupStage.DesktopVisible:
successState = new StartupSuccessState(
stage,
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback",
_policy == LaunchSuccessPolicy.Foreground
? "Desktop is visible and ready."
: "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;
case StartupStage.BackgroundReady:
_backgroundReady = true;
break;
}
if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed in the background.");
return true;
}
if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed with tray recovery ready.");
return true;
}
successState = default!;
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
{
LaunchSuccessPolicy.RestartTray => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed tray readiness."),
LaunchSuccessPolicy.RestartBackground => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed readiness."),
_ => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery from the running desktop instance.")
};
}
}
private sealed record StartupSuccessState(
StartupStage Stage,
string Code,
string Message);
private enum LaunchSuccessPolicy
{
Foreground,
RestartBackground,
RestartTray
}
}