mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
refactor(launcher): add DI, IUpdateEngine facade, and architecture tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,4 +6,3 @@ global using LanMountainDesktop.Launcher.Oobe;
|
|||||||
global using LanMountainDesktop.Launcher.Plugins;
|
global using LanMountainDesktop.Launcher.Plugins;
|
||||||
global using LanMountainDesktop.Launcher.Startup;
|
global using LanMountainDesktop.Launcher.Startup;
|
||||||
global using LanMountainDesktop.Launcher.Update;
|
global using LanMountainDesktop.Launcher.Update;
|
||||||
global using LanMountainDesktop.Launcher.Services;
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ internal static class Commands
|
|||||||
{
|
{
|
||||||
var appRoot = ResolveAppRoot(context);
|
var appRoot = ResolveAppRoot(context);
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
var updateEngine = new UpdateEngineFacade(deploymentLocator);
|
||||||
var pluginInstaller = new PluginInstallerService();
|
var pluginInstaller = new PluginInstallerService();
|
||||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ internal static class Commands
|
|||||||
|
|
||||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
UpdateEngineService updateEngine,
|
UpdateEngineFacade updateEngine,
|
||||||
PluginInstallerService pluginInstaller,
|
PluginInstallerService pluginInstaller,
|
||||||
PluginUpgradeQueueService pluginUpgrades)
|
PluginUpgradeQueueService pluginUpgrades)
|
||||||
{
|
{
|
||||||
@@ -84,7 +84,7 @@ internal static class Commands
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineFacade updateEngine)
|
||||||
{
|
{
|
||||||
return context.SubCommand.ToLowerInvariant() switch
|
return context.SubCommand.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@ internal static class Commands
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineFacade updateEngine)
|
||||||
{
|
{
|
||||||
return await updateEngine.DownloadAsync(
|
return await updateEngine.DownloadAsync(
|
||||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Tmds.DBus.Protocol" />
|
<PackageReference Include="Tmds.DBus.Protocol" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Shell;
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
public static class Program
|
public static class Program
|
||||||
@@ -32,6 +33,7 @@ public static class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
LauncherRuntimeContext.Current = commandContext;
|
LauncherRuntimeContext.Current = commandContext;
|
||||||
|
LauncherServiceRegistration.Initialize(commandContext);
|
||||||
|
|
||||||
var appRoot = Commands.ResolveAppRoot(commandContext);
|
var appRoot = Commands.ResolveAppRoot(commandContext);
|
||||||
var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
|
var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
|
||||||
using LanMountainDesktop.Launcher.Startup;
|
|
||||||
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 partial class LauncherFlowCoordinator
|
|
||||||
{
|
|
||||||
private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
|
|
||||||
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
|
|
||||||
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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>();
|
|
||||||
var status = await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
|
||||||
StartupDiagnostics.TraceShellStatus("existing_host_probe", status);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
|
|
||||||
LanMountainDesktopIpcClient ipcClient,
|
|
||||||
MultiInstanceLaunchBehavior behavior,
|
|
||||||
PublicShellStatus status)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
|
||||||
return behavior switch
|
|
||||||
{
|
|
||||||
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
|
|
||||||
shellProxy,
|
|
||||||
showLauncherNotice: false,
|
|
||||||
successCode: "existing_host_activated",
|
|
||||||
successMessage: "Launcher activated the existing desktop instance.",
|
|
||||||
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
|
||||||
|
|
||||||
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
|
|
||||||
shellProxy,
|
|
||||||
showLauncherNotice: true,
|
|
||||||
successCode: "existing_host_activated_with_notice",
|
|
||||||
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
|
||||||
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
|
|
||||||
|
|
||||||
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
|
|
||||||
shellProxy,
|
|
||||||
status).ConfigureAwait(false),
|
|
||||||
|
|
||||||
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
|
|
||||||
|
|
||||||
_ => await ActivateExistingHostForBehaviorAsync(
|
|
||||||
shellProxy,
|
|
||||||
showLauncherNotice: true,
|
|
||||||
successCode: "existing_host_activated_with_notice",
|
|
||||||
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
|
|
||||||
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
|
|
||||||
return new ExistingHostBehaviorResult(
|
|
||||||
false,
|
|
||||||
"multi_instance_behavior_failed",
|
|
||||||
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
|
|
||||||
IPublicShellControlService shellProxy,
|
|
||||||
bool showLauncherNotice,
|
|
||||||
string successCode,
|
|
||||||
string successMessage,
|
|
||||||
string failureCode)
|
|
||||||
{
|
|
||||||
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
||||||
var success = activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation);
|
|
||||||
if (showLauncherNotice && success)
|
|
||||||
{
|
|
||||||
var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
|
|
||||||
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
|
||||||
{
|
|
||||||
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ExistingHostBehaviorResult(
|
|
||||||
success,
|
|
||||||
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
|
|
||||||
activation.Accepted ? successMessage : activation.Message,
|
|
||||||
activation);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
|
|
||||||
IPublicShellControlService shellProxy)
|
|
||||||
{
|
|
||||||
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
|
|
||||||
return new ExistingHostBehaviorResult(
|
|
||||||
accepted,
|
|
||||||
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
|
|
||||||
accepted
|
|
||||||
? "Launcher requested the existing desktop instance to restart."
|
|
||||||
: "Launcher could not request restart from the existing desktop instance.",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
|
|
||||||
IPublicShellControlService shellProxy,
|
|
||||||
PublicShellStatus status)
|
|
||||||
{
|
|
||||||
var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (promptResult == MultiInstancePromptResult.OpenDesktop)
|
|
||||||
{
|
|
||||||
return await ActivateExistingHostForBehaviorAsync(
|
|
||||||
shellProxy,
|
|
||||||
showLauncherNotice: false,
|
|
||||||
successCode: "existing_host_activated_from_prompt",
|
|
||||||
successMessage: "Launcher activated the existing desktop instance from the prompt.",
|
|
||||||
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ExistingHostBehaviorResult(
|
|
||||||
true,
|
|
||||||
"existing_host_prompt_only",
|
|
||||||
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
|
|
||||||
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.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
|
||||||
LanMountainDesktopIpcClient ipcClient) =>
|
|
||||||
await HostStartupMonitor.TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
|
||||||
|
|
||||||
private static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
|
|
||||||
LanMountainDesktopIpcClient ipcClient,
|
|
||||||
StartupSuccessTracker startupSuccessTracker,
|
|
||||||
TimeSpan timeout) =>
|
|
||||||
await HostStartupMonitor.TryRecoverActivationThroughExistingHostAsync(
|
|
||||||
ipcClient,
|
|
||||||
startupSuccessTracker,
|
|
||||||
timeout).ConfigureAwait(false);
|
|
||||||
|
|
||||||
private static Dictionary<string, string> BuildAttemptDetails(
|
|
||||||
StartupAttemptRecord? trackedAttempt,
|
|
||||||
bool attachedToExistingAttempt,
|
|
||||||
bool ipcConnected,
|
|
||||||
bool hostProcessAlive,
|
|
||||||
StartupStage lastStage,
|
|
||||||
string lastStageMessage,
|
|
||||||
string? activationFailureReason,
|
|
||||||
bool softTimeoutShown,
|
|
||||||
bool recoveryActivationAttempted)
|
|
||||||
{
|
|
||||||
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["hostProcessAlive"] = hostProcessAlive.ToString(),
|
|
||||||
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
|
|
||||||
["ipcConnected"] = ipcConnected.ToString(),
|
|
||||||
["ipcStage"] = lastStage.ToString(),
|
|
||||||
["ipcMessage"] = lastStageMessage,
|
|
||||||
["activationFailureReason"] = activationFailureReason ?? string.Empty,
|
|
||||||
["softTimeoutShown"] = softTimeoutShown.ToString(),
|
|
||||||
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (trackedAttempt is not null)
|
|
||||||
{
|
|
||||||
details["startupAttemptId"] = trackedAttempt.AttemptId;
|
|
||||||
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
|
||||||
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
|
||||||
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
|
||||||
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
|
|
||||||
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
|
||||||
details["hostPid"] = trackedAttempt.HostPid.ToString();
|
|
||||||
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
|
|
||||||
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
|
|
||||||
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
|
|
||||||
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
|
|
||||||
details["shellStatus"] = trackedAttempt.ShellStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryGetLiveProcess(int processId, out Process? process)
|
|
||||||
{
|
|
||||||
process = null;
|
|
||||||
if (processId <= 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
process = Process.GetProcessById(processId);
|
|
||||||
return !process.HasExited;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
process?.Dispose();
|
|
||||||
process = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum HostStartMode
|
|
||||||
{
|
|
||||||
ShellExecute,
|
|
||||||
Direct
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record HostStartAttempt(
|
|
||||||
HostStartMode StartMode,
|
|
||||||
bool ProcessCreated,
|
|
||||||
Process? Process,
|
|
||||||
bool ExitedEarly,
|
|
||||||
int? ExitCode,
|
|
||||||
string? FailureReason,
|
|
||||||
string? PackageRoot,
|
|
||||||
string? WorkingDirectory,
|
|
||||||
string? Arguments)
|
|
||||||
{
|
|
||||||
public int? ProcessId => Process?.Id;
|
|
||||||
|
|
||||||
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
|
|
||||||
new(
|
|
||||||
startMode,
|
|
||||||
true,
|
|
||||||
process,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
plan.PackageRoot,
|
|
||||||
plan.WorkingDirectory,
|
|
||||||
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
||||||
|
|
||||||
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
|
|
||||||
new(
|
|
||||||
startMode,
|
|
||||||
true,
|
|
||||||
process,
|
|
||||||
true,
|
|
||||||
exitCode,
|
|
||||||
null,
|
|
||||||
plan.PackageRoot,
|
|
||||||
plan.WorkingDirectory,
|
|
||||||
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
||||||
|
|
||||||
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
|
|
||||||
new(
|
|
||||||
startMode,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
failureReason,
|
|
||||||
plan?.PackageRoot,
|
|
||||||
plan?.WorkingDirectory,
|
|
||||||
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record ExistingHostBehaviorResult(
|
|
||||||
bool Success,
|
|
||||||
string Code,
|
|
||||||
string Message,
|
|
||||||
PublicShellActivationResult? ActivationResult);
|
|
||||||
|
|
||||||
private sealed record HostLaunchOutcome(
|
|
||||||
LauncherResult Result,
|
|
||||||
Process? Process,
|
|
||||||
LauncherResult? ImmediateResult,
|
|
||||||
Dictionary<string, string> Details)
|
|
||||||
{
|
|
||||||
public static HostLaunchOutcome FromResult(LauncherResult result) =>
|
|
||||||
new(result, null, result.Success ? result : null, result.Details);
|
|
||||||
|
|
||||||
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
|
|
||||||
new(result, null, result, result.Details);
|
|
||||||
|
|
||||||
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
|
|
||||||
new(result, process, null, details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
|
||||||
using LanMountainDesktop.Launcher.Startup;
|
|
||||||
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 partial class LauncherFlowCoordinator
|
|
||||||
{
|
|
||||||
private async Task<HostLaunchOutcome> LaunchHostWithIpcAsync(bool forceDirectMode = false, string? retryTag = null)
|
|
||||||
{
|
|
||||||
var resolution = _deploymentLocator.ResolveHostExecutable(_context);
|
|
||||||
if (!resolution.Success || string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
|
||||||
{
|
|
||||||
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync().ConfigureAwait(false);
|
|
||||||
if (errorResult == ErrorWindowResult.Retry)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(selectedPath) && File.Exists(selectedPath))
|
|
||||||
{
|
|
||||||
return await LaunchHostWithExplicitPathAsync(selectedPath, forceDirectMode, retryTag).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await LaunchHostWithIpcAsync(forceDirectMode, retryTag).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return HostLaunchOutcome.FromResult(BuildResult(
|
|
||||||
success: false,
|
|
||||||
stage: "launchHost",
|
|
||||||
code: "host_not_found",
|
|
||||||
message: "LanMountainDesktop host executable was not found.",
|
|
||||||
details: BuildResolutionDetails(resolution, null, null, "resolve")));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<HostLaunchOutcome> LaunchHostWithExplicitPathAsync(string hostPath, bool forceDirectMode, string? retryTag)
|
|
||||||
{
|
|
||||||
var resolution = new HostResolutionResult
|
|
||||||
{
|
|
||||||
Success = true,
|
|
||||||
ResolvedHostPath = Path.GetFullPath(hostPath),
|
|
||||||
ResolutionSource = "user_selected_path",
|
|
||||||
AppRoot = _deploymentLocator.GetAppRoot(),
|
|
||||||
ExplicitAppRoot = Path.GetDirectoryName(hostPath),
|
|
||||||
SearchedPaths = [Path.GetFullPath(hostPath)]
|
|
||||||
};
|
|
||||||
|
|
||||||
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static LauncherResult? ValidateDotNetRuntimePrerequisite(
|
|
||||||
HostLaunchPlan plan,
|
|
||||||
HostResolutionResult resolution,
|
|
||||||
DotNetRuntimeProbeOptions? probeOptions = null)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(plan);
|
|
||||||
ArgumentNullException.ThrowIfNull(resolution);
|
|
||||||
|
|
||||||
if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var runtime = DotNetRuntimeProbe.Probe(probeOptions);
|
|
||||||
Logger.Info(
|
|
||||||
$"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " +
|
|
||||||
$"Architecture={runtime.Architecture}; Message='{runtime.Message}'.");
|
|
||||||
|
|
||||||
if (runtime.IsAvailable)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var details = BuildResolutionDetails(resolution, null, null, "runtime");
|
|
||||||
foreach (var pair in runtime.ToDetails())
|
|
||||||
{
|
|
||||||
details[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildResult(
|
|
||||||
success: false,
|
|
||||||
stage: "launchHost",
|
|
||||||
code: "dotnet_runtime_missing",
|
|
||||||
message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.",
|
|
||||||
details: details,
|
|
||||||
errorMessage: runtime.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
|
|
||||||
HostResolutionResult resolution,
|
|
||||||
bool forceDirectMode,
|
|
||||||
string? retryTag)
|
|
||||||
{
|
|
||||||
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
|
||||||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
|
|
||||||
var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution);
|
|
||||||
if (prerequisiteFailure is not null)
|
|
||||||
{
|
|
||||||
return HostLaunchOutcome.FromResult(prerequisiteFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
var hostPath = plan.HostPath;
|
|
||||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
EnsureExecutable(hostPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var primaryMode = HostStartMode.Direct;
|
|
||||||
var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
|
|
||||||
? HostStartMode.ShellExecute
|
|
||||||
: (HostStartMode?)null;
|
|
||||||
|
|
||||||
var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false);
|
|
||||||
if (firstAttempt.ProcessCreated && firstAttempt.Process is not null)
|
|
||||||
{
|
|
||||||
var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null);
|
|
||||||
return HostLaunchOutcome.FromProcess(
|
|
||||||
firstAttempt.Process,
|
|
||||||
BuildResult(true, "launchHost", "ok", "Host launched.", firstDetails),
|
|
||||||
firstDetails);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fallbackMode is null)
|
|
||||||
{
|
|
||||||
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Warn(
|
|
||||||
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
|
|
||||||
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
|
|
||||||
|
|
||||||
var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false);
|
|
||||||
if (secondAttempt.ProcessCreated && secondAttempt.Process is not null)
|
|
||||||
{
|
|
||||||
var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null);
|
|
||||||
return HostLaunchOutcome.FromProcess(
|
|
||||||
secondAttempt.Process,
|
|
||||||
BuildResult(true, "launchHost", "ok", "Host launched.", details),
|
|
||||||
details);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildOutcomeFromAttempt(resolution, secondAttempt, firstAttempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HostLaunchOutcome BuildOutcomeFromAttempt(
|
|
||||||
HostResolutionResult resolution,
|
|
||||||
HostStartAttempt finalAttempt,
|
|
||||||
HostStartAttempt? previousAttempt)
|
|
||||||
{
|
|
||||||
var details = BuildResolutionDetails(
|
|
||||||
resolution,
|
|
||||||
previousAttempt ?? finalAttempt,
|
|
||||||
previousAttempt is null ? null : finalAttempt,
|
|
||||||
!finalAttempt.ProcessCreated
|
|
||||||
? "start"
|
|
||||||
: finalAttempt.ExitCode is int finalExitCode && HostActivationPolicy.IsFailedActivationExitCode(finalExitCode)
|
|
||||||
? "activation"
|
|
||||||
: "early-exit");
|
|
||||||
|
|
||||||
if (!finalAttempt.ProcessCreated)
|
|
||||||
{
|
|
||||||
return HostLaunchOutcome.FromResult(BuildResult(
|
|
||||||
false,
|
|
||||||
"launchHost",
|
|
||||||
"host_start_failed",
|
|
||||||
$"Failed to start host using start mode '{finalAttempt.StartMode}'.",
|
|
||||||
details));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
|
|
||||||
{
|
|
||||||
return HostLaunchOutcome.FromImmediateResult(BuildResult(
|
|
||||||
true,
|
|
||||||
"launch",
|
|
||||||
"activation_redirected",
|
|
||||||
"Launcher activation was redirected to the existing desktop instance.",
|
|
||||||
details));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalAttempt.ExitCode is not null && HostActivationPolicy.IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
|
|
||||||
{
|
|
||||||
return HostLaunchOutcome.FromResult(BuildResult(
|
|
||||||
false,
|
|
||||||
"launch",
|
|
||||||
"activation_failed",
|
|
||||||
$"Host activation handshake failed using start mode '{finalAttempt.StartMode}'.",
|
|
||||||
details));
|
|
||||||
}
|
|
||||||
|
|
||||||
return HostLaunchOutcome.FromResult(BuildResult(
|
|
||||||
false,
|
|
||||||
"launchHost",
|
|
||||||
"host_exited_early",
|
|
||||||
$"Host exited early using start mode '{finalAttempt.StartMode}'.",
|
|
||||||
details));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HostStartAttempt> StartHostProcessAsync(
|
|
||||||
HostLaunchPlan plan,
|
|
||||||
HostStartMode startMode,
|
|
||||||
string? retryTag)
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = plan.HostPath,
|
|
||||||
WorkingDirectory = plan.WorkingDirectory,
|
|
||||||
UseShellExecute = startMode == HostStartMode.ShellExecute
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startMode == HostStartMode.Direct)
|
|
||||||
{
|
|
||||||
foreach (var argument in plan.Arguments)
|
|
||||||
{
|
|
||||||
startInfo.ArgumentList.Add(argument);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pair in plan.EnvironmentVariables)
|
|
||||||
{
|
|
||||||
startInfo.EnvironmentVariables[pair.Key] = pair.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var process = Process.Start(startInfo);
|
|
||||||
Logger.Info(
|
|
||||||
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
|
|
||||||
$"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " +
|
|
||||||
$"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'.");
|
|
||||||
|
|
||||||
if (process is null)
|
|
||||||
{
|
|
||||||
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Yield();
|
|
||||||
return HostStartAttempt.Started(startMode, process, plan);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
|
||||||
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
|
||||||
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 partial class LauncherFlowCoordinator
|
|
||||||
{
|
|
||||||
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<(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 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LanMountainDesktop.Launcher.Models;
|
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
|
||||||
using LanMountainDesktop.Launcher.Startup;
|
|
||||||
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 partial class LauncherFlowCoordinator
|
|
||||||
{
|
|
||||||
private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
|
|
||||||
private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
|
|
||||||
|
|
||||||
private readonly CommandContext _context;
|
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
|
||||||
private readonly OobeStateService _oobeStateService;
|
|
||||||
private readonly UpdateEngineService _updateEngine;
|
|
||||||
private readonly 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,
|
|
||||||
StartupAttemptRegistry? startupAttemptRegistry = null,
|
|
||||||
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_deploymentLocator = deploymentLocator;
|
|
||||||
_oobeStateService = oobeStateService;
|
|
||||||
_updateEngine = updateEngine;
|
|
||||||
_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 (HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(_context))
|
|
||||||
{
|
|
||||||
var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
|
|
||||||
var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, StartupTimeoutPolicy.ExistingHostProbeTimeout)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus))
|
|
||||||
{
|
|
||||||
ipcConnected = true;
|
|
||||||
shellStatus = existingShellStatus;
|
|
||||||
var decisionResult = await ApplyExistingHostBehaviorAsync(
|
|
||||||
ipcClient,
|
|
||||||
multiInstanceBehavior,
|
|
||||||
existingShellStatus!)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
|
|
||||||
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
|
|
||||||
HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult);
|
|
||||||
lastStage = decisionResult.Success || recoverableActivationFailure
|
|
||||||
? StartupStage.ActivationRedirected
|
|
||||||
: StartupStage.ActivationFailed;
|
|
||||||
lastStageMessage = decisionResult.Message;
|
|
||||||
if (decisionResult.Success || recoverableActivationFailure)
|
|
||||||
{
|
|
||||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
|
|
||||||
windowsClosingByCoordinator = true;
|
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
||||||
return BuildResult(
|
|
||||||
success: decisionResult.Success,
|
|
||||||
stage: "launch",
|
|
||||||
code: decisionResult.Code,
|
|
||||||
message: decisionResult.Message,
|
|
||||||
details: MergeDetails(
|
|
||||||
launcherContextDetails,
|
|
||||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["publicIpcConnected"] = "true",
|
|
||||||
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
|
|
||||||
["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
|
|
||||||
["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
|
|
||||||
["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
|
|
||||||
["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
|
|
||||||
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter.Report("update", "Checking updates...");
|
|
||||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
|
||||||
if (!updateResult.Success)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
|
|
||||||
reporter.Report("update", "Update failed, launching existing version...");
|
|
||||||
// Clean up corrupted update files to prevent repeated failures
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_updateEngine.CleanupIncomingArtifacts();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
|
|
||||||
}
|
|
||||||
// Continue to launch existing version instead of aborting
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = HostStartupMonitor.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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var monitor = new HostStartupMonitor();
|
|
||||||
var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request(
|
|
||||||
launchOutcome.Process,
|
|
||||||
ipcClient,
|
|
||||||
startupSuccessTracker,
|
|
||||||
_startupAttemptRegistry,
|
|
||||||
trackedAttempt,
|
|
||||||
attachedToExistingAttempt,
|
|
||||||
launcherContextDetails,
|
|
||||||
successTcs,
|
|
||||||
activationFailedTcs,
|
|
||||||
reporter,
|
|
||||||
loadingDetailsWindow,
|
|
||||||
loadingState,
|
|
||||||
lastStage,
|
|
||||||
lastStageMessage,
|
|
||||||
ipcConnected,
|
|
||||||
activationFailureReason,
|
|
||||||
softTimeoutShown,
|
|
||||||
(hostProcessAliveOverride, completed, succeeded) =>
|
|
||||||
PublishCoordinatorStatus(hostProcessAliveOverride, completed, succeeded),
|
|
||||||
ComposeLaunchDetails)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
windowsClosingByCoordinator = true;
|
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
|
||||||
return BuildResult(
|
|
||||||
success: monitorOutcome.Success,
|
|
||||||
stage: "launch",
|
|
||||||
code: monitorOutcome.Code,
|
|
||||||
message: monitorOutcome.Message,
|
|
||||||
details: monitorOutcome.Details);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (splashClosedHandler is not null)
|
|
||||||
{
|
|
||||||
splashWindow.Closed -= splashClosedHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!windowsClosingByCoordinator)
|
|
||||||
{
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
|
||||||
{
|
|
||||||
splashWindow.Close();
|
|
||||||
Logger.Info("Splash window closed in coordinator cleanup.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("Failed to close splash window during coordinator cleanup.", ex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.Error("Launcher coordinator failed.", ex);
|
|
||||||
return BuildResult(
|
|
||||||
success: false,
|
|
||||||
stage: "launch",
|
|
||||||
code: "exception",
|
|
||||||
message: ex.Message,
|
|
||||||
details: BuildLauncherContextDetails(_context, _oobeStateService.Evaluate(_context), _deploymentLocator.GetAppRoot()),
|
|
||||||
errorMessage: ex.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static 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> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -19,17 +19,8 @@ internal static class LauncherCompositionRoot
|
|||||||
CommandContext context,
|
CommandContext context,
|
||||||
string appRoot,
|
string appRoot,
|
||||||
StartupAttemptRegistry startupAttemptRegistry,
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
LauncherCoordinatorIpcServer coordinatorServer)
|
LauncherCoordinatorIpcServer coordinatorServer) =>
|
||||||
{
|
LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
|
||||||
return new LauncherOrchestrator(
|
|
||||||
context,
|
|
||||||
deploymentLocator,
|
|
||||||
new OobeStateService(appRoot),
|
|
||||||
new UpdateEngineService(deploymentLocator),
|
|
||||||
startupAttemptRegistry,
|
|
||||||
coordinatorServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task RunOrchestratorWithSplashAsync(
|
public static async Task RunOrchestratorWithSplashAsync(
|
||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
@@ -161,7 +152,7 @@ internal static class LauncherCompositionRoot
|
|||||||
{
|
{
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
var updateEngine = new UpdateEngineFacade(deploymentLocator);
|
||||||
var pluginInstaller = new PluginInstallerService();
|
var pluginInstaller = new PluginInstallerService();
|
||||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ internal sealed class LauncherOrchestrator
|
|||||||
private readonly CommandContext _context;
|
private readonly CommandContext _context;
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
private readonly OobeStateService _oobeStateService;
|
private readonly OobeStateService _oobeStateService;
|
||||||
private readonly UpdateEngineService _updateEngine;
|
private readonly IUpdateEngine _updateEngine;
|
||||||
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||||
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||||
private readonly DataLocationResolver _dataLocationResolver;
|
private readonly DataLocationResolver _dataLocationResolver;
|
||||||
@@ -24,9 +24,10 @@ internal sealed class LauncherOrchestrator
|
|||||||
CommandContext context,
|
CommandContext context,
|
||||||
DeploymentLocator deploymentLocator,
|
DeploymentLocator deploymentLocator,
|
||||||
OobeStateService oobeStateService,
|
OobeStateService oobeStateService,
|
||||||
UpdateEngineService updateEngine,
|
IUpdateEngine updateEngine,
|
||||||
StartupAttemptRegistry startupAttemptRegistry,
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
|
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
|
||||||
|
LaunchPipeline? pipeline = null)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
@@ -40,7 +41,7 @@ internal sealed class LauncherOrchestrator
|
|||||||
new WelcomeOobeStep(_oobeStateService, _context),
|
new WelcomeOobeStep(_oobeStateService, _context),
|
||||||
new DataLocationOobeStep(_dataLocationResolver)
|
new DataLocationOobeStep(_dataLocationResolver)
|
||||||
];
|
];
|
||||||
_pipeline = new LaunchPipeline(
|
_pipeline = pipeline ?? new LaunchPipeline(
|
||||||
[
|
[
|
||||||
new CleanupDeploymentsPhase(),
|
new CleanupDeploymentsPhase(),
|
||||||
new ExistingHostProbePhase(),
|
new ExistingHostProbePhase(),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Shell;
|
||||||
|
|
||||||
|
internal static class LauncherServiceRegistration
|
||||||
|
{
|
||||||
|
private static ServiceProvider? _provider;
|
||||||
|
|
||||||
|
public static IServiceProvider Provider =>
|
||||||
|
_provider ?? throw new InvalidOperationException("Launcher services are not initialized.");
|
||||||
|
|
||||||
|
public static void Initialize(CommandContext context)
|
||||||
|
{
|
||||||
|
if (_provider is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var appRoot = Commands.ResolveAppRoot(context);
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton(context);
|
||||||
|
services.AddSingleton(new DeploymentLocator(appRoot));
|
||||||
|
services.AddSingleton(sp => new OobeStateService(appRoot));
|
||||||
|
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
||||||
|
services.AddSingleton<IUpdateEngine>(sp => new UpdateEngineFacade(sp.GetRequiredService<DeploymentLocator>()));
|
||||||
|
services.AddSingleton<HostLaunchService>();
|
||||||
|
services.AddSingleton<StartupAttemptRegistry>();
|
||||||
|
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, ApplyPendingUpdatePhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
|
||||||
|
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
|
||||||
|
services.AddSingleton(sp => new LaunchPipeline(sp.GetServices<ILaunchPhase>()));
|
||||||
|
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LauncherOrchestrator CreateOrchestrator(
|
||||||
|
CommandContext context,
|
||||||
|
StartupAttemptRegistry startupAttemptRegistry,
|
||||||
|
LauncherCoordinatorIpcServer coordinatorServer)
|
||||||
|
{
|
||||||
|
Initialize(context);
|
||||||
|
var services = Provider;
|
||||||
|
return new LauncherOrchestrator(
|
||||||
|
context,
|
||||||
|
services.GetRequiredService<DeploymentLocator>(),
|
||||||
|
services.GetRequiredService<OobeStateService>(),
|
||||||
|
services.GetRequiredService<IUpdateEngine>(),
|
||||||
|
startupAttemptRegistry,
|
||||||
|
coordinatorServer,
|
||||||
|
services.GetRequiredService<LaunchPipeline>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ internal sealed class LaunchContext
|
|||||||
public required CommandContext CommandContext { get; init; }
|
public required CommandContext CommandContext { get; init; }
|
||||||
public required DeploymentLocator DeploymentLocator { get; init; }
|
public required DeploymentLocator DeploymentLocator { get; init; }
|
||||||
public required OobeStateService OobeStateService { get; init; }
|
public required OobeStateService OobeStateService { get; init; }
|
||||||
public required UpdateEngineService UpdateEngine { get; init; }
|
public required IUpdateEngine UpdateEngine { get; init; }
|
||||||
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
|
||||||
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
|
||||||
public required DataLocationResolver DataLocationResolver { get; init; }
|
public required DataLocationResolver DataLocationResolver { get; init; }
|
||||||
|
|||||||
18
LanMountainDesktop.Launcher/Update/IUpdateEngine.cs
Normal file
18
LanMountainDesktop.Launcher/Update/IUpdateEngine.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Update;
|
||||||
|
|
||||||
|
internal interface IUpdateEngine
|
||||||
|
{
|
||||||
|
LauncherResult CheckPendingUpdate();
|
||||||
|
|
||||||
|
Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<LauncherResult> ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
|
LauncherResult RollbackLatest();
|
||||||
|
|
||||||
|
void CleanupDestroyedDeployments();
|
||||||
|
|
||||||
|
void CleanupIncomingArtifacts();
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Update;
|
namespace LanMountainDesktop.Launcher.Update;
|
||||||
|
|
||||||
internal sealed class UpdateEngineService
|
internal sealed class UpdateEngineFacade : IUpdateEngine
|
||||||
{
|
{
|
||||||
private const string UpdateDirectoryName = "update";
|
private const string UpdateDirectoryName = "update";
|
||||||
private const string IncomingDirectoryName = "incoming";
|
private const string IncomingDirectoryName = "incoming";
|
||||||
@@ -28,7 +28,7 @@ internal sealed class UpdateEngineService
|
|||||||
private readonly string _snapshotsRoot;
|
private readonly string _snapshotsRoot;
|
||||||
private readonly string _installCheckpointPath;
|
private readonly string _installCheckpointPath;
|
||||||
|
|
||||||
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
||||||
{
|
{
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
|
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
|
||||||
@@ -1674,7 +1674,7 @@ internal sealed class UpdateEngineService
|
|||||||
|
|
||||||
private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
||||||
|
|
||||||
internal void CleanupIncomingArtifacts()
|
public void CleanupIncomingArtifacts()
|
||||||
{
|
{
|
||||||
foreach (var path in new[]
|
foreach (var path in new[]
|
||||||
{
|
{
|
||||||
@@ -8,7 +8,7 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
using LanMountainDesktop.Launcher.ViewModels;
|
using LanMountainDesktop.Launcher.ViewModels;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Avalonia.Media;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Resources;
|
using LanMountainDesktop.Launcher.Resources;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Infrastructure;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using LanMountainDesktop.Launcher;
|
using LanMountainDesktop.Launcher;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
@@ -192,7 +191,7 @@ public sealed class DotNetRuntimeProbeTests : IDisposable
|
|||||||
SearchedPaths = [hostPath]
|
SearchedPaths = [hostPath]
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = LauncherFlowCoordinator.ValidateDotNetRuntimePrerequisite(
|
var result = HostLaunchService.ValidateDotNetRuntimePrerequisite(
|
||||||
plan,
|
plan,
|
||||||
resolution,
|
resolution,
|
||||||
CreateOptions(DotNetRuntimeArchitecture.X64));
|
CreateOptions(DotNetRuntimeArchitecture.X64));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using LanMountainDesktop.Launcher;
|
using LanMountainDesktop.Launcher;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
41
LanMountainDesktop.Tests/LauncherArchitectureTests.cs
Normal file
41
LanMountainDesktop.Tests/LauncherArchitectureTests.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LauncherArchitectureTests
|
||||||
|
{
|
||||||
|
private static readonly string LauncherAssemblyName = "LanMountainDesktop.Launcher";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deployment_Update_Startup_Infrastructure_DoNotReferenceAvalonia()
|
||||||
|
{
|
||||||
|
var forbidden = new[] { "Deployment", "Update", "Startup", "Infrastructure" };
|
||||||
|
foreach (var nsSuffix in forbidden)
|
||||||
|
{
|
||||||
|
var types = GetLauncherTypes($"LanMountainDesktop.Launcher.{nsSuffix}");
|
||||||
|
var assembly = types.First().Assembly;
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
assembly.GetReferencedAssemblies(),
|
||||||
|
a => string.Equals(a.Name, "Avalonia", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherFlowCoordinator_TypeDoesNotExist()
|
||||||
|
{
|
||||||
|
var coordinator = typeof(LanMountainDesktop.Launcher.Shell.LauncherOrchestrator).Assembly
|
||||||
|
.GetType("LanMountainDesktop.Launcher.Services.LauncherFlowCoordinator", throwOnError: false);
|
||||||
|
Assert.Null(coordinator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> GetLauncherTypes(string namespacePrefix)
|
||||||
|
{
|
||||||
|
var assembly = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.FirstOrDefault(a => string.Equals(a.GetName().Name, LauncherAssemblyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new InvalidOperationException("Launcher assembly not loaded.");
|
||||||
|
|
||||||
|
return assembly.GetTypes()
|
||||||
|
.Where(t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher;
|
using LanMountainDesktop.Launcher;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Tests;
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using System.Text.Json;
|
|||||||
using LanMountainDesktop;
|
using LanMountainDesktop;
|
||||||
using LanMountainDesktop.Launcher;
|
using LanMountainDesktop.Launcher;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Update;
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
@@ -25,7 +24,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
|
|
||||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.True(result.Success, result.ErrorMessage);
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
@@ -49,7 +48,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
|
|
||||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
|
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
@@ -71,7 +70,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
targetVersion: "1.1.0",
|
targetVersion: "1.1.0",
|
||||||
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
|
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = service.RollbackLatest();
|
var result = service.RollbackLatest();
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
@@ -87,7 +86,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
@@ -101,7 +100,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
@@ -116,7 +115,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||||
_directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0");
|
_directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0");
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.True(result.Success, result.ErrorMessage);
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
@@ -135,7 +134,7 @@ public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
|||||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||||
_directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0");
|
_directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0");
|
||||||
|
|
||||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot));
|
||||||
var result = await service.ApplyPendingUpdateAsync();
|
var result = await service.ApplyPendingUpdateAsync();
|
||||||
|
|
||||||
Assert.True(result.Success, result.ErrorMessage);
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
|
|||||||
@@ -141,42 +141,34 @@ Task<UpdateCheckResult> CheckForUpdateAsync(
|
|||||||
- `Stable` - 只检查 `prerelease=false` 的版本
|
- `Stable` - 只检查 `prerelease=false` 的版本
|
||||||
- `Preview` - 检查所有版本 (包括 `prerelease=true`)
|
- `Preview` - 检查所有版本 (包括 `prerelease=true`)
|
||||||
|
|
||||||
### UpdateEngineService
|
### IUpdateEngine / UpdateEngineFacade
|
||||||
**职责**: 下载、验证、应用更新
|
**职责**: 下载、验证、应用更新(实现位于 `Update/UpdateEngineFacade.cs`,契约 `Update/IUpdateEngine.cs`)
|
||||||
|
|
||||||
**关键方法**:
|
**关键方法**:
|
||||||
```csharp
|
```csharp
|
||||||
// 检查待处理的更新
|
|
||||||
LauncherResult CheckPendingUpdate()
|
LauncherResult CheckPendingUpdate()
|
||||||
|
Task<LauncherResult> DownloadAsync(...)
|
||||||
// 下载更新
|
Task<LauncherResult> ApplyPendingUpdateAsync()
|
||||||
Task<LauncherResult> DownloadAsync(
|
|
||||||
string manifestUrl,
|
|
||||||
string signatureUrl,
|
|
||||||
string archiveUrl,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
|
|
||||||
// 应用待处理的更新
|
|
||||||
LauncherResult ApplyPendingUpdate()
|
|
||||||
|
|
||||||
// 回退到上一个版本
|
|
||||||
LauncherResult RollbackLatest()
|
LauncherResult RollbackLatest()
|
||||||
|
|
||||||
// 清理待删除的部署
|
|
||||||
void CleanupDestroyedDeployments()
|
void CleanupDestroyedDeployments()
|
||||||
|
void CleanupIncomingArtifacts()
|
||||||
```
|
```
|
||||||
|
|
||||||
### LauncherFlowCoordinator
|
### LauncherOrchestrator / LaunchPipeline
|
||||||
**职责**: 协调完整的启动流程
|
**职责**: 协调完整的启动流程(`Shell/LauncherOrchestrator.cs` + `Startup/LaunchPipeline.cs`)
|
||||||
|
|
||||||
**启动流程**:
|
**启动阶段 (ILaunchPhase)**:
|
||||||
1. 清理待删除的旧版本
|
1. `CleanupDeploymentsPhase` — 清理旧部署
|
||||||
2. 检查是否首次运行,显示 OOBE
|
2. `ExistingHostProbePhase` — 多实例 / 现有 Host 探测
|
||||||
3. 显示 Splash 窗口
|
3. `ApplyPendingUpdatePhase` — 应用 pending 更新
|
||||||
4. 应用待处理的更新
|
4. `OobeGatePhase` — OOBE 步骤
|
||||||
5. 处理插件升级队列
|
5. `LaunchHostPhase` — 启动 Host
|
||||||
6. 启动主程序
|
6. `MonitorStartupPhase` — IPC 启动监控
|
||||||
7. 关闭 Splash 窗口
|
|
||||||
|
**GUI 入口**: `Shell/LauncherCompositionRoot` + `Shell/LauncherServiceRegistration`(MS DI 轻量装配)
|
||||||
|
|
||||||
|
### ~~LauncherFlowCoordinator~~ (已移除)
|
||||||
|
已由 `LauncherOrchestrator` + `LaunchPipeline` 替代。
|
||||||
|
|
||||||
### OobeStateService
|
### OobeStateService
|
||||||
**职责**: 管理首次运行状态
|
**职责**: 管理首次运行状态
|
||||||
|
|||||||
Reference in New Issue
Block a user