mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Add launcher debug settings, recovery & version fixes
Introduce a persistent LauncherDebugSettingsStore and wire it into ErrorWindow and SplashWindow so dev-mode and custom host path can be saved/loaded. Harden DeploymentLocator/FlexibleHostLocator to safely normalize and validate saved debug paths and log warnings for malformed values. Add a WaitingForShell startup state and recoverable-activation logic across App and LauncherFlowCoordinator (with registry updates) so Launcher can attach to an in-progress desktop shell rather than failing. Clean up ErrorDebugWindow UI/flow (WasAccepted flag, localization fixes, event wiring) and improve splash version population. Improve AppVersionProvider to trim surrounding quotes, robustly parse version.json via JsonDocument and read string properties; add unit tests for AppVersionProvider, DeploymentLocator and LauncherDebugSettingsStore. Also quote Exec commands in the csproj and harden scripts/Generate-VersionFile.ps1 (argument normalization, LiteralPath, error handling).
This commit is contained in:
@@ -120,7 +120,23 @@ public partial class App : Application
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
||||
return new SplashWindow(preferences.Mode);
|
||||
var window = new SplashWindow(preferences.Mode);
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
@@ -318,12 +334,16 @@ public partial class App : Application
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = response.Accepted,
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = response.Code,
|
||||
Message = response.Message,
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
@@ -334,12 +354,19 @@ public partial class App : Application
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = activation.Accepted,
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
@@ -370,6 +397,18 @@ public partial class App : Application
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
@@ -419,6 +458,32 @@ public partial class App : Application
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
|
||||
@@ -9,7 +9,8 @@ internal enum StartupAttemptState
|
||||
SoftTimeout,
|
||||
DetachedWaiting,
|
||||
Succeeded,
|
||||
Failed
|
||||
Failed,
|
||||
WaitingForShell
|
||||
}
|
||||
|
||||
internal sealed class StartupAttemptRecord
|
||||
|
||||
@@ -204,13 +204,17 @@ internal sealed class DeploymentLocator
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
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";
|
||||
return fullSavedPath;
|
||||
}
|
||||
|
||||
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +233,21 @@ internal sealed class DeploymentLocator
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
fullSavedPath = Path.GetFullPath(savedPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
fullSavedPath = string.Empty;
|
||||
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindBestDeploymentHost(
|
||||
string root,
|
||||
string executable,
|
||||
@@ -303,9 +322,17 @@ internal sealed class DeploymentLocator
|
||||
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||
{
|
||||
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
|
||||
if (!string.IsNullOrWhiteSpace(savedCustomPath))
|
||||
{
|
||||
return savedCustomPath;
|
||||
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
|
||||
File.Exists(fullSavedPath))
|
||||
{
|
||||
return fullSavedPath;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
|
||||
{
|
||||
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var devPath = ScanDevelopmentPaths(executable);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
ipcConnected = true;
|
||||
shellStatus = existingActivation.Status;
|
||||
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
|
||||
lastStage = existingActivation.Accepted
|
||||
? StartupStage.ActivationRedirected
|
||||
: StartupStage.ActivationFailed;
|
||||
@@ -236,6 +237,10 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
||||
}
|
||||
else if (recoverableActivationFailure)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
||||
@@ -244,14 +249,20 @@ internal sealed class LauncherFlowCoordinator
|
||||
PublishCoordinatorStatus(
|
||||
hostProcessAliveOverride: true,
|
||||
completed: true,
|
||||
succeeded: existingActivation.Accepted);
|
||||
succeeded: existingActivation.Accepted || recoverableActivationFailure);
|
||||
windowsClosingByCoordinator = true;
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: existingActivation.Accepted,
|
||||
success: existingActivation.Accepted || recoverableActivationFailure,
|
||||
stage: "launch",
|
||||
code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
message: existingActivation.Message,
|
||||
code: existingActivation.Accepted
|
||||
? "existing_host_activated"
|
||||
: recoverableActivationFailure
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
message: recoverableActivationFailure
|
||||
? "Existing desktop process is still starting; Launcher will not start another process."
|
||||
: existingActivation.Message,
|
||||
details: MergeDetails(
|
||||
launcherContextDetails,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -438,6 +449,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
@@ -464,6 +480,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
var softTimeoutAt = startedAt + StartupSoftTimeout;
|
||||
var hardTimeoutAt = startedAt + StartupHardTimeout;
|
||||
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
var activationRetryAttempted = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
@@ -482,10 +499,35 @@ internal sealed class LauncherFlowCoordinator
|
||||
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
|
||||
}
|
||||
|
||||
if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason))
|
||||
if (activationFailedTcs.Task.IsCompleted && !activationRetryAttempted)
|
||||
{
|
||||
activationRetryAttempted = true;
|
||||
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
|
||||
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
windowsClosingByCoordinator = true;
|
||||
if (retryOutcome.Success)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
|
||||
PublishCoordinatorStatus(
|
||||
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
||||
completed: true,
|
||||
succeeded: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(
|
||||
hostProcessAliveOverride: !launchOutcome.Process.HasExited,
|
||||
completed: true,
|
||||
succeeded: false);
|
||||
}
|
||||
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return WithAdditionalDetails(retryOutcome, ComposeLaunchDetails(!launchOutcome.Process.HasExited, recoveryActivationAttempted: true));
|
||||
}
|
||||
}
|
||||
|
||||
if (processExitTask.IsCompleted)
|
||||
@@ -512,6 +554,36 @@ internal sealed class LauncherFlowCoordinator
|
||||
}));
|
||||
}
|
||||
|
||||
if (!activationRetryAttempted &&
|
||||
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||
{
|
||||
activationRetryAttempted = true;
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
if (retryOutcome.Success)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, retryOutcome.Message);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||||
}
|
||||
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return WithAdditionalDetails(
|
||||
retryOutcome,
|
||||
MergeDetails(
|
||||
ComposeLaunchDetails(hostProcessAlive: false, recoveryActivationAttempted: true),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
@@ -543,6 +615,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC reconnected; waiting for desktop shell.");
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
@@ -602,6 +679,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
if (shellStatus is { DesktopVisible: false })
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedWaitingForShell("Host public IPC is ready; waiting for desktop shell.");
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
}
|
||||
@@ -632,6 +714,23 @@ 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);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: false, succeeded: false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: "startup_pending",
|
||||
message: "Host process is still running; Launcher will not start another process while the desktop shell finishes startup.",
|
||||
details: ComposeLaunchDetails(
|
||||
hostProcessAlive: true,
|
||||
recoveryActivationAttempted));
|
||||
}
|
||||
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
||||
@@ -1369,6 +1468,25 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
|
||||
{
|
||||
if (activation.Accepted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(activation.Code, "shutdown_in_progress", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return activation.Status.PublicIpcReady &&
|
||||
(!activation.Status.MainWindowOpened ||
|
||||
!activation.Status.DesktopVisible ||
|
||||
string.Equals(activation.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
||||
LanMountainDesktopIpcClient ipcClient)
|
||||
{
|
||||
|
||||
@@ -309,6 +309,19 @@ internal sealed class StartupAttemptRegistry
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedWaitingForShell(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.WaitingForShell;
|
||||
}
|
||||
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedDetachedWaiting()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
@@ -423,7 +436,11 @@ internal sealed class StartupAttemptRegistry
|
||||
|
||||
private static bool IsAttachable(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -433,7 +450,11 @@ internal sealed class StartupAttemptRegistry
|
||||
|
||||
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -448,7 +469,11 @@ internal sealed class StartupAttemptRegistry
|
||||
|
||||
private static bool IsCoordinatorLive(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
if (record.State is not (
|
||||
StartupAttemptState.Pending or
|
||||
StartupAttemptState.SoftTimeout or
|
||||
StartupAttemptState.DetachedWaiting or
|
||||
StartupAttemptState.WaitingForShell))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误调试窗口 - 开发人员专用调试设置
|
||||
/// </summary>
|
||||
public partial class ErrorDebugWindow : Window
|
||||
{
|
||||
private string? _selectedHostPath;
|
||||
private bool _isInitialized = false;
|
||||
private bool _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 选择的主程序路径
|
||||
/// </summary>
|
||||
public bool WasAccepted { get; private set; }
|
||||
|
||||
public string? SelectedHostPath => _selectedHostPath;
|
||||
|
||||
public ErrorDebugWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
|
||||
: this()
|
||||
{
|
||||
IsDevModeEnabled = devModeEnabled;
|
||||
_selectedHostPath = initialPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
|
||||
_isInitialized = true;
|
||||
InitializeComponents();
|
||||
|
||||
// 设置初始值(在视觉树准备好后)
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
{
|
||||
devModeToggle.IsChecked = IsDevModeEnabled;
|
||||
}
|
||||
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
// 开发模式开关
|
||||
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||
if (devModeToggle is not null)
|
||||
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
|
||||
{
|
||||
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||
devModeToggle.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
|
||||
}
|
||||
|
||||
// 浏览按钮
|
||||
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||
if (browseButton is not null)
|
||||
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
|
||||
{
|
||||
browseButton.Click += OnBrowseClick;
|
||||
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
|
||||
}
|
||||
|
||||
// 确定按钮
|
||||
var okButton = this.FindControl<Button>("OkButton");
|
||||
if (okButton is not null)
|
||||
if (this.FindControl<Button>("OkButton") is { } okButton)
|
||||
{
|
||||
okButton.Click += (s, e) => Close();
|
||||
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
|
||||
}
|
||||
else
|
||||
okButton.Click += (_, _) =>
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||
if (cancelButton is not null)
|
||||
{
|
||||
cancelButton.Click += (s, e) =>
|
||||
{
|
||||
// 取消时恢复原始状态
|
||||
IsDevModeEnabled = false;
|
||||
_selectedHostPath = null;
|
||||
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
|
||||
WasAccepted = true;
|
||||
Close();
|
||||
};
|
||||
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
|
||||
}
|
||||
else
|
||||
|
||||
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
|
||||
cancelButton.Click += (_, _) => Close();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浏览按钮点击
|
||||
/// </summary>
|
||||
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var storageProvider = StorageProvider;
|
||||
if (storageProvider is null) return;
|
||||
if (storageProvider is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "选择阑山桌面主程序",
|
||||
Title = "Select LanMountainDesktop host executable",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("可执行文件")
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Executable")
|
||||
{
|
||||
Patterns = OperatingSystem.IsWindows()
|
||||
? new[] { "*.exe" }
|
||||
: new[] { "*" }
|
||||
}
|
||||
? ["*.exe"]
|
||||
: ["*"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||
if (result.Count > 0)
|
||||
if (result.Count <= 0)
|
||||
{
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedHostPath = result[0].Path.LocalPath;
|
||||
UpdatePathDisplay(_selectedHostPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新路径显示
|
||||
/// </summary>
|
||||
private void UpdatePathDisplay(string? path)
|
||||
{
|
||||
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||
if (pathTextBlock is not null)
|
||||
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
|
||||
{
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
|
||||
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +185,23 @@ public partial class ErrorWindow : Window
|
||||
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
if (!debugWindow.WasAccepted)
|
||||
{
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
|
||||
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _customHostPath));
|
||||
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
};
|
||||
@@ -285,74 +291,17 @@ public partial class ErrorWindow : Window
|
||||
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return LauncherDebugSettingsStore.ConfigBaseDirectory;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(GetDevModePath()) &&
|
||||
bool.TryParse(File.ReadAllText(GetDevModePath()).Trim(), out var enabled) &&
|
||||
enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetConfigBaseDirectory());
|
||||
File.WriteAllText(GetDevModePath(), enabled.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return LauncherDebugSettingsStore.IsDevModeEnabled();
|
||||
}
|
||||
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pathFile = GetCustomHostPathFile();
|
||||
if (!File.Exists(pathFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var savedPath = File.ReadAllText(pathFile).Trim();
|
||||
return string.IsNullOrWhiteSpace(savedPath) ? null : savedPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveCustomHostPathInternal(string? customHostPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetConfigBaseDirectory());
|
||||
File.WriteAllText(GetCustomHostPathFile(), customHostPath ?? string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,13 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
if (debugWindow.WasAccepted)
|
||||
{
|
||||
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
|
||||
debugWindow.IsDevModeEnabled,
|
||||
debugWindow.SelectedHostPath));
|
||||
}
|
||||
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
|
||||
@@ -108,7 +108,9 @@ public static class AppVersionProvider
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
|
||||
var normalized = TrimSurroundingQuotes(rawValue)
|
||||
.Split('+', 2, StringSplitOptions.TrimEntries)[0]
|
||||
.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: normalized;
|
||||
@@ -116,9 +118,10 @@ public static class AppVersionProvider
|
||||
|
||||
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(rawValue)
|
||||
var normalized = TrimSurroundingQuotes(rawValue);
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? fallback
|
||||
: rawValue.Trim();
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static AppVersionInfo OverrideMissingParts(
|
||||
@@ -158,17 +161,24 @@ public static class AppVersionProvider
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(versionFilePath);
|
||||
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
|
||||
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = ReadStringProperty(root, nameof(AppVersionInfo.Version));
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var codename = ReadStringProperty(root, nameof(AppVersionInfo.Codename));
|
||||
info = new AppVersionInfo
|
||||
{
|
||||
Version = NormalizeVersionText(parsedInfo.Version),
|
||||
Codename = NormalizeCodename(parsedInfo.Codename)
|
||||
Version = NormalizeVersionText(version),
|
||||
Codename = NormalizeCodename(codename)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
@@ -359,4 +369,43 @@ public static class AppVersionProvider
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadStringProperty(JsonElement root, string propertyName)
|
||||
{
|
||||
foreach (var property in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
|
||||
property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.Value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TrimSurroundingQuotes(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = rawValue.Trim();
|
||||
while (normalized.Length >= 2)
|
||||
{
|
||||
var first = normalized[0];
|
||||
var last = normalized[^1];
|
||||
if ((first == '\'' && last == '\'') ||
|
||||
(first == '"' && last == '"'))
|
||||
{
|
||||
normalized = normalized[1..^1].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1760,9 +1760,23 @@ public partial class App : Application
|
||||
{
|
||||
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
var status = GetPublicShellStatus();
|
||||
return restored
|
||||
? new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status)
|
||||
: new PublicShellActivationResult(false, "activation_failed", "Desktop window activation failed.", status);
|
||||
if (restored)
|
||||
{
|
||||
return new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status);
|
||||
}
|
||||
|
||||
if (IsShutdownInProgress)
|
||||
{
|
||||
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
|
||||
}
|
||||
|
||||
var code = status.PublicIpcReady && (!status.MainWindowOpened || !status.DesktopVisible)
|
||||
? "shell_not_ready"
|
||||
: "activation_failed";
|
||||
var message = code == "shell_not_ready"
|
||||
? "Desktop process is running, but the shell is not ready for activation yet."
|
||||
: "Desktop window activation failed.";
|
||||
return new PublicShellActivationResult(false, code, message, status);
|
||||
}
|
||||
|
||||
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File "$(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>
|
||||
|
||||
<!-- 发布时也生成版本信息文件 -->
|
||||
@@ -101,7 +101,7 @@
|
||||
<AppVersion>$(Version)</AppVersion>
|
||||
<AppCodename>Administrate</AppCodename>
|
||||
</PropertyGroup>
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
|
||||
<Exec Command="powershell -ExecutionPolicy Bypass -File "$(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>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# 生成版本信息文件
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputPath,
|
||||
@@ -10,6 +9,39 @@ param(
|
||||
[string]$Codename = "Administrate"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Normalize-ArgumentValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$trimmed = $Value.Trim()
|
||||
if ($trimmed.Length -ge 2) {
|
||||
$first = $trimmed[0]
|
||||
$last = $trimmed[$trimmed.Length - 1]
|
||||
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
|
||||
return $trimmed.Substring(1, $trimmed.Length - 2).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return $trimmed
|
||||
}
|
||||
|
||||
$OutputPath = Normalize-ArgumentValue -Value $OutputPath
|
||||
$Version = Normalize-ArgumentValue -Value $Version
|
||||
$Codename = Normalize-ArgumentValue -Value $Codename
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
throw "OutputPath is required."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
throw "Version is required."
|
||||
}
|
||||
|
||||
$versionInfo = @{
|
||||
Version = $Version
|
||||
Codename = $Codename
|
||||
@@ -18,11 +50,15 @@ $versionInfo = @{
|
||||
$json = $versionInfo | ConvertTo-Json -Compress
|
||||
$dir = Split-Path -Parent $OutputPath
|
||||
|
||||
if (!(Test-Path $dir)) {
|
||||
if ([string]::IsNullOrWhiteSpace($dir)) {
|
||||
throw "OutputPath must include a directory: $OutputPath"
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
|
||||
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
|
||||
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
|
||||
Write-Host " Version: $Version" -ForegroundColor Gray
|
||||
Write-Host " Codename: $Codename" -ForegroundColor Gray
|
||||
|
||||
Reference in New Issue
Block a user