mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00: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:
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user