Introduce HostLaunchPlan and refine launch flow

Add HostLaunchPlan/HostLaunchPlanBuilder to encapsulate host path, package root, working dir, forwarded args and env; add unit tests for builder. Refactor LauncherFlowCoordinator to use HostLaunchPlan when starting hosts, improve IPC handling and startup logic (shorter soft/hard timeouts, more frequent reconnects and shell status polling, activation recovery via existing host). Move argument formatting and environment setup into the plan, include package/working/args metadata in start attempts. Update Commands to prefer ProcessPath for launcher base directory. App and Program: start single-instance activation listener earlier and harden ActivateMainWindow to handle shell initialization state and return richer activation status codes. SingleInstanceService: signal listener readiness (ManualResetEventSlim) and wait briefly when starting, and dispose it. Various logging and minor error handling improvements.
This commit is contained in:
lincube
2026-04-23 23:07:37 +08:00
parent d4901e436f
commit 0085c66514
7 changed files with 654 additions and 204 deletions

View File

@@ -166,7 +166,10 @@ internal static class Commands
return Path.GetFullPath(configured); 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-* 目录在同一目录 // 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版) // 检查当前目录是否有 app-* 子目录(发布版)

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

View File

@@ -11,21 +11,11 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator internal sealed class LauncherFlowCoordinator
{ {
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120); private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(30);
private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。"; private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行Launcher 会继续等待,不会重复启动。"; 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 CommandContext _context;
private readonly DeploymentLocator _deploymentLocator; private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService; private readonly OobeStateService _oobeStateService;
@@ -439,24 +429,6 @@ internal sealed class LauncherFlowCoordinator
PublishCoordinatorStatus(hostProcessAliveOverride: true); 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) Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
{ {
return MergeDetails( return MergeDetails(
@@ -475,11 +447,51 @@ internal sealed class LauncherFlowCoordinator
recoveryActivationAttempted))); 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 processExitTask = launchOutcome.Process.WaitForExitAsync();
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow; var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
var softTimeoutAt = startedAt + StartupSoftTimeout; var softTimeoutAt = startedAt + StartupSoftTimeout;
var hardTimeoutAt = startedAt + StartupHardTimeout; 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; var activationRetryAttempted = false;
while (true) while (true)
@@ -504,6 +516,29 @@ internal sealed class LauncherFlowCoordinator
activationRetryAttempted = true; activationRetryAttempted = true;
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false); activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'."); 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); var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
if (retryOutcome is not null) if (retryOutcome is not null)
{ {
@@ -558,6 +593,28 @@ internal sealed class LauncherFlowCoordinator
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired) exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
{ {
activationRetryAttempted = true; 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); var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
if (retryOutcome is not null) if (retryOutcome is not null)
{ {
@@ -605,6 +662,21 @@ internal sealed class LauncherFlowCoordinator
} }
var now = DateTimeOffset.UtcNow; 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 && if (!ipcConnected &&
!launchOutcome.Process.HasExited && !launchOutcome.Process.HasExited &&
now >= nextReconnectAttemptAt) now >= nextReconnectAttemptAt)
@@ -612,18 +684,16 @@ internal sealed class LauncherFlowCoordinator
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
if (connected) if (connected)
{ {
ipcConnected = true; var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
_startupAttemptRegistry.MarkOwnedIpcConnected(); .ConfigureAwait(false);
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); if (shellSuccess is not null)
if (shellStatus is { DesktopVisible: false })
{ {
_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 && if (!softTimeoutShown &&
@@ -676,15 +746,21 @@ internal sealed class LauncherFlowCoordinator
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false); connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
if (connected) if (connected)
{ {
ipcConnected = true; var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
_startupAttemptRegistry.MarkOwnedIpcConnected(); .ConfigureAwait(false);
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); if (shellSuccess is not null)
if (shellStatus is { DesktopVisible: false })
{ {
_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; windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window."); _startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); 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); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult( return BuildResult(
success: true, success: true,
stage: "launch", stage: "launch",
code: "startup_pending", 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( details: ComposeLaunchDetails(
hostProcessAlive: true, hostProcessAlive: true,
recoveryActivationAttempted)); recoveryActivationAttempted));
@@ -739,7 +846,7 @@ internal sealed class LauncherFlowCoordinator
success: false, success: false,
stage: "launch", stage: "launch",
code: "desktop_not_visible", 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( details: ComposeLaunchDetails(
!launchOutcome.Process.HasExited, !launchOutcome.Process.HasExited,
recoveryActivationAttempted)); recoveryActivationAttempted));
@@ -906,25 +1013,20 @@ internal sealed class LauncherFlowCoordinator
bool forceDirectMode, bool forceDirectMode,
string? retryTag) string? retryTag)
{ {
var hostPath = resolution.ResolvedHostPath!; var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution);
var hostPath = plan.HostPath;
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{ {
EnsureExecutable(hostPath); EnsureExecutable(hostPath);
} }
var hostWorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot(); var primaryMode = HostStartMode.Direct;
var versionInfo = _deploymentLocator.GetVersionInfo(); var fallbackMode = !forceDirectMode && OperatingSystem.IsWindows()
var forwardedArguments = BuildForwardedArguments(versionInfo); ? HostStartMode.ShellExecute
var primaryMode = forceDirectMode || !OperatingSystem.IsWindows()
? HostStartMode.Direct
: HostStartMode.ShellExecute;
var fallbackMode = primaryMode == HostStartMode.ShellExecute
? HostStartMode.Direct
: (HostStartMode?)null; : (HostStartMode?)null;
var firstAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, primaryMode, retryTag).ConfigureAwait(false); var firstAttempt = await StartHostProcessAsync(plan, primaryMode, retryTag).ConfigureAwait(false);
if (firstAttempt.ProcessCreated && !firstAttempt.ExitedEarly && firstAttempt.Process is not null) if (firstAttempt.ProcessCreated && firstAttempt.Process is not null)
{ {
var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null); var firstDetails = BuildResolutionDetails(resolution, firstAttempt, null, null);
return HostLaunchOutcome.FromProcess( return HostLaunchOutcome.FromProcess(
@@ -933,11 +1035,6 @@ internal sealed class LauncherFlowCoordinator
firstDetails); firstDetails);
} }
if (firstAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
}
if (fallbackMode is null) if (fallbackMode is null)
{ {
return BuildOutcomeFromAttempt(resolution, firstAttempt, null); return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
@@ -947,8 +1044,8 @@ internal sealed class LauncherFlowCoordinator
$"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " + $"Primary host start attempt failed. Retrying with fallback mode '{fallbackMode}'. " +
$"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'."); $"FailureReason='{firstAttempt.FailureReason ?? "unknown"}'; ExitCode='{firstAttempt.ExitCode?.ToString() ?? "<none>"}'.");
var secondAttempt = await StartHostProcessAsync(hostPath, hostWorkingDirectory, forwardedArguments, versionInfo, fallbackMode.Value, retryTag).ConfigureAwait(false); var secondAttempt = await StartHostProcessAsync(plan, fallbackMode.Value, retryTag).ConfigureAwait(false);
if (secondAttempt.ProcessCreated && !secondAttempt.ExitedEarly && secondAttempt.Process is not null) if (secondAttempt.ProcessCreated && secondAttempt.Process is not null)
{ {
var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null); var details = BuildResolutionDetails(resolution, firstAttempt, secondAttempt, null);
return HostLaunchOutcome.FromProcess( return HostLaunchOutcome.FromProcess(
@@ -1014,113 +1111,57 @@ internal sealed class LauncherFlowCoordinator
} }
private async Task<HostStartAttempt> StartHostProcessAsync( private async Task<HostStartAttempt> StartHostProcessAsync(
string hostPath, HostLaunchPlan plan,
string hostWorkingDirectory,
string arguments,
AppVersionInfo versionInfo,
HostStartMode startMode, HostStartMode startMode,
string? retryTag) string? retryTag)
{ {
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = hostPath, FileName = plan.HostPath,
WorkingDirectory = hostWorkingDirectory, WorkingDirectory = plan.WorkingDirectory,
Arguments = arguments,
UseShellExecute = startMode == HostStartMode.ShellExecute UseShellExecute = startMode == HostStartMode.ShellExecute
}; };
if (startMode == HostStartMode.Direct) if (startMode == HostStartMode.Direct)
{ {
startInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(); foreach (var argument in plan.Arguments)
startInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] = _deploymentLocator.GetAppRoot(); {
startInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version; startInfo.ArgumentList.Add(argument);
startInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename; }
foreach (var pair in plan.EnvironmentVariables)
{
startInfo.EnvironmentVariables[pair.Key] = pair.Value;
}
}
else
{
startInfo.Arguments = HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments);
} }
try try
{ {
var process = Process.Start(startInfo); var process = Process.Start(startInfo);
Logger.Info( Logger.Info(
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{hostPath}'; " + $"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
$"WorkingDir='{hostWorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; Args='{startInfo.Arguments}'."); $"PackageRoot='{plan.PackageRoot}'; WorkingDir='{plan.WorkingDirectory}'; Pid={(process is null ? -1 : process.Id)}; " +
$"Args='{HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}'.");
if (process is null) 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(); await Task.Yield();
var completed = await Task.WhenAny(exitTask, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); return HostStartAttempt.Started(startMode, process, plan);
if (completed == exitTask)
{
return HostStartAttempt.EarlyExit(startMode, process, process.ExitCode);
}
return HostStartAttempt.Started(startMode, process);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error($"Host start failed. Mode='{startMode}'.", 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() private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
{ {
ErrorWindow? errorWindow = null; ErrorWindow? errorWindow = null;
@@ -1333,6 +1374,9 @@ internal sealed class LauncherFlowCoordinator
details["startMode"] = firstAttempt.StartMode.ToString(); details["startMode"] = firstAttempt.StartMode.ToString();
details["processCreated"] = firstAttempt.ProcessCreated.ToString(); details["processCreated"] = firstAttempt.ProcessCreated.ToString();
details["hostPid"] = firstAttempt.ProcessId?.ToString() ?? string.Empty; 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["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty;
details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty; details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty;
} }
@@ -1342,6 +1386,9 @@ internal sealed class LauncherFlowCoordinator
details["fallbackStartMode"] = secondAttempt.StartMode.ToString(); details["fallbackStartMode"] = secondAttempt.StartMode.ToString();
details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString(); details["fallbackProcessCreated"] = secondAttempt.ProcessCreated.ToString();
details["fallbackHostPid"] = secondAttempt.ProcessId?.ToString() ?? string.Empty; 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["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty;
details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty; details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty;
} }
@@ -1362,36 +1409,6 @@ internal sealed class LauncherFlowCoordinator
return merged; 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) private static void EnsureExecutable(string path)
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
@@ -1419,15 +1436,23 @@ internal sealed class LauncherFlowCoordinator
return true; return true;
} }
var connectTask = ipcClient.ConnectAsync(); try
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != connectTask)
{ {
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; return false;
} }
await connectTask.ConfigureAwait(false);
return true;
} }
private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context) 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) private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{ {
if (activation.Accepted) if (activation.Accepted)
@@ -1511,10 +1565,10 @@ internal sealed class LauncherFlowCoordinator
try try
{ {
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>(); var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activationAccepted = await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false); var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
if (!activationAccepted) 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); 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); return await successTask.ConfigureAwait(false);
} }
if (!hostProcess.HasExited) if (!hostProcess.HasExited && (activation.Accepted || IsRecoverableActivationFailure(activation)))
{ {
return startupSuccessTracker.BuildRecoverySuccessState(); return startupSuccessTracker.BuildRecoverySuccessState();
} }
@@ -1642,18 +1696,48 @@ internal sealed class LauncherFlowCoordinator
Process? Process, Process? Process,
bool ExitedEarly, bool ExitedEarly,
int? ExitCode, int? ExitCode,
string? FailureReason) string? FailureReason,
string? PackageRoot,
string? WorkingDirectory,
string? Arguments)
{ {
public int? ProcessId => Process?.Id; public int? ProcessId => Process?.Id;
public static HostStartAttempt Started(HostStartMode startMode, Process process) => public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
new(startMode, true, process, false, null, null); 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) => public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
new(startMode, true, process, true, exitCode, null); new(
startMode,
true,
process,
true,
exitCode,
null,
plan.PackageRoot,
plan.WorkingDirectory,
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason) => public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
new(startMode, false, null, false, null, failureReason); new(
startMode,
false,
null,
false,
null,
failureReason,
plan?.PackageRoot,
plan?.WorkingDirectory,
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
} }
private sealed record HostLaunchOutcome( private sealed record HostLaunchOutcome(
@@ -1715,6 +1799,13 @@ internal sealed class LauncherFlowCoordinator
: "Desktop recovered in a visible state."); : "Desktop recovered in a visible state.");
return true; 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: case StartupStage.TrayReady:
_trayReady = true; _trayReady = true;
break; break;
@@ -1746,6 +1837,26 @@ internal sealed class LauncherFlowCoordinator
return false; 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() public StartupSuccessState BuildRecoverySuccessState()
{ {
return _policy switch return _policy switch

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

View File

@@ -85,6 +85,7 @@ public partial class App : Application
private LoadingStateReporter? _loadingStateReporter; private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased; private bool _singleInstanceReleased;
private int _forcedExitScheduled; private int _forcedExitScheduled;
private volatile bool _desktopShellInitializationStarted;
private bool _mainWindowOpened; private bool _mainWindowOpened;
private bool _trayInitialized; private bool _trayInitialized;
private readonly object _launcherProgressLock = new(); private readonly object _launcherProgressLock = new();
@@ -184,6 +185,7 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard(); RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled(); LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePublicIpc(); InitializePublicIpc();
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
_ = InitializeLauncherIpcAsync(); _ = InitializeLauncherIpcAsync();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
@@ -324,6 +326,7 @@ public partial class App : Application
private void InitializeDesktopShell() private void InitializeDesktopShell()
{ {
_desktopShellInitializationStarted = true;
_desktopShellHost ??= new DesktopShellHost( _desktopShellHost ??= new DesktopShellHost(
InitializePluginRuntime, InitializePluginRuntime,
InitializeTrayIcon, InitializeTrayIcon,
@@ -801,10 +804,16 @@ public partial class App : Application
Resources["AppFontFamily"] = fontFamily; Resources["AppFontFamily"] = fontFamily;
} }
private void ActivateMainWindow() internal void ActivateMainWindow()
{ {
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}."); 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 try
{ {
var restored = Dispatcher.UIThread.CheckAccess() var restored = Dispatcher.UIThread.CheckAccess()
@@ -815,7 +824,8 @@ public partial class App : Application
if (!restored) 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."); AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
@@ -823,7 +833,6 @@ public partial class App : Application
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", 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) 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 restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
var status = GetPublicShellStatus(); var status = GetPublicShellStatus();
if (restored) if (restored)
@@ -1770,12 +1788,17 @@ public partial class App : Application
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status); return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
} }
var code = status.PublicIpcReady && (!status.MainWindowOpened || !status.DesktopVisible) var code = status.PublicIpcReady && (!status.MainWindowCreated || !status.MainWindowOpened)
? "shell_not_ready" ? "startup_pending"
: "activation_failed"; : status.PublicIpcReady && !status.DesktopVisible
var message = code == "shell_not_ready" ? "shell_not_ready"
? "Desktop process is running, but the shell is not ready for activation yet." : "activation_failed";
: "Desktop window 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); return new PublicShellActivationResult(false, code, message, status);
} }

View File

@@ -77,6 +77,16 @@ public sealed class Program
StartupRenderMode = renderMode; StartupRenderMode = renderMode;
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance; 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); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally."); AppLogger.Info("Startup", "Application exited normally.");
} }

View File

@@ -16,6 +16,7 @@ public sealed class SingleInstanceService : IDisposable
private readonly Mutex _mutex; private readonly Mutex _mutex;
private readonly string _pipeName; private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new(); private readonly CancellationTokenSource _listenCts = new();
private readonly ManualResetEventSlim _listenerReady = new(false);
private bool _ownsMutex; private bool _ownsMutex;
private bool _disposed; private bool _disposed;
private Task? _listenTask; private Task? _listenTask;
@@ -64,6 +65,7 @@ public sealed class SingleInstanceService : IDisposable
"SingleInstance", "SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}."); $"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token)); _listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
_listenerReady.Wait(TimeSpan.FromMilliseconds(500));
} }
public bool TryNotifyPrimaryInstance(TimeSpan timeout) public bool TryNotifyPrimaryInstance(TimeSpan timeout)
@@ -142,6 +144,7 @@ public sealed class SingleInstanceService : IDisposable
} }
_listenCts.Dispose(); _listenCts.Dispose();
_listenerReady.Dispose();
if (_ownsMutex) if (_ownsMutex)
{ {
try try
@@ -170,6 +173,7 @@ public sealed class SingleInstanceService : IDisposable
PipeTransmissionMode.Byte, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous); PipeOptions.Asynchronous);
_listenerReady.Set();
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
var buffer = new byte[1]; var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);