mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Introduce support for choosing and resolving the application's data root (system user dir vs. portable app folder). Adds DataLocationConfig model, DataLocationResolver (load/save/resolve/migrate), a UI prompt (DataLocationPromptWindow) and an OOBE step (DataLocationOobeStep) to let users pick and optionally migrate existing data. Wire the chosen data root into the launcher flow and host launch plan (forwarded via --data-root and LMD_DATA_ROOT), and add AppDataPathProvider to let runtime services read the effective data root (initialized in Program.Main). Update various services (logging, settings, DB, plugin/market, startup registry, etc.) to use the new provider/resolver and register the config type in the JSON context. This enables portable installs, safe migration, and runtime overrides via CLI or environment variable.
1911 lines
84 KiB
C#
1911 lines
84 KiB
C#
using System.Diagnostics;
|
||
using Avalonia.Threading;
|
||
using LanMountainDesktop.Launcher.Models;
|
||
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(10);
|
||
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30);
|
||
private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
|
||
private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。";
|
||
|
||
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 existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
|
||
.ConfigureAwait(false);
|
||
if (existingActivation is not null)
|
||
{
|
||
ipcConnected = true;
|
||
shellStatus = existingActivation.Status;
|
||
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
|
||
lastStage = existingActivation.Accepted
|
||
? StartupStage.ActivationRedirected
|
||
: StartupStage.ActivationFailed;
|
||
lastStageMessage = existingActivation.Message;
|
||
if (existingActivation.Accepted)
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
||
}
|
||
else if (recoverableActivationFailure)
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
|
||
}
|
||
else
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
||
}
|
||
|
||
PublishCoordinatorStatus(
|
||
hostProcessAliveOverride: true,
|
||
completed: true,
|
||
succeeded: existingActivation.Accepted || recoverableActivationFailure);
|
||
windowsClosingByCoordinator = true;
|
||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||
return BuildResult(
|
||
success: existingActivation.Accepted || recoverableActivationFailure,
|
||
stage: "launch",
|
||
code: existingActivation.Accepted
|
||
? "existing_host_activated"
|
||
: recoverableActivationFailure
|
||
? "existing_host_startup_pending"
|
||
: "existing_host_activation_failed",
|
||
message: recoverableActivationFailure
|
||
? "Existing desktop process is still starting; Launcher will not start another process."
|
||
: existingActivation.Message,
|
||
details: MergeDetails(
|
||
launcherContextDetails,
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["publicIpcConnected"] = "true",
|
||
["existingHostPid"] = existingActivation.Status.ProcessId.ToString(),
|
||
["existingShellState"] = existingActivation.Status.ShellState,
|
||
["existingTrayState"] = existingActivation.Status.Tray.State,
|
||
["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString()
|
||
}));
|
||
}
|
||
}
|
||
|
||
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.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(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));
|
||
}
|
||
|
||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||
if (retryOutcome is not null)
|
||
{
|
||
windowsClosingByCoordinator = true;
|
||
if (retryOutcome.Success)
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
|
||
PublishCoordinatorStatus(
|
||
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
||
completed: true,
|
||
succeeded: true);
|
||
}
|
||
else
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||
PublishCoordinatorStatus(
|
||
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
||
completed: true,
|
||
succeeded: false);
|
||
}
|
||
|
||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||
return WithAdditionalDetails(retryOutcome, ComposeLaunchDetails(!launchOutcome.Process.HasExited, recoveryActivationAttempted: true));
|
||
}
|
||
}
|
||
|
||
if (processExitTask.IsCompleted)
|
||
{
|
||
var exitCode = launchOutcome.Process.ExitCode;
|
||
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
||
|
||
windowsClosingByCoordinator = true;
|
||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
|
||
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 &&
|
||
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<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["exitCode"] = exitCode.ToString()
|
||
}));
|
||
}
|
||
|
||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||
if (retryOutcome is not null)
|
||
{
|
||
if (retryOutcome.Success)
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
|
||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
|
||
}
|
||
else
|
||
{
|
||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||
}
|
||
|
||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||
return WithAdditionalDetails(
|
||
retryOutcome,
|
||
MergeDetails(
|
||
ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["exitCode"] = exitCode.ToString()
|
||
}));
|
||
}
|
||
}
|
||
|
||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||
return BuildResult(
|
||
success: false,
|
||
stage: "launch",
|
||
code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||
? "activation_failed"
|
||
: "host_exited_early",
|
||
message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
|
||
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
||
details: MergeDetails(
|
||
ComposeLaunchDetails(hostProcessAlive: false),
|
||
new Dictionary<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 async Task<LauncherResult?> RetryActivationAfterEarlyFailureAsync()
|
||
{
|
||
Logger.Warn("Attempting one explicit activation retry after host early failure.");
|
||
var retryOutcome = await LaunchHostWithIpcAsync(forceDirectMode: true, retryTag: "explicit-activation-retry").ConfigureAwait(false);
|
||
if (!retryOutcome.Result.Success)
|
||
{
|
||
return retryOutcome.Result;
|
||
}
|
||
|
||
if (retryOutcome.ImmediateResult is not null)
|
||
{
|
||
return retryOutcome.ImmediateResult;
|
||
}
|
||
|
||
if (retryOutcome.Process is not null)
|
||
{
|
||
var retryExitTask = retryOutcome.Process.WaitForExitAsync();
|
||
var completed = await Task.WhenAny(retryExitTask, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
|
||
|
||
if (completed != retryExitTask)
|
||
{
|
||
return BuildResult(
|
||
success: true,
|
||
stage: "launch",
|
||
code: "activation_retry_started",
|
||
message: "Activation retry started the host successfully.",
|
||
details: retryOutcome.Details);
|
||
}
|
||
|
||
if (retryOutcome.Process.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||
{
|
||
return BuildResult(
|
||
success: true,
|
||
stage: "launch",
|
||
code: "activation_redirected",
|
||
message: "Activation retry redirected to the existing desktop instance.",
|
||
details: retryOutcome.Details);
|
||
}
|
||
}
|
||
|
||
return BuildResult(
|
||
success: false,
|
||
stage: "launch",
|
||
code: "activation_failed",
|
||
message: "Activation retry failed to make the desktop visible.",
|
||
details: retryOutcome.Details);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
||
HostResolutionResult resolution,
|
||
bool forceDirectMode,
|
||
string? retryTag)
|
||
{
|
||
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
||
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 HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||
? "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 == HostExitCodes.SecondaryActivationSucceeded)
|
||
{
|
||
return HostLaunchOutcome.FromImmediateResult(BuildResult(
|
||
true,
|
||
"launch",
|
||
"activation_redirected",
|
||
"Launcher activation was redirected to the existing desktop instance.",
|
||
details));
|
||
}
|
||
|
||
if (finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||
{
|
||
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.Warn($"Public IPC connect failed: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private 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 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.Warn($"Existing host activation probe failed: {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;
|
||
}
|
||
|
||
private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
|
||
{
|
||
if (activation.Accepted)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return activation.Status.PublicIpcReady &&
|
||
(!activation.Status.MainWindowOpened ||
|
||
!activation.Status.DesktopVisible ||
|
||
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
||
LanMountainDesktopIpcClient ipcClient)
|
||
{
|
||
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 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
|
||
}
|
||
}
|