mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0085c66514 | ||
|
|
d4901e436f | ||
|
|
2d9391f930 |
@@ -0,0 +1,17 @@
|
|||||||
|
# Tray Menu Shutdown Addendum
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Tray menu `Exit App` must commit an irreversible host shutdown request.
|
||||||
|
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
|
||||||
|
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
|
||||||
|
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
|
||||||
|
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
|
||||||
|
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
|
||||||
|
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
|
||||||
|
- Repeated tray clicks during shutdown are ignored and logged.
|
||||||
|
- Repeated component-library clicks focus the existing window instead of opening duplicates.
|
||||||
@@ -120,7 +120,23 @@ public partial class App : Application
|
|||||||
private static SplashWindow CreateSplashWindow()
|
private static SplashWindow CreateSplashWindow()
|
||||||
{
|
{
|
||||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
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)
|
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||||
@@ -318,12 +334,16 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
reporter?.Report("activation", response.Message);
|
reporter?.Report("activation", response.Message);
|
||||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||||
|
var success = response.Accepted ||
|
||||||
|
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = response.Accepted,
|
Success = success,
|
||||||
Stage = "launch",
|
Stage = "launch",
|
||||||
Code = response.Code,
|
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||||
Message = response.Message,
|
Message = success && !response.Accepted
|
||||||
|
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||||
|
: response.Message,
|
||||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -334,12 +354,19 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
reporter?.Report("activation", activation.Message);
|
reporter?.Report("activation", activation.Message);
|
||||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||||
|
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = activation.Accepted,
|
Success = success,
|
||||||
Stage = "launch",
|
Stage = "launch",
|
||||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
Code = activation.Accepted
|
||||||
Message = activation.Message,
|
? "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)
|
Details = BuildCoordinatorResultDetails(null, activation)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -370,6 +397,18 @@ public partial class App : Application
|
|||||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||||
if (activation is not null)
|
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
|
return new LauncherCoordinatorResponse
|
||||||
{
|
{
|
||||||
Accepted = activation.Accepted,
|
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(
|
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||||
LauncherCoordinatorStatus? status,
|
LauncherCoordinatorStatus? status,
|
||||||
PublicShellActivationResult? activation)
|
PublicShellActivationResult? activation)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ internal enum StartupAttemptState
|
|||||||
SoftTimeout,
|
SoftTimeout,
|
||||||
DetachedWaiting,
|
DetachedWaiting,
|
||||||
Succeeded,
|
Succeeded,
|
||||||
Failed
|
Failed,
|
||||||
|
WaitingForShell
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class StartupAttemptRecord
|
internal sealed class StartupAttemptRecord
|
||||||
|
|||||||
@@ -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-* 子目录(发布版)
|
||||||
|
|||||||
@@ -204,12 +204,16 @@ internal sealed class DeploymentLocator
|
|||||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||||
{
|
{
|
||||||
var fullSavedPath = Path.GetFullPath(savedCustomPath);
|
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
|
||||||
searchedPaths.Add(fullSavedPath);
|
|
||||||
if (File.Exists(fullSavedPath))
|
|
||||||
{
|
{
|
||||||
source = "debug_saved_custom_path";
|
searchedPaths.Add(fullSavedPath);
|
||||||
return 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;
|
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(
|
private static string? FindBestDeploymentHost(
|
||||||
string root,
|
string root,
|
||||||
string executable,
|
string executable,
|
||||||
@@ -303,9 +322,17 @@ internal sealed class DeploymentLocator
|
|||||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||||
{
|
{
|
||||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
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);
|
var devPath = ScanDevelopmentPaths(executable);
|
||||||
|
|||||||
@@ -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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
199
LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed record HostLaunchPlan(
|
||||||
|
string HostPath,
|
||||||
|
string PackageRoot,
|
||||||
|
string WorkingDirectory,
|
||||||
|
IReadOnlyList<string> Arguments,
|
||||||
|
IReadOnlyDictionary<string, string> EnvironmentVariables,
|
||||||
|
AppVersionInfo VersionInfo);
|
||||||
|
|
||||||
|
internal static class HostLaunchPlanBuilder
|
||||||
|
{
|
||||||
|
private static readonly string[] LauncherOnlyOptions =
|
||||||
|
[
|
||||||
|
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||||
|
"app-root",
|
||||||
|
LauncherIpcConstants.LauncherPidEnvVar,
|
||||||
|
LauncherIpcConstants.PackageRootEnvVar,
|
||||||
|
LauncherIpcConstants.VersionEnvVar,
|
||||||
|
LauncherIpcConstants.CodenameEnvVar
|
||||||
|
];
|
||||||
|
|
||||||
|
public static HostLaunchPlan Build(
|
||||||
|
CommandContext context,
|
||||||
|
DeploymentLocator deploymentLocator,
|
||||||
|
HostResolutionResult resolution)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(deploymentLocator);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolution);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
|
||||||
|
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
|
||||||
|
var versionInfo = deploymentLocator.GetVersionInfo();
|
||||||
|
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
|
||||||
|
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
|
||||||
|
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
|
||||||
|
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
|
||||||
|
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HostLaunchPlan(
|
||||||
|
hostPath,
|
||||||
|
packageRoot,
|
||||||
|
Directory.Exists(packageRoot)
|
||||||
|
? packageRoot
|
||||||
|
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
|
||||||
|
arguments,
|
||||||
|
environment,
|
||||||
|
versionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
|
||||||
|
{
|
||||||
|
return string.Join(" ", arguments.Select(QuoteArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
|
||||||
|
{
|
||||||
|
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
|
||||||
|
? AppContext.BaseDirectory
|
||||||
|
: Path.GetFullPath(appRoot);
|
||||||
|
|
||||||
|
var hostDirectory = Path.GetDirectoryName(hostPath);
|
||||||
|
if (hostDirectory is not null &&
|
||||||
|
Directory.Exists(fullAppRoot) &&
|
||||||
|
IsAppDeploymentDirectory(hostDirectory) &&
|
||||||
|
IsParentOf(fullAppRoot, hostDirectory))
|
||||||
|
{
|
||||||
|
return fullAppRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return fullAppRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostDirectory ?? fullAppRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildForwardedArguments(
|
||||||
|
CommandContext context,
|
||||||
|
string packageRoot,
|
||||||
|
AppVersionInfo versionInfo)
|
||||||
|
{
|
||||||
|
var arguments = new List<string>();
|
||||||
|
|
||||||
|
for (var index = 0; index < context.RawArgs.Count; index++)
|
||||||
|
{
|
||||||
|
var arg = context.RawArgs[index];
|
||||||
|
|
||||||
|
if (index == 0 &&
|
||||||
|
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||||
|
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == 1 &&
|
||||||
|
!arg.StartsWith("--", StringComparison.Ordinal) &&
|
||||||
|
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var key = arg[2..];
|
||||||
|
var equalsIndex = key.IndexOf('=');
|
||||||
|
if (equalsIndex >= 0)
|
||||||
|
{
|
||||||
|
key = key[..equalsIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (equalsIndex < 0 &&
|
||||||
|
index + 1 < context.RawArgs.Count &&
|
||||||
|
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.Add(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
|
||||||
|
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
|
||||||
|
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
|
||||||
|
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
|
||||||
|
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAppDeploymentDirectory(string path)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
|
||||||
|
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsParentOf(string parent, string child)
|
||||||
|
{
|
||||||
|
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return childPath.StartsWith(
|
||||||
|
parentPath + Path.DirectorySeparatorChar,
|
||||||
|
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new System.Text.StringBuilder();
|
||||||
|
builder.Append('"');
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (ch == '"')
|
||||||
|
{
|
||||||
|
builder.Append("\\\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -228,6 +218,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
ipcConnected = true;
|
ipcConnected = true;
|
||||||
shellStatus = existingActivation.Status;
|
shellStatus = existingActivation.Status;
|
||||||
|
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
|
||||||
lastStage = existingActivation.Accepted
|
lastStage = existingActivation.Accepted
|
||||||
? StartupStage.ActivationRedirected
|
? StartupStage.ActivationRedirected
|
||||||
: StartupStage.ActivationFailed;
|
: StartupStage.ActivationFailed;
|
||||||
@@ -236,6 +227,10 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
||||||
}
|
}
|
||||||
|
else if (recoverableActivationFailure)
|
||||||
|
{
|
||||||
|
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
||||||
@@ -244,14 +239,20 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
PublishCoordinatorStatus(
|
PublishCoordinatorStatus(
|
||||||
hostProcessAliveOverride: true,
|
hostProcessAliveOverride: true,
|
||||||
completed: true,
|
completed: true,
|
||||||
succeeded: existingActivation.Accepted);
|
succeeded: existingActivation.Accepted || recoverableActivationFailure);
|
||||||
windowsClosingByCoordinator = true;
|
windowsClosingByCoordinator = true;
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: existingActivation.Accepted,
|
success: existingActivation.Accepted || recoverableActivationFailure,
|
||||||
stage: "launch",
|
stage: "launch",
|
||||||
code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
code: existingActivation.Accepted
|
||||||
message: existingActivation.Message,
|
? "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(
|
details: MergeDetails(
|
||||||
launcherContextDetails,
|
launcherContextDetails,
|
||||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -428,19 +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);
|
|
||||||
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(
|
||||||
@@ -459,11 +447,52 @@ 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;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -482,10 +511,58 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
|
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);
|
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);
|
||||||
|
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)
|
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);
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
@@ -533,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)
|
||||||
@@ -540,13 +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)
|
||||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
{
|
||||||
|
successTcs.TrySetResult(shellSuccess);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!softTimeoutShown &&
|
if (!softTimeoutShown &&
|
||||||
@@ -599,10 +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)
|
||||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
{
|
||||||
|
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;
|
windowsClosingByCoordinator = true;
|
||||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
||||||
@@ -640,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));
|
||||||
@@ -807,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(
|
||||||
@@ -834,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);
|
||||||
@@ -848,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(
|
||||||
@@ -915,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;
|
||||||
@@ -1234,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;
|
||||||
}
|
}
|
||||||
@@ -1243,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;
|
||||||
}
|
}
|
||||||
@@ -1263,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())
|
||||||
@@ -1320,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)
|
||||||
@@ -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(
|
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
||||||
LanMountainDesktopIpcClient ipcClient)
|
LanMountainDesktopIpcClient ipcClient)
|
||||||
{
|
{
|
||||||
@@ -1393,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);
|
||||||
@@ -1405,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();
|
||||||
}
|
}
|
||||||
@@ -1524,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(
|
||||||
@@ -1597,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;
|
||||||
@@ -1628,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
|
||||||
|
|||||||
@@ -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()
|
public void MarkOwnedDetachedWaiting()
|
||||||
{
|
{
|
||||||
UpdateOwned(record =>
|
UpdateOwned(record =>
|
||||||
@@ -423,7 +436,11 @@ internal sealed class StartupAttemptRegistry
|
|||||||
|
|
||||||
private static bool IsAttachable(StartupAttemptRecord record)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -433,7 +450,11 @@ internal sealed class StartupAttemptRegistry
|
|||||||
|
|
||||||
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -448,7 +469,11 @@ internal sealed class StartupAttemptRegistry
|
|||||||
|
|
||||||
private static bool IsCoordinatorLive(StartupAttemptRecord record)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 错误调试窗口 - 开发人员专用调试设置
|
|
||||||
/// </summary>
|
|
||||||
public partial class ErrorDebugWindow : Window
|
public partial class ErrorDebugWindow : Window
|
||||||
{
|
{
|
||||||
private string? _selectedHostPath;
|
private string? _selectedHostPath;
|
||||||
private bool _isInitialized = false;
|
private bool _isInitialized;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否启用了开发模式
|
|
||||||
/// </summary>
|
|
||||||
public bool IsDevModeEnabled { get; private set; }
|
public bool IsDevModeEnabled { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
public bool WasAccepted { get; private set; }
|
||||||
/// 选择的主程序路径
|
|
||||||
/// </summary>
|
|
||||||
public string? SelectedHostPath => _selectedHostPath;
|
public string? SelectedHostPath => _selectedHostPath;
|
||||||
|
|
||||||
public ErrorDebugWindow()
|
public ErrorDebugWindow()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
Loaded += OnWindowLoaded;
|
||||||
// 延迟到窗口加载完成后再初始化组件
|
|
||||||
this.Loaded += OnWindowLoaded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
|
||||||
|
: this()
|
||||||
{
|
{
|
||||||
IsDevModeEnabled = devModeEnabled;
|
IsDevModeEnabled = devModeEnabled;
|
||||||
_selectedHostPath = initialPath;
|
_selectedHostPath = initialPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 窗口加载完成事件
|
|
||||||
/// </summary>
|
|
||||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_isInitialized) return;
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
|
||||||
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
|
|
||||||
InitializeComponents();
|
InitializeComponents();
|
||||||
|
|
||||||
// 设置初始值(在视觉树准备好后)
|
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
|
||||||
if (devModeToggle is not null)
|
|
||||||
{
|
{
|
||||||
devModeToggle.IsChecked = IsDevModeEnabled;
|
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||||
}
|
}
|
||||||
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
|
|||||||
|
|
||||||
private void InitializeComponents()
|
private void InitializeComponents()
|
||||||
{
|
{
|
||||||
// 开发模式开关
|
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
|
||||||
if (devModeToggle is not null)
|
|
||||||
{
|
{
|
||||||
devModeToggle.IsCheckedChanged += (s, e) =>
|
devModeToggle.IsCheckedChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
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!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 浏览按钮
|
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
|
||||||
var browseButton = this.FindControl<Button>("BrowseButton");
|
|
||||||
if (browseButton is not null)
|
|
||||||
{
|
{
|
||||||
browseButton.Click += OnBrowseClick;
|
browseButton.Click += OnBrowseClick;
|
||||||
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定按钮
|
if (this.FindControl<Button>("OkButton") is { } okButton)
|
||||||
var okButton = this.FindControl<Button>("OkButton");
|
|
||||||
if (okButton is not null)
|
|
||||||
{
|
{
|
||||||
okButton.Click += (s, e) => Close();
|
okButton.Click += (_, _) =>
|
||||||
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) =>
|
|
||||||
{
|
{
|
||||||
// 取消时恢复原始状态
|
WasAccepted = true;
|
||||||
IsDevModeEnabled = false;
|
|
||||||
_selectedHostPath = null;
|
|
||||||
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
|
||||||
Close();
|
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)
|
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var storageProvider = StorageProvider;
|
var storageProvider = StorageProvider;
|
||||||
if (storageProvider is null) return;
|
if (storageProvider is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var options = new FilePickerOpenOptions
|
var options = new FilePickerOpenOptions
|
||||||
{
|
{
|
||||||
Title = "选择阑山桌面主程序",
|
Title = "Select LanMountainDesktop host executable",
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
FileTypeFilter = new[]
|
FileTypeFilter =
|
||||||
{
|
[
|
||||||
new FilePickerFileType("可执行文件")
|
new FilePickerFileType("Executable")
|
||||||
{
|
{
|
||||||
Patterns = OperatingSystem.IsWindows()
|
Patterns = OperatingSystem.IsWindows()
|
||||||
? new[] { "*.exe" }
|
? ["*.exe"]
|
||||||
: new[] { "*" }
|
: ["*"]
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||||
if (result.Count > 0)
|
if (result.Count <= 0)
|
||||||
{
|
{
|
||||||
_selectedHostPath = result[0].Path.LocalPath;
|
return;
|
||||||
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
|
||||||
UpdatePathDisplay(_selectedHostPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_selectedHostPath = result[0].Path.LocalPath;
|
||||||
|
UpdatePathDisplay(_selectedHostPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新路径显示
|
|
||||||
/// </summary>
|
|
||||||
private void UpdatePathDisplay(string? path)
|
private void UpdatePathDisplay(string? path)
|
||||||
{
|
{
|
||||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
|
||||||
if (pathTextBlock is not null)
|
|
||||||
{
|
{
|
||||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,17 +185,23 @@ public partial class ErrorWindow : Window
|
|||||||
|
|
||||||
debugWindow.Closed += (_, _) =>
|
debugWindow.Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
|
if (!debugWindow.WasAccepted)
|
||||||
|
{
|
||||||
|
_isDebugMode = false;
|
||||||
|
_iconClickCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||||
_customHostPath = debugWindow.SelectedHostPath;
|
_customHostPath = debugWindow.SelectedHostPath;
|
||||||
SaveDevModeStateInternal(_devModeEnabled);
|
|
||||||
SaveCustomHostPathInternal(_customHostPath);
|
|
||||||
|
|
||||||
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
||||||
{
|
{
|
||||||
ScanDevPaths();
|
ScanDevPaths();
|
||||||
SaveCustomHostPathInternal(_customHostPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
|
||||||
|
|
||||||
_isDebugMode = false;
|
_isDebugMode = false;
|
||||||
_iconClickCount = 0;
|
_iconClickCount = 0;
|
||||||
};
|
};
|
||||||
@@ -285,74 +291,17 @@ public partial class ErrorWindow : Window
|
|||||||
|
|
||||||
private static string GetConfigBaseDirectory()
|
private static string GetConfigBaseDirectory()
|
||||||
{
|
{
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
return LauncherDebugSettingsStore.ConfigBaseDirectory;
|
||||||
if (!string.IsNullOrWhiteSpace(appData))
|
|
||||||
{
|
|
||||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.Combine(AppContext.BaseDirectory, ".launcher");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
private static bool LoadDevModeStateInternal()
|
||||||
{
|
{
|
||||||
try
|
return LauncherDebugSettingsStore.IsDevModeEnabled();
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? LoadCustomHostPathInternal()
|
private static string? LoadCustomHostPathInternal()
|
||||||
{
|
{
|
||||||
try
|
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,13 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
|||||||
|
|
||||||
debugWindow.Closed += (_, _) =>
|
debugWindow.Closed += (_, _) =>
|
||||||
{
|
{
|
||||||
|
if (debugWindow.WasAccepted)
|
||||||
|
{
|
||||||
|
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
|
||||||
|
debugWindow.IsDevModeEnabled,
|
||||||
|
debugWindow.SelectedHostPath));
|
||||||
|
}
|
||||||
|
|
||||||
_isDebugModeOpened = false;
|
_isDebugModeOpened = false;
|
||||||
_versionTextClickCount = 0;
|
_versionTextClickCount = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ public static class AppVersionProvider
|
|||||||
return fallback;
|
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)
|
return string.IsNullOrWhiteSpace(normalized)
|
||||||
? fallback
|
? fallback
|
||||||
: normalized;
|
: normalized;
|
||||||
@@ -116,9 +118,10 @@ public static class AppVersionProvider
|
|||||||
|
|
||||||
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(rawValue)
|
var normalized = TrimSurroundingQuotes(rawValue);
|
||||||
|
return string.IsNullOrWhiteSpace(normalized)
|
||||||
? fallback
|
? fallback
|
||||||
: rawValue.Trim();
|
: normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppVersionInfo OverrideMissingParts(
|
private static AppVersionInfo OverrideMissingParts(
|
||||||
@@ -158,17 +161,24 @@ public static class AppVersionProvider
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(versionFilePath);
|
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
|
||||||
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
var root = document.RootElement;
|
||||||
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return false;
|
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
|
info = new AppVersionInfo
|
||||||
{
|
{
|
||||||
Version = NormalizeVersionText(parsedInfo.Version),
|
Version = NormalizeVersionText(version),
|
||||||
Codename = NormalizeCodename(parsedInfo.Codename)
|
Codename = NormalizeCodename(codename)
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -359,4 +369,43 @@ public static class AppVersionProvider
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
LanMountainDesktop.Tests/AppVersionProviderTests.cs
Normal file
83
LanMountainDesktop.Tests/AppVersionProviderTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
LanMountainDesktop.Tests/DeploymentLocatorTests.cs
Normal file
43
LanMountainDesktop.Tests/DeploymentLocatorTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
100
LanMountainDesktop.Tests/HostLaunchPlanBuilderTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using LanMountainDesktop.Launcher;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class HostLaunchPlanBuilderTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _testRoot;
|
||||||
|
|
||||||
|
public HostLaunchPlanBuilderTests()
|
||||||
|
{
|
||||||
|
_testRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.HostLaunchPlanTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_testRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_UsesPackageRootAsWorkingDirectory_ForPublishedDeployment()
|
||||||
|
{
|
||||||
|
var packageRoot = Path.Combine(_testRoot, "package-root");
|
||||||
|
var deployment = CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||||
|
var resultPath = Path.Combine(_testRoot, "launcher-result.json");
|
||||||
|
var context = CommandContext.FromArgs(
|
||||||
|
[
|
||||||
|
"launch",
|
||||||
|
"--app-root", packageRoot,
|
||||||
|
"--result", resultPath,
|
||||||
|
"--launch-source", "postinstall",
|
||||||
|
"--custom-host-arg", "custom-value"
|
||||||
|
]);
|
||||||
|
var locator = new DeploymentLocator(packageRoot);
|
||||||
|
var resolution = locator.ResolveHostExecutable(context);
|
||||||
|
|
||||||
|
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(packageRoot), plan.PackageRoot);
|
||||||
|
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||||
|
Assert.Equal(Path.Combine(deployment, GetExecutableName()), plan.HostPath);
|
||||||
|
Assert.Contains("--launch-source", plan.Arguments);
|
||||||
|
Assert.Contains("postinstall", plan.Arguments);
|
||||||
|
Assert.Contains("--custom-host-arg", plan.Arguments);
|
||||||
|
Assert.Contains("custom-value", plan.Arguments);
|
||||||
|
Assert.DoesNotContain("--app-root", plan.Arguments);
|
||||||
|
Assert.DoesNotContain(packageRoot, plan.Arguments);
|
||||||
|
Assert.DoesNotContain("--result", plan.Arguments);
|
||||||
|
Assert.DoesNotContain(resultPath, plan.Arguments);
|
||||||
|
Assert.Contains($"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}", plan.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_KeepsPathsWithSpacesAsSingleArgumentListTokens()
|
||||||
|
{
|
||||||
|
var packageRoot = Path.Combine(_testRoot, "package root with spaces");
|
||||||
|
CreateDeployment(packageRoot, "app-0.8.5.7");
|
||||||
|
var context = CommandContext.FromArgs(["launch", "--app-root", packageRoot]);
|
||||||
|
var locator = new DeploymentLocator(packageRoot);
|
||||||
|
var resolution = locator.ResolveHostExecutable(context);
|
||||||
|
|
||||||
|
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
|
||||||
|
|
||||||
|
var packageRootArgument = $"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}";
|
||||||
|
Assert.Contains(packageRootArgument, plan.Arguments);
|
||||||
|
Assert.Equal(Path.GetFullPath(packageRoot), plan.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar]);
|
||||||
|
Assert.DoesNotContain(plan.Arguments, argument => argument.StartsWith("\"", StringComparison.Ordinal));
|
||||||
|
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateDeployment(string packageRoot, string deploymentName)
|
||||||
|
{
|
||||||
|
var deployment = Path.Combine(packageRoot, deploymentName);
|
||||||
|
Directory.CreateDirectory(deployment);
|
||||||
|
File.WriteAllText(Path.Combine(deployment, GetExecutableName()), string.Empty);
|
||||||
|
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(deployment, "version.json"),
|
||||||
|
"""
|
||||||
|
{"Version":"0.8.5.7","Codename":"Administrate"}
|
||||||
|
""");
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetExecutableName()
|
||||||
|
{
|
||||||
|
return OperatingSystem.IsWindows()
|
||||||
|
? "LanMountainDesktop.exe"
|
||||||
|
: "LanMountainDesktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_testRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
48
LanMountainDesktop.Tests/HostShutdownGateTests.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class HostShutdownGateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Submit_WhenFirstExitRequest_AcceptsAndRecordsExit()
|
||||||
|
{
|
||||||
|
var gate = new HostShutdownGate();
|
||||||
|
|
||||||
|
var submission = gate.Submit(HostShutdownMode.Exit);
|
||||||
|
|
||||||
|
Assert.True(submission.Accepted);
|
||||||
|
Assert.True(submission.IsFirstSubmission);
|
||||||
|
Assert.Equal(HostShutdownMode.Exit, submission.EffectiveMode);
|
||||||
|
Assert.True(gate.IsShutdownRequested);
|
||||||
|
Assert.Equal(HostShutdownMode.Exit, gate.EffectiveMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Submit_WhenDuplicateSameMode_AcceptsButDoesNotExecuteAgain()
|
||||||
|
{
|
||||||
|
var gate = new HostShutdownGate();
|
||||||
|
gate.Submit(HostShutdownMode.Exit);
|
||||||
|
|
||||||
|
var duplicate = gate.Submit(HostShutdownMode.Exit);
|
||||||
|
|
||||||
|
Assert.True(duplicate.Accepted);
|
||||||
|
Assert.False(duplicate.IsFirstSubmission);
|
||||||
|
Assert.Equal(HostShutdownMode.Exit, duplicate.EffectiveMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Submit_WhenExitArrivesAfterRestart_DoesNotOverwriteRestart()
|
||||||
|
{
|
||||||
|
var gate = new HostShutdownGate();
|
||||||
|
gate.Submit(HostShutdownMode.Restart);
|
||||||
|
|
||||||
|
var conflictingExit = gate.Submit(HostShutdownMode.Exit);
|
||||||
|
|
||||||
|
Assert.False(conflictingExit.Accepted);
|
||||||
|
Assert.False(conflictingExit.IsFirstSubmission);
|
||||||
|
Assert.Equal(HostShutdownMode.Restart, conflictingExit.EffectiveMode);
|
||||||
|
Assert.Equal(HostShutdownMode.Restart, gate.EffectiveMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs
Normal file
50
LanMountainDesktop.Tests/LauncherDebugSettingsStoreTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ public partial class App : Application
|
|||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly FontFamilyService _fontFamilyService = new();
|
private readonly FontFamilyService _fontFamilyService = new();
|
||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
|
private readonly HostShutdownGate _shutdownGate = new();
|
||||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
||||||
@@ -75,6 +76,7 @@ public partial class App : Application
|
|||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||||
|
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
@@ -83,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();
|
||||||
@@ -107,6 +110,7 @@ public partial class App : Application
|
|||||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||||
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
||||||
internal INotificationService? NotificationService => _notificationService;
|
internal INotificationService? NotificationService => _notificationService;
|
||||||
|
internal bool IsShutdownInProgress => _shutdownGate.IsShutdownRequested || _shutdownIntent != ShutdownIntent.None;
|
||||||
internal RestartPresentationMode GetCurrentRestartPresentationMode()
|
internal RestartPresentationMode GetCurrentRestartPresentationMode()
|
||||||
{
|
{
|
||||||
return _desktopShellState switch
|
return _desktopShellState switch
|
||||||
@@ -119,6 +123,14 @@ public partial class App : Application
|
|||||||
|
|
||||||
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"SettingsFacade",
|
||||||
|
$"Settings open ignored because shutdown is in progress. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
EnsureSettingsWindowService();
|
EnsureSettingsWindowService();
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"SettingsFacade",
|
"SettingsFacade",
|
||||||
@@ -173,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);
|
||||||
|
|
||||||
@@ -313,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,
|
||||||
@@ -348,11 +362,23 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
|
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("DesktopShell", "Tray Open Desktop ignored because shutdown is in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
|
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("HostLifecycle", "Tray Restart ignored because shutdown is already in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
Source: "TrayMenu",
|
Source: "TrayMenu",
|
||||||
Reason: "User selected Restart App from the tray menu."));
|
Reason: "User selected Restart App from the tray menu."));
|
||||||
@@ -362,6 +388,13 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
_ = e;
|
_ = e;
|
||||||
|
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("SettingsFacade", "Tray Settings ignored because shutdown is in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
OpenIndependentSettingsModule("TrayMenu");
|
OpenIndependentSettingsModule("TrayMenu");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,28 +402,52 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
_ = e;
|
_ = e;
|
||||||
|
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("FusedDesktop", "Tray Component Library ignored because shutdown is in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
|
||||||
|
|
||||||
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
|
|
||||||
EnsureTransparentOverlayWindow();
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (_fusedComponentLibraryWindow is { } existingWindow)
|
||||||
|
{
|
||||||
|
if (!existingWindow.IsVisible)
|
||||||
|
{
|
||||||
|
existingWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
existingWindow.Activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
||||||
|
fusedDesktopManager.EnterEditMode();
|
||||||
|
|
||||||
|
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
|
||||||
|
EnsureTransparentOverlayWindow();
|
||||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
||||||
{
|
{
|
||||||
_transparentOverlayWindow.Show();
|
_transparentOverlayWindow.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
var window = new FusedDesktopComponentLibraryWindow();
|
var window = new FusedDesktopComponentLibraryWindow();
|
||||||
|
_fusedComponentLibraryWindow = window;
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null)
|
if (_transparentOverlayWindow is not null)
|
||||||
{
|
{
|
||||||
@@ -406,7 +463,11 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
|
// 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
|
||||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
fusedDesktopManager.ExitEditMode();
|
||||||
|
if (ReferenceEquals(_fusedComponentLibraryWindow, s))
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.Show();
|
window.Show();
|
||||||
@@ -415,6 +476,25 @@ public partial class App : Application
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_transparentOverlayWindow?.SaveLayoutAndHide();
|
||||||
|
}
|
||||||
|
catch (Exception overlayEx)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay after library open failure.", overlayEx);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
|
}
|
||||||
|
catch (Exception exitEx)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktop", "Failed to exit edit mode after library open failure.", exitEx);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
}
|
}
|
||||||
}, DispatcherPriority.Send);
|
}, DispatcherPriority.Send);
|
||||||
}
|
}
|
||||||
@@ -724,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()
|
||||||
@@ -738,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.");
|
||||||
@@ -746,12 +833,17 @@ 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("DesktopShell", $"Restore ignored because shutdown is in progress. Source='{source}'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
|
||||||
@@ -760,6 +852,12 @@ public partial class App : Application
|
|||||||
|
|
||||||
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
|
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("DesktopShell", $"Restore skipped because shutdown is in progress. Source='{source}'.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
|
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
|
||||||
@@ -838,6 +936,62 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"HostLifecycle",
|
||||||
|
$"Shutdown request ignored because desktop lifetime is unavailable. Mode='{mode}'; Source='{request?.Source ?? "Unknown"}'.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Dispatcher.UIThread.CheckAccess()
|
||||||
|
? TrySubmitShutdownCore(mode, request, desktop)
|
||||||
|
: Dispatcher.UIThread.InvokeAsync(
|
||||||
|
() => TrySubmitShutdownCore(mode, request, desktop),
|
||||||
|
DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TrySubmitShutdownCore(
|
||||||
|
HostShutdownMode mode,
|
||||||
|
HostApplicationLifecycleRequest? request,
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var source = request?.Source ?? "Unknown";
|
||||||
|
var submission = _shutdownGate.Submit(mode);
|
||||||
|
if (!submission.IsFirstSubmission)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"HostLifecycle",
|
||||||
|
$"Shutdown request ignored because shutdown is already in progress. Requested='{submission.RequestedMode}'; Effective='{submission.EffectiveMode}'; Source='{source}'.");
|
||||||
|
return submission.Accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdownIntent = mode == HostShutdownMode.Restart
|
||||||
|
? ShutdownIntent.RestartRequested
|
||||||
|
: ShutdownIntent.ExitRequested;
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Shutdown committed. Intent='{_shutdownIntent}'; Source='{source}'; Reason='{request?.Reason ?? string.Empty}'; CurrentShellState='{_desktopShellState}'.");
|
||||||
|
|
||||||
|
ScheduleForcedProcessTermination($"ShutdownRequest:{source}");
|
||||||
|
StopShellRecoveryWatchdog();
|
||||||
|
PerformExitCleanup();
|
||||||
|
ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
desktop.Shutdown();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("HostLifecycle", $"Desktop lifetime shutdown failed. Source='{source}'.", ex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal void PrepareForShutdown(bool isRestart, string source)
|
internal void PrepareForShutdown(bool isRestart, string source)
|
||||||
{
|
{
|
||||||
void Mark()
|
void Mark()
|
||||||
@@ -1123,6 +1277,30 @@ public partial class App : Application
|
|||||||
disposableRegistry.Dispose();
|
disposableRegistry.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_fusedComponentLibraryWindow is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow.Close();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktop", "Failed to close fused desktop component library during shutdown.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_fusedComponentLibraryWindow = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_transparentOverlayWindow is not null)
|
if (_transparentOverlayWindow is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1487,6 +1665,12 @@ public partial class App : Application
|
|||||||
|
|
||||||
private bool EnsureTaskbarEntry(string source)
|
private bool EnsureTaskbarEntry(string source)
|
||||||
{
|
{
|
||||||
|
if (IsShutdownInProgress)
|
||||||
|
{
|
||||||
|
AppLogger.Info("DesktopShell", $"Taskbar repair skipped because shutdown is in progress. Source='{source}'.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ShouldShowMainWindowInTaskbar())
|
if (!ShouldShowMainWindowInTaskbar())
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -1583,11 +1767,39 @@ 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();
|
||||||
return restored
|
if (restored)
|
||||||
? new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status)
|
{
|
||||||
: new PublicShellActivationResult(false, "activation_failed", "Desktop window activation failed.", status);
|
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)
|
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)
|
||||||
|
|||||||
@@ -90,8 +90,8 @@
|
|||||||
<AppVersion>$(Version)</AppVersion>
|
<AppVersion>$(Version)</AppVersion>
|
||||||
<AppCodename>Administrate</AppCodename>
|
<AppCodename>Administrate</AppCodename>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
<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="pwsh -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' != 'Windows_NT'" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<!-- 发布时也生成版本信息文件 -->
|
<!-- 发布时也生成版本信息文件 -->
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<AppVersion>$(Version)</AppVersion>
|
<AppVersion>$(Version)</AppVersion>
|
||||||
<AppCodename>Administrate</AppCodename>
|
<AppCodename>Administrate</AppCodename>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
<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="pwsh -ExecutionPolicy Bypass -File "$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1" -OutputPath "$(VersionFilePath)" -Version "$(AppVersion)" -Codename "$(AppCodename)"" Condition="'$(OS)' != 'Windows_NT'" />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,27 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TrayIcon.SetIcons(_application, []);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_trayIcon is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_trayIcon = null;
|
||||||
|
|
||||||
SetState(TrayAvailabilityState.Unavailable, "Dispose");
|
SetState(TrayAvailabilityState.Unavailable, "Dispose");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,23 +23,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
|
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
|
||||||
|
|
||||||
app = Application.Current as App;
|
app = Application.Current as App;
|
||||||
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
|
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
|
return app.TrySubmitShutdown(HostShutdownMode.Exit, request);
|
||||||
if (Dispatcher.UIThread.CheckAccess())
|
|
||||||
{
|
|
||||||
desktop.Shutdown();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -55,6 +45,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
app = Application.Current as App;
|
app = Application.Current as App;
|
||||||
|
if (app?.IsShutdownInProgress == true)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"HostLifecycle",
|
||||||
|
$"Restart request ignored because shutdown is already in progress. Source='{request?.Source ?? "Unknown"}'.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (HasPendingPluginUpgrades())
|
if (HasPendingPluginUpgrades())
|
||||||
{
|
{
|
||||||
@@ -123,10 +120,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
||||||
|
|
||||||
Process.Start(helperStartInfo);
|
Process.Start(helperStartInfo);
|
||||||
|
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
||||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
|
||||||
|
|
||||||
return TryExit(request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||||
@@ -143,8 +137,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
}
|
}
|
||||||
|
|
||||||
Process.Start(startInfo);
|
Process.Start(startInfo);
|
||||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
var shutdownRequest = request is null
|
||||||
var exitRequest = request is null
|
|
||||||
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||||
: request with
|
: request with
|
||||||
{
|
{
|
||||||
@@ -153,7 +146,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
|||||||
: request.Reason
|
: request.Reason
|
||||||
};
|
};
|
||||||
|
|
||||||
return TryExit(exitRequest);
|
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveUpgradeHelperPath()
|
private static string ResolveUpgradeHelperPath()
|
||||||
|
|||||||
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
65
LanMountainDesktop/Services/HostShutdownGate.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal enum HostShutdownMode
|
||||||
|
{
|
||||||
|
Exit = 0,
|
||||||
|
Restart = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct HostShutdownSubmission(
|
||||||
|
bool Accepted,
|
||||||
|
bool IsFirstSubmission,
|
||||||
|
HostShutdownMode EffectiveMode,
|
||||||
|
HostShutdownMode RequestedMode);
|
||||||
|
|
||||||
|
internal sealed class HostShutdownGate
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private bool _submitted;
|
||||||
|
private HostShutdownMode _mode;
|
||||||
|
|
||||||
|
public bool IsShutdownRequested
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _submitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HostShutdownMode? EffectiveMode
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _submitted ? _mode : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HostShutdownSubmission Submit(HostShutdownMode requestedMode)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_submitted)
|
||||||
|
{
|
||||||
|
_submitted = true;
|
||||||
|
_mode = requestedMode;
|
||||||
|
return new HostShutdownSubmission(
|
||||||
|
Accepted: true,
|
||||||
|
IsFirstSubmission: true,
|
||||||
|
EffectiveMode: requestedMode,
|
||||||
|
RequestedMode: requestedMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HostShutdownSubmission(
|
||||||
|
Accepted: _mode == requestedMode,
|
||||||
|
IsFirstSubmission: false,
|
||||||
|
EffectiveMode: _mode,
|
||||||
|
RequestedMode: requestedMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
# 生成版本信息文件
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$OutputPath,
|
[string]$OutputPath,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Version,
|
[string]$Version,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Codename = "Administrate"
|
[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 = @{
|
$versionInfo = @{
|
||||||
Version = $Version
|
Version = $Version
|
||||||
Codename = $Codename
|
Codename = $Codename
|
||||||
@@ -18,11 +50,15 @@ $versionInfo = @{
|
|||||||
$json = $versionInfo | ConvertTo-Json -Compress
|
$json = $versionInfo | ConvertTo-Json -Compress
|
||||||
$dir = Split-Path -Parent $OutputPath
|
$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
|
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 "Generated version file: $OutputPath" -ForegroundColor Green
|
||||||
Write-Host " Version: $Version" -ForegroundColor Gray
|
Write-Host " Version: $Version" -ForegroundColor Gray
|
||||||
Write-Host " Codename: $Codename" -ForegroundColor Gray
|
Write-Host " Codename: $Codename" -ForegroundColor Gray
|
||||||
|
|||||||
Reference in New Issue
Block a user