Compare commits

...

6 Commits

Author SHA1 Message Date
lincube
1ef47c780b refactor(launcher): add DI, IUpdateEngine facade, and architecture tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:13:14 +08:00
lincube
a26b6faace refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 11:03:49 +08:00
lincube
b219f109ec refactor(launcher): reorganize into responsibility folders
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:43:30 +08:00
lincube
1ee6e68f33 refactor(launcher): converge plugin pending to Host via PluginPackaging
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:28:31 +08:00
lincube
545dee85a7 fix(launcher): wire HostStartupMonitor into launch flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:28:16 +08:00
lincube
ebe35d6f91 fix(launcher): extract startup subsystem and harden IPC detection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 10:27:33 +08:00
126 changed files with 4609 additions and 4910 deletions

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.Launcher.AirApp;
internal sealed class AirAppHostLocator
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.Launcher.AirApp;
internal static class AirAppInstanceKey
{

View File

@@ -1,7 +1,5 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.Launcher.AirApp;
internal interface IAirAppProcessStarter
{
@@ -60,7 +58,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
}
LanMountainDesktop.Launcher.Services.Logger.Info(
Logger.Info(
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
var process = Process.Start(startInfo);
if (process is not null)
@@ -70,12 +68,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
{
try
{
LanMountainDesktop.Launcher.Services.Logger.Info(
Logger.Info(
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
}
catch (Exception ex)
{
LanMountainDesktop.Launcher.Services.Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
}
};
}

View File

@@ -1,7 +1,7 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.Launcher.AirApp;
internal sealed class LauncherAirAppLifecycleIpcHost : IDisposable
{

View File

@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp;
namespace LanMountainDesktop.Launcher.AirApp;
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
{

View File

@@ -1,18 +1,11 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
@@ -46,804 +39,56 @@ public partial class App : Application
return;
}
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
base.OnFrameworkInitializationCompleted();
return;
}
var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
if (HandlePreviewCommand(context, desktop))
{
base.OnFrameworkInitializationCompleted();
return;
}
var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
if (context.IsAirAppBrokerCommand)
{
_ = RunAirAppBrokerAsync(desktop, context);
base.OnFrameworkInitializationCompleted();
return;
}
if (PreviewEntryHandler.TryHandle(context, desktop))
{
base.OnFrameworkInitializationCompleted();
return;
}
// 调试模式:只显示 DevDebugWindow不走正常启动流程
// 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI
if (context.IsDebugMode && !context.IsPreviewCommand &&
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow.");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
base.OnFrameworkInitializationCompleted();
return;
}
if (context.IsAirAppBrokerCommand)
{
_ = AirAppBrokerEntryHandler.RunAsync(desktop, context);
base.OnFrameworkInitializationCompleted();
return;
}
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
}
else
{
var splashWindow = CreateSplashWindow();
splashWindow.Show();
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
if (context.IsDebugMode && !context.IsPreviewCommand &&
!string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow.");
new DevDebugWindow().Show();
base.OnFrameworkInitializationCompleted();
return;
}
if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
var updateWindow = new UpdateWindow();
updateWindow.Show();
_ = ApplyUpdateEntryHandler.RunAsync(desktop, context, updateWindow);
}
else
{
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
splashWindow.Show();
_ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
}
base.OnFrameworkInitializationCompleted();
}
private static async Task RunAirAppBrokerAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context)
{
var appRoot = Commands.ResolveAppRoot(context);
var requesterPid = context.GetIntOption("requester-pid", 0);
var dataLocationResolver = new DataLocationResolver(appRoot);
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
Logger.Info("Air APP broker exiting.");
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
}
internal static async Task WaitForAirAppBrokerExitAsync(
int requesterPid,
LauncherAirAppLifecycleService airAppLifecycleService)
{
while (ShouldKeepAirAppBrokerAlive(requesterPid, airAppLifecycleService))
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
}
internal static bool ShouldKeepAirAppBrokerAlive(
int requesterPid,
LauncherAirAppLifecycleService airAppLifecycleService)
{
return TryGetLiveProcess(requesterPid) || airAppLifecycleService.HasLiveAirApps();
}
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
switch (context.Command.ToLowerInvariant())
{
case "preview-splash":
{
Logger.Info("Preview command: splash.");
var splashWindow = CreateSplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true;
}
case "preview-error":
{
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
}
case "preview-multi-instance":
{
Logger.Info("Preview command: multi-instance prompt.");
var promptWindow = new MultiInstancePromptWindow();
promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
promptWindow.Show();
_ = WaitForWindowCloseAsync(desktop, promptWindow);
return true;
}
case "preview-update":
{
Logger.Info("Preview command: update.");
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true;
}
case "preview-oobe":
{
Logger.Info("Preview command: oobe.");
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true;
}
case "preview-debug":
{
Logger.Info("Preview command: debug window.");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
return true;
}
default:
return false;
}
}
private static SplashWindow CreateSplashWindow()
{
var window = new SplashWindow();
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { Strings.Preview_SplashInitializing, Strings.Preview_SplashCheckingUpdates, Strings.Preview_SplashCheckingPlugins, Strings.Preview_SplashLaunchingHost, Strings.Preview_SplashReady };
var reporter = (ISplashStageReporter)window;
for (var i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800).ConfigureAwait(false);
}
await Task.Delay(5000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (var i = 0; i < stages.Length; i++)
{
window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false);
}
window.ReportComplete(true, null);
await Task.Delay(3000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
await window.WaitForEnterAsync().ConfigureAwait(false);
Logger.Info("OOBE preview completed by user.");
}
catch (Exception ex)
{
Logger.Error("OOBE preview failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
window.Closed += (_, _) => tcs.TrySetResult();
await tcs.Task.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context);
var dataLocationResolver = new DataLocationResolver(appRoot);
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
coordinatorPipeName,
out var reservedAttempt,
out var activeCoordinatorAttempt))
{
result = await AttachToExistingCoordinatorAsync(
context,
currentSplashWindow,
activeCoordinatorAttempt).ConfigureAwait(false);
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
return;
}
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
HandleCoordinatorRequestAsync,
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
coordinatorServer.Start();
while (true)
{
try
{
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot);
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
new PluginInstallerService(),
startupAttemptRegistry,
coordinatorServer);
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString()
};
}
if (result.Success ||
result.Code == "host_not_found" ||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
break;
}
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
if (failureAction == ErrorWindowResult.Exit)
{
break;
}
if (failureAction == ErrorWindowResult.ActivateExisting &&
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
{
result = new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_requested",
Message = "Launcher activated the existing desktop instance.",
Details = result.Details
};
break;
}
currentSplashWindow = CreateSplashWindow();
currentSplashWindow.Show();
}
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
if (result.Success)
{
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
{
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
int.TryParse(hostPidText, out var hostPid))
{
return hostPid;
}
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
int.TryParse(existingHostPidText, out var existingHostPid))
{
return existingHostPid;
}
return fallbackHostPid;
}
private static async Task WaitForManagedProcessesToExitAsync(
int hostPid,
LauncherAirAppLifecycleService airAppLifecycleService)
{
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
}
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
CommandContext context,
SplashWindow? splashWindow,
StartupAttemptRecord? activeCoordinatorAttempt)
{
var reporter = splashWindow as ISplashStageReporter;
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
if (activeCoordinatorAttempt is not null &&
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
{
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
? LauncherCoordinatorCommands.Attach
: LauncherCoordinatorCommands.ActivateDesktop;
var request = new LauncherCoordinatorRequest
{
Command = command,
LaunchSource = context.LaunchSource,
SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context)
};
var response = await new LauncherCoordinatorIpcClient()
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
.ConfigureAwait(false);
if (response is not null)
{
reporter?.Report("activation", response.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: response.Message,
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
};
}
}
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
reporter?.Report("activation", activation.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: activation.Message,
Details = BuildCoordinatorResultDetails(null, activation)
};
}
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "launcher_coordinator_unavailable",
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
}
};
}
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
LauncherCoordinatorRequest request,
LauncherCoordinatorStatus status)
{
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = activation.Accepted,
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
Message = activation.Message,
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator.",
Status = status
};
}
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
{
return new LauncherCoordinatorStatus
{
AttemptId = attempt.AttemptId,
CoordinatorPid = Environment.ProcessId,
HostPid = attempt.HostPid,
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
LaunchSource = attempt.LaunchSource,
SuccessPolicy = attempt.SuccessPolicy,
LastObservedStage = attempt.LastObservedStage,
LastObservedMessage = attempt.LastObservedMessage,
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
State = attempt.State.ToString(),
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
Succeeded = attempt.State == StartupAttemptState.Succeeded,
UpdatedAtUtc = attempt.UpdatedAtUtc
};
}
private static bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
["startupState"] = status?.State ?? string.Empty,
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
};
return details;
}
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
{
if (splashWindow is null)
{
return;
}
try
{
await splashWindow.DismissAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
}
}
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
{
var resultPath = context.GetOption("result");
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
try
{
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
}
catch (Exception ex)
{
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
}
}
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
{
ErrorWindow? errorWindow = null;
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
hostProcessAliveValue;
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
int.TryParse(hostPidText, out var parsedPid)
? parsedPid
: (int?)null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
if (hostProcessAlive)
{
errorWindow.ConfigureForRunningHostFailure(hostPid);
}
else
{
errorWindow.ConfigureForGenericFailure(allowRetry: true);
}
errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show launcher failure window.", ex);
}
});
if (errorWindow is null)
{
return ErrorWindowResult.Exit;
}
try
{
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failure window closed unexpectedly.", ex);
return ErrorWindowResult.Exit;
}
}
private static async Task<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
{
try
{
using var ipcClient = new LanMountainDesktopIpcClient();
var connectTask = ipcClient.ConnectAsync();
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != connectTask)
{
return null;
}
await connectTask.ConfigureAwait(false);
if (!ipcClient.IsConnected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != activationTask)
{
return null;
}
return await activationTask.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
return null;
}
}
private static bool TryGetLiveProcess(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
return false;
}
}
private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
}
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Plugins;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
@@ -30,15 +30,10 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PublicTaskbarStatus))]
[JsonSerializable(typeof(PublicShellActivationResult))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(OobeStateFile))]
[JsonSerializable(typeof(DataLocationConfig))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(StartupAttemptRecord))]
[JsonSerializable(typeof(PrivacyConfig))]
[JsonSerializable(typeof(PrivacyAgreementState))]

View File

@@ -3,7 +3,7 @@ using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed class DeploymentLocator
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
/// <summary>
/// 主程序发现选项

View File

@@ -1,6 +1,6 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed record HostLaunchPlan(
string HostPath,

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
internal sealed class HostResolutionResult
{

View File

@@ -1,7 +1,7 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Deployment;
/// <summary>
/// 老版本检测器 - 检测 0.8.x 及更早的单应用模式安装

View File

@@ -0,0 +1,8 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;
global using LanMountainDesktop.Launcher.Oobe;
global using LanMountainDesktop.Launcher.Plugins;
global using LanMountainDesktop.Launcher.Startup;
global using LanMountainDesktop.Launcher.Update;

View File

@@ -2,7 +2,7 @@ using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class Commands
{
@@ -36,7 +36,7 @@ internal static class Commands
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var updateEngine = new UpdateEngineFacade(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
@@ -63,7 +63,7 @@ internal static class Commands
private static async Task<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineService updateEngine,
UpdateEngineFacade updateEngine,
PluginInstallerService pluginInstaller,
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
{
@@ -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(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
@@ -171,16 +171,12 @@ internal static class Commands
? launcherDir
: AppContext.BaseDirectory);
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
@@ -190,7 +186,6 @@ internal static class Commands
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 解析应用数据目录位置。

View File

@@ -1,7 +1,7 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{

View File

@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal enum DotNetRuntimeArchitecture
{

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal interface ISplashStageReporter
{

View File

@@ -1,7 +1,7 @@
using System.Globalization;
using System.Text.Json.Nodes;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class LanguagePreferenceService
{

View File

@@ -1,6 +1,6 @@
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 启动器背景图片服务

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);

View File

@@ -1,7 +1,7 @@
using System.Security.Principal;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
internal static class LauncherExecutionContext
{

View File

@@ -1,6 +1,6 @@
using System.Text;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 简单的日志记录器 - 同时输出到控制台和文件

View File

@@ -2,7 +2,7 @@ using Avalonia;
using Avalonia.Styling;
using FluentAvalonia.Styling;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Infrastructure;
/// <summary>
/// 主题服务,管理启动器的主题设置

View File

@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
namespace LanMountainDesktop.Launcher.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{

View File

@@ -4,7 +4,7 @@ using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
namespace LanMountainDesktop.Launcher.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{

View File

@@ -2,10 +2,9 @@ using System.Buffers;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services.Ipc;
namespace LanMountainDesktop.Launcher.Ipc;
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginPackaging\LanMountainDesktop.PluginPackaging.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
@@ -29,6 +30,7 @@
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Tmds.DBus.Protocol" />
</ItemGroup>

View File

@@ -2,7 +2,7 @@ using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class DataLocationOobeStep : IOobeStep
{

View File

@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
/// <summary>
/// 在 OOBE 中向 Host 的 settings.json 写入启动与展示相关字段,属性名与 Host

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
internal interface IOobeStep
{

View File

@@ -1,7 +1,7 @@
using System;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
/// <summary>
/// 将当前 Windows 用户登录时自启动项指向<strong>本 Launcher 进程</strong>(与正式入口一致)。

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class OobeStateService
{

View File

@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
/// <summary>
/// 隐私协议同意状态管理服务(带防篡改保护)

View File

@@ -1,7 +1,7 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class WelcomeOobeStep : IOobeStep
{

View File

@@ -2,7 +2,7 @@ using System.IO.Compression;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Plugins;
/// <summary>
/// 插件安装服务 - 简化版,不依赖 PluginSdk
@@ -290,7 +290,7 @@ internal sealed class PluginInstallerService
/// <summary>
/// 简化的插件清单模型
/// </summary>
public class PluginManifest
internal class PluginManifest
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Plugins;
internal sealed class PluginUpgradeQueueService
{
@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
}
var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text, AppJsonContext.Default.Options) ?? [];
var failures = new List<string>();
var succeeded = new List<PendingUpgrade>();
@@ -63,7 +63,7 @@ internal sealed class PluginUpgradeQueueService
}
else
{
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.Options));
}
return new LauncherResult

View File

@@ -1,7 +1,6 @@
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Shell;
namespace LanMountainDesktop.Launcher;
public static class Program
@@ -34,6 +33,7 @@ public static class Program
}
LauncherRuntimeContext.Current = commandContext;
LauncherServiceRegistration.Initialize(commandContext);
var appRoot = Commands.ResolveAppRoot(commandContext);
var languageCode = LanguagePreferenceService.ResolveLanguageCode(appRoot);

View File

@@ -1,634 +0,0 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 灵活的主程序定位器
/// </summary>
internal sealed class FlexibleHostLocator
{
private readonly HostDiscoveryOptions _options;
private readonly string _appRoot;
private readonly DeploymentLocator _deploymentLocator;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
}
/// <summary>
/// 解析主程序可执行文件路径
/// </summary>
public string? ResolveHostExecutablePath()
{
var executable = GetExecutableName();
var searchContext = new SearchContext
{
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)==========
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{
var validated = ValidateAndReturn(envPath, "environment variable");
if (validated != null) return validated;
}
// 2. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var deploymentExePath = Path.Combine(deploymentDir, executable);
if (File.Exists(deploymentExePath))
{
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
return deploymentExePath;
}
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
}
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 4. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath))
{
var validated = ValidateAndReturn(configPath, "config file");
if (validated != null) return validated;
}
// 5. 搜索附近目录(向上、向下各一层)
var nearbyPath = SearchNearbyDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(nearbyPath))
{
return nearbyPath;
}
// 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedPath))
{
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
if (validated != null) return validated;
}
}
// 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 10. 递归搜索(如果启用)
if (_options.RecursiveSearch)
{
var recursivePath = SearchRecursively(searchContext);
if (!string.IsNullOrWhiteSpace(recursivePath))
{
return recursivePath;
}
}
return null;
}
/// <summary>
/// 从环境变量获取路径
/// </summary>
private string? GetPathFromEnvironment()
{
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
{
return null;
}
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
return path;
}
/// <summary>
/// 从配置文件获取路径
/// </summary>
private string? GetPathFromConfigFile()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
{
return null;
}
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
if (!File.Exists(configPath))
{
return null;
}
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
}
}
catch
{
// 忽略配置文件读取错误
}
return null;
}
/// <summary>
/// 搜索部署目录
/// </summary>
private string? SearchDeploymentDirectories(SearchContext context)
{
if (!Directory.Exists(_appRoot))
{
return null;
}
try
{
// 查找 app-* 目录
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
.ToList();
// 优先选择带 .current 标记的
var currentMarked = appDirs
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
.Select(dir => Path.Combine(dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
if (currentMarked != null)
{
return currentMarked;
}
// 选择版本号最高的
var latest = appDirs
.Select(dir => new
{
Dir = dir,
Version = ParseVersionFromDirectoryName(dir)
})
.OrderByDescending(x => x.Version)
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
return latest;
}
catch
{
return null;
}
}
/// <summary>
/// 搜索便携模式位置Launcher 同级目录)
/// </summary>
private string? SearchPortableLocation(SearchContext context)
{
try
{
var launcherDir = AppContext.BaseDirectory;
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
if (File.Exists(portablePath))
{
return portablePath;
}
}
catch
{
// 忽略错误
}
return null;
}
/// <summary>
/// 搜索附近目录(灵活查找,适用于各种部署场景)
/// </summary>
private string? SearchNearbyDirectories(SearchContext context)
{
try
{
var searchDirs = new List<string>();
// Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
searchDirs.Add(launcherDir);
// 上级目录
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
searchDirs.Add(parentDir);
}
// 上上级目录
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
if (Directory.Exists(grandparentDir))
{
searchDirs.Add(grandparentDir);
}
// AppRoot 及其上级
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
{
searchDirs.Add(_appRoot);
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
if (Directory.Exists(appParent))
{
searchDirs.Add(appParent);
}
}
// 去重后搜索
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
{
// 直接搜索
var directPath = Path.Combine(dir, context.ExecutableName);
if (File.Exists(directPath))
{
return directPath;
}
// 搜索子目录(一层)
if (Directory.Exists(dir))
{
foreach (var subDir in Directory.GetDirectories(dir))
{
var subPath = Path.Combine(subDir, context.ExecutableName);
if (File.Exists(subPath))
{
return subPath;
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return null;
}
/// <summary>
/// 搜索开发路径
/// </summary>
private string? SearchDevelopmentPaths(SearchContext context)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 动态构建可能的开发路径(支持不同的项目结构)
var possiblePaths = new List<string>();
// 从解决方案根目录搜索(支持不同的解决方案结构)
var solutionRoot = FindSolutionRoot(launcherDir);
if (!string.IsNullOrWhiteSpace(solutionRoot))
{
// 搜索所有可能的 bin 目录
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
}
// 添加硬编码的备用路径
possiblePaths.AddRange(new[]
{
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
});
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 搜索额外的配置路径
/// </summary>
private string? SearchAdditionalPaths(SearchContext context)
{
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
{
return null;
}
foreach (var pattern in _options.AdditionalSearchPaths)
{
try
{
// 替换变量
var expandedPattern = ExpandVariables(pattern);
// 支持通配符
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
{
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
var filePattern = Path.GetFileName(expandedPattern);
if (Directory.Exists(dir))
{
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
var validMatch = matches.FirstOrDefault(File.Exists);
if (validMatch != null)
{
return validMatch;
}
}
}
else if (File.Exists(expandedPattern))
{
return expandedPattern;
}
}
catch
{
// 忽略搜索错误
}
}
return null;
}
/// <summary>
/// 递归搜索
/// </summary>
private string? SearchRecursively(SearchContext context)
{
try
{
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
foreach (var searchDir in searchDirs.Where(Directory.Exists))
{
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略递归搜索错误
}
return null;
}
/// <summary>
/// 递归搜索目录
/// </summary>
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
{
if (depth > _options.MaxRecursionDepth)
{
return null;
}
try
{
// 检查当前目录
var directPath = Path.Combine(dir, executableName);
if (File.Exists(directPath))
{
return directPath;
}
// 检查子目录
foreach (var subDir in Directory.GetDirectories(dir))
{
// 跳过某些目录
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
{
continue;
}
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略访问错误
}
return null;
}
/// <summary>
/// 查找解决方案根目录
/// </summary>
private string? FindSolutionRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
// 查找 .sln 文件
if (current.GetFiles("*.sln").Any())
{
return current.FullName;
}
// 查找 .git 目录作为备选
if (current.GetDirectories(".git").Any())
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
/// <summary>
/// 搜索 bin 目录
/// </summary>
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
{
var results = new List<string>();
try
{
// 查找所有 bin 目录
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
foreach (var binDir in binDirs)
{
// 检查 Debug 和 Release 子目录
var configDirs = new[] { "Debug", "Release" };
foreach (var config in configDirs)
{
var configPath = Path.Combine(binDir, config);
if (Directory.Exists(configPath))
{
// 检查所有 net* 子目录
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
foreach (var fwDir in frameworkDirs)
{
var exePath = Path.Combine(fwDir, executableName);
if (File.Exists(exePath))
{
results.Add(exePath);
}
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return results;
}
/// <summary>
/// 验证路径并返回
/// </summary>
private string? ValidateAndReturn(string path, string source)
{
if (File.Exists(path))
{
Debug.WriteLine($"Found host executable from {source}: {path}");
return path;
}
// 尝试添加 .exeWindows
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
var withExe = path + ".exe";
if (File.Exists(withExe))
{
Debug.WriteLine($"Found host executable from {source}: {withExe}");
return withExe;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}
/// <summary>
/// 获取可执行文件名
/// </summary>
private string GetExecutableName()
{
var name = _options.ExecutableName;
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name += ".exe";
}
return name;
}
/// <summary>
/// 展开路径变量
/// </summary>
private string ExpandVariables(string path)
{
return path
.Replace("${AppRoot}", _appRoot)
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
/// <summary>
/// 从目录名解析版本
/// </summary>
private static Version ParseVersionFromDirectoryName(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return new Version(0, 0, 0);
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return new Version(0, 0, 0);
}
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
}
/// <summary>
/// 搜索上下文
/// </summary>
private class SearchContext
{
public required string ExecutableName { get; set; }
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +0,0 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 更新检查服务 - 基于 GitHub Release API
/// </summary>
internal sealed class UpdateCheckService
{
private const string GitHubApiBase = "https://api.github.com";
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
public UpdateCheckService(string repoOwner, string repoName)
{
_repoOwner = repoOwner;
_repoName = repoName;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
}
/// <summary>
/// 检查更新
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string currentVersion,
UpdateChannel channel,
CancellationToken cancellationToken = default)
{
try
{
var releases = await FetchReleasesAsync(cancellationToken);
// 根据频道过滤版本
var filteredReleases = channel == UpdateChannel.Stable
? releases.Where(r => !r.Prerelease).ToList()
: releases;
// 找到最新版本
var latestRelease = filteredReleases
.OrderByDescending(r => ParseVersion(r.TagName))
.FirstOrDefault();
if (latestRelease == null)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = "No releases found"
};
}
var latestVersion = ParseVersionString(latestRelease.TagName);
var current = ParseVersion(currentVersion);
var latest = ParseVersion(latestVersion);
return new UpdateCheckResult
{
HasUpdate = latest > current,
LatestVersion = latestVersion,
CurrentVersion = currentVersion,
Release = latestRelease
};
}
catch (Exception ex)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 获取所有 Release
/// </summary>
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
{
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
TagName = r.TagName ?? "",
Name = r.Name ?? "",
Prerelease = r.Prerelease,
PublishedAt = r.PublishedAt,
Body = r.Body,
Assets = r.Assets?.Select(a => new ReleaseAsset
{
Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
Size = a.Size
}).ToList() ?? []
}).ToList() ?? [];
}
/// <summary>
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
/// </summary>
private static string ParseVersionString(string tag)
{
return tag.TrimStart('v', 'V');
}
/// <summary>
/// 解析版本号
/// </summary>
private static Version ParseVersion(string versionString)
{
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
}
// GitHub API 响应模型
internal sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}

View File

@@ -0,0 +1,90 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
internal static class LaunchEntryHandler
{
public static SplashWindow CreateSplashWindow()
{
var window = new SplashWindow();
try
{
var appRoot = Commands.ResolveAppRoot(LauncherRuntimeContext.Current);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info: {ex.Message}");
}
return window;
}
public static Task RunAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow) =>
LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow);
}
internal static class ApplyUpdateEntryHandler
{
public static Task RunAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window) =>
LauncherCompositionRoot.RunApplyUpdateWithWindowAsync(desktop, context, window);
}
internal static class AirAppBrokerEntryHandler
{
public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context)
{
var appRoot = Commands.ResolveAppRoot(context);
var requesterPid = context.GetIntOption("requester-pid", 0);
var dataLocationResolver = new DataLocationResolver(appRoot);
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
while (ShouldKeepAlive(requesterPid, airAppIpcHost.LifecycleService))
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
Logger.Info("Air APP broker exiting.");
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0), DispatcherPriority.Background);
}
internal static bool ShouldKeepAirAppBrokerAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService)
{
if (requesterPid <= 0)
{
return lifecycleService.HasLiveAirApps();
}
try
{
using var process = System.Diagnostics.Process.GetProcessById(requesterPid);
return !process.HasExited || lifecycleService.HasLiveAirApps();
}
catch
{
return lifecycleService.HasLiveAirApps();
}
}
private static bool ShouldKeepAlive(int requesterPid, LauncherAirAppLifecycleService lifecycleService) =>
ShouldKeepAirAppBrokerAlive(requesterPid, lifecycleService);
}

View File

@@ -0,0 +1,136 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Shell.EntryHandlers;
internal static class PreviewEntryHandler
{
public static bool TryHandle(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
switch (context.Command.ToLowerInvariant())
{
case "preview-splash":
RunSplashPreview(desktop);
return true;
case "preview-error":
RunErrorPreview(desktop);
return true;
case "preview-multi-instance":
RunMultiInstancePreview(desktop);
return true;
case "preview-update":
RunUpdatePreview(desktop);
return true;
case "preview-oobe":
RunOobePreview(desktop);
return true;
case "preview-debug":
new DevDebugWindow().Show();
return true;
default:
return false;
}
}
private static void RunSplashPreview(IClassicDesktopStyleApplicationLifetime desktop)
{
var splashWindow = LaunchEntryHandler.CreateSplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
}
private static void RunErrorPreview(IClassicDesktopStyleApplicationLifetime desktop)
{
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage(Strings.Preview_ErrorMessage);
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
}
private static void RunMultiInstancePreview(IClassicDesktopStyleApplicationLifetime desktop)
{
var promptWindow = new MultiInstancePromptWindow();
promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
promptWindow.Show();
_ = WaitForWindowCloseAsync(desktop, promptWindow);
}
private static void RunUpdatePreview(IClassicDesktopStyleApplicationLifetime desktop)
{
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
}
private static void RunOobePreview(IClassicDesktopStyleApplicationLifetime desktop)
{
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
}
private static async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[]
{
Strings.Preview_SplashInitializing,
Strings.Preview_SplashCheckingUpdates,
Strings.Preview_SplashCheckingPlugins,
Strings.Preview_SplashLaunchingHost,
Strings.Preview_SplashReady
};
var reporter = (ISplashStageReporter)window;
for (var i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800).ConfigureAwait(false);
}
await Task.Delay(5000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (var i = 0; i < stages.Length; i++)
{
window.Report(stages[i], string.Format(Strings.Preview_UpdateProcessing, stages[i]), (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false);
}
window.ReportComplete(true, null);
await Task.Delay(3000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
await window.WaitForEnterAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("OOBE preview failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
window.Closed += (_, _) => tcs.TrySetResult();
await tcs.Task.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
}

View File

@@ -0,0 +1,611 @@
using System.Diagnostics;
using Avalonia.Controls.ApplicationLifetimes;
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.Shell;
/// <summary>
/// Launcher GUI 入口装配:创建编排器并驱动启动流程。
/// </summary>
internal static class LauncherCompositionRoot
{
public static LauncherOrchestrator CreateOrchestrator(
CommandContext context,
string appRoot,
StartupAttemptRegistry startupAttemptRegistry,
LauncherCoordinatorIpcServer coordinatorServer) =>
LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
public static async Task RunOrchestratorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context);
var dataLocationResolver = new DataLocationResolver(appRoot);
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
coordinatorPipeName,
out var reservedAttempt,
out var activeCoordinatorAttempt))
{
result = await AttachToExistingCoordinatorAsync(
context,
currentSplashWindow,
activeCoordinatorAttempt).ConfigureAwait(false);
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
return;
}
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
new LauncherAirAppLifecycleService(
new AirAppProcessStarter(
new AirAppHostLocator(),
() => appRoot,
() => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start();
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
HandleCoordinatorRequestAsync,
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
coordinatorServer.Start();
while (true)
{
try
{
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var orchestrator = CreateOrchestrator(
context,
appRoot,
startupAttemptRegistry,
coordinatorServer);
result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString()
};
}
if (result.Success ||
result.Code == "host_not_found" ||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
break;
}
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
if (failureAction == ErrorWindowResult.Exit)
{
break;
}
if (failureAction == ErrorWindowResult.ActivateExisting &&
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
{
result = new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_requested",
Message = "Launcher activated the existing desktop instance.",
Details = result.Details
};
break;
}
currentSplashWindow = CreateSplashWindow();
currentSplashWindow.Show();
}
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
if (result.Success)
{
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
public static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineFacade(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
}
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
private static SplashWindow CreateSplashWindow()
{
var window = new SplashWindow();
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
{
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
int.TryParse(hostPidText, out var hostPid))
{
return hostPid;
}
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
int.TryParse(existingHostPidText, out var existingHostPid))
{
return existingHostPid;
}
return fallbackHostPid;
}
private static async Task WaitForManagedProcessesToExitAsync(
int hostPid,
LauncherAirAppLifecycleService airAppLifecycleService)
{
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
}
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
CommandContext context,
SplashWindow? splashWindow,
StartupAttemptRecord? activeCoordinatorAttempt)
{
var reporter = splashWindow as ISplashStageReporter;
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
if (activeCoordinatorAttempt is not null &&
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
{
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
? LauncherCoordinatorCommands.Attach
: LauncherCoordinatorCommands.ActivateDesktop;
var request = new LauncherCoordinatorRequest
{
Command = command,
LaunchSource = context.LaunchSource,
SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context)
};
var response = await new LauncherCoordinatorIpcClient()
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
.ConfigureAwait(false);
if (response is not null)
{
reporter?.Report("activation", response.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: response.Message,
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
};
}
}
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
reporter?.Report("activation", activation.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: activation.Message,
Details = BuildCoordinatorResultDetails(null, activation)
};
}
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "launcher_coordinator_unavailable",
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
}
};
}
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
LauncherCoordinatorRequest request,
LauncherCoordinatorStatus status)
{
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = activation.Accepted,
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
Message = activation.Message,
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator.",
Status = status
};
}
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
{
return new LauncherCoordinatorStatus
{
AttemptId = attempt.AttemptId,
CoordinatorPid = Environment.ProcessId,
HostPid = attempt.HostPid,
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
LaunchSource = attempt.LaunchSource,
SuccessPolicy = attempt.SuccessPolicy,
LastObservedStage = attempt.LastObservedStage,
LastObservedMessage = attempt.LastObservedMessage,
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
State = attempt.State.ToString(),
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
Succeeded = attempt.State == StartupAttemptState.Succeeded,
UpdatedAtUtc = attempt.UpdatedAtUtc
};
}
private static bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
["startupState"] = status?.State ?? string.Empty,
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
};
}
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
{
if (splashWindow is null)
{
return;
}
try
{
await splashWindow.DismissAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
}
}
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
{
var resultPath = context.GetOption("result");
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
try
{
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
}
catch (Exception ex)
{
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
}
}
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
{
ErrorWindow? errorWindow = null;
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
hostProcessAliveValue;
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
int.TryParse(hostPidText, out var parsedPid)
? parsedPid
: (int?)null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
if (hostProcessAlive)
{
errorWindow.ConfigureForRunningHostFailure(hostPid);
}
else
{
errorWindow.ConfigureForGenericFailure(allowRetry: true);
}
errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show launcher failure window.", ex);
}
});
if (errorWindow is null)
{
return ErrorWindowResult.Exit;
}
try
{
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failure window closed unexpectedly.", ex);
return ErrorWindowResult.Exit;
}
}
private static async Task<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
{
try
{
using var ipcClient = new LanMountainDesktopIpcClient();
var connectTask = ipcClient.ConnectAsync();
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != connectTask)
{
return null;
}
await connectTask.ConfigureAwait(false);
if (!ipcClient.IsConnected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != activationTask)
{
return null;
}
return await activationTask.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
return null;
}
}
private static bool TryGetLiveProcess(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,283 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
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.Shell;
internal sealed class LauncherOrchestrator
{
private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService;
private readonly IUpdateEngine _updateEngine;
private readonly StartupAttemptRegistry _startupAttemptRegistry;
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
private readonly DataLocationResolver _dataLocationResolver;
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
private readonly LaunchPipeline _pipeline;
public LauncherOrchestrator(
CommandContext context,
DeploymentLocator deploymentLocator,
OobeStateService oobeStateService,
IUpdateEngine updateEngine,
StartupAttemptRegistry startupAttemptRegistry,
LauncherCoordinatorIpcServer? coordinatorIpcServer = null,
LaunchPipeline? pipeline = null)
{
_context = context;
_deploymentLocator = deploymentLocator;
_oobeStateService = oobeStateService;
_updateEngine = updateEngine;
_startupAttemptRegistry = startupAttemptRegistry;
_coordinatorIpcServer = coordinatorIpcServer;
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
_oobeSteps =
[
new WelcomeOobeStep(_oobeStateService, _context),
new DataLocationOobeStep(_dataLocationResolver)
];
_pipeline = pipeline ?? new LaunchPipeline(
[
new CleanupDeploymentsPhase(),
new ExistingHostProbePhase(),
new ApplyPendingUpdatePhase(),
new OobeGatePhase(),
new LaunchHostPhase(),
new MonitorStartupPhase()
]);
}
public static string ResolveSuccessPolicyKey(CommandContext context) =>
new StartupSuccessTracker(context).PolicyKey;
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
{
try
{
var oobeDecision = _oobeStateService.Evaluate(_context);
if (oobeDecision.ShouldShowOobe)
{
var legacyInfo = LegacyVersionDetector.DetectLegacyInstallation();
if (legacyInfo is not null)
{
var migrationResult = await LaunchUiPresenter.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 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;
var windowsClosingByOrchestrator = false;
StartupAttemptRecord? trackedAttempt = null;
PublicShellStatus? shellStatus = null;
var loadingState = new LoadingStateMessage();
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 && LaunchResultBuilder.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();
EventHandler? splashClosedHandler = null;
splashClosedHandler = (_, _) =>
{
if (windowsClosingByOrchestrator)
{
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(LaunchUiPresenter.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);
}
});
});
var launchContext = new LaunchContext
{
CommandContext = _context,
DeploymentLocator = _deploymentLocator,
OobeStateService = _oobeStateService,
UpdateEngine = _updateEngine,
StartupAttemptRegistry = _startupAttemptRegistry,
CoordinatorIpcServer = _coordinatorIpcServer,
DataLocationResolver = _dataLocationResolver,
OobeSteps = _oobeSteps,
SplashWindow = splashWindow,
LoadingDetailsWindow = loadingDetailsWindow,
Reporter = reporter,
IpcClient = ipcClient,
SuccessTracker = startupSuccessTracker,
SuccessTcs = successTcs,
ActivationFailedTcs = activationFailedTcs,
LoadingState = loadingState,
PublishCoordinatorStatus = PublishCoordinatorStatus,
SplashClosedHandler = splashClosedHandler
};
try
{
var result = await _pipeline.ExecuteAsync(launchContext).ConfigureAwait(false);
windowsClosingByOrchestrator = launchContext.WindowsClosingByOrchestrator;
return result;
}
finally
{
if (splashClosedHandler is not null)
{
splashWindow.Closed -= splashClosedHandler;
}
if (!windowsClosingByOrchestrator && !launchContext.WindowsClosingByOrchestrator)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (splashWindow.IsVisible && splashWindow.IsLoaded)
{
splashWindow.Close();
Logger.Info("Splash window closed in orchestrator cleanup.");
}
}
catch (Exception ex)
{
Logger.Error("Failed to close splash window during orchestrator cleanup.", ex);
}
});
}
}
}
catch (Exception ex)
{
Logger.Error("Launcher orchestrator failed.", ex);
var oobeDecision = _oobeStateService.Evaluate(_context);
return LaunchResultBuilder.Build(
false,
"launch",
"exception",
ex.Message,
LaunchResultBuilder.BuildLauncherContextDetails(_context, oobeDecision, _deploymentLocator.GetAppRoot()),
ex.ToString());
}
}
}

View File

@@ -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>());
}
}

View File

@@ -0,0 +1,162 @@
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal static class ExistingHostProbe
{
public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(DataLocationResolver dataLocationResolver)
{
try
{
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataLocationResolver.ResolveDataRoot());
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
}
catch (Exception ex)
{
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
public static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
try
{
var connected = ipcClient.IsConnected ||
await PublicIpcConnection.TryConnectAsync(ipcClient, timeout).ConfigureAwait(false);
if (!connected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
return null;
}
}
public static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
LanMountainDesktopIpcClient ipcClient,
MultiInstanceLaunchBehavior behavior,
PublicShellStatus status)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return behavior switch
{
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated",
successMessage: "Launcher activated the existing desktop instance.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
shellProxy,
status).ConfigureAwait(false),
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
_ => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
};
}
catch (Exception ex)
{
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
return new ExistingHostBehaviorResult(
false,
"multi_instance_behavior_failed",
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
null);
}
}
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
IPublicShellControlService shellProxy,
bool showLauncherNotice,
string successCode,
string successMessage,
string failureCode)
{
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
var success = activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation);
if (showLauncherNotice && success)
{
var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
}
}
return new ExistingHostBehaviorResult(
success,
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
activation.Accepted ? successMessage : activation.Message,
activation);
}
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
IPublicShellControlService shellProxy)
{
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
return new ExistingHostBehaviorResult(
accepted,
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
accepted
? "Launcher requested the existing desktop instance to restart."
: "Launcher could not request restart from the existing desktop instance.",
null);
}
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
IPublicShellControlService shellProxy,
PublicShellStatus status)
{
var promptResult = await LaunchUiPresenter.ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
return await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated_from_prompt",
successMessage: "Launcher activated the existing desktop instance from the prompt.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
}
return new ExistingHostBehaviorResult(
true,
"existing_host_prompt_only",
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
null);
}
}
internal sealed record ExistingHostBehaviorResult(
bool Success,
string Code,
string Message,
PublicShellActivationResult? ActivationResult);

View File

@@ -0,0 +1,50 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal static class HostActivationPolicy
{
internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
{
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (context.IsPreviewCommand || context.IsMaintenanceCommand)
{
return false;
}
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
}
internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status) =>
status is { PublicIpcReady: true, ProcessId: > 0 };
internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{
if (activation.Accepted)
{
return false;
}
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return activation.Status.PublicIpcReady &&
(!activation.Status.MainWindowOpened ||
!activation.Status.DesktopVisible ||
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
exitCode == HostExitCodes.SecondaryActivationSucceeded;
internal static bool IsFailedActivationExitCode(int exitCode) =>
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
}

View File

@@ -0,0 +1,77 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;
internal enum HostStartMode
{
ShellExecute,
Direct
}
internal sealed record HostStartAttempt(
HostStartMode StartMode,
bool ProcessCreated,
Process? Process,
bool ExitedEarly,
int? ExitCode,
string? FailureReason,
string? PackageRoot,
string? WorkingDirectory,
string? Arguments)
{
public int? ProcessId => Process?.Id;
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
new(
startMode,
true,
process,
false,
null,
null,
plan.PackageRoot,
plan.WorkingDirectory,
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
new(
startMode,
true,
process,
true,
exitCode,
null,
plan.PackageRoot,
plan.WorkingDirectory,
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
new(
startMode,
false,
null,
false,
null,
failureReason,
plan?.PackageRoot,
plan?.WorkingDirectory,
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
}
internal sealed record HostLaunchOutcome(
LauncherResult Result,
Process? Process,
LauncherResult? ImmediateResult,
Dictionary<string, string> Details)
{
public static HostLaunchOutcome FromResult(LauncherResult result) =>
new(result, null, result.Success ? result : null, result.Details);
public static HostLaunchOutcome FromImmediateResult(LauncherResult result) =>
new(result, null, result, result.Details);
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
new(result, process, null, details);
}

View 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
{
}
}
}

View File

@@ -0,0 +1,510 @@
using System.Diagnostics;
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.Startup;
internal sealed class HostStartupMonitor
{
private static readonly string SoftTimeoutStatusMessage = Strings.Coordinator_SlowDeviceMessage;
private static readonly string SoftTimeoutDetailsMessage = Strings.Coordinator_RunningHostMessage;
internal sealed record Request(
Process HostProcess,
LanMountainDesktopIpcClient IpcClient,
StartupSuccessTracker SuccessTracker,
StartupAttemptRegistry AttemptRegistry,
StartupAttemptRecord? TrackedAttempt,
bool AttachedToExistingAttempt,
Dictionary<string, string> LaunchDetails,
TaskCompletionSource<StartupSuccessState> SuccessTcs,
TaskCompletionSource<string> ActivationFailedTcs,
ISplashStageReporter Reporter,
LoadingDetailsWindow? LoadingDetailsWindow,
LoadingStateMessage LoadingState,
StartupStage LastStage,
string LastStageMessage,
bool IpcConnected,
string ActivationFailureReason,
bool SoftTimeoutShown,
Action<bool?, bool, bool> PublishCoordinatorStatus,
Func<bool, bool, Dictionary<string, string>> ComposeLaunchDetails);
internal sealed record Outcome(
bool Success,
string Code,
string Message,
bool RecoveryActivationAttempted,
Dictionary<string, string> Details);
public async Task<Outcome> MonitorUntilCompleteAsync(Request request)
{
var ipcConnected = request.IpcConnected;
var softTimeoutShown = request.SoftTimeoutShown;
var lastStage = request.LastStage;
var lastStageMessage = request.LastStageMessage;
var activationFailureReason = request.ActivationFailureReason;
var loadingState = request.LoadingState;
PublicShellStatus? shellStatus = null;
var trackedAttempt = request.TrackedAttempt;
async Task<StartupSuccessState?> RefreshShellStatusAsync(string waitingMessage)
{
if (!request.IpcClient.IsConnected)
{
return null;
}
ipcConnected = true;
request.AttemptRegistry.MarkOwnedIpcConnected();
shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false);
StartupDiagnostics.TraceShellStatus("refresh", shellStatus, lastStage);
if (request.SuccessTracker.TryResolve(shellStatus, out var successState))
{
return successState;
}
if (shellStatus is not null && !shellStatus.MainWindowOpened && !shellStatus.DesktopVisible)
{
request.AttemptRegistry.MarkOwnedWaitingForShell(waitingMessage);
}
request.PublishCoordinatorStatus(true, false, false);
return null;
}
var connected = await PublicIpcConnection.TryConnectWithBackoffAsync(
request.IpcClient,
[
StartupTimeoutPolicy.InitialIpcConnectTimeout,
TimeSpan.FromMilliseconds(3000),
TimeSpan.FromMilliseconds(5000)
]).ConfigureAwait(false);
if (!connected)
{
Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
}
else
{
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
request.SuccessTcs.TrySetResult(shellSuccess);
}
}
var processExitTask = request.HostProcess.WaitForExitAsync();
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
var softTimeoutAt = startedAt + StartupTimeoutPolicy.SoftTimeout;
var hardTimeoutAt = startedAt + StartupTimeoutPolicy.HardTimeout;
var nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
var nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
var ipcReconnectAttemptIndex = 0;
var activationRetryAttempted = false;
while (true)
{
if (request.SuccessTcs.Task.IsCompleted)
{
var successState = await request.SuccessTcs.Task.ConfigureAwait(false);
request.AttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
return new Outcome(
true,
successState.Code,
successState.Message,
false,
request.ComposeLaunchDetails(!request.HostProcess.HasExited, false));
}
if (request.ActivationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
{
activationRetryAttempted = true;
activationFailureReason = await request.ActivationFailedTcs.Task.ConfigureAwait(false);
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
request.IpcClient,
request.SuccessTracker,
TimeSpan.FromSeconds(1)).ConfigureAwait(false);
if (activationRecovery is not null)
{
request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
return new Outcome(
true,
activationRecovery.Code,
activationRecovery.Message,
true,
request.ComposeLaunchDetails(!request.HostProcess.HasExited, true));
}
Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
}
if (processExitTask.IsCompleted)
{
var exitCode = request.HostProcess.ExitCode;
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
if (HostActivationPolicy.IsSuccessfulActivationExitCode(exitCode))
{
request.AttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
request.PublishCoordinatorStatus(false, true, true);
return new Outcome(
true,
"activation_redirected",
"Host redirected activation to the existing desktop instance.",
false,
MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode));
}
if (!activationRetryAttempted && HostActivationPolicy.IsFailedActivationExitCode(exitCode))
{
activationRetryAttempted = true;
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
request.IpcClient,
request.SuccessTracker,
TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activationRecovery is not null)
{
request.AttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
request.PublishCoordinatorStatus(true, true, true);
return new Outcome(
true,
activationRecovery.Code,
activationRecovery.Message,
true,
MergeExitCodeDetails(request.ComposeLaunchDetails(true, true), exitCode));
}
Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
}
request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
request.PublishCoordinatorStatus(false, true, false);
return new Outcome(
false,
HostActivationPolicy.IsFailedActivationExitCode(exitCode) ? "activation_failed" : "host_exited_early",
HostActivationPolicy.IsFailedActivationExitCode(exitCode)
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
false,
MergeExitCodeDetails(request.ComposeLaunchDetails(false, false), exitCode));
}
var now = DateTimeOffset.UtcNow;
if (ipcConnected &&
!request.HostProcess.HasExited &&
now >= nextShellStatusPollAt)
{
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
request.SuccessTcs.TrySetResult(shellSuccess);
continue;
}
nextShellStatusPollAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.ShellStatusPollInterval;
}
if (!ipcConnected &&
!request.HostProcess.HasExited &&
now >= nextReconnectAttemptAt)
{
var reconnectTimeout = StartupTimeoutPolicy.IpcReconnectAttemptTimeouts[
Math.Min(ipcReconnectAttemptIndex, StartupTimeoutPolicy.IpcReconnectAttemptTimeouts.Length - 1)];
ipcReconnectAttemptIndex++;
connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, reconnectTimeout).ConfigureAwait(false);
if (connected)
{
ipcConnected = true;
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
request.SuccessTcs.TrySetResult(shellSuccess);
continue;
}
}
nextReconnectAttemptAt = DateTimeOffset.UtcNow + StartupTimeoutPolicy.IpcReconnectInterval;
}
if (!softTimeoutShown &&
now >= softTimeoutAt &&
(!request.HostProcess.HasExited || ipcConnected))
{
softTimeoutShown = true;
request.AttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage);
request.Reporter.Report("delayed", SoftTimeoutStatusMessage);
loadingState = BuildDelayedLoadingState(
loadingState,
SoftTimeoutStatusMessage,
SoftTimeoutDetailsMessage,
trackedAttempt?.StartedAtUtc ?? startedAt);
request.LoadingDetailsWindow?.UpdateLoadingState(loadingState);
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, false, false);
}
if (now >= hardTimeoutAt)
{
break;
}
var nextCheckpointAt = hardTimeoutAt;
if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt)
{
nextCheckpointAt = softTimeoutAt;
}
var delay = nextCheckpointAt - now;
if (delay > TimeSpan.FromSeconds(1))
{
delay = TimeSpan.FromSeconds(1);
}
else if (delay < TimeSpan.FromMilliseconds(100))
{
delay = TimeSpan.FromMilliseconds(100);
}
await Task.WhenAny(
request.SuccessTcs.Task,
request.ActivationFailedTcs.Task,
processExitTask,
Task.Delay(delay)).ConfigureAwait(false);
}
var recoveryActivationAttempted = false;
if (!connected && !request.HostProcess.HasExited)
{
connected = await PublicIpcConnection.TryConnectAsync(request.IpcClient, TimeSpan.FromSeconds(3)).ConfigureAwait(false);
if (connected)
{
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
request.AttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message);
request.PublishCoordinatorStatus(true, true, true);
return new Outcome(
true,
shellSuccess.Code,
shellSuccess.Message,
false,
request.ComposeLaunchDetails(true, false));
}
}
}
if (connected && !request.HostProcess.HasExited)
{
recoveryActivationAttempted = true;
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
request.IpcClient,
request.HostProcess,
request.SuccessTcs.Task,
request.SuccessTracker).ConfigureAwait(false);
if (recoveryOutcome is not null)
{
request.AttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, true);
return new Outcome(
true,
recoveryOutcome.Code,
recoveryOutcome.Message,
true,
request.ComposeLaunchDetails(!request.HostProcess.HasExited, true));
}
}
if (connected && !request.HostProcess.HasExited)
{
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
shellStatus = await TryGetPublicShellStatusAsync(request.IpcClient).ConfigureAwait(false);
if (request.SuccessTracker.TryResolve(shellStatus, out var finalShellSuccess))
{
request.AttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message);
request.PublishCoordinatorStatus(true, true, true);
return new Outcome(
true,
finalShellSuccess.Code,
finalShellSuccess.Message,
recoveryActivationAttempted,
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
}
request.PublishCoordinatorStatus(true, true, false);
return new Outcome(
false,
"shell_not_ready",
"Host public IPC is connected, but the desktop shell did not create or show the main window in time.",
recoveryActivationAttempted,
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
}
if (!connected && !request.HostProcess.HasExited)
{
request.AttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet.");
request.PublishCoordinatorStatus(true, false, true);
return new Outcome(
true,
"startup_pending",
"Host process is still running; Launcher will not start another process while public IPC finishes startup.",
recoveryActivationAttempted,
request.ComposeLaunchDetails(true, recoveryActivationAttempted));
}
request.AttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
request.PublishCoordinatorStatus(!request.HostProcess.HasExited, true, false);
return new Outcome(
false,
"desktop_not_visible",
$"Host process started, but it never reached the required startup state within {StartupTimeoutPolicy.HardTimeout.TotalSeconds:0} seconds.",
recoveryActivationAttempted,
request.ComposeLaunchDetails(!request.HostProcess.HasExited, recoveryActivationAttempted));
}
internal static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
LanMountainDesktopIpcClient ipcClient,
StartupSuccessTracker startupSuccessTracker,
TimeSpan timeout)
{
var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false);
if (activation is null)
{
return null;
}
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
{
return shellSuccess;
}
if (activation.Accepted)
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
return HostActivationPolicy.IsRecoverableActivationFailure(activation)
? new StartupSuccessState(
StartupStage.Ready,
"startup_pending",
activation.Message)
: null;
}
internal static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(LanMountainDesktopIpcClient ipcClient)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to query public shell status: {ex.Message}");
return null;
}
}
private static async Task<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<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
LanMountainDesktopIpcClient ipcClient,
Process hostProcess,
Task<StartupSuccessState> successTask,
StartupSuccessTracker startupSuccessTracker)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
StartupDiagnostics.TraceShellStatus("recovery_activation", activation.Status);
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
{
return shellSuccess;
}
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
if (completedTask == successTask)
{
return await successTask.ConfigureAwait(false);
}
if (!hostProcess.HasExited && (activation.Accepted || HostActivationPolicy.IsRecoverableActivationFailure(activation)))
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
}
catch (Exception ex)
{
Logger.Warn($"Public activation recovery failed: {ex.Message}");
}
return null;
}
internal static LoadingStateMessage BuildDelayedLoadingState(
LoadingStateMessage loadingState,
string summaryMessage,
string detailMessage,
DateTimeOffset startedAtUtc)
{
var delayedItems = loadingState.ActiveItems
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
.ToList();
delayedItems.Insert(0, new LoadingItem
{
Id = "launcher-soft-timeout",
Type = LoadingItemType.System,
Name = "Startup still in progress",
Description = detailMessage,
State = LoadingState.Delayed,
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
Message = detailMessage,
StartTime = startedAtUtc
});
return loadingState with
{
ActiveItems = delayedItems,
Message = summaryMessage,
Timestamp = DateTimeOffset.UtcNow,
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
};
}
private static Dictionary<string, string> MergeExitCodeDetails(Dictionary<string, string> details, int exitCode)
{
details["exitCode"] = exitCode.ToString();
return details;
}
}

View File

@@ -0,0 +1,49 @@
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;
internal static class LaunchAttemptDetails
{
public static Dictionary<string, string> Build(
StartupAttemptRecord? trackedAttempt,
bool attachedToExistingAttempt,
bool ipcConnected,
bool hostProcessAlive,
StartupStage lastStage,
string lastStageMessage,
string? activationFailureReason,
bool softTimeoutShown,
bool recoveryActivationAttempted)
{
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["hostProcessAlive"] = hostProcessAlive.ToString(),
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
["ipcConnected"] = ipcConnected.ToString(),
["ipcStage"] = lastStage.ToString(),
["ipcMessage"] = lastStageMessage,
["activationFailureReason"] = activationFailureReason ?? string.Empty,
["softTimeoutShown"] = softTimeoutShown.ToString(),
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
};
if (trackedAttempt is not null)
{
details["startupAttemptId"] = trackedAttempt.AttemptId;
details["startupAttemptState"] = trackedAttempt.State.ToString();
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
details["successPolicy"] = trackedAttempt.SuccessPolicy;
details["hostPid"] = trackedAttempt.HostPid.ToString();
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
details["shellStatus"] = trackedAttempt.ShellStatus;
}
return details;
}
}

View File

@@ -0,0 +1,191 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal enum LaunchPhaseStatus
{
Continue,
Completed
}
internal sealed record LaunchPhaseResult(LaunchPhaseStatus Status, LauncherResult? Result = null);
internal interface ILaunchPhase
{
string Name { get; }
Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default);
}
internal sealed class LaunchContext
{
public required CommandContext CommandContext { get; init; }
public required DeploymentLocator DeploymentLocator { get; init; }
public required OobeStateService OobeStateService { get; init; }
public required IUpdateEngine UpdateEngine { get; init; }
public required StartupAttemptRegistry StartupAttemptRegistry { get; init; }
public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; }
public required DataLocationResolver DataLocationResolver { get; init; }
public required IReadOnlyList<IOobeStep> OobeSteps { get; init; }
public SplashWindow SplashWindow { get; set; } = null!;
public LoadingDetailsWindow? LoadingDetailsWindow { get; set; }
public ISplashStageReporter Reporter { get; set; } = null!;
public LanMountainDesktopIpcClient IpcClient { get; set; } = null!;
public StartupSuccessTracker SuccessTracker { get; set; } = null!;
public TaskCompletionSource<StartupSuccessState> SuccessTcs { get; set; } = null!;
public TaskCompletionSource<string> ActivationFailedTcs { get; set; } = null!;
public LoadingStateMessage LoadingState { get; set; }
public Dictionary<string, string> LauncherContextDetails { get; set; } = [];
public OobeLaunchDecision OobeDecision { get; set; } = null!;
public StartupStage LastStage { get; set; } = StartupStage.Initializing;
public string LastStageMessage { get; set; } = "launcher-started";
public string ActivationFailureReason { get; set; } = string.Empty;
public bool IpcConnected { get; set; }
public bool SoftTimeoutShown { get; set; }
public bool AttachedToExistingAttempt { get; set; }
public bool WindowsClosingByOrchestrator { get; set; }
public StartupAttemptRecord? TrackedAttempt { get; set; }
public PublicShellStatus? ShellStatus { get; set; }
public HostLaunchOutcome? LaunchOutcome { get; set; }
public Action<bool?, bool, bool> PublishCoordinatorStatus { get; set; } = static (_, _, _) => { };
public EventHandler? SplashClosedHandler { get; set; }
}
internal sealed class LaunchPipeline
{
private readonly IReadOnlyList<ILaunchPhase> _phases;
public LaunchPipeline(IEnumerable<ILaunchPhase> phases)
{
_phases = phases.ToList();
}
public async Task<LauncherResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
foreach (var phase in _phases)
{
Logger.Info($"Launch pipeline entering phase '{phase.Name}'.");
var phaseResult = await phase.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
if (phaseResult.Status == LaunchPhaseStatus.Completed)
{
return phaseResult.Result ?? LaunchResultBuilder.BuildFailure(
"launch",
"phase_completed_without_result",
$"Launch phase '{phase.Name}' completed without a result.");
}
}
return LaunchResultBuilder.BuildFailure(
"launch",
"pipeline_incomplete",
"Launch pipeline finished without producing a result.");
}
}
internal static class LaunchResultBuilder
{
public static LauncherResult Build(
bool success,
string stage,
string code,
string message,
Dictionary<string, string>? details = null,
string? errorMessage = null)
{
Logger.Info($"Launcher result prepared. Success={success}; Stage='{stage}'; Code='{code}'.");
return new LauncherResult
{
Success = success,
Stage = stage,
Code = code,
Message = message,
ErrorMessage = errorMessage,
Details = details ?? []
};
}
public static LauncherResult BuildFailure(string stage, string code, string message) =>
Build(false, stage, code, message);
public static LauncherResult WithAdditionalDetails(LauncherResult result, Dictionary<string, string> details) =>
new()
{
Success = result.Success,
Stage = result.Stage,
Code = result.Code,
Message = result.Message,
CurrentVersion = result.CurrentVersion,
TargetVersion = result.TargetVersion,
RolledBackTo = result.RolledBackTo,
Details = MergeDetails(details, result.Details),
InstalledPackagePath = result.InstalledPackagePath,
ManifestId = result.ManifestId,
ManifestName = result.ManifestName,
ErrorMessage = result.ErrorMessage
};
public static Dictionary<string, string> BuildLauncherContextDetails(
CommandContext context,
OobeLaunchDecision oobeDecision,
string appRoot) =>
new(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource,
["isGuiMode"] = context.IsGuiCommand.ToString(),
["isDebugMode"] = context.IsDebugMode.ToString(),
["isElevated"] = oobeDecision.IsElevated.ToString(),
["resolvedAppRoot"] = appRoot,
["oobeStatePath"] = oobeDecision.StatePath,
["oobeStateStatus"] = oobeDecision.Status.ToString(),
["oobeDecision"] = oobeDecision.ShouldShowOobe ? "show" : "skip",
["oobeSuppressionReason"] = oobeDecision.SuppressionReason,
["oobeResultCode"] = oobeDecision.ResultCode,
["userSid"] = oobeDecision.UserSid ?? string.Empty,
["usedLegacyOobeMarker"] = oobeDecision.UsedLegacyMarker.ToString(),
["migratedLegacyOobeMarker"] = oobeDecision.MigratedLegacyMarker.ToString(),
["oobeStateError"] = oobeDecision.ErrorMessage
};
public static Dictionary<string, string> MergeDetails(
Dictionary<string, string> left,
Dictionary<string, string> right)
{
var merged = new Dictionary<string, string>(left, StringComparer.OrdinalIgnoreCase);
foreach (var pair in right)
{
merged[pair.Key] = pair.Value;
}
return merged;
}
public static bool TryGetLiveProcess(int processId, out Process? process)
{
process = null;
if (processId <= 0)
{
return false;
}
try
{
process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
process?.Dispose();
process = null;
return false;
}
}
}

View File

@@ -0,0 +1,174 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal static class LaunchUiPresenter
{
public static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
try
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
}
catch (Exception ex)
{
Logger.Error("Failed to dismiss splash window.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
{
loadingDetailsWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close loading details window.", ex);
}
});
}
public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{
ErrorWindow? errorWindow = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
errorWindow.ConfigureForHostNotFound();
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
errorWindow.Show();
Logger.Warn("Host not found. Showing error window.");
}
catch (Exception ex)
{
Logger.Error("Failed to show host-not-found error window.", ex);
}
});
if (errorWindow is null)
{
return (ErrorWindowResult.Exit, null);
}
ErrorWindowResult result;
string? customPath;
try
{
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
customPath = errorWindow.GetCustomHostPath();
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
}
catch (Exception ex)
{
Logger.Error("Failed while waiting for host-not-found window result.", ex);
result = ErrorWindowResult.Exit;
customPath = null;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (errorWindow.IsVisible && errorWindow.IsLoaded)
{
errorWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close host-not-found error window.", ex);
}
});
return (result, customPath);
}
public static async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
{
MigrationPromptWindow? migrationWindow = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
migrationWindow = new MigrationPromptWindow();
migrationWindow.SetLegacyInfo(legacyInfo);
migrationWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show migration prompt window.", ex);
}
});
if (migrationWindow is null)
{
return MigrationResult.Skipped;
}
MigrationResult result;
try
{
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failed while waiting for migration prompt result.", ex);
result = MigrationResult.Skipped;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
{
migrationWindow.Close();
}
}
catch (Exception ex)
{
Logger.Error("Failed to close migration prompt window.", ex);
}
});
return result;
}
public static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
{
StartupStage.Initializing => "initializing",
StartupStage.LoadingSettings => "settings",
StartupStage.LoadingPlugins => "plugins",
StartupStage.TrayReady => "shell",
StartupStage.InitializingUI => "ui",
StartupStage.ShellInitialized => "shell",
StartupStage.BackgroundReady => "ready",
StartupStage.DesktopVisible => "ready",
StartupStage.ActivationRedirected => "activation",
StartupStage.ActivationFailed => "error",
StartupStage.Ready => "ready",
_ => "launch"
};
public static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
{
var prompt = new MultiInstancePromptWindow();
prompt.SetDetails(status.ProcessId, status.ShellState);
prompt.Show();
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
});
}
}

View File

@@ -0,0 +1,27 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class ApplyPendingUpdatePhase : ILaunchPhase
{
public string Name => nameof(ApplyPendingUpdatePhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
context.Reporter.Report("update", "Checking updates...");
var updateResult = await context.UpdateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'.");
context.Reporter.Report("update", "Update failed, launching existing version...");
try
{
context.UpdateEngine.CleanupIncomingArtifacts();
}
catch (Exception ex)
{
Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}");
}
}
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class CleanupDeploymentsPhase : ILaunchPhase
{
public string Name => nameof(CleanupDeploymentsPhase);
public Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
context.DeploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
context.OobeDecision = context.OobeStateService.Evaluate(context.CommandContext);
context.LauncherContextDetails = LaunchResultBuilder.BuildLauncherContextDetails(
context.CommandContext,
context.OobeDecision,
context.DeploymentLocator.GetAppRoot());
return Task.FromResult(new LaunchPhaseResult(LaunchPhaseStatus.Continue));
}
}

View File

@@ -0,0 +1,71 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class ExistingHostProbePhase : ILaunchPhase
{
public string Name => nameof(ExistingHostProbePhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
if (!HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context.CommandContext))
{
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
var multiInstanceBehavior = ExistingHostProbe.LoadMultiInstanceLaunchBehavior(context.DataLocationResolver);
var existingShellStatus = await ExistingHostProbe.TryGetExistingHostStatusAsync(
context.IpcClient,
StartupTimeoutPolicy.ExistingHostProbeTimeout).ConfigureAwait(false);
if (!HostActivationPolicy.IsExistingHostReadyForLauncherDecision(existingShellStatus))
{
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
context.IpcConnected = true;
context.ShellStatus = existingShellStatus;
var decisionResult = await ExistingHostProbe.ApplyExistingHostBehaviorAsync(
context.IpcClient,
multiInstanceBehavior,
existingShellStatus!).ConfigureAwait(false);
context.ShellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
HostActivationPolicy.IsRecoverableActivationFailure(decisionResult.ActivationResult);
context.LastStage = decisionResult.Success || recoverableActivationFailure
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
context.LastStageMessage = decisionResult.Message;
if (decisionResult.Success || recoverableActivationFailure)
{
context.StartupAttemptRegistry.MarkOwnedSucceeded(context.LastStage, context.LastStageMessage);
}
else
{
context.StartupAttemptRegistry.MarkOwnedFailed(context.LastStage, context.LastStageMessage);
}
context.PublishCoordinatorStatus(true, true, decisionResult.Success);
context.WindowsClosingByOrchestrator = true;
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.Build(
decisionResult.Success,
"launch",
decisionResult.Code,
decisionResult.Message,
LaunchResultBuilder.MergeDetails(
context.LauncherContextDetails,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["publicIpcConnected"] = "true",
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
["existingHostPid"] = context.ShellStatus?.ProcessId.ToString() ?? string.Empty,
["existingShellState"] = context.ShellStatus?.ShellState ?? string.Empty,
["existingTrayState"] = context.ShellStatus?.Tray.State ?? string.Empty,
["existingTaskbarUsable"] = context.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
})));
}
}

View File

@@ -0,0 +1,173 @@
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class LaunchHostPhase : ILaunchPhase
{
private static readonly string SoftTimeoutStatusMessage = Resources.Strings.Coordinator_SlowDeviceMessage;
private static readonly string SoftTimeoutDetailsMessage = Resources.Strings.Coordinator_RunningHostMessage;
private readonly HostLaunchService _hostLaunchService = new();
public string Name => nameof(LaunchHostPhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
context.Reporter.Report("launch", "Launching desktop...");
var startupSuccessTracker = context.SuccessTracker;
var attachableAttempt = context.StartupAttemptRegistry.TryGetAttachableAttempt(
context.CommandContext.LaunchSource,
startupSuccessTracker.PolicyKey);
HostLaunchOutcome launchOutcome;
if (attachableAttempt is not null &&
context.StartupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) &&
LaunchResultBuilder.TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess))
{
context.TrackedAttempt = attachableAttempt;
context.AttachedToExistingAttempt = true;
context.IpcConnected = attachableAttempt.IpcConnected;
context.LastStage = attachableAttempt.LastObservedStage;
context.LastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage)
? "Attached to the existing startup attempt."
: attachableAttempt.LastObservedMessage;
context.Reporter.Report(
LaunchUiPresenter.MapStartupStageToSplashStage(context.LastStage),
context.LastStageMessage);
context.PublishCoordinatorStatus(true, false, false);
if (startupSuccessTracker.TryResolve(context.LastStage, out var attachedSuccessState))
{
context.WindowsClosingByOrchestrator = true;
context.StartupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message);
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.Build(
true,
"launch",
attachedSuccessState.Code,
attachedSuccessState.Message,
LaunchResultBuilder.MergeDetails(
context.LauncherContextDetails,
LaunchAttemptDetails.Build(
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.IpcConnected,
hostProcessAlive: true,
context.LastStage,
context.LastStageMessage,
context.ActivationFailureReason,
softTimeoutShown: false,
recoveryActivationAttempted: false))));
}
if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
context.SoftTimeoutShown = true;
context.Reporter.Report("delayed", SoftTimeoutStatusMessage);
context.LoadingState = HostStartupMonitor.BuildDelayedLoadingState(
context.LoadingState,
SoftTimeoutStatusMessage,
SoftTimeoutDetailsMessage,
context.TrackedAttempt!.StartedAtUtc);
context.LoadingDetailsWindow?.UpdateLoadingState(context.LoadingState);
}
launchOutcome = HostLaunchOutcome.FromProcess(
attachedProcess!,
LaunchResultBuilder.Build(
true,
"launchHost",
"attached_attempt",
"Attached to an existing startup attempt.",
LaunchAttemptDetails.Build(
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.IpcConnected,
hostProcessAlive: true,
context.LastStage,
context.LastStageMessage,
context.ActivationFailureReason,
context.SoftTimeoutShown,
recoveryActivationAttempted: false)),
LaunchAttemptDetails.Build(
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.IpcConnected,
hostProcessAlive: true,
context.LastStage,
context.LastStageMessage,
context.ActivationFailureReason,
context.SoftTimeoutShown,
recoveryActivationAttempted: false));
}
else
{
launchOutcome = await _hostLaunchService.LaunchAsync(context).ConfigureAwait(false);
}
context.LaunchOutcome = launchOutcome;
if (!launchOutcome.Result.Success)
{
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.WithAdditionalDetails(launchOutcome.Result, context.LauncherContextDetails));
}
if (launchOutcome.ImmediateResult is not null)
{
context.WindowsClosingByOrchestrator = true;
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.WithAdditionalDetails(launchOutcome.ImmediateResult, context.LauncherContextDetails));
}
if (launchOutcome.Process is null)
{
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.Build(
success: false,
stage: "launch",
code: "host_start_failed",
message: "Host launch did not create a process.",
details: LaunchResultBuilder.MergeDetails(
context.LauncherContextDetails,
LaunchResultBuilder.MergeDetails(
launchOutcome.Details,
LaunchAttemptDetails.Build(
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.IpcConnected,
hostProcessAlive: false,
context.LastStage,
context.LastStageMessage,
context.ActivationFailureReason,
context.SoftTimeoutShown,
recoveryActivationAttempted: false)))));
}
if (!context.AttachedToExistingAttempt)
{
var reservedAttempt = context.StartupAttemptRegistry.GetOwnedAttempt();
context.TrackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
? context.StartupAttemptRegistry.AssignOwnedHostProcess(
launchOutcome.Process.Id,
context.LastStage,
context.LastStageMessage)
: context.StartupAttemptRegistry.StartOwnedAttempt(
launchOutcome.Process.Id,
context.CommandContext.LaunchSource,
startupSuccessTracker.PolicyKey,
context.LastStage,
context.LastStageMessage);
context.PublishCoordinatorStatus(true, false, false);
}
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
}

View File

@@ -0,0 +1,68 @@
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class MonitorStartupPhase : ILaunchPhase
{
public string Name => nameof(MonitorStartupPhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
var launchOutcome = context.LaunchOutcome
?? throw new InvalidOperationException("LaunchHostPhase must run before MonitorStartupPhase.");
if (launchOutcome.Process is null)
{
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.BuildFailure("launch", "host_start_failed", "Host process is missing."));
}
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) =>
LaunchResultBuilder.MergeDetails(
context.LauncherContextDetails,
LaunchResultBuilder.MergeDetails(
launchOutcome.Details,
LaunchAttemptDetails.Build(
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.IpcConnected,
hostProcessAlive,
context.LastStage,
context.LastStageMessage,
context.ActivationFailureReason,
context.SoftTimeoutShown,
recoveryActivationAttempted)));
var monitor = new HostStartupMonitor();
var monitorOutcome = await monitor.MonitorUntilCompleteAsync(new HostStartupMonitor.Request(
launchOutcome.Process,
context.IpcClient,
context.SuccessTracker,
context.StartupAttemptRegistry,
context.TrackedAttempt,
context.AttachedToExistingAttempt,
context.LauncherContextDetails,
context.SuccessTcs,
context.ActivationFailedTcs,
context.Reporter,
context.LoadingDetailsWindow,
context.LoadingState,
context.LastStage,
context.LastStageMessage,
context.IpcConnected,
context.ActivationFailureReason,
context.SoftTimeoutShown,
context.PublishCoordinatorStatus,
ComposeLaunchDetails)).ConfigureAwait(false);
context.WindowsClosingByOrchestrator = true;
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
LaunchResultBuilder.Build(
monitorOutcome.Success,
"launch",
monitorOutcome.Code,
monitorOutcome.Message,
monitorOutcome.Details));
}
}

View File

@@ -0,0 +1,24 @@
using Avalonia.Threading;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class OobeGatePhase : ILaunchPhase
{
public string Name => nameof(OobeGatePhase);
public async Task<LaunchPhaseResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
if (context.OobeDecision.ShouldShowOobe)
{
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Hide());
foreach (var step in context.OobeSteps)
{
await step.RunAsync(cancellationToken).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Show());
}
return new LaunchPhaseResult(LaunchPhaseStatus.Continue);
}
}

View File

@@ -0,0 +1,56 @@
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.Launcher.Startup;
internal static class PublicIpcConnection
{
public static async Task<bool> TryConnectAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (ipcClient.IsConnected)
{
return true;
}
try
{
var connectTask = ipcClient.ConnectAsync();
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout, cancellationToken)).ConfigureAwait(false);
if (completedTask != connectTask)
{
return false;
}
await connectTask.ConfigureAwait(false);
return ipcClient.IsConnected;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.Info($"Public IPC is not ready yet: {ex.Message}");
return false;
}
}
public static async Task<bool> TryConnectWithBackoffAsync(
LanMountainDesktopIpcClient ipcClient,
IReadOnlyList<TimeSpan> attemptTimeouts,
CancellationToken cancellationToken = default)
{
if (ipcClient.IsConnected)
{
return true;
}
foreach (var timeout in attemptTimeouts)
{
if (await TryConnectAsync(ipcClient, timeout, cancellationToken).ConfigureAwait(false))
{
return true;
}
}
return ipcClient.IsConnected;
}
}

View File

@@ -5,7 +5,7 @@ using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class StartupAttemptRegistry
{

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal static class StartupDiagnostics
{
private static readonly bool Enabled =
string.Equals(
Environment.GetEnvironmentVariable("LMD_LAUNCHER_STARTUP_DIAG"),
"1",
StringComparison.OrdinalIgnoreCase);
public static bool IsEnabled => Enabled;
public static void Trace(string eventName, IReadOnlyDictionary<string, string?> fields)
{
if (!Enabled)
{
return;
}
var payload = new Dictionary<string, string?>(fields, StringComparer.OrdinalIgnoreCase)
{
["event"] = eventName,
["timestampUtc"] = DateTimeOffset.UtcNow.ToString("O")
};
Logger.Info($"[startup-diag] {eventName}: {string.Join("; ", payload.Select(static kv => $"{kv.Key}={kv.Value}"))}");
try
{
var directory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
".launcher",
"diag");
Directory.CreateDirectory(directory);
var filePath = Path.Combine(directory, $"startup-{DateTime.UtcNow:yyyyMMdd}.jsonl");
var line = JsonSerializer.Serialize(payload);
File.AppendAllText(filePath, line + Environment.NewLine);
}
catch (Exception ex)
{
Logger.Warn($"Failed to write startup diagnostic bundle: {ex.Message}");
}
}
public static void TraceShellStatus(string source, PublicShellStatus? status, StartupStage? stage = null)
{
if (!Enabled)
{
return;
}
Trace(
"shell_status",
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["source"] = source,
["stage"] = stage?.ToString(),
["processId"] = status?.ProcessId.ToString(),
["publicIpcReady"] = status?.PublicIpcReady.ToString(),
["desktopVisible"] = status?.DesktopVisible.ToString(),
["mainWindowVisible"] = status?.MainWindowVisible.ToString(),
["mainWindowOpened"] = status?.MainWindowOpened.ToString(),
["shellState"] = status?.ShellState
});
}
}

View File

@@ -0,0 +1,137 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Startup;
internal enum LaunchSuccessPolicy
{
Foreground,
RestartBackground,
RestartTray
}
internal sealed record StartupSuccessState(
StartupStage Stage,
string Code,
string Message);
internal sealed class StartupSuccessTracker
{
private readonly LaunchSuccessPolicy _policy;
private bool _trayReady;
private bool _backgroundReady;
public string PolicyKey => _policy.ToString();
public StartupSuccessTracker(CommandContext context)
{
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
_policy = !isRestartLaunch
? LaunchSuccessPolicy.Foreground
: restartPresentation switch
{
RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray,
RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground,
_ => LaunchSuccessPolicy.Foreground
};
}
public bool TryResolve(StartupStage stage, out StartupSuccessState successState)
{
switch (stage)
{
case StartupStage.ActivationRedirected:
successState = new StartupSuccessState(
stage,
"activation_redirected",
"Launcher activation was redirected to the existing desktop instance.");
return true;
case StartupStage.DesktopVisible:
successState = new StartupSuccessState(
stage,
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback",
_policy == LaunchSuccessPolicy.Foreground
? "Desktop is visible and ready."
: "Desktop recovered in a visible state.");
return true;
case StartupStage.Ready:
successState = new StartupSuccessState(
stage,
_policy == LaunchSuccessPolicy.Foreground ? "ready" : "background_ready",
"Desktop reported that startup is ready.");
return true;
case StartupStage.TrayReady:
_trayReady = true;
break;
case StartupStage.BackgroundReady:
_backgroundReady = true;
break;
}
if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed in the background.");
return true;
}
if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed with tray recovery ready.");
return true;
}
successState = default!;
return false;
}
public bool TryResolve(PublicShellStatus? status, out StartupSuccessState successState)
{
if (status is not null &&
(status.DesktopVisible || status.MainWindowVisible || status.MainWindowOpened))
{
successState = new StartupSuccessState(
status.DesktopVisible || status.MainWindowVisible
? StartupStage.DesktopVisible
: StartupStage.Ready,
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "background_ready",
status.DesktopVisible || status.MainWindowVisible
? "Desktop shell is visible and ready."
: "Desktop shell window has opened.");
return true;
}
successState = default!;
return false;
}
public StartupSuccessState BuildRecoverySuccessState()
{
return _policy switch
{
LaunchSuccessPolicy.RestartTray => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed tray readiness."),
LaunchSuccessPolicy.RestartBackground => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed readiness."),
_ => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery from the running desktop instance.")
};
}
}

View File

@@ -0,0 +1,23 @@
namespace LanMountainDesktop.Launcher.Startup;
internal static class StartupTimeoutPolicy
{
public static readonly TimeSpan SoftTimeout = TimeSpan.FromSeconds(30);
public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(120);
/// <summary>Initial Public IPC connect attempt (AOT cold start may be slower).</summary>
public static readonly TimeSpan InitialIpcConnectTimeout = TimeSpan.FromMilliseconds(1200);
/// <summary>Subsequent reconnect attempts use increasing per-try timeouts.</summary>
public static readonly TimeSpan[] IpcReconnectAttemptTimeouts =
[
TimeSpan.FromMilliseconds(800),
TimeSpan.FromMilliseconds(1500),
TimeSpan.FromMilliseconds(3000),
TimeSpan.FromMilliseconds(5000)
];
public static readonly TimeSpan ExistingHostProbeTimeout = TimeSpan.FromMilliseconds(900);
public static readonly TimeSpan ShellStatusPollInterval = TimeSpan.FromSeconds(1);
public static readonly TimeSpan IpcReconnectInterval = TimeSpan.FromSeconds(2);
}

View 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();
}

View File

@@ -1,6 +1,6 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Update;
public interface IUpdateProgressReporter
{

View File

@@ -1,6 +1,6 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
{

View File

@@ -4,9 +4,9 @@ using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Update;
internal sealed class UpdateEngineService
internal sealed class UpdateEngineFacade : IUpdateEngine
{
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
@@ -28,7 +28,7 @@ internal sealed class UpdateEngineService
private readonly string _snapshotsRoot;
private readonly string _installCheckpointPath;
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
{
_deploymentLocator = deploymentLocator;
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
@@ -1674,7 +1674,7 @@ internal sealed class UpdateEngineService
private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
internal void CleanupIncomingArtifacts()
public void CleanupIncomingArtifacts()
{
foreach (var path in new[]
{

View File

@@ -8,7 +8,7 @@ using Avalonia.Media;
using Avalonia.Styling;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Views;

View File

@@ -1,7 +1,7 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
using LanMountainDesktop.Launcher.ViewModels;
using LanMountainDesktop.Launcher.Views;

View File

@@ -6,7 +6,7 @@ using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Views;

View File

@@ -4,7 +4,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
using LanMountainDesktop.Shared.Contracts.Launcher;
using System.Collections.ObjectModel;
using System.ComponentModel;

View File

@@ -2,7 +2,7 @@ using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Views;

View File

@@ -8,7 +8,7 @@ using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Views;
@@ -28,7 +28,7 @@ public partial class OobeWindow : Window
private bool _migrateExistingData;
// 主题选择
private Services.ThemeMode _selectedThemeMode = Services.ThemeMode.Light;
private ThemeMode _selectedThemeMode = ThemeMode.Light;
private string _selectedAccentColor = "#0078D4";
private MonetSource _selectedMonetSource = MonetSource.Wallpaper;
@@ -82,19 +82,19 @@ public partial class OobeWindow : Window
// 浅色/深色模式选择
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
{
lightModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Light);
lightModeOption.PointerPressed += (s, e) => SelectThemeMode(ThemeMode.Light);
}
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
{
darkModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Dark);
darkModeOption.PointerPressed += (s, e) => SelectThemeMode(ThemeMode.Dark);
}
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
{
lightModeRadio.IsCheckedChanged += (s, e) =>
{
if (lightModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Light);
if (lightModeRadio.IsChecked == true) SelectThemeMode(ThemeMode.Light);
};
}
@@ -102,7 +102,7 @@ public partial class OobeWindow : Window
{
darkModeRadio.IsCheckedChanged += (s, e) =>
{
if (darkModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Dark);
if (darkModeRadio.IsChecked == true) SelectThemeMode(ThemeMode.Dark);
};
}
@@ -812,7 +812,7 @@ public partial class OobeWindow : Window
}
// 主题选择方法
private void SelectThemeMode(Services.ThemeMode mode)
private void SelectThemeMode(ThemeMode mode)
{
_selectedThemeMode = mode;
@@ -821,30 +821,30 @@ public partial class OobeWindow : Window
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
{
lightModeRadio.IsChecked = mode == Services.ThemeMode.Light;
lightModeRadio.IsChecked = mode == ThemeMode.Light;
}
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
{
darkModeRadio.IsChecked = mode == Services.ThemeMode.Dark;
darkModeRadio.IsChecked = mode == ThemeMode.Dark;
}
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
{
lightModeOption.BorderBrush = mode == Services.ThemeMode.Light
lightModeOption.BorderBrush = mode == ThemeMode.Light
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
lightModeOption.BorderThickness = mode == Services.ThemeMode.Light
lightModeOption.BorderThickness = mode == ThemeMode.Light
? new Thickness(2)
: new Thickness(1);
}
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
{
darkModeOption.BorderBrush = mode == Services.ThemeMode.Dark
darkModeOption.BorderBrush = mode == ThemeMode.Dark
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
darkModeOption.BorderThickness = mode == Services.ThemeMode.Dark
darkModeOption.BorderThickness = mode == ThemeMode.Dark
? new Thickness(2)
: new Thickness(1);
}

View File

@@ -7,7 +7,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Views;

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<RootNamespace>LanMountainDesktop.PluginPackaging</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,216 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginPackaging;
public enum PendingPluginOperation
{
InstallOrUpgrade = 0
}
public sealed record PendingPluginUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt,
PendingPluginOperation Operation = PendingPluginOperation.InstallOrUpgrade)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion);
}
}
public sealed record PendingPluginOperationApplySummary(
int SuccessCount,
int FailureCount,
IReadOnlyList<PendingPluginOperationFailure> Failures);
public sealed record PendingPluginOperationFailure(
string PluginId,
PendingPluginOperation Operation,
string ErrorMessage);
public sealed class PendingPluginUpgradeStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly string _pluginsDirectory;
private readonly string _pendingUpgradesFilePath;
private readonly object _gate = new();
public PendingPluginUpgradeStore(string pluginsDirectory)
{
_pluginsDirectory = Path.GetFullPath(pluginsDirectory);
_pendingUpgradesFilePath = Path.Combine(_pluginsDirectory, PluginPackagingConstants.PendingUpgradesFileName);
}
public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades()
{
lock (_gate)
{
return ReadPendingUpgradesCore();
}
}
public void AddPendingInstallOrUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
{
AddPendingOperation(pluginId, sourcePackagePath, targetVersion, PendingPluginOperation.InstallOrUpgrade);
}
public void RemovePendingUpgrade(string pluginId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
lock (_gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
var removed = upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
if (removed > 0)
{
SavePendingUpgradesCore(upgrades);
}
}
}
public void ClearPendingUpgrades()
{
lock (_gate)
{
if (File.Exists(_pendingUpgradesFilePath))
{
File.Delete(_pendingUpgradesFilePath);
}
}
}
public bool HasPendingUpgrades()
{
lock (_gate)
{
return ReadPendingUpgradesCore().Count > 0;
}
}
public PendingPluginOperationApplySummary ApplyPendingOperations(
PluginPackageInstaller installer,
PluginPackageInstallOptions? options = null,
Action<PluginPackageManifest>? prepareManifest = null)
{
options ??= PluginPackageInstallOptions.Default;
lock (_gate)
{
var pending = ReadPendingUpgradesCore();
if (pending.Count == 0)
{
return new PendingPluginOperationApplySummary(0, 0, []);
}
Directory.CreateDirectory(_pluginsDirectory);
var succeeded = new List<PendingPluginUpgrade>();
var failures = new List<PendingPluginOperationFailure>();
foreach (var operation in pending)
{
try
{
if (operation.Operation != PendingPluginOperation.InstallOrUpgrade)
{
throw new InvalidOperationException($"Unsupported pending plugin operation '{operation.Operation}'.");
}
installer.Install(operation.SourcePackagePath, _pluginsDirectory, options, prepareManifest);
succeeded.Add(operation);
}
catch (Exception ex)
{
failures.Add(new PendingPluginOperationFailure(
operation.PluginId,
operation.Operation,
ex.Message));
}
}
var remaining = pending.Except(succeeded).ToList();
SavePendingUpgradesCore(remaining);
return new PendingPluginOperationApplySummary(succeeded.Count, failures.Count, failures);
}
}
private void AddPendingOperation(
string pluginId,
string sourcePackagePath,
string targetVersion,
PendingPluginOperation operation)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion);
lock (_gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
upgrades.Add(new PendingPluginUpgrade(
pluginId,
Path.GetFullPath(sourcePackagePath),
targetVersion,
DateTimeOffset.UtcNow,
operation));
SavePendingUpgradesCore(upgrades);
}
}
private List<PendingPluginUpgrade> ReadPendingUpgradesCore()
{
if (!File.Exists(_pendingUpgradesFilePath))
{
return [];
}
try
{
var json = File.ReadAllText(_pendingUpgradesFilePath);
var upgrades = JsonSerializer.Deserialize<List<PendingPluginUpgrade>>(json, SerializerOptions);
return upgrades?.Where(u => u.IsValid()).ToList() ?? [];
}
catch
{
return [];
}
}
private void SavePendingUpgradesCore(List<PendingPluginUpgrade> upgrades)
{
var directory = Path.GetDirectoryName(_pendingUpgradesFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
if (upgrades.Count == 0)
{
if (File.Exists(_pendingUpgradesFilePath))
{
File.Delete(_pendingUpgradesFilePath);
}
return;
}
var json = JsonSerializer.Serialize(upgrades, SerializerOptions);
File.WriteAllText(_pendingUpgradesFilePath, json);
}
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed class PluginPackageInstallOptions
{
public bool IncludeLegacyPackages { get; init; }
public static PluginPackageInstallOptions Default { get; } = new();
public static PluginPackageInstallOptions WithLegacySupport { get; } = new() { IncludeLegacyPackages = true };
}

View File

@@ -0,0 +1,3 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed record PluginPackageInstallResult(string InstalledPackagePath, PluginPackageManifest Manifest);

View File

@@ -0,0 +1,195 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed class PluginPackageInstaller
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public PluginPackageInstallResult Install(
string sourcePackagePath,
string pluginsDirectory,
PluginPackageInstallOptions? options = null,
Action<PluginPackageManifest>? prepareManifest = null)
{
options ??= PluginPackageInstallOptions.Default;
var fullSourcePath = Path.GetFullPath(sourcePackagePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = PluginPackageManifestReader.Read(fullSourcePath, options.IncludeLegacyPackages);
prepareManifest?.Invoke(manifest);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath, options);
MoveWithOverwriteRetry(stagingPath, destinationPath);
return new PluginPackageInstallResult(destinationPath, manifest);
}
private static void RemoveExistingPluginPackages(
string pluginsDirectory,
string pluginId,
string destinationPath,
string stagingPath,
PluginPackageInstallOptions options)
{
var runtimeRootDirectory = EnsureTrailingSeparator(
Path.Combine(Path.GetFullPath(pluginsDirectory), PluginPackagingConstants.RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, PluginPackagingConstants.PendingDeletionDirectoryName);
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in EnumerateExistingPackages(pluginsDirectory, options)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = PluginPackageManifestReader.Read(existingPackagePath, options.IncludeLegacyPackages);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
// Ignore unrelated or malformed packages while replacing one plugin id.
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private static IEnumerable<string> EnumerateExistingPackages(string pluginsDirectory, PluginPackageInstallOptions options)
{
if (options.IncludeLegacyPackages)
{
return Directory
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
.Where(path =>
path.EndsWith(PluginPackagingConstants.PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(PluginPackagingConstants.LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase));
}
return Directory.EnumerateFiles(
pluginsDirectory,
$"*{PluginPackagingConstants.PackageFileExtension}",
SearchOption.AllDirectories);
}
private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
File.Move(existingPackagePath, pendingPath);
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
// Best-effort cleanup only.
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PluginPackagingConstants.PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
}

View File

@@ -0,0 +1,3 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed record PluginPackageManifest(string Id, string Name, string Version);

View File

@@ -0,0 +1,70 @@
using System.IO.Compression;
using System.Text.Json;
namespace LanMountainDesktop.PluginPackaging;
public static class PluginPackageManifestReader
{
public static PluginPackageManifest Read(string packagePath, bool includeLegacyManifest = false)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = FindManifestEntries(archive, PluginPackagingConstants.ManifestFileName);
if (entries.Length == 0 && includeLegacyManifest)
{
entries = FindManifestEntries(archive, PluginPackagingConstants.LegacyManifestFileName);
}
if (entries.Length == 0)
{
var expected = includeLegacyManifest
? $"'{PluginPackagingConstants.ManifestFileName}' or '{PluginPackagingConstants.LegacyManifestFileName}'"
: $"'{PluginPackagingConstants.ManifestFileName}'";
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain {expected}.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{PluginPackagingConstants.ManifestFileName}' files.");
}
using var stream = entries[0].Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var id = ReadRequiredString(root, "id");
var name = ReadRequiredString(root, "name");
var version = ReadOptionalString(root, "version") ?? string.Empty;
return new PluginPackageManifest(id, name, version);
}
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
{
return archive.Entries
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
private static string ReadRequiredString(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var value) ||
value.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(value.GetString()))
{
throw new InvalidOperationException($"Plugin manifest is missing required property '{propertyName}'.");
}
return value.GetString()!;
}
private static string? ReadOptionalString(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.String)
{
return null;
}
return value.GetString();
}
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginPackaging;
public static class PluginPackagingConstants
{
public const string ManifestFileName = "plugin.json";
public const string LegacyManifestFileName = "manifest.json";
public const string PackageFileExtension = ".laapp";
public const string LegacyPackageFileExtension = ".lmdp";
public const string RuntimeDirectoryName = ".runtime";
public const string PendingDeletionDirectoryName = ".pending-deletions";
public const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
}

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>LanMountainDesktop.PluginUpgradeHelper</AssemblyName>
<RootNamespace>LanMountainDesktop.PluginUpgradeHelper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,372 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginUpgradeHelper;
internal static class Program
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private const string LogFileName = "plugin-upgrade-helper.log";
private static int Main(string[] args)
{
var logPath = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", LogFileName);
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
File.AppendAllText(logPath, $"\n[{DateTime.Now:O}] PluginUpgradeHelper started. Args: {string.Join(" ", args)}\n");
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
string.IsNullOrWhiteSpace(pluginsDirectory))
{
LogError(logPath, "Missing required argument: --plugins-dir");
return 1;
}
if (!parsedArgs.TryGetValue("parent-pid", out var parentPidStr) ||
!int.TryParse(parentPidStr, out var parentPid))
{
LogError(logPath, "Missing or invalid argument: --parent-pid");
return 1;
}
parsedArgs.TryGetValue("launch", out var launchCommand);
LogInfo(logPath, $"Waiting for parent process {parentPid} to exit...");
WaitForParentProcess(parentPid);
LogInfo(logPath, $"Processing pending upgrades in '{pluginsDirectory}'...");
var upgradeResults = ProcessPendingUpgrades(pluginsDirectory, logPath);
LogInfo(logPath, $"Upgrades completed. Success: {upgradeResults.SuccessCount}, Failed: {upgradeResults.FailureCount}");
if (!string.IsNullOrWhiteSpace(launchCommand))
{
LogInfo(logPath, $"Launching application: {launchCommand}");
LaunchApplication(launchCommand, parsedArgs);
}
return upgradeResults.FailureCount > 0 ? 2 : 0;
}
catch (Exception ex)
{
LogError(logPath, $"Unexpected error: {ex}");
return 1;
}
}
private static void WaitForParentProcess(int parentPid)
{
try
{
var parentProcess = Process.GetProcessById(parentPid);
parentProcess.WaitForExit(TimeSpan.FromSeconds(30));
}
catch (ArgumentException)
{
// Process already exited
}
catch (Exception)
{
// Ignore errors, continue anyway
}
Thread.Sleep(500);
}
private static UpgradeResults ProcessPendingUpgrades(string pluginsDirectory, string logPath)
{
var pendingUpgradesPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
var successCount = 0;
var failureCount = 0;
if (!File.Exists(pendingUpgradesPath))
{
LogInfo(logPath, "No pending upgrades found.");
return new UpgradeResults(0, 0);
}
List<PendingUpgrade>? pendingUpgrades;
try
{
var json = File.ReadAllText(pendingUpgradesPath);
pendingUpgrades = JsonSerializer.Deserialize<List<PendingUpgrade>>(json);
}
catch (Exception ex)
{
LogError(logPath, $"Failed to read pending upgrades: {ex.Message}");
return new UpgradeResults(0, 0);
}
if (pendingUpgrades is null || pendingUpgrades.Count == 0)
{
LogInfo(logPath, "No pending upgrades to process.");
return new UpgradeResults(0, 0);
}
Directory.CreateDirectory(pluginsDirectory);
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var upgrade in pendingUpgrades)
{
if (!upgrade.IsValid())
{
LogWarn(logPath, $"Skipping invalid upgrade entry for plugin '{upgrade.PluginId}'.");
failureCount++;
continue;
}
try
{
LogInfo(logPath, $"Processing upgrade for plugin '{upgrade.PluginId}' to version '{upgrade.TargetVersion}'...");
var manifest = ReadManifestFromPackage(upgrade.SourcePackagePath);
var destinationPath = Path.Combine(pluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
RemoveExistingPluginPackages(pluginsDirectory, manifest.Id, destinationPath, pendingDeletionDir, logPath);
File.Copy(upgrade.SourcePackagePath, destinationPath, overwrite: true);
LogInfo(logPath, $"Successfully upgraded plugin '{upgrade.PluginId}' to '{upgrade.TargetVersion}'.");
successCount++;
}
catch (Exception ex)
{
LogError(logPath, $"Failed to upgrade plugin '{upgrade.PluginId}': {ex.Message}");
failureCount++;
}
}
try
{
File.Delete(pendingUpgradesPath);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending upgrades file: {ex.Message}");
}
CleanupPendingDeletions(pendingDeletionDir, logPath);
return new UpgradeResults(successCount, failureCount);
}
private static void RemoveExistingPluginPackages(
string pluginsDirectory,
string pluginId,
string destinationPath,
string pendingDeletionDir,
string logPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), ".runtime"));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryDeleteOrMoveFile(existingPackagePath, pendingDeletionDir, logPath);
}
catch
{
// Ignore unrelated or malformed packages
}
}
}
private static void TryDeleteOrMoveFile(string filePath, string pendingDeletionDir, string logPath)
{
try
{
File.Delete(filePath);
LogInfo(logPath, $"Deleted existing package: {filePath}");
}
catch (IOException)
{
var fileName = Path.GetFileName(filePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
try
{
File.Move(filePath, pendingPath);
LogInfo(logPath, $"Moved existing package to pending deletion: {filePath} -> {pendingPath}");
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to move existing package '{filePath}': {ex.Message}");
}
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir, string logPath)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending file '{pendingFile}': {ex.Message}");
}
}
try
{
if (Directory.GetFiles(pendingDeletionDir).Length == 0 &&
Directory.GetDirectories(pendingDeletionDir).Length == 0)
{
Directory.Delete(pendingDeletionDir);
}
}
catch
{
// Ignore
}
}
private static void LaunchApplication(string launchCommand, Dictionary<string, string> args)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = launchCommand,
UseShellExecute = true,
WorkingDirectory = args.TryGetValue("working-dir", out var workingDir)
? workingDir
: AppContext.BaseDirectory
};
if (args.TryGetValue("launch-args", out var launchArgs) && !string.IsNullOrWhiteSpace(launchArgs))
{
startInfo.Arguments = launchArgs;
}
Process.Start(startInfo);
}
catch (Exception ex)
{
Debug.WriteLine($"[PluginUpgradeHelper] Failed to launch application: {ex}");
}
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + ".laapp";
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static void LogInfo(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [INFO] {message}\n");
}
private static void LogWarn(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [WARN] {message}\n");
}
private static void LogError(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [ERROR] {message}\n");
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}
private sealed record UpgradeResults(int SuccessCount, int FailureCount);
}

View File

@@ -1,5 +1,5 @@
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Infrastructure;
using Xunit;
namespace LanMountainDesktop.Tests;

View File

@@ -69,7 +69,7 @@ public sealed class CornerRadiusStyleTests
ThemeVariant: "Dark"));
var context = new PluginDesktopComponentContext(
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
new LanMountainDesktop.PluginSdk.PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
"C:\\Plugins\\plugin.id",
"C:\\Data\\plugin.id",
new NullServiceProvider(),

View File

@@ -1,5 +1,4 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;

View File

@@ -1,4 +1,3 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -192,7 +191,7 @@ public sealed class DotNetRuntimeProbeTests : IDisposable
SearchedPaths = [hostPath]
};
var result = LauncherFlowCoordinator.ValidateDotNetRuntimePrerequisite(
var result = HostLaunchService.ValidateDotNetRuntimePrerequisite(
plan,
resolution,
CreateOptions(DotNetRuntimeArchitecture.X64));

View File

@@ -0,0 +1,46 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Startup;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class HostActivationPolicyTests
{
[Theory]
[InlineData("launch", "normal", true)]
[InlineData("launch", "restart", false)]
[InlineData("apply-update", "normal", false)]
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
string command,
string launchSource,
bool expected)
{
var context = CommandContext.FromArgs([command, "--launch-source", launchSource]);
Assert.Equal(expected, HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context));
}
[Fact]
public void IsRecoverableActivationFailure_AllowsStartupPendingWhenIpcReady()
{
var activation = new PublicShellActivationResult(
false,
"startup_pending",
"pending",
new PublicShellStatus(
ProcessId: 1,
StartedAtUtc: DateTimeOffset.UtcNow,
LaunchSource: "normal",
ShellState: "initializing",
MainWindowCreated: false,
MainWindowVisible: false,
MainWindowOpened: false,
DesktopVisible: false,
PublicIpcReady: true,
Tray: new PublicTrayStatus("Unavailable", false, false, false, false, 0),
Taskbar: new PublicTaskbarStatus(false, false, false, false, false, false)));
Assert.True(HostActivationPolicy.IsRecoverableActivationFailure(activation));
}
}

View File

@@ -1,4 +1,3 @@
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;

View File

@@ -1,5 +1,4 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;

View File

@@ -1,7 +1,8 @@
using System.Diagnostics;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
@@ -115,7 +116,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
}
[Fact]
@@ -123,7 +124,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
Assert.False(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
}
[Fact]
@@ -141,7 +142,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
BuiltInComponentIds.DesktopWorldClock,
"clock-2"));
Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
}
[Fact]

View 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));
}
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json.Nodes;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;

View File

@@ -1,4 +1,3 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;

View File

@@ -0,0 +1,7 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;
global using LanMountainDesktop.Launcher.Oobe;
global using LanMountainDesktop.Launcher.Startup;
global using LanMountainDesktop.Launcher.Update;

View File

@@ -1,5 +1,5 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Startup;
using LanMountainDesktop.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
@@ -22,7 +22,7 @@ public sealed class LauncherMultiInstancePolicyTests
{
var context = CommandContext.FromArgs(["launch"]);
Assert.True(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
Assert.True(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context));
}
[Fact]
@@ -33,16 +33,16 @@ public sealed class LauncherMultiInstancePolicyTests
$"--{LauncherIpcConstants.LaunchSourceOptionName}=restart"
]);
Assert.False(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
Assert.False(HostActivationPolicy.ShouldProbeExistingHostBeforeLaunch(context));
}
[Fact]
public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit()
{
Assert.True(LauncherFlowCoordinator.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded));
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed));
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired));
Assert.False(LauncherFlowCoordinator.IsFailedActivationExitCode(1));
Assert.True(HostActivationPolicy.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded));
Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed));
Assert.True(HostActivationPolicy.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired));
Assert.False(HostActivationPolicy.IsFailedActivationExitCode(1));
}
[Fact]
@@ -57,7 +57,7 @@ public sealed class LauncherMultiInstancePolicyTests
mainWindowOpened: false,
desktopVisible: false));
Assert.True(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
Assert.True(HostActivationPolicy.IsRecoverableActivationFailure(activation));
}
[Fact]
@@ -72,18 +72,18 @@ public sealed class LauncherMultiInstancePolicyTests
mainWindowOpened: false,
desktopVisible: false));
Assert.False(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
Assert.False(HostActivationPolicy.IsRecoverableActivationFailure(activation));
}
[Fact]
public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady()
{
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(null));
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(null));
Assert.False(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
publicIpcReady: false,
mainWindowOpened: true,
desktopVisible: true)));
Assert.True(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
Assert.True(HostActivationPolicy.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
publicIpcReady: true,
mainWindowOpened: true,
desktopVisible: true)));

Some files were not shown because too many files have changed in this diff Show More