Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
0085c66514 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.
2026-04-23 23:07:37 +08:00
lincube
d4901e436f Add launcher debug settings, recovery & version fixes
Introduce a persistent LauncherDebugSettingsStore and wire it into ErrorWindow and SplashWindow so dev-mode and custom host path can be saved/loaded. Harden DeploymentLocator/FlexibleHostLocator to safely normalize and validate saved debug paths and log warnings for malformed values. Add a WaitingForShell startup state and recoverable-activation logic across App and LauncherFlowCoordinator (with registry updates) so Launcher can attach to an in-progress desktop shell rather than failing. Clean up ErrorDebugWindow UI/flow (WasAccepted flag, localization fixes, event wiring) and improve splash version population. Improve AppVersionProvider to trim surrounding quotes, robustly parse version.json via JsonDocument and read string properties; add unit tests for AppVersionProvider, DeploymentLocator and LauncherDebugSettingsStore. Also quote Exec commands in the csproj and harden scripts/Generate-VersionFile.ps1 (argument normalization, LiteralPath, error handling).
2026-04-23 19:04:39 +08:00
22 changed files with 1377 additions and 383 deletions

View File

@@ -120,7 +120,23 @@ public partial class App : Application
private static SplashWindow CreateSplashWindow()
{
var preferences = StartupVisualPreferencesResolver.Resolve();
return new SplashWindow(preferences.Mode);
var window = new SplashWindow(preferences.Mode);
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
@@ -318,12 +334,16 @@ public partial class App : Application
{
reporter?.Report("activation", response.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = response.Accepted,
Success = success,
Stage = "launch",
Code = response.Code,
Message = response.Message,
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: response.Message,
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
};
}
@@ -334,12 +354,19 @@ public partial class App : Application
{
reporter?.Report("activation", activation.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = activation.Accepted,
Success = success,
Stage = "launch",
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
Message = activation.Message,
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: activation.Message,
Details = BuildCoordinatorResultDetails(null, activation)
};
}
@@ -370,6 +397,18 @@ public partial class App : Application
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = activation.Accepted,
@@ -419,6 +458,32 @@ public partial class App : Application
};
}
private static bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)

View File

@@ -9,7 +9,8 @@ internal enum StartupAttemptState
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed
Failed,
WaitingForShell
}
internal sealed class StartupAttemptRecord

View File

@@ -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-* 子目录(发布版)

View File

@@ -204,12 +204,16 @@ internal sealed class DeploymentLocator
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
var fullSavedPath = Path.GetFullPath(savedCustomPath);
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
}
@@ -229,6 +233,21 @@ internal sealed class DeploymentLocator
return null;
}
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
{
try
{
fullSavedPath = Path.GetFullPath(savedPath);
return true;
}
catch (Exception ex)
{
fullSavedPath = string.Empty;
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
return false;
}
}
private static string? FindBestDeploymentHost(
string root,
string executable,
@@ -303,9 +322,17 @@ internal sealed class DeploymentLocator
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
return savedCustomPath;
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
var devPath = ScanDevelopmentPaths(executable);

View File

@@ -560,6 +560,11 @@ namespace LanMountainDesktop.Launcher.Services;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}

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

@@ -0,0 +1,124 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
internal static class LauncherDebugSettingsStore
{
private const string DevModeFileName = "dev-mode.flag";
private const string CustomHostPathFileName = "custom-host-path.txt";
private const string LegacyDevModeFileName = "devmode.config";
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
internal static string? ConfigBaseDirectoryOverride { get; set; }
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
public static LauncherDebugSettings Load()
{
return new LauncherDebugSettings(
LoadDevModeState(),
LoadCustomHostPath());
}
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
public static void Save(LauncherDebugSettings settings)
{
try
{
Directory.CreateDirectory(ConfigBaseDirectory);
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
}
catch (Exception ex)
{
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
}
}
public static void SaveDevModeState(bool enabled)
{
var current = Load();
Save(current with { DevModeEnabled = enabled });
}
public static void SaveCustomHostPath(string? customHostPath)
{
var current = Load();
Save(current with { CustomHostPath = customHostPath });
}
private static bool LoadDevModeState()
{
var newValue = TryReadText(GetPath(DevModeFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return TryParseDevMode(newValue);
}
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
}
private static string? LoadCustomHostPath()
{
var newValue = TryReadText(GetPath(CustomHostPathFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return newValue.Trim();
}
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
}
private static bool TryParseDevMode(string value)
{
var normalized = value.Trim();
return normalized == "1" ||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
}
private static string? TryReadText(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
return null;
}
}
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
private static string ResolveConfigBaseDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
}
catch
{
}
try
{
return Path.Combine(AppContext.BaseDirectory, ".launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
}

View File

@@ -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;
@@ -228,6 +218,7 @@ internal sealed class LauncherFlowCoordinator
{
ipcConnected = true;
shellStatus = existingActivation.Status;
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
lastStage = existingActivation.Accepted
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
@@ -236,6 +227,10 @@ internal sealed class LauncherFlowCoordinator
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
else if (recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
@@ -244,14 +239,20 @@ internal sealed class LauncherFlowCoordinator
PublishCoordinatorStatus(
hostProcessAliveOverride: true,
completed: true,
succeeded: existingActivation.Accepted);
succeeded: existingActivation.Accepted || recoverableActivationFailure);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: existingActivation.Accepted,
success: existingActivation.Accepted || recoverableActivationFailure,
stage: "launch",
code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
message: existingActivation.Message,
code: existingActivation.Accepted
? "existing_host_activated"
: recoverableActivationFailure
? "existing_host_startup_pending"
: "existing_host_activation_failed",
message: recoverableActivationFailure
? "Existing desktop process is still starting; Launcher will not start another process."
: existingActivation.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -428,19 +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);
PublishCoordinatorStatus(hostProcessAliveOverride: true);
}
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
{
return MergeDetails(
@@ -459,11 +447,52 @@ 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)
{
@@ -482,10 +511,58 @@ internal sealed class LauncherFlowCoordinator
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
}
if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason))
if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
{
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)
{
windowsClosingByCoordinator = true;
if (retryOutcome.Success)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
PublishCoordinatorStatus(
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
completed: true,
succeeded: true);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
completed: true,
succeeded: false);
}
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(retryOutcome, ComposeLaunchDetails(!launchOutcome.Process.HasExited, recoveryActivationAttempted: true));
}
}
if (processExitTask.IsCompleted)
@@ -512,6 +589,58 @@ internal sealed class LauncherFlowCoordinator
}));
}
if (!activationRetryAttempted &&
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)
{
if (retryOutcome.Success)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
}
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(
retryOutcome,
MergeDetails(
ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
}
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
@@ -533,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)
@@ -540,13 +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);
PublishCoordinatorStatus(hostProcessAliveOverride: true);
var shellSuccess = await RefreshShellStatusAsync("Host public IPC reconnected; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
successTcs.TrySetResult(shellSuccess);
continue;
}
}
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
}
if (!softTimeoutShown &&
@@ -599,10 +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);
PublishCoordinatorStatus(hostProcessAliveOverride: true);
var shellSuccess = await RefreshShellStatusAsync("Host public IPC is ready; waiting for desktop shell.")
.ConfigureAwait(false);
if (shellSuccess is not null)
{
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));
}
}
}
@@ -632,6 +790,54 @@ internal sealed class LauncherFlowCoordinator
}
}
if (connected && !launchOutcome.Process.HasExited)
{
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host process is still running after the launcher wait window.");
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(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 public IPC finishes startup.",
details: ComposeLaunchDetails(
hostProcessAlive: true,
recoveryActivationAttempted));
}
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
@@ -640,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));
@@ -807,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(
@@ -834,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);
@@ -848,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(
@@ -915,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;
@@ -1234,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;
}
@@ -1243,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;
}
@@ -1263,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())
@@ -1320,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)
@@ -1369,6 +1493,54 @@ 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)
{
return false;
}
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return activation.Status.PublicIpcReady &&
(!activation.Status.MainWindowOpened ||
!activation.Status.DesktopVisible ||
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
@@ -1393,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);
@@ -1405,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();
}
@@ -1524,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(
@@ -1597,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;
@@ -1628,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

View File

@@ -309,6 +309,19 @@ internal sealed class StartupAttemptRegistry
});
}
public void MarkOwnedWaitingForShell(string? message)
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.WaitingForShell;
}
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedDetachedWaiting()
{
UpdateOwned(record =>
@@ -423,7 +436,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsAttachable(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
@@ -433,7 +450,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}
@@ -448,7 +469,11 @@ internal sealed class StartupAttemptRegistry
private static bool IsCoordinatorLive(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
if (record.State is not (
StartupAttemptState.Pending or
StartupAttemptState.SoftTimeout or
StartupAttemptState.DetachedWaiting or
StartupAttemptState.WaitingForShell))
{
return false;
}

View File

@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
private bool _isInitialized;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public bool WasAccepted { get; private set; }
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
: this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized) return;
if (_isInitialized)
{
return;
}
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
private void InitializeComponents()
{
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsCheckedChanged += (s, e) =>
devModeToggle.IsCheckedChanged += (_, _) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
if (this.FindControl<Button>("OkButton") is { } okButton)
{
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
okButton.Click += (_, _) =>
{
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
WasAccepted = true;
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
else
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
cancelButton.Click += (_, _) => Close();
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null) return;
if (storageProvider is null)
{
return;
}
var options = new FilePickerOpenOptions
{
Title = "选择阑山桌面主程序",
Title = "Select LanMountainDesktop host executable",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
FileTypeFilter =
[
new FilePickerFileType("Executable")
{
Patterns = OperatingSystem.IsWindows()
? new[] { "*.exe" }
: new[] { "*" }
? ["*.exe"]
: ["*"]
}
}
]
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count > 0)
if (result.Count <= 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
return;
}
_selectedHostPath = result[0].Path.LocalPath;
UpdatePathDisplay(_selectedHostPath);
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
}
}
}

View File

@@ -185,17 +185,23 @@ public partial class ErrorWindow : Window
debugWindow.Closed += (_, _) =>
{
if (!debugWindow.WasAccepted)
{
_isDebugMode = false;
_iconClickCount = 0;
return;
}
_devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath;
SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath);
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
{
ScanDevPaths();
SaveCustomHostPathInternal(_customHostPath);
}
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
_isDebugMode = false;
_iconClickCount = 0;
};
@@ -285,74 +291,17 @@ public partial class ErrorWindow : Window
private static string GetConfigBaseDirectory()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
return Path.Combine(AppContext.BaseDirectory, ".launcher");
return LauncherDebugSettingsStore.ConfigBaseDirectory;
}
private static string GetDevModePath() => Path.Combine(GetConfigBaseDirectory(), "dev-mode.flag");
private static string GetCustomHostPathFile() => Path.Combine(GetConfigBaseDirectory(), "custom-host-path.txt");
private static bool LoadDevModeStateInternal()
{
try
{
return File.Exists(GetDevModePath()) &&
bool.TryParse(File.ReadAllText(GetDevModePath()).Trim(), out var enabled) &&
enabled;
}
catch
{
return false;
}
}
private static void SaveDevModeStateInternal(bool enabled)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetDevModePath(), enabled.ToString());
}
catch
{
}
return LauncherDebugSettingsStore.IsDevModeEnabled();
}
private static string? LoadCustomHostPathInternal()
{
try
{
var pathFile = GetCustomHostPathFile();
if (!File.Exists(pathFile))
{
return null;
}
var savedPath = File.ReadAllText(pathFile).Trim();
return string.IsNullOrWhiteSpace(savedPath) ? null : savedPath;
}
catch
{
return null;
}
}
private static void SaveCustomHostPathInternal(string? customHostPath)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetCustomHostPathFile(), customHostPath ?? string.Empty);
}
catch
{
}
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
}
}

View File

@@ -261,6 +261,13 @@ public partial class SplashWindow : Window, ISplashStageReporter
debugWindow.Closed += (_, _) =>
{
if (debugWindow.WasAccepted)
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
debugWindow.IsDevModeEnabled,
debugWindow.SelectedHostPath));
}
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};

View File

@@ -108,7 +108,9 @@ public static class AppVersionProvider
return fallback;
}
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
var normalized = TrimSurroundingQuotes(rawValue)
.Split('+', 2, StringSplitOptions.TrimEntries)[0]
.Trim();
return string.IsNullOrWhiteSpace(normalized)
? fallback
: normalized;
@@ -116,9 +118,10 @@ public static class AppVersionProvider
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
{
return string.IsNullOrWhiteSpace(rawValue)
var normalized = TrimSurroundingQuotes(rawValue);
return string.IsNullOrWhiteSpace(normalized)
? fallback
: rawValue.Trim();
: normalized;
}
private static AppVersionInfo OverrideMissingParts(
@@ -158,17 +161,24 @@ public static class AppVersionProvider
try
{
var json = File.ReadAllText(versionFilePath);
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
var version = ReadStringProperty(root, nameof(AppVersionInfo.Version));
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
var codename = ReadStringProperty(root, nameof(AppVersionInfo.Codename));
info = new AppVersionInfo
{
Version = NormalizeVersionText(parsedInfo.Version),
Codename = NormalizeCodename(parsedInfo.Codename)
Version = NormalizeVersionText(version),
Codename = NormalizeCodename(codename)
};
return true;
}
@@ -359,4 +369,43 @@ public static class AppVersionProvider
return null;
}
}
private static string? ReadStringProperty(JsonElement root, string propertyName)
{
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
return property.Value.GetString();
}
}
return null;
}
private static string TrimSurroundingQuotes(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
var normalized = rawValue.Trim();
while (normalized.Length >= 2)
{
var first = normalized[0];
var last = normalized[^1];
if ((first == '\'' && last == '\'') ||
(first == '"' && last == '"'))
{
normalized = normalized[1..^1].Trim();
continue;
}
break;
}
return normalized;
}
}

View File

@@ -0,0 +1,83 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AppVersionProviderTests
{
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonExists_UsesVersionFile()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7", """
{"Version":"0.8.5.7","Codename":"Administrate"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
Assert.Equal("Administrate", info.Codename);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonIsMissing_FallsBackToDeploymentDirectory()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonContainsQuotedValues_NormalizesValues()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-1.2.3", """
{"Version":"'1.2.3'","Codename":"'Administrate'"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("1.2.3", info.Version);
Assert.Equal("Administrate", info.Codename);
}
private sealed class TemporaryPackage : IDisposable
{
private TemporaryPackage(string root)
{
Root = root;
}
public string Root { get; }
public static TemporaryPackage Create()
{
var root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.VersionTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return new TemporaryPackage(root);
}
public void CreateDeployment(string name, string? versionJson = null)
{
var deployment = Path.Combine(Root, name);
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, "LanMountainDesktop.exe"), string.Empty);
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
if (versionJson is not null)
{
File.WriteAllText(Path.Combine(deployment, "version.json"), versionJson);
}
}
public void Dispose()
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,43 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class DeploymentLocatorTests : IDisposable
{
private readonly string _appRoot;
private readonly string _configRoot;
public DeploymentLocatorTests()
{
var testRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DeploymentLocatorTests", Guid.NewGuid().ToString("N"));
_appRoot = Path.Combine(testRoot, "app-root");
_configRoot = Path.Combine(testRoot, "config");
Directory.CreateDirectory(_appRoot);
Directory.CreateDirectory(_configRoot);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _configRoot;
}
[Fact]
public void ResolveHostExecutable_WhenSavedDebugPathIsMalformed_DoesNotThrow()
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, "bad\0path"));
var locator = new DeploymentLocator(_appRoot);
var result = locator.ResolveHostExecutable(CommandContext.FromArgs(["launch", "--debug"]));
Assert.NotEqual("debug_saved_custom_path", result.ResolutionSource);
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
var testRoot = Directory.GetParent(_appRoot)?.FullName;
if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot))
{
Directory.Delete(testRoot, recursive: true);
}
}
}

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

@@ -0,0 +1,50 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class LauncherDebugSettingsStoreTests : IDisposable
{
private readonly string _tempDirectory;
public LauncherDebugSettingsStoreTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DebugSettingsTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDirectory);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _tempDirectory;
}
[Fact]
public void Load_WhenOnlyLegacyFilesExist_ReadsLegacySettings()
{
var customPath = Path.Combine(_tempDirectory, "legacy-host.exe");
File.WriteAllText(Path.Combine(_tempDirectory, "devmode.config"), "1");
File.WriteAllText(Path.Combine(_tempDirectory, "custom-host-path.config"), customPath);
var settings = LauncherDebugSettingsStore.Load();
Assert.True(settings.DevModeEnabled);
Assert.Equal(customPath, settings.CustomHostPath);
}
[Fact]
public void Save_WritesNewSettingsFiles()
{
var customPath = Path.Combine(_tempDirectory, "host.exe");
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, customPath));
Assert.Equal("True", File.ReadAllText(Path.Combine(_tempDirectory, "dev-mode.flag")).Trim());
Assert.Equal(customPath, File.ReadAllText(Path.Combine(_tempDirectory, "custom-host-path.txt")).Trim());
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
}

View File

@@ -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,11 +1767,39 @@ 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();
return restored
? new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status)
: new PublicShellActivationResult(false, "activation_failed", "Desktop window activation failed.", status);
if (restored)
{
return new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status);
}
if (IsShutdownInProgress)
{
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
}
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);
}
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)

View File

@@ -90,8 +90,8 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
<!-- 发布时也生成版本信息文件 -->
@@ -101,7 +101,7 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
</Project>

View File

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

View File

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

View File

@@ -1,15 +1,47 @@
# 生成版本信息文件
param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$Codename = "Administrate"
)
$ErrorActionPreference = "Stop"
function Normalize-ArgumentValue {
param(
[Parameter(Mandatory=$true)]
[AllowEmptyString()]
[string]$Value
)
$trimmed = $Value.Trim()
if ($trimmed.Length -ge 2) {
$first = $trimmed[0]
$last = $trimmed[$trimmed.Length - 1]
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $trimmed.Substring(1, $trimmed.Length - 2).Trim()
}
}
return $trimmed
}
$OutputPath = Normalize-ArgumentValue -Value $OutputPath
$Version = Normalize-ArgumentValue -Value $Version
$Codename = Normalize-ArgumentValue -Value $Codename
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
throw "OutputPath is required."
}
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Version is required."
}
$versionInfo = @{
Version = $Version
Codename = $Codename
@@ -18,11 +50,15 @@ $versionInfo = @{
$json = $versionInfo | ConvertTo-Json -Compress
$dir = Split-Path -Parent $OutputPath
if (!(Test-Path $dir)) {
if ([string]::IsNullOrWhiteSpace($dir)) {
throw "OutputPath must include a directory: $OutputPath"
}
if (!(Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
Write-Host " Version: $Version" -ForegroundColor Gray
Write-Host " Codename: $Codename" -ForegroundColor Gray