changed.更了好多

This commit is contained in:
lincube
2026-05-12 16:46:49 +08:00
parent 563f12caa1
commit 33c264f6dd
127 changed files with 5257 additions and 10534 deletions

View File

@@ -5,5 +5,8 @@
RequestedThemeVariant="Default">
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="Topmost" Value="True" />
</Style>
</Application.Styles>
</Application>

View File

@@ -112,6 +112,15 @@ public partial class App : Application
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
}
case "preview-multi-instance":
{
Logger.Info("Preview command: multi-instance prompt.");
var promptWindow = new MultiInstancePromptWindow();
promptWindow.SetDetails(Environment.ProcessId, "ForegroundDesktop");
promptWindow.Show();
_ = WaitForWindowCloseAsync(desktop, promptWindow);
return true;
}
case "preview-update":
{
Logger.Info("Preview command: update.");

View File

@@ -16,6 +16,7 @@ public static class HostAppSettingsOobeMerger
public const string EnableFusedDesktopKey = "EnableFusedDesktop";
public const string EnableThreeFingerSwipeKey = "EnableThreeFingerSwipe";
public const string AutoStartWithWindowsKey = "AutoStartWithWindows";
public const string MultiInstanceLaunchBehaviorKey = "MultiInstanceLaunchBehavior";
public static string GetSettingsFilePath(string dataRoot) =>
Path.Combine(Path.GetFullPath(dataRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)), "settings.json");
@@ -54,6 +55,30 @@ public static class HostAppSettingsOobeMerger
}
}
public static MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior(string settingsPath)
{
if (!File.Exists(settingsPath))
{
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
try
{
var root = JsonNode.Parse(File.ReadAllText(settingsPath))?.AsObject();
if (root is null)
{
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
return ReadMultiInstanceLaunchBehavior(root);
}
catch (Exception ex)
{
Logger.Warn($"HostAppSettingsOobeMerger: failed to read multi-instance behavior from '{settingsPath}'. {ex.Message}");
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
public static void MergeStartupPresentation(string settingsPath, HostAppSettingsStartupChoices choices)
{
var directory = Path.GetDirectoryName(settingsPath);
@@ -109,6 +134,31 @@ public static class HostAppSettingsOobeMerger
_ => defaultValue
};
}
private static MultiInstanceLaunchBehavior ReadMultiInstanceLaunchBehavior(JsonObject root)
{
if (!root.TryGetPropertyValue(MultiInstanceLaunchBehaviorKey, out var node) || node is null)
{
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
if (node is JsonValue value)
{
if (value.TryGetValue<string>(out var text) &&
Enum.TryParse<MultiInstanceLaunchBehavior>(text, ignoreCase: true, out var parsed))
{
return parsed;
}
if (value.TryGetValue<int>(out var numeric) &&
Enum.IsDefined(typeof(MultiInstanceLaunchBehavior), numeric))
{
return (MultiInstanceLaunchBehavior)numeric;
}
}
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
public readonly record struct HostAppSettingsStartupDefaults(

View File

@@ -1,210 +0,0 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
private NamedPipeServerStream? _currentPipe;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// 这在 Windows Message 模式和 unix Byte 模式下均能可靠工作。
/// </summary>
private const int LengthPrefixSize = 4;
private const int BackoffBaseMs = 200;
private const int BackoffMaxMs = 5000;
private const int BackoffJitterMs = 100;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
public void Start()
{
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
}
private async Task ListenLoopAsync()
{
var consecutiveErrors = 0;
while (!_cts.Token.IsCancellationRequested)
{
NamedPipeServerStream? pipe = null;
try
{
pipe = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
_currentPipe = pipe;
await pipe.WaitForConnectionAsync(_cts.Token);
consecutiveErrors = 0;
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
{
consecutiveErrors = 0;
continue;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
consecutiveErrors++;
var delay = ComputeBackoff(consecutiveErrors);
Console.Error.WriteLine($"IPC listen error (attempt {consecutiveErrors}), retrying in {delay}ms: {ex.Message}");
try
{
await Task.Delay(delay, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
try
{
pipe?.Dispose();
}
catch { }
if (ReferenceEquals(_currentPipe, pipe))
{
_currentPipe = null;
}
}
}
}
private int ComputeBackoff(int attempt)
{
var exponential = BackoffBaseMs * (1 << Math.Min(attempt - 1, 5));
var capped = Math.Min(exponential, BackoffMaxMs);
var jitter = Random.Shared.Next(0, BackoffJitterMs);
return capped + jitter;
}
/// <summary>
/// 从已连接的管道中持续读取消息,直到连接断开或取消
/// </summary>
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
{
// 1. 读取 4 字节长度前缀
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
if (read == 0)
{
// 连接已关闭
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
{
// 无效长度,跳过此连接
return;
}
// 2. 读取消息正文
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
if (read == 0)
{
return;
}
totalRead += read;
}
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误,继续读取下一条消息
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -218,56 +218,53 @@ internal sealed class LauncherFlowCoordinator
{
if (ShouldProbeExistingHostBeforeLaunch(_context))
{
var existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
var multiInstanceBehavior = LoadMultiInstanceLaunchBehavior();
var existingShellStatus = await TryGetExistingHostStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
.ConfigureAwait(false);
if (existingActivation is not null)
if (IsExistingHostReadyForLauncherDecision(existingShellStatus))
{
ipcConnected = true;
shellStatus = existingActivation.Status;
var recoverableActivationFailure = IsRecoverableActivationFailure(existingActivation);
lastStage = existingActivation.Accepted
shellStatus = existingShellStatus;
var decisionResult = await ApplyExistingHostBehaviorAsync(
ipcClient,
multiInstanceBehavior,
existingShellStatus!)
.ConfigureAwait(false);
shellStatus = decisionResult.ActivationResult?.Status ?? existingShellStatus;
var recoverableActivationFailure = decisionResult.ActivationResult is not null &&
IsRecoverableActivationFailure(decisionResult.ActivationResult);
lastStage = decisionResult.Success || recoverableActivationFailure
? StartupStage.ActivationRedirected
: StartupStage.ActivationFailed;
lastStageMessage = existingActivation.Message;
if (existingActivation.Accepted)
lastStageMessage = decisionResult.Message;
if (decisionResult.Success || recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
}
else if (recoverableActivationFailure)
{
_startupAttemptRegistry.MarkOwnedWaitingForShell(lastStageMessage);
}
else
{
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
}
PublishCoordinatorStatus(
hostProcessAliveOverride: true,
completed: true,
succeeded: existingActivation.Accepted || recoverableActivationFailure);
PublishCoordinatorStatus(hostProcessAliveOverride: true, completed: true, succeeded: decisionResult.Success);
windowsClosingByCoordinator = true;
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: existingActivation.Accepted || recoverableActivationFailure,
success: decisionResult.Success,
stage: "launch",
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,
code: decisionResult.Code,
message: decisionResult.Message,
details: MergeDetails(
launcherContextDetails,
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["publicIpcConnected"] = "true",
["existingHostPid"] = existingActivation.Status.ProcessId.ToString(),
["existingShellState"] = existingActivation.Status.ShellState,
["existingTrayState"] = existingActivation.Status.Tray.State,
["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString()
["multiInstanceBehavior"] = multiInstanceBehavior.ToString(),
["existingHostPid"] = shellStatus?.ProcessId.ToString() ?? string.Empty,
["existingShellState"] = shellStatus?.ShellState ?? string.Empty,
["existingTrayState"] = shellStatus?.Tray.State ?? string.Empty,
["existingTaskbarUsable"] = shellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty,
["activationAccepted"] = decisionResult.ActivationResult?.Accepted.ToString() ?? string.Empty
}));
}
}
@@ -492,7 +489,7 @@ internal sealed class LauncherFlowCoordinator
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.");
Logger.Info("Host public IPC is not ready yet. Launcher will keep monitoring the host process and retry.");
}
else
{
@@ -557,30 +554,7 @@ internal sealed class LauncherFlowCoordinator
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));
}
Logger.Info("Activation failure did not recover through public IPC yet. Launcher will keep monitoring the current host attempt.");
}
if (processExitTask.IsCompleted)
@@ -589,7 +563,7 @@ internal sealed class LauncherFlowCoordinator
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
windowsClosingByCoordinator = true;
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
if (IsSuccessfulActivationExitCode(exitCode))
{
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
@@ -608,7 +582,7 @@ internal sealed class LauncherFlowCoordinator
}
if (!activationRetryAttempted &&
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
IsFailedActivationExitCode(exitCode))
{
activationRetryAttempted = true;
var activationRecovery = await TryRecoverActivationThroughExistingHostAsync(
@@ -633,30 +607,7 @@ internal sealed class LauncherFlowCoordinator
}));
}
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()
}));
}
Logger.Info("Activation exit code did not recover through public IPC. Launcher will report the activation failure without launching another host.");
}
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
@@ -665,10 +616,10 @@ internal sealed class LauncherFlowCoordinator
return BuildResult(
success: false,
stage: "launch",
code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
code: IsFailedActivationExitCode(exitCode)
? "activation_failed"
: "host_exited_early",
message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
message: IsFailedActivationExitCode(exitCode)
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
details: MergeDetails(
@@ -909,54 +860,6 @@ internal sealed class LauncherFlowCoordinator
}
}
private async Task<LauncherResult?> RetryActivationAfterEarlyFailureAsync()
{
Logger.Warn("Attempting one explicit activation retry after host early failure.");
var retryOutcome = await LaunchHostWithIpcAsync(forceDirectMode: true, retryTag: "explicit-activation-retry").ConfigureAwait(false);
if (!retryOutcome.Result.Success)
{
return retryOutcome.Result;
}
if (retryOutcome.ImmediateResult is not null)
{
return retryOutcome.ImmediateResult;
}
if (retryOutcome.Process is not null)
{
var retryExitTask = retryOutcome.Process.WaitForExitAsync();
var completed = await Task.WhenAny(retryExitTask, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);
if (completed != retryExitTask)
{
return BuildResult(
success: true,
stage: "launch",
code: "activation_retry_started",
message: "Activation retry started the host successfully.",
details: retryOutcome.Details);
}
if (retryOutcome.Process.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
return BuildResult(
success: true,
stage: "launch",
code: "activation_redirected",
message: "Activation retry redirected to the existing desktop instance.",
details: retryOutcome.Details);
}
}
return BuildResult(
success: false,
stage: "launch",
code: "activation_failed",
message: "Activation retry failed to make the desktop visible.",
details: retryOutcome.Details);
}
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
try
@@ -1087,7 +990,7 @@ internal sealed class LauncherFlowCoordinator
previousAttempt is null ? null : finalAttempt,
!finalAttempt.ProcessCreated
? "start"
: finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
: finalAttempt.ExitCode is int finalExitCode && IsFailedActivationExitCode(finalExitCode)
? "activation"
: "early-exit");
@@ -1101,7 +1004,7 @@ internal sealed class LauncherFlowCoordinator
details));
}
if (finalAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
if (finalAttempt.ExitCode is not null && IsSuccessfulActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromImmediateResult(BuildResult(
true,
@@ -1111,7 +1014,7 @@ internal sealed class LauncherFlowCoordinator
details));
}
if (finalAttempt.ExitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
if (finalAttempt.ExitCode is not null && IsFailedActivationExitCode(finalAttempt.ExitCode.Value))
{
return HostLaunchOutcome.FromResult(BuildResult(
false,
@@ -1469,12 +1372,12 @@ internal sealed class LauncherFlowCoordinator
}
catch (Exception ex)
{
Logger.Warn($"Public IPC connect failed: {ex.Message}");
Logger.Info($"Public IPC is not ready yet: {ex.Message}");
return false;
}
}
private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
internal static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
{
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
{
@@ -1489,6 +1392,169 @@ internal sealed class LauncherFlowCoordinator
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
}
private MultiInstanceLaunchBehavior LoadMultiInstanceLaunchBehavior()
{
try
{
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(_dataLocationResolver.ResolveDataRoot());
return HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(settingsPath);
}
catch (Exception ex)
{
Logger.Warn($"Failed to load multi-instance launch behavior. Falling back to default. {ex.Message}");
return MultiInstanceLaunchBehavior.NotifyAndOpenDesktop;
}
}
internal static bool IsExistingHostReadyForLauncherDecision(PublicShellStatus? status)
{
return status is { PublicIpcReady: true, ProcessId: > 0 };
}
private static async Task<PublicShellStatus?> TryGetExistingHostStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
try
{
var connected = ipcClient.IsConnected ||
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
if (!connected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Info($"Existing host status probe did not complete: {ex.Message}");
return null;
}
}
private static async Task<ExistingHostBehaviorResult> ApplyExistingHostBehaviorAsync(
LanMountainDesktopIpcClient ipcClient,
MultiInstanceLaunchBehavior behavior,
PublicShellStatus status)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return behavior switch
{
MultiInstanceLaunchBehavior.OpenDesktopSilently => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated",
successMessage: "Launcher activated the existing desktop instance.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false),
MultiInstanceLaunchBehavior.PromptOnly => await ShowPromptOnlyExistingHostAsync(
shellProxy,
status).ConfigureAwait(false),
MultiInstanceLaunchBehavior.RestartApp => await RestartExistingHostAsync(shellProxy).ConfigureAwait(false),
_ => await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: true,
successCode: "existing_host_activated_with_notice",
successMessage: "Launcher activated the existing desktop instance and showed the repeated-launch notice.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false)
};
}
catch (Exception ex)
{
Logger.Warn($"Failed to apply multi-instance behavior '{behavior}': {ex.Message}");
return new ExistingHostBehaviorResult(
false,
"multi_instance_behavior_failed",
$"Failed to apply multi-instance behavior '{behavior}': {ex.Message}",
null);
}
}
private static async Task<ExistingHostBehaviorResult> ActivateExistingHostForBehaviorAsync(
IPublicShellControlService shellProxy,
bool showLauncherNotice,
string successCode,
string successMessage,
string failureCode)
{
var activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation);
if (showLauncherNotice && success)
{
var promptResult = await ShowMultiInstancePromptAsync(activation.Status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
activation = await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
}
}
return new ExistingHostBehaviorResult(
success,
activation.Accepted ? successCode : success ? "existing_host_startup_pending" : failureCode,
activation.Accepted ? successMessage : activation.Message,
activation);
}
private static async Task<ExistingHostBehaviorResult> RestartExistingHostAsync(
IPublicShellControlService shellProxy)
{
var accepted = await shellProxy.RestartAsync().ConfigureAwait(false);
return new ExistingHostBehaviorResult(
accepted,
accepted ? "existing_host_restart_requested" : "existing_host_restart_failed",
accepted
? "Launcher requested the existing desktop instance to restart."
: "Launcher could not request restart from the existing desktop instance.",
null);
}
private static async Task<ExistingHostBehaviorResult> ShowPromptOnlyExistingHostAsync(
IPublicShellControlService shellProxy,
PublicShellStatus status)
{
var promptResult = await ShowMultiInstancePromptAsync(status).ConfigureAwait(false);
if (promptResult == MultiInstancePromptResult.OpenDesktop)
{
return await ActivateExistingHostForBehaviorAsync(
shellProxy,
showLauncherNotice: false,
successCode: "existing_host_activated_from_prompt",
successMessage: "Launcher activated the existing desktop instance from the prompt.",
failureCode: "existing_host_activation_failed").ConfigureAwait(false);
}
return new ExistingHostBehaviorResult(
true,
"existing_host_prompt_only",
"Launcher showed the repeated-launch prompt and did not open the desktop automatically.",
null);
}
private static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
{
var prompt = new MultiInstancePromptWindow();
prompt.SetDetails(status.ProcessId, status.ShellState);
prompt.Show();
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
});
}
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
@@ -1507,7 +1573,7 @@ internal sealed class LauncherFlowCoordinator
}
catch (Exception ex)
{
Logger.Warn($"Existing host activation probe failed: {ex.Message}");
Logger.Info($"Existing host activation probe did not complete: {ex.Message}");
return null;
}
}
@@ -1541,7 +1607,7 @@ internal sealed class LauncherFlowCoordinator
: null;
}
private static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
internal static bool IsRecoverableActivationFailure(PublicShellActivationResult activation)
{
if (activation.Accepted)
{
@@ -1560,6 +1626,12 @@ internal sealed class LauncherFlowCoordinator
string.Equals(activation.Code, "startup_pending", StringComparison.OrdinalIgnoreCase));
}
internal static bool IsSuccessfulActivationExitCode(int exitCode) =>
exitCode == HostExitCodes.SecondaryActivationSucceeded;
internal static bool IsFailedActivationExitCode(int exitCode) =>
exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired;
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
@@ -1759,6 +1831,12 @@ internal sealed class LauncherFlowCoordinator
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
}
private sealed record ExistingHostBehaviorResult(
bool Success,
string Code,
string Message,
PublicShellActivationResult? ActivationResult);
private sealed record HostLaunchOutcome(
LauncherResult Result,
Process? Process,

View File

@@ -3,16 +3,20 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow"
Title="LanMountain Desktop"
Width="560"
Height="320"
Width="760"
Height="460"
MinWidth="640"
MinHeight="420"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="#111318"
TransparencyLevelHint="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="Mica, AcrylicBlur, None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:ErrorWindow />
@@ -20,79 +24,128 @@
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0"
Margin="24"
Margin="28,24,28,20"
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,*">
<Border x:Name="ErrorIconBorder"
Grid.Column="0"
Width="52"
Height="52"
Margin="0,4,18,0"
Background="#2B161A"
CornerRadius="26"
Width="56"
Height="56"
Margin="0,0,18,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="28"
VerticalAlignment="Top">
<TextBlock Text="!"
FontSize="24"
FontWeight="Bold"
Foreground="#FFB4AB"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<fi:SymbolIcon Symbol="ErrorCircle"
IconVariant="Regular"
FontSize="28"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="10">
Spacing="8">
<TextBlock x:Name="TitleText"
Text="Launcher could not confirm startup"
FontSize="20"
FontSize="22"
FontWeight="SemiBold"
Foreground="#F6F7FB"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap" />
<TextBlock x:Name="ErrorMessageText"
Text="LanMountain Desktop did not reach the expected startup state."
FontSize="14"
Foreground="#D2D7E1"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="22" />
<TextBlock x:Name="SuggestionText"
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
FontSize="13"
Foreground="#9BA5B7"
TextWrapping="Wrap"
LineHeight="20" />
</StackPanel>
<ui:FAInfoBar x:Name="SuggestionInfoBar"
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,20,0,14"
IsOpen="True"
IsClosable="False"
Severity="Warning"
Title="Startup recovery"
Message="You can inspect logs, wait for the current process, or activate the running desktop instance.">
<ui:FAInfoBar.IconSource>
<ui:FAFontIconSource Glyph="&#xF0288;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FAInfoBar.IconSource>
</ui:FAInfoBar>
<Expander Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
Header="Diagnostic details"
IsExpanded="True">
<TextBox x:Name="ErrorDetailsTextBox"
Margin="0,10,0,0"
MinHeight="150"
MaxHeight="190"
AcceptsReturn="True"
TextWrapping="Wrap"
IsReadOnly="True"
BorderThickness="0"
FontSize="12"
Text="Stage: launch&#x0a;Code: unknown"
VerticalContentAlignment="Top" />
</Expander>
</Grid>
<Border Grid.Row="1"
Padding="24,16"
Background="#171A21">
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
ColumnSpacing="8">
<Button x:Name="OpenLogButton"
Grid.Column="0"
Content="Open Logs"
MinWidth="108"
Height="34"
HorizontalAlignment="Left" />
Padding="18,14"
Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="OpenLogButton"
MinWidth="112"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="FolderOpen" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Open Logs"/>
</StackPanel>
</Button>
<Button x:Name="SecondaryActionButton"
Grid.Column="1"
Content="Wait"
MinWidth="108"
Height="34"
IsVisible="False" />
<Button x:Name="CopyDetailsButton"
MinWidth="100"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Copy" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Copy"/>
</StackPanel>
</Button>
</StackPanel>
<Button x:Name="ExitButton"
Grid.Column="2"
Content="Exit"
MinWidth="90"
Height="34" />
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="SecondaryActionButton"
Content="Wait"
MinWidth="96"
Height="34"
IsVisible="False" />
<Button x:Name="PrimaryActionButton"
Grid.Column="3"
Content="Retry"
MinWidth="108"
Height="34" />
<Button x:Name="ExitButton"
MinWidth="92"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Dismiss" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Exit"/>
</StackPanel>
</Button>
<Button x:Name="PrimaryActionButton"
Classes="accent"
Content="Retry"
MinWidth="112"
Height="34" />
</StackPanel>
</Grid>
</Border>
</Grid>

View File

@@ -1,8 +1,10 @@
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
@@ -33,9 +35,21 @@ public partial class ErrorWindow : Window
public void SetErrorMessage(string message)
{
var normalizedMessage = string.IsNullOrWhiteSpace(message)
? "LanMountain Desktop did not reach the expected startup state."
: message.Trim();
var firstLine = normalizedMessage
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault() ?? normalizedMessage;
if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
{
errorText.Text = message;
errorText.Text = firstLine;
}
if (this.FindControl<TextBox>("ErrorDetailsTextBox") is { } detailsTextBox)
{
detailsTextBox.Text = normalizedMessage;
}
}
@@ -120,6 +134,11 @@ public partial class ErrorWindow : Window
{
openLogButton.Click += OnOpenLogClick;
}
if (this.FindControl<Button>("CopyDetailsButton") is { } copyDetailsButton)
{
copyDetailsButton.Click += OnCopyDetailsClick;
}
}
private void ApplyActionLayout(
@@ -138,9 +157,9 @@ public partial class ErrorWindow : Window
titleText.Text = title;
}
if (this.FindControl<TextBlock>("SuggestionText") is { } suggestionText)
if (this.FindControl<FAInfoBar>("SuggestionInfoBar") is { } suggestionInfoBar)
{
suggestionText.Text = suggestion;
suggestionInfoBar.Message = suggestion;
}
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryButton)
@@ -243,6 +262,28 @@ public partial class ErrorWindow : Window
await Task.CompletedTask;
}
private async void OnCopyDetailsClick(object? sender, RoutedEventArgs e)
{
try
{
var details = this.FindControl<TextBox>("ErrorDetailsTextBox")?.Text;
if (string.IsNullOrWhiteSpace(details))
{
details = this.FindControl<TextBlock>("ErrorMessageText")?.Text;
}
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is not null && !string.IsNullOrWhiteSpace(details))
{
await clipboard.SetTextAsync(details);
}
}
catch (Exception ex)
{
Debug.WriteLine($"[ErrorWindow] Failed to copy diagnostics: {ex}");
}
}
private void ScanDevPaths()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";

View File

@@ -0,0 +1,124 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Launcher.Views.MultiInstancePromptWindow"
x:DataType="views:MultiInstancePromptWindow"
Title="LanMountain Desktop"
Width="620"
Height="360"
MinWidth="560"
MinHeight="330"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="Mica, AcrylicBlur, None"
Icon="/Assets/logo.ico">
<Design.DataContext>
<views:MultiInstancePromptWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0"
Margin="28,26,28,20"
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
Width="52"
Height="52"
Margin="0,0,18,0"
Background="{DynamicResource SystemFillColorAttentionBackgroundBrush}"
CornerRadius="26"
VerticalAlignment="Top">
<fi:SymbolIcon Symbol="Desktop"
IconVariant="Regular"
FontSize="26"
Foreground="{DynamicResource SystemFillColorAttentionBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="8">
<TextBlock Text="LanMountain Desktop is already running"
FontSize="22"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
TextWrapping="Wrap" />
<TextBlock x:Name="MessageText"
Text="Launcher found an existing desktop instance and did not start another process."
FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
LineHeight="22" />
</StackPanel>
<ui:FAInfoBar Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,22,0,14"
IsOpen="True"
IsClosable="False"
Severity="Informational"
Title="Repeated launch"
Message="Your current setting is to show this prompt without opening the desktop automatically.">
<ui:FAInfoBar.IconSource>
<ui:FAFontIconSource Glyph="&#xF0288;"
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FAInfoBar.IconSource>
</ui:FAInfoBar>
<TextBlock x:Name="DetailsText"
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="No second Host process was created." />
</Grid>
<Border Grid.Row="1"
Padding="18,14"
Background="{DynamicResource LayerOnMicaBaseAltFillColorDefaultBrush}">
<Grid ColumnDefinitions="*,Auto">
<Button x:Name="CopyDetailsButton"
Grid.Column="0"
MinWidth="104"
Height="34"
HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Copy" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Copy"/>
</StackPanel>
</Button>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<Button x:Name="CloseButton"
MinWidth="92"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="Dismiss" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Close"/>
</StackPanel>
</Button>
<Button x:Name="OpenDesktopButton"
Classes="accent"
MinWidth="136"
Height="34">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:SymbolIcon Symbol="ArrowRight" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="Open desktop"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,76 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace LanMountainDesktop.Launcher.Views;
public partial class MultiInstancePromptWindow : Window
{
private readonly TaskCompletionSource<MultiInstancePromptResult> _completionSource =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private string _details = "LanMountain Desktop is already running.";
public MultiInstancePromptWindow()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnLoaded;
Closed += (_, _) => _completionSource.TrySetResult(MultiInstancePromptResult.Close);
}
public Task<MultiInstancePromptResult> WaitForChoiceAsync() => _completionSource.Task;
public void SetDetails(int processId, string shellState)
{
_details = $"Existing host PID: {processId}\nShell state: {shellState}\nNo second Host process was created.";
if (this.FindControl<TextBlock>("DetailsText") is { } detailsText)
{
detailsText.Text = _details;
}
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
if (this.FindControl<Button>("CloseButton") is { } closeButton)
{
closeButton.Click += (_, _) => Complete(MultiInstancePromptResult.Close);
}
if (this.FindControl<Button>("OpenDesktopButton") is { } openDesktopButton)
{
openDesktopButton.Click += (_, _) => Complete(MultiInstancePromptResult.OpenDesktop);
}
if (this.FindControl<Button>("CopyDetailsButton") is { } copyDetailsButton)
{
copyDetailsButton.Click += OnCopyDetailsClick;
}
}
private void Complete(MultiInstancePromptResult result)
{
_completionSource.TrySetResult(result);
Close();
}
private async void OnCopyDetailsClick(object? sender, RoutedEventArgs e)
{
try
{
if (TopLevel.GetTopLevel(this)?.Clipboard is IClipboard clipboard)
{
await clipboard.SetTextAsync(_details);
}
}
catch
{
}
}
}
public enum MultiInstancePromptResult
{
Close,
OpenDesktop
}