mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0085c66514 |
@@ -166,7 +166,10 @@ internal static class Commands
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
|
||||
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
|
||||
? launcherDir
|
||||
: AppContext.BaseDirectory);
|
||||
|
||||
// 发布版结构:Launcher 和 app-* 目录在同一目录
|
||||
// 检查当前目录是否有 app-* 子目录(发布版)
|
||||
|
||||
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed record HostLaunchPlan(
|
||||
string HostPath,
|
||||
string PackageRoot,
|
||||
string WorkingDirectory,
|
||||
IReadOnlyList<string> Arguments,
|
||||
IReadOnlyDictionary<string, string> EnvironmentVariables,
|
||||
AppVersionInfo VersionInfo);
|
||||
|
||||
internal static class HostLaunchPlanBuilder
|
||||
{
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
public static HostLaunchPlan Build(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
HostResolutionResult resolution)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(deploymentLocator);
|
||||
ArgumentNullException.ThrowIfNull(resolution);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||
{
|
||||
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
|
||||
}
|
||||
|
||||
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
|
||||
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
|
||||
var versionInfo = deploymentLocator.GetVersionInfo();
|
||||
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
|
||||
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
|
||||
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
|
||||
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||
};
|
||||
|
||||
return new HostLaunchPlan(
|
||||
hostPath,
|
||||
packageRoot,
|
||||
Directory.Exists(packageRoot)
|
||||
? packageRoot
|
||||
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
|
||||
arguments,
|
||||
environment,
|
||||
versionInfo);
|
||||
}
|
||||
|
||||
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
|
||||
{
|
||||
return string.Join(" ", arguments.Select(QuoteArgument));
|
||||
}
|
||||
|
||||
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
|
||||
{
|
||||
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(appRoot);
|
||||
|
||||
var hostDirectory = Path.GetDirectoryName(hostPath);
|
||||
if (hostDirectory is not null &&
|
||||
Directory.Exists(fullAppRoot) &&
|
||||
IsAppDeploymentDirectory(hostDirectory) &&
|
||||
IsParentOf(fullAppRoot, hostDirectory))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fullAppRoot;
|
||||
}
|
||||
|
||||
return hostDirectory ?? fullAppRoot;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildForwardedArguments(
|
||||
CommandContext context,
|
||||
string packageRoot,
|
||||
AppVersionInfo versionInfo)
|
||||
{
|
||||
var arguments = new List<string>();
|
||||
|
||||
for (var index = 0; index < context.RawArgs.Count; index++)
|
||||
{
|
||||
var arg = context.RawArgs[index];
|
||||
|
||||
if (index == 0 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == 1 &&
|
||||
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (equalsIndex < 0 &&
|
||||
index + 1 < context.RawArgs.Count &&
|
||||
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
arguments.Add(arg);
|
||||
}
|
||||
|
||||
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
|
||||
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private static bool IsAppDeploymentDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
|
||||
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsParentOf(string parent, string child)
|
||||
{
|
||||
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return childPath.StartsWith(
|
||||
parentPath + Path.DirectorySeparatorChar,
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,11 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120);
|
||||
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30);
|
||||
private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
|
||||
private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。";
|
||||
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
"app-root",
|
||||
LauncherIpcConstants.LauncherPidEnvVar,
|
||||
LauncherIpcConstants.PackageRootEnvVar,
|
||||
LauncherIpcConstants.VersionEnvVar,
|
||||
LauncherIpcConstants.CodenameEnvVar
|
||||
];
|
||||
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
@@ -439,24 +429,6 @@ internal sealed class LauncherFlowCoordinator
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
|
||||
{
|
||||
return MergeDetails(
|
||||
@@ -475,11 +447,51 @@ internal sealed class LauncherFlowCoordinator
|
||||
recoveryActivationAttempted)));
|
||||
}
|
||||
|
||||
async Task<StartupSuccessState?> RefreshShellStatusAsync(string waitingMessage)
|
||||
{
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (startupSuccessTracker.TryResolve(shellStatus, out var successState))
|
||||
{
|
||||
return successState;
|
||||
}
|
||||
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell(waitingMessage);
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(1200)).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||
.ConfigureAwait(false);
|
||||
if (shellSuccess is not null)
|
||||
{
|
||||
successTcs.TrySetResult(shellSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
|
||||
var softTimeoutAt = startedAt + StartupSoftTimeout;
|
||||
var hardTimeoutAt = startedAt + StartupHardTimeout;
|
||||
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
var nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
var activationRetryAttempted = false;
|
||||
|
||||
while (true)
|
||||
@@ -504,6 +516,29 @@ internal sealed class LauncherFlowCoordinator
|
||||
activationRetryAttempted = true;
|
||||
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
|
||||
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
|
||||
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
||||
ipcClient,
|
||||
startupSuccessTracker,
|
||||
TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
if (activationRecovery is not null)
|
||||
{
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
||||
PublishCoordinatorStatus(
|
||||
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
||||
completed: true,
|
||||
succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: activationRecovery.Code,
|
||||
message: activationRecovery.Message,
|
||||
details: ComposeLaunchDetails(
|
||||
!launchOutcome.Process.HasExited,
|
||||
recoveryActivationAttempted: true));
|
||||
}
|
||||
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
@@ -558,6 +593,28 @@ internal sealed class LauncherFlowCoordinator
|
||||
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
activationRetryAttempted = true;
|
||||
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
|
||||
ipcClient,
|
||||
startupSuccessTracker,
|
||||
TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activationRecovery is not null)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(activationRecovery.Stage, activationRecovery.Message);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: activationRecovery.Code,
|
||||
message: activationRecovery.Message,
|
||||
details: MergeDetails(
|
||||
ComposeLaunchDetails(hostProcessAlive: true, recoveryActivationAttempted: true),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
@@ -605,6 +662,21 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (ipcConnected &&
|
||||
!launchOutcome.Process.HasExited &&
|
||||
now >= nextShellStatusPollAt)
|
||||
{
|
||||
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||
.ConfigureAwait(false);
|
||||
if (shellSuccess is not null)
|
||||
{
|
||||
successTcs.TrySetResult(shellSuccess);
|
||||
continue;
|
||||
}
|
||||
|
||||
nextShellStatusPollAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
}
|
||||
|
||||
if (!ipcConnected &&
|
||||
!launchOutcome.Process.HasExited &&
|
||||
now >= nextReconnectAttemptAt)
|
||||
@@ -612,18 +684,16 @@ internal sealed class LauncherFlowCoordinator
|
||||
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
|
||||
if (connected)
|
||||
{
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
|
||||
.ConfigureAwait(false);
|
||||
if (shellSuccess is not null)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC reconnected; waiting for desktop shell.");
|
||||
successTcs.TrySetResult(shellSuccess);
|
||||
continue;
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
}
|
||||
|
||||
if (!softTimeoutShown &&
|
||||
@@ -676,15 +746,21 @@ internal sealed class LauncherFlowCoordinator
|
||||
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
if (connected)
|
||||
{
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
|
||||
.ConfigureAwait(false);
|
||||
if (shellSuccess is not null)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(shellSuccess.Stage, shellSuccess.Message);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: shellSuccess.Code,
|
||||
message: shellSuccess.Message,
|
||||
details: ComposeLaunchDetails(hostProcessAlive: true));
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,13 +795,44 @@ internal sealed class LauncherFlowCoordinator
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: false);
|
||||
if (startupSuccessTracker.TryResolve(shellStatus, out var finalShellSuccess))
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(finalShellSuccess.Stage, finalShellSuccess.Message);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: finalShellSuccess.Code,
|
||||
message: finalShellSuccess.Message,
|
||||
details: ComposeLaunchDetails(
|
||||
hostProcessAlive: true,
|
||||
recoveryActivationAttempted));
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: "shell_not_ready",
|
||||
message: "Host public IPC is connected, but the desktop shell did not create or show the main window in time.",
|
||||
details: ComposeLaunchDetails(
|
||||
hostProcessAlive: true,
|
||||
recoveryActivationAttempted));
|
||||
}
|
||||
|
||||
if (!connected && !launchOutcome.Process.HasExited)
|
||||
{
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running, but public IPC is not ready yet.");
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: "startup_pending",
|
||||
message: "Host process is still running; Launcher will not start another process while the desktop shell finishes startup.",
|
||||
message: "Host process is still running; Launcher will not start another process while public IPC finishes startup.",
|
||||
details: ComposeLaunchDetails(
|
||||
hostProcessAlive: true,
|
||||
recoveryActivationAttempted));
|
||||
@@ -739,7 +846,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: "desktop_not_visible",
|
||||
message: "Host process started, but it never reached the required startup state within 120 seconds.",
|
||||
message: $"Host process started, but it never reached the required startup state within {StartupHardTimeout.TotalSeconds:0} seconds.",
|
||||
details: ComposeLaunchDetails(
|
||||
!launchOutcome.Process.HasExited,
|
||||
recoveryActivationAttempted));
|
||||
@@ -906,25 +1013,20 @@ internal sealed class LauncherFlowCoordinator
|
||||
bool forceDirectMode,
|
||||
string? retryTag)
|
||||
{
|
||||
var hostPath = resolution.ResolvedHostPath!;
|
||||
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution);
|
||||
var hostPath = plan.HostPath;
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var hostWorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
var forwardedArguments = BuildForwardedArguments(versionInfo);
|
||||
|
||||
var primaryMode = forceDirectMode || !OperatingSystem.IsWindows()
|
||||
? HostStartMode.Direct
|
||||
: HostStartMode.ShellExecute;
|
||||
var fallbackMode = primaryMode == HostStartMode.ShellExecute
|
||||
? HostStartMode.Direct
|
||||
var primaryMode = HostStartMode.Direct;
|
||||
var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
|
||||
? HostStartMode.ShellExecute
|
||||
: (HostStartMode?)null;
|
||||
|
||||
var firstAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, primaryMode, retryTag).ConfigureAwait(false);
|
||||
if (firstAttempt.ProcessCreated && !firstAttempt.ExitedEarly && firstAttempt.Process is not 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(
|
||||
@@ -933,11 +1035,6 @@ internal sealed class LauncherFlowCoordinator
|
||||
firstDetails);
|
||||
}
|
||||
|
||||
if (firstAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||
}
|
||||
|
||||
if (fallbackMode is null)
|
||||
{
|
||||
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
|
||||
@@ -947,8 +1044,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
|
||||
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
|
||||
|
||||
var secondAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, fallbackMode.Value, retryTag).ConfigureAwait(false);
|
||||
if (secondAttempt.ProcessCreated && !secondAttempt.ExitedEarly && secondAttempt.Process is not null)
|
||||
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(
|
||||
@@ -1014,113 +1111,57 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
|
||||
private async Task<HostStartAttempt> StartHostProcessAsync(
|
||||
string hostPath,
|
||||
string hostWorkingDirectory,
|
||||
string arguments,
|
||||
AppVersionInfo versionInfo,
|
||||
HostLaunchPlan plan,
|
||||
HostStartMode startMode,
|
||||
string? retryTag)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
WorkingDirectory = hostWorkingDirectory,
|
||||
Arguments = arguments,
|
||||
FileName = plan.HostPath,
|
||||
WorkingDirectory = plan.WorkingDirectory,
|
||||
UseShellExecute = startMode == HostStartMode.ShellExecute
|
||||
};
|
||||
|
||||
if (startMode == HostStartMode.Direct)
|
||||
{
|
||||
startInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString();
|
||||
startInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] = _deploymentLocator.GetAppRoot();
|
||||
startInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
|
||||
startInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
|
||||
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='{hostPath}'; " +
|
||||
$"WorkingDir='{hostWorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; Args='{startInfo.Arguments}'.");
|
||||
$"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");
|
||||
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
||||
}
|
||||
|
||||
var exitTask = process.WaitForExitAsync();
|
||||
var completed = await Task.WhenAny(exitTask, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false);
|
||||
if (completed == exitTask)
|
||||
{
|
||||
return HostStartAttempt.EarlyExit(startMode, process, process.ExitCode);
|
||||
}
|
||||
|
||||
return HostStartAttempt.Started(startMode, process);
|
||||
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);
|
||||
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildForwardedArguments(AppVersionInfo versionInfo)
|
||||
{
|
||||
var arguments = new System.Text.StringBuilder();
|
||||
|
||||
for (var index = 0; index < _context.RawArgs.Count; index++)
|
||||
{
|
||||
var arg = _context.RawArgs[index];
|
||||
|
||||
if (arg == _context.Command || arg == _context.SubCommand)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var key = arg[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (equalsIndex < 0 &&
|
||||
index + 1 < _context.RawArgs.Count &&
|
||||
!_context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (arguments.Length > 0)
|
||||
{
|
||||
arguments.Append(' ');
|
||||
}
|
||||
|
||||
arguments.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
if (arguments.Length > 0)
|
||||
{
|
||||
arguments.Append(' ');
|
||||
}
|
||||
|
||||
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
|
||||
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={QuoteArgument(versionInfo.Codename)}");
|
||||
|
||||
return arguments.ToString();
|
||||
}
|
||||
|
||||
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
@@ -1333,6 +1374,9 @@ internal sealed class LauncherFlowCoordinator
|
||||
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;
|
||||
}
|
||||
@@ -1342,6 +1386,9 @@ internal sealed class LauncherFlowCoordinator
|
||||
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;
|
||||
}
|
||||
@@ -1362,36 +1409,6 @@ internal sealed class LauncherFlowCoordinator
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
@@ -1419,15 +1436,23 @@ internal sealed class LauncherFlowCoordinator
|
||||
return true;
|
||||
}
|
||||
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
try
|
||||
{
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
return ipcClient.IsConnected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Public IPC connect failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
|
||||
@@ -1468,6 +1493,35 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StartupSuccessState?> TryRecoverActivationThroughExistingHostAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
StartupSuccessTracker startupSuccessTracker,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var activation = await TryActivateExistingHostWithStatusAsync(ipcClient, timeout).ConfigureAwait(false);
|
||||
if (activation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
||||
{
|
||||
return shellSuccess;
|
||||
}
|
||||
|
||||
if (activation.Accepted)
|
||||
{
|
||||
return startupSuccessTracker.BuildRecoverySuccessState();
|
||||
}
|
||||
|
||||
return IsRecoverableActivationFailure(activation)
|
||||
? new StartupSuccessState(
|
||||
StartupStage.Ready,
|
||||
"startup_pending",
|
||||
activation.Message)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
|
||||
{
|
||||
if (activation.Accepted)
|
||||
@@ -1511,10 +1565,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
try
|
||||
{
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationAccepted = await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false);
|
||||
if (!activationAccepted)
|
||||
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||
if (startupSuccessTracker.TryResolve(activation.Status, out var shellSuccess))
|
||||
{
|
||||
return null;
|
||||
return shellSuccess;
|
||||
}
|
||||
|
||||
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
|
||||
@@ -1523,7 +1577,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
return await successTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!hostProcess.HasExited)
|
||||
if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation)))
|
||||
{
|
||||
return startupSuccessTracker.BuildRecoverySuccessState();
|
||||
}
|
||||
@@ -1642,18 +1696,48 @@ internal sealed class LauncherFlowCoordinator
|
||||
Process? Process,
|
||||
bool ExitedEarly,
|
||||
int? ExitCode,
|
||||
string? FailureReason)
|
||||
string? FailureReason,
|
||||
string? PackageRoot,
|
||||
string? WorkingDirectory,
|
||||
string? Arguments)
|
||||
{
|
||||
public int? ProcessId => Process?.Id;
|
||||
|
||||
public static HostStartAttempt Started(HostStartMode startMode, Process process) =>
|
||||
new(startMode, true, process, false, null, null);
|
||||
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) =>
|
||||
new(startMode, true, process, true, exitCode, null);
|
||||
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) =>
|
||||
new(startMode, false, null, false, null, failureReason);
|
||||
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
|
||||
new(
|
||||
startMode,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
failureReason,
|
||||
plan?.PackageRoot,
|
||||
plan?.WorkingDirectory,
|
||||
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
|
||||
}
|
||||
|
||||
private sealed record HostLaunchOutcome(
|
||||
@@ -1715,6 +1799,13 @@ internal sealed class LauncherFlowCoordinator
|
||||
: "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;
|
||||
@@ -1746,6 +1837,26 @@ internal sealed class LauncherFlowCoordinator
|
||||
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
|
||||
|
||||
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class HostLaunchPlanBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _testRoot;
|
||||
|
||||
public HostLaunchPlanBuilderTests()
|
||||
{
|
||||
_testRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.HostLaunchPlanTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_testRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_UsesPackageRootAsWorkingDirectory_ForPublishedDeployment()
|
||||
{
|
||||
var packageRoot = Path.Combine(_testRoot, "package-root");
|
||||
var deployment = CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||
var resultPath = Path.Combine(_testRoot, "launcher-result.json");
|
||||
var context = CommandContext.FromArgs(
|
||||
[
|
||||
"launch",
|
||||
"--app-root", packageRoot,
|
||||
"--result", resultPath,
|
||||
"--launch-source", "postinstall",
|
||||
"--custom-host-arg", "custom-value"
|
||||
]);
|
||||
var locator = new DeploymentLocator(packageRoot);
|
||||
var resolution = locator.ResolveHostExecutable(context);
|
||||
|
||||
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.PackageRoot);
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||
Assert.Equal(Path.Combine(deployment, GetExecutableName()), plan.HostPath);
|
||||
Assert.Contains("--launch-source", plan.Arguments);
|
||||
Assert.Contains("postinstall", plan.Arguments);
|
||||
Assert.Contains("--custom-host-arg", plan.Arguments);
|
||||
Assert.Contains("custom-value", plan.Arguments);
|
||||
Assert.DoesNotContain("--app-root", plan.Arguments);
|
||||
Assert.DoesNotContain(packageRoot, plan.Arguments);
|
||||
Assert.DoesNotContain("--result", plan.Arguments);
|
||||
Assert.DoesNotContain(resultPath, plan.Arguments);
|
||||
Assert.Contains($"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}", plan.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_KeepsPathsWithSpacesAsSingleArgumentListTokens()
|
||||
{
|
||||
var packageRoot = Path.Combine(_testRoot, "package root with spaces");
|
||||
CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||
var context = CommandContext.FromArgs(["launch", "--app-root", packageRoot]);
|
||||
var locator = new DeploymentLocator(packageRoot);
|
||||
var resolution = locator.ResolveHostExecutable(context);
|
||||
|
||||
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||
|
||||
var packageRootArgument = $"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}";
|
||||
Assert.Contains(packageRootArgument, plan.Arguments);
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar]);
|
||||
Assert.DoesNotContain(plan.Arguments, argument => argument.StartsWith("\"", StringComparison.Ordinal));
|
||||
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||
}
|
||||
|
||||
private static string CreateDeployment(string packageRoot, string deploymentName)
|
||||
{
|
||||
var deployment = Path.Combine(packageRoot, deploymentName);
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, GetExecutableName()), string.Empty);
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
File.WriteAllText(
|
||||
Path.Combine(deployment, "version.json"),
|
||||
"""
|
||||
{"Version":"0.8.5.7","Codename":"Administrate"}
|
||||
""");
|
||||
return deployment;
|
||||
}
|
||||
|
||||
private static string GetExecutableName()
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testRoot))
|
||||
{
|
||||
Directory.Delete(_testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ public partial class App : Application
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
private int _forcedExitScheduled;
|
||||
private volatile bool _desktopShellInitializationStarted;
|
||||
private bool _mainWindowOpened;
|
||||
private bool _trayInitialized;
|
||||
private readonly object _launcherProgressLock = new();
|
||||
@@ -184,6 +185,7 @@ public partial class App : Application
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
@@ -324,6 +326,7 @@ public partial class App : Application
|
||||
|
||||
private void InitializeDesktopShell()
|
||||
{
|
||||
_desktopShellInitializationStarted = true;
|
||||
_desktopShellHost ??= new DesktopShellHost(
|
||||
InitializePluginRuntime,
|
||||
InitializeTrayIcon,
|
||||
@@ -801,10 +804,16 @@ public partial class App : Application
|
||||
Resources["AppFontFamily"] = fontFamily;
|
||||
}
|
||||
|
||||
private void ActivateMainWindow()
|
||||
internal void ActivateMainWindow()
|
||||
{
|
||||
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
|
||||
|
||||
if (!_desktopShellInitializationStarted && _mainWindow is null)
|
||||
{
|
||||
AppLogger.Info("SingleInstance", "Activation acknowledged while desktop shell is still initializing.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var restored = Dispatcher.UIThread.CheckAccess()
|
||||
@@ -815,7 +824,8 @@ public partial class App : Application
|
||||
|
||||
if (!restored)
|
||||
{
|
||||
throw new InvalidOperationException("Main window restore failed in activation callback.");
|
||||
AppLogger.Warn("SingleInstance", "Activation callback could not restore the main window yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
|
||||
@@ -823,7 +833,6 @@ public partial class App : Application
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1758,6 +1767,15 @@ public partial class App : Application
|
||||
|
||||
internal PublicShellActivationResult TryActivateMainWindowWithStatusFromExternalIpc(string source)
|
||||
{
|
||||
if (!_desktopShellInitializationStarted && _mainWindow is null)
|
||||
{
|
||||
return new PublicShellActivationResult(
|
||||
false,
|
||||
"startup_pending",
|
||||
"Desktop process is running, but the shell has not started yet.",
|
||||
GetPublicShellStatus());
|
||||
}
|
||||
|
||||
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
var status = GetPublicShellStatus();
|
||||
if (restored)
|
||||
@@ -1770,12 +1788,17 @@ public partial class App : Application
|
||||
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
|
||||
}
|
||||
|
||||
var code = status.PublicIpcReady && (!status.MainWindowOpened || !status.DesktopVisible)
|
||||
? "shell_not_ready"
|
||||
: "activation_failed";
|
||||
var message = code == "shell_not_ready"
|
||||
? "Desktop process is running, but the shell is not ready for activation yet."
|
||||
: "Desktop window activation failed.";
|
||||
var code = status.PublicIpcReady && (!status.MainWindowCreated || !status.MainWindowOpened)
|
||||
? "startup_pending"
|
||||
: status.PublicIpcReady && !status.DesktopVisible
|
||||
? "shell_not_ready"
|
||||
: "activation_failed";
|
||||
var message = code switch
|
||||
{
|
||||
"startup_pending" => "Desktop process is running, but the shell is still creating the main window.",
|
||||
"shell_not_ready" => "Desktop process is running, but the shell is not ready for activation yet.",
|
||||
_ => "Desktop window activation failed."
|
||||
};
|
||||
return new PublicShellActivationResult(false, code, message, status);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,16 @@ public sealed class Program
|
||||
StartupRenderMode = renderMode;
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
singleInstance.StartActivationListener(() =>
|
||||
{
|
||||
if (Avalonia.Application.Current is App app)
|
||||
{
|
||||
app.ActivateMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready.");
|
||||
});
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
private readonly ManualResetEventSlim _listenerReady = new(false);
|
||||
private bool _ownsMutex;
|
||||
private bool _disposed;
|
||||
private Task? _listenTask;
|
||||
@@ -64,6 +65,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
"SingleInstance",
|
||||
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
_listenerReady.Wait(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
@@ -142,6 +144,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
}
|
||||
|
||||
_listenCts.Dispose();
|
||||
_listenerReady.Dispose();
|
||||
if (_ownsMutex)
|
||||
{
|
||||
try
|
||||
@@ -170,6 +173,7 @@ public sealed class SingleInstanceService : IDisposable
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
_listenerReady.Set();
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var buffer = new byte[1];
|
||||
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user