mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
162
LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs
Normal file
162
LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal static class ExistingHostProbe
|
||||
{
|
||||
public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(DataLocationResolver dataLocationResolver)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connected = ipcClient.IsConnected ||
|
||||
await PublicIpcConnection.TryConnectAsync(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;
|
||||
}
|
||||
}
|
||||
|
||||
public 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 || HostActivationPolicy.IsRecoverableActivationFailure(activation);
|
||||
if (showLauncherNotice && success)
|
||||
{
|
||||
var promptResult = await LaunchUiPresenter.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 LaunchUiPresenter.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);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ExistingHostBehaviorResult(
|
||||
bool Success,
|
||||
string Code,
|
||||
string Message,
|
||||
PublicShellActivationResult? ActivationResult);
|
||||
77
LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs
Normal file
77
LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal enum HostStartMode
|
||||
{
|
||||
ShellExecute,
|
||||
Direct
|
||||
}
|
||||
|
||||
internal 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));
|
||||
}
|
||||
|
||||
internal 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);
|
||||
}
|
||||
319
LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
Normal file
319
LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class HostLaunchService
|
||||
{
|
||||
public async Task<HostLaunchOutcome> LaunchAsync(LaunchContext context, bool forceDirectMode = false, string? retryTag = null)
|
||||
{
|
||||
var commandContext = context.CommandContext;
|
||||
var deploymentLocator = context.DeploymentLocator;
|
||||
var resolution = deploymentLocator.ResolveHostExecutable(commandContext);
|
||||
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||
{
|
||||
var (errorResult, selectedPath) = await LaunchUiPresenter.ShowHostNotFoundErrorAsync().ConfigureAwait(false);
|
||||
if (errorResult == ErrorWindowResult.Retry)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
|
||||
{
|
||||
return await LaunchWithExplicitPathAsync(context, selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await LaunchAsync(context, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||
success: false,
|
||||
stage: "launchHost",
|
||||
code: "host_not_found",
|
||||
message: "LanMountainDesktop host executable was not found.",
|
||||
details: BuildResolutionDetails(resolution, null, null, "resolve")));
|
||||
}
|
||||
|
||||
return await LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public 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 LaunchResultBuilder.Build(
|
||||
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 static Task<HostLaunchOutcome> LaunchWithExplicitPathAsync(
|
||||
LaunchContext context,
|
||||
string hostPath,
|
||||
bool forceDirectMode,
|
||||
string? retryTag)
|
||||
{
|
||||
var resolution = new HostResolutionResult
|
||||
{
|
||||
Success = true,
|
||||
ResolvedHostPath = Path.GetFullPath(hostPath),
|
||||
ResolutionSource = "user_selected_path",
|
||||
AppRoot = context.DeploymentLocator.GetAppRoot(),
|
||||
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
|
||||
SearchedPaths = [Path.GetFullPath(hostPath)]
|
||||
};
|
||||
|
||||
return LaunchWithResolvedPathAsync(context, resolution, forceDirectMode, retryTag);
|
||||
}
|
||||
|
||||
private static async Task<HostLaunchOutcome> LaunchWithResolvedPathAsync(
|
||||
LaunchContext context,
|
||||
HostResolutionResult resolution,
|
||||
bool forceDirectMode,
|
||||
string? retryTag)
|
||||
{
|
||||
var dataRoot = context.DataLocationResolver.ResolveDataRoot();
|
||||
var plan = HostLaunchPlanBuilder.Build(context.CommandContext, 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,
|
||||
LaunchResultBuilder.Build(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,
|
||||
LaunchResultBuilder.Build(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 && HostActivationPolicy.IsFailedActivationExitCode(finalExitCode)
|
||||
? "activation"
|
||||
: "early-exit");
|
||||
|
||||
if (!finalAttempt.ProcessCreated)
|
||||
{
|
||||
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"launchHost",
|
||||
"host_start_failed",
|
||||
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
|
||||
details));
|
||||
}
|
||||
|
||||
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
|
||||
{
|
||||
return HostLaunchOutcome.FromImmediateResult(LaunchResultBuilder.Build(
|
||||
true,
|
||||
"launch",
|
||||
"activation_redirected",
|
||||
"Launcher activation was redirected to the existing desktop instance.",
|
||||
details));
|
||||
}
|
||||
|
||||
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
|
||||
{
|
||||
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"launch",
|
||||
"activation_failed",
|
||||
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
|
||||
details));
|
||||
}
|
||||
|
||||
return HostLaunchOutcome.FromResult(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"launchHost",
|
||||
"host_exited_early",
|
||||
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
|
||||
details));
|
||||
}
|
||||
|
||||
private static 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);
|
||||
}
|
||||
}
|
||||
|
||||
internal 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 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
49
LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs
Normal file
49
LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal static class LaunchAttemptDetails
|
||||
{
|
||||
public static Dictionary<string, string> Build(
|
||||
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;
|
||||
}
|
||||
}
|
||||
191
LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs
Normal file
191
LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal enum LaunchPhaseStatus
|
||||
{
|
||||
Continue,
|
||||
Completed
|
||||
}
|
||||
|
||||
internal sealed record LaunchPhaseResult(LaunchPhaseStatus Status, LauncherResult? Result = null);
|
||||
|
||||
internal interface ILaunchPhase
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class LaunchContext
|
||||
{
|
||||
public required CommandContext CommandContext { get; init; }
|
||||
public required DeploymentLocator DeploymentLocator { get; init; }
|
||||
public required OobeStateService OobeStateService { get; init; }
|
||||
public required UpdateEngineService UpdateEngine { get; init; }
|
||||
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
||||
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
||||
public required DataLocationResolver DataLocationResolver { get; init; }
|
||||
public required IReadOnlyList<IOobeStep> OobeSteps { get; init; }
|
||||
|
||||
public SplashWindow SplashWindow { get; set; } = null!;
|
||||
public LoadingDetailsWindow? LoadingDetailsWindow { get; set; }
|
||||
public ISplashStageReporter Reporter { get; set; } = null!;
|
||||
public LanMountainDesktopIpcClient IpcClient { get; set; } = null!;
|
||||
public StartupSuccessTracker SuccessTracker { get; set; } = null!;
|
||||
public TaskCompletionSource<StartupSuccessState> SuccessTcs { get; set; } = null!;
|
||||
public TaskCompletionSource<string> ActivationFailedTcs { get; set; } = null!;
|
||||
public LoadingStateMessage LoadingState { get; set; }
|
||||
public Dictionary<string, string> LauncherContextDetails { get; set; } = [];
|
||||
public OobeLaunchDecision OobeDecision { get; set; } = null!;
|
||||
|
||||
public StartupStage LastStage { get; set; } = StartupStage.Initializing;
|
||||
public string LastStageMessage { get; set; } = "launcher-started";
|
||||
public string ActivationFailureReason { get; set; } = string.Empty;
|
||||
public bool IpcConnected { get; set; }
|
||||
public bool SoftTimeoutShown { get; set; }
|
||||
public bool AttachedToExistingAttempt { get; set; }
|
||||
public bool WindowsClosingByOrchestrator { get; set; }
|
||||
public StartupAttemptRecord? TrackedAttempt { get; set; }
|
||||
public PublicShellStatus? ShellStatus { get; set; }
|
||||
public HostLaunchOutcome? LaunchOutcome { get; set; }
|
||||
|
||||
public Action<bool?, bool, bool> PublishCoordinatorStatus { get; set; } = static (_, _, _) => { };
|
||||
public EventHandler? SplashClosedHandler { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class LaunchPipeline
|
||||
{
|
||||
private readonly IReadOnlyList<ILaunchPhase> _phases;
|
||||
|
||||
public LaunchPipeline(IEnumerable<ILaunchPhase> phases)
|
||||
{
|
||||
_phases = phases.ToList();
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var phase in _phases)
|
||||
{
|
||||
Logger.Info($"Launch pipeline entering phase '{phase.Name}'.");
|
||||
var phaseResult = await phase.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
if (phaseResult.Status == LaunchPhaseStatus.Completed)
|
||||
{
|
||||
return phaseResult.Result ?? LaunchResultBuilder.BuildFailure(
|
||||
"launch",
|
||||
"phase_completed_without_result",
|
||||
$"Launch phase '{phase.Name}' completed without a result.");
|
||||
}
|
||||
}
|
||||
|
||||
return LaunchResultBuilder.BuildFailure(
|
||||
"launch",
|
||||
"pipeline_incomplete",
|
||||
"Launch pipeline finished without producing a result.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LaunchResultBuilder
|
||||
{
|
||||
public static LauncherResult Build(
|
||||
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 ?? []
|
||||
};
|
||||
}
|
||||
|
||||
public static LauncherResult BuildFailure(string stage, string code, string message) =>
|
||||
Build(false, stage, code, message);
|
||||
|
||||
public static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details) =>
|
||||
new()
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
public static Dictionary<string, string> BuildLauncherContextDetails(
|
||||
CommandContext context,
|
||||
OobeLaunchDecision oobeDecision,
|
||||
string appRoot) =>
|
||||
new(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
|
||||
};
|
||||
|
||||
public 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;
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs
Normal file
174
LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal static class LaunchUiPresenter
|
||||
{
|
||||
public 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static 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);
|
||||
}
|
||||
|
||||
public static 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;
|
||||
}
|
||||
|
||||
public 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"
|
||||
};
|
||||
|
||||
public 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class ApplyPendingUpdatePhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(ApplyPendingUpdatePhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Reporter.Report("update", "Checking updates...");
|
||||
var updateResult = await context.UpdateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
|
||||
context.Reporter.Report("update", "Update failed, launching existing version...");
|
||||
try
|
||||
{
|
||||
context.UpdateEngine.CleanupIncomingArtifacts();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class CleanupDeploymentsPhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(CleanupDeploymentsPhase);
|
||||
|
||||
public Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DeploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
context.OobeDecision = context.OobeStateService.Evaluate(context.CommandContext);
|
||||
context.LauncherContextDetails = LaunchResultBuilder.BuildLauncherContextDetails(
|
||||
context.CommandContext,
|
||||
context.OobeDecision,
|
||||
context.DeploymentLocator.GetAppRoot());
|
||||
return Task.FromResult(new LaunchPhaseResult(LaunchPhaseStatus.Continue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class ExistingHostProbePhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(ExistingHostProbePhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context.CommandContext))
|
||||
{
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
}
|
||||
|
||||
var multiInstanceBehavior = ExistingHostProbe.LoadMultiInstanceLaunchBehavior(context.DataLocationResolver);
|
||||
var existingShellStatus = await ExistingHostProbe.TryGetExistingHostStatusAsync(
|
||||
context.IpcClient,
|
||||
StartupTimeoutPolicy.ExistingHostProbeTimeout).ConfigureAwait(false);
|
||||
|
||||
if (!HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus))
|
||||
{
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
}
|
||||
|
||||
context.IpcConnected = true;
|
||||
context.ShellStatus = existingShellStatus;
|
||||
var decisionResult = await ExistingHostProbe.ApplyExistingHostBehaviorAsync(
|
||||
context.IpcClient,
|
||||
multiInstanceBehavior,
|
||||
existingShellStatus!).ConfigureAwait(false);
|
||||
context.ShellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
|
||||
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
|
||||
HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult);
|
||||
context.LastStage = decisionResult.Success || recoverableActivationFailure
|
||||
? StartupStage.ActivationRedirected
|
||||
: StartupStage.ActivationFailed;
|
||||
context.LastStageMessage = decisionResult.Message;
|
||||
if (decisionResult.Success || recoverableActivationFailure)
|
||||
{
|
||||
context.StartupAttemptRegistry.MarkOwnedSucceeded(context.LastStage, context.LastStageMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.StartupAttemptRegistry.MarkOwnedFailed(context.LastStage, context.LastStageMessage);
|
||||
}
|
||||
|
||||
context.PublishCoordinatorStatus(true, true, decisionResult.Success);
|
||||
context.WindowsClosingByOrchestrator = true;
|
||||
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.Build(
|
||||
decisionResult.Success,
|
||||
"launch",
|
||||
decisionResult.Code,
|
||||
decisionResult.Message,
|
||||
LaunchResultBuilder.MergeDetails(
|
||||
context.LauncherContextDetails,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["publicIpcConnected"] = "true",
|
||||
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
|
||||
["existingHostPid"] = context.ShellStatus?.ProcessId.ToString() ?? string.Empty,
|
||||
["existingShellState"] = context.ShellStatus?.ShellState ?? string.Empty,
|
||||
["existingTrayState"] = context.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["existingTaskbarUsable"] = context.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
|
||||
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
|
||||
})));
|
||||
}
|
||||
}
|
||||
173
LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs
Normal file
173
LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class LaunchHostPhase : ILaunchPhase
|
||||
{
|
||||
private static readonly string SoftTimeoutStatusMessage = Resources.Strings.Coordinator_SlowDeviceMessage;
|
||||
private static readonly string SoftTimeoutDetailsMessage = Resources.Strings.Coordinator_RunningHostMessage;
|
||||
|
||||
private readonly HostLaunchService _hostLaunchService = new();
|
||||
|
||||
public string Name => nameof(LaunchHostPhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Reporter.Report("launch", "Launching desktop...");
|
||||
var startupSuccessTracker = context.SuccessTracker;
|
||||
var attachableAttempt = context.StartupAttemptRegistry.TryGetAttachableAttempt(
|
||||
context.CommandContext.LaunchSource,
|
||||
startupSuccessTracker.PolicyKey);
|
||||
|
||||
HostLaunchOutcome launchOutcome;
|
||||
if (attachableAttempt is not null &&
|
||||
context.StartupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) &&
|
||||
LaunchResultBuilder.TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess))
|
||||
{
|
||||
context.TrackedAttempt = attachableAttempt;
|
||||
context.AttachedToExistingAttempt = true;
|
||||
context.IpcConnected = attachableAttempt.IpcConnected;
|
||||
context.LastStage = attachableAttempt.LastObservedStage;
|
||||
context.LastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage)
|
||||
? "Attached to the existing startup attempt."
|
||||
: attachableAttempt.LastObservedMessage;
|
||||
context.Reporter.Report(
|
||||
LaunchUiPresenter.MapStartupStageToSplashStage(context.LastStage),
|
||||
context.LastStageMessage);
|
||||
context.PublishCoordinatorStatus(true, false, false);
|
||||
|
||||
if (startupSuccessTracker.TryResolve(context.LastStage, out var attachedSuccessState))
|
||||
{
|
||||
context.WindowsClosingByOrchestrator = true;
|
||||
context.StartupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message);
|
||||
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.Build(
|
||||
true,
|
||||
"launch",
|
||||
attachedSuccessState.Code,
|
||||
attachedSuccessState.Message,
|
||||
LaunchResultBuilder.MergeDetails(
|
||||
context.LauncherContextDetails,
|
||||
LaunchAttemptDetails.Build(
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.IpcConnected,
|
||||
hostProcessAlive: true,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.ActivationFailureReason,
|
||||
softTimeoutShown: false,
|
||||
recoveryActivationAttempted: false))));
|
||||
}
|
||||
|
||||
if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
context.SoftTimeoutShown = true;
|
||||
context.Reporter.Report("delayed", SoftTimeoutStatusMessage);
|
||||
context.LoadingState = HostStartupMonitor.BuildDelayedLoadingState(
|
||||
context.LoadingState,
|
||||
SoftTimeoutStatusMessage,
|
||||
SoftTimeoutDetailsMessage,
|
||||
context.TrackedAttempt!.StartedAtUtc);
|
||||
context.LoadingDetailsWindow?.UpdateLoadingState(context.LoadingState);
|
||||
}
|
||||
|
||||
launchOutcome = HostLaunchOutcome.FromProcess(
|
||||
attachedProcess!,
|
||||
LaunchResultBuilder.Build(
|
||||
true,
|
||||
"launchHost",
|
||||
"attached_attempt",
|
||||
"Attached to an existing startup attempt.",
|
||||
LaunchAttemptDetails.Build(
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.IpcConnected,
|
||||
hostProcessAlive: true,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.ActivationFailureReason,
|
||||
context.SoftTimeoutShown,
|
||||
recoveryActivationAttempted: false)),
|
||||
LaunchAttemptDetails.Build(
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.IpcConnected,
|
||||
hostProcessAlive: true,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.ActivationFailureReason,
|
||||
context.SoftTimeoutShown,
|
||||
recoveryActivationAttempted: false));
|
||||
}
|
||||
else
|
||||
{
|
||||
launchOutcome = await _hostLaunchService.LaunchAsync(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
context.LaunchOutcome = launchOutcome;
|
||||
|
||||
if (!launchOutcome.Result.Success)
|
||||
{
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.WithAdditionalDetails(launchOutcome.Result, context.LauncherContextDetails));
|
||||
}
|
||||
|
||||
if (launchOutcome.ImmediateResult is not null)
|
||||
{
|
||||
context.WindowsClosingByOrchestrator = true;
|
||||
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.WithAdditionalDetails(launchOutcome.ImmediateResult, context.LauncherContextDetails));
|
||||
}
|
||||
|
||||
if (launchOutcome.Process is null)
|
||||
{
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.Build(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: "host_start_failed",
|
||||
message: "Host launch did not create a process.",
|
||||
details: LaunchResultBuilder.MergeDetails(
|
||||
context.LauncherContextDetails,
|
||||
LaunchResultBuilder.MergeDetails(
|
||||
launchOutcome.Details,
|
||||
LaunchAttemptDetails.Build(
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.IpcConnected,
|
||||
hostProcessAlive: false,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.ActivationFailureReason,
|
||||
context.SoftTimeoutShown,
|
||||
recoveryActivationAttempted: false)))));
|
||||
}
|
||||
|
||||
if (!context.AttachedToExistingAttempt)
|
||||
{
|
||||
var reservedAttempt = context.StartupAttemptRegistry.GetOwnedAttempt();
|
||||
context.TrackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
|
||||
? context.StartupAttemptRegistry.AssignOwnedHostProcess(
|
||||
launchOutcome.Process.Id,
|
||||
context.LastStage,
|
||||
context.LastStageMessage)
|
||||
: context.StartupAttemptRegistry.StartOwnedAttempt(
|
||||
launchOutcome.Process.Id,
|
||||
context.CommandContext.LaunchSource,
|
||||
startupSuccessTracker.PolicyKey,
|
||||
context.LastStage,
|
||||
context.LastStageMessage);
|
||||
context.PublishCoordinatorStatus(true, false, false);
|
||||
}
|
||||
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class MonitorStartupPhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(MonitorStartupPhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var launchOutcome = context.LaunchOutcome
|
||||
?? throw new InvalidOperationException("LaunchHostPhase must run before MonitorStartupPhase.");
|
||||
|
||||
if (launchOutcome.Process is null)
|
||||
{
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.BuildFailure("launch", "host_start_failed", "Host process is missing."));
|
||||
}
|
||||
|
||||
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) =>
|
||||
LaunchResultBuilder.MergeDetails(
|
||||
context.LauncherContextDetails,
|
||||
LaunchResultBuilder.MergeDetails(
|
||||
launchOutcome.Details,
|
||||
LaunchAttemptDetails.Build(
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.IpcConnected,
|
||||
hostProcessAlive,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.ActivationFailureReason,
|
||||
context.SoftTimeoutShown,
|
||||
recoveryActivationAttempted)));
|
||||
|
||||
var monitor = new HostStartupMonitor();
|
||||
var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request(
|
||||
launchOutcome.Process,
|
||||
context.IpcClient,
|
||||
context.SuccessTracker,
|
||||
context.StartupAttemptRegistry,
|
||||
context.TrackedAttempt,
|
||||
context.AttachedToExistingAttempt,
|
||||
context.LauncherContextDetails,
|
||||
context.SuccessTcs,
|
||||
context.ActivationFailedTcs,
|
||||
context.Reporter,
|
||||
context.LoadingDetailsWindow,
|
||||
context.LoadingState,
|
||||
context.LastStage,
|
||||
context.LastStageMessage,
|
||||
context.IpcConnected,
|
||||
context.ActivationFailureReason,
|
||||
context.SoftTimeoutShown,
|
||||
context.PublishCoordinatorStatus,
|
||||
ComposeLaunchDetails)).ConfigureAwait(false);
|
||||
|
||||
context.WindowsClosingByOrchestrator = true;
|
||||
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
|
||||
return new LaunchPhaseResult(
|
||||
LaunchPhaseStatus.Completed,
|
||||
LaunchResultBuilder.Build(
|
||||
monitorOutcome.Success,
|
||||
"launch",
|
||||
monitorOutcome.Code,
|
||||
monitorOutcome.Message,
|
||||
monitorOutcome.Details));
|
||||
}
|
||||
}
|
||||
24
LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs
Normal file
24
LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class OobeGatePhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(OobeGatePhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (context.OobeDecision.ShouldShowOobe)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Hide());
|
||||
foreach (var step in context.OobeSteps)
|
||||
{
|
||||
await step.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Show());
|
||||
}
|
||||
|
||||
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user