diff --git a/.trae/specs/launcher-shell-hardening/launcher-coordinator-addendum.md b/.trae/specs/launcher-shell-hardening/launcher-coordinator-addendum.md new file mode 100644 index 0000000..c8073a5 --- /dev/null +++ b/.trae/specs/launcher-shell-hardening/launcher-coordinator-addendum.md @@ -0,0 +1,29 @@ +# Launcher Coordinator And Always-On Tray Addendum + +## Launcher-to-launcher coordination + +- Launcher reserves startup ownership in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before it starts the host process. +- The reserved record includes `CoordinatorPid`, `CoordinatorPipeName`, `HeartbeatAtUtc`, `PublicIpcConnected`, `ShellStatus`, and `ReservedBeforeHostStart`. +- Only the active coordinator may call `Process.Start()` for the host. Secondary Launchers attach to the coordinator pipe and request desktop activation or status. +- If the coordinator heartbeat is newer than `10s` and the coordinator pid is alive, a new Launcher must not take over. +- If the coordinator is stale, the next Launcher may take over the same pending attempt instead of creating a second host attempt. +- Normal launches probe Host Public IPC first. If a host is already running, Launcher activates that instance and exits without starting another host. + +## Finer shell status + +- Public shell IPC exposes `GetShellStatusAsync()`, `ActivateMainWindowWithStatusAsync()`, `EnsureTrayReadyAsync()`, and `EnsureTaskbarEntryAsync()`. +- `PublicShellStatus` separates process, shell state, main-window visibility, tray health, taskbar-entry health, and Public IPC readiness. +- Launcher success/failure details must include coordinator pid, attempt id, host pid, Public IPC status, tray state, and taskbar usability when available. + +## Always-on tray and taskbar repair + +- The tray icon and menu are mandatory application-liveness indicators and are not controlled by user settings. +- Tray watchdog starts during shell initialization and keeps running until application exit. +- `ShowInTaskbar=true` means hidden/background states prefer `MinimizedToTaskbar`; it never disables the tray. +- `ShowInTaskbar=false` is the only mode that may enter pure `TrayOnly`, and only after `TrayReady`. +- When taskbar entry is requested but missing, shell repair recreates or shows the main window minimized with `ShowInTaskbar=true` while keeping the tray visible. + +## Regression coverage + +- Unit tests cover active coordinator rejection, stale heartbeat takeover, and host-pid assignment after a reserved attempt. +- Manual QA still needs multi-process Launcher concurrency and real tray loss simulation on Windows. diff --git a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md index 241e786..6c1ce8d 100644 --- a/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md +++ b/.trae/specs/launcher-shell-hardening/startup-visuals-addendum.md @@ -27,3 +27,11 @@ - `Open Logs` - `Exit` - Retry is only valid when Launcher is not about to create a duplicate desktop process. + +## Launcher coordinator guard + +- Startup attempts are now reserved before host launch, so concurrent Launchers cannot all reach `Process.Start()`. +- A live coordinator is identified by `CoordinatorPid`, `CoordinatorPipeName`, and a heartbeat newer than `10s`. +- Secondary Launchers send `activate-desktop` or `attach` to the coordinator pipe and then exit with the coordinator status. +- If Host Public IPC is already available during a normal launch, Launcher activates the existing desktop and does not start a new host process. +- Public shell status now reports tray readiness and taskbar-entry usability separately, allowing Launcher to distinguish "running but hidden" from "not recoverable". diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index c004f89..96bde41 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -5,6 +6,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Launcher.Services.Ipc; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC; @@ -183,6 +185,36 @@ public partial class App : Application LauncherResult result; SplashWindow? currentSplashWindow = splashWindow; var appRoot = Commands.ResolveAppRoot(context); + var startupAttemptRegistry = new StartupAttemptRegistry(); + var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); + var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context); + + if (!startupAttemptRegistry.TryReserveCoordinator( + context.LaunchSource, + successPolicy, + coordinatorPipeName, + out var reservedAttempt, + out var activeCoordinatorAttempt)) + { + result = await AttachToExistingCoordinatorAsync( + context, + currentSplashWindow, + activeCoordinatorAttempt).ConfigureAwait(false); + + Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'."); + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + + Environment.ExitCode = result.Success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + return; + } + + using var coordinatorServer = new LauncherCoordinatorIpcServer( + coordinatorPipeName, + BuildCoordinatorStatusFromAttempt(reservedAttempt), + HandleCoordinatorRequestAsync, + startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat); + coordinatorServer.Start(); while (true) { @@ -199,7 +231,9 @@ public partial class App : Application deploymentLocator, new OobeStateService(appRoot), new UpdateEngineService(deploymentLocator), - new PluginInstallerService()); + new PluginInstallerService(), + startupAttemptRegistry, + coordinatorServer); result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false); } @@ -255,6 +289,175 @@ public partial class App : Application await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); } + private static async Task AttachToExistingCoordinatorAsync( + CommandContext context, + SplashWindow? splashWindow, + StartupAttemptRecord? activeCoordinatorAttempt) + { + var reporter = splashWindow as ISplashStageReporter; + reporter?.Report("activation", "Connecting to the active launcher..."); + + if (activeCoordinatorAttempt is not null && + !string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName)) + { + var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase) + ? LauncherCoordinatorCommands.Attach + : LauncherCoordinatorCommands.ActivateDesktop; + var request = new LauncherCoordinatorRequest + { + Command = command, + LaunchSource = context.LaunchSource, + SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context) + }; + + var response = await new LauncherCoordinatorIpcClient() + .SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + + if (response is not null) + { + reporter?.Report("activation", response.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + return new LauncherResult + { + Success = response.Accepted, + Stage = "launch", + Code = response.Code, + Message = response.Message, + Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult) + }; + } + } + + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + reporter?.Report("activation", activation.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + return new LauncherResult + { + Success = activation.Accepted, + Stage = "launch", + Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", + Message = activation.Message, + Details = BuildCoordinatorResultDetails(null, activation) + }; + } + + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + return new LauncherResult + { + Success = false, + Stage = "launch", + Code = "launcher_coordinator_unavailable", + Message = "Another Launcher is coordinating startup, but it did not respond in time.", + Details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty, + ["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty, + ["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty, + ["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty + } + }; + } + + private static async Task HandleCoordinatorRequestAsync( + LauncherCoordinatorRequest request, + LauncherCoordinatorStatus status) + { + if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase)) + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + return new LauncherCoordinatorResponse + { + Accepted = activation.Accepted, + Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", + Message = activation.Message, + Status = status, + ActivationResult = activation + }; + } + + 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 + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator.", + Status = status + }; + } + + private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt) + { + return new LauncherCoordinatorStatus + { + AttemptId = attempt.AttemptId, + CoordinatorPid = Environment.ProcessId, + HostPid = attempt.HostPid, + HostProcessAlive = TryGetLiveProcess(attempt.HostPid), + LaunchSource = attempt.LaunchSource, + SuccessPolicy = attempt.SuccessPolicy, + LastObservedStage = attempt.LastObservedStage, + LastObservedMessage = attempt.LastObservedMessage, + PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected, + State = attempt.State.ToString(), + SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting, + Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed, + Succeeded = attempt.State == StartupAttemptState.Succeeded, + UpdatedAtUtc = attempt.UpdatedAtUtc + }; + } + + private static Dictionary BuildCoordinatorResultDetails( + LauncherCoordinatorStatus? status, + PublicShellActivationResult? activation) + { + var details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty, + ["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty, + ["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty, + ["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty, + ["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(), + ["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty, + ["startupState"] = status?.State ?? string.Empty, + ["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty, + ["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty, + ["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty, + ["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty + }; + + return details; + } + + private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow) + { + if (splashWindow is null) + { + return; + } + + try + { + await splashWindow.DismissAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}"); + } + } + private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result) { var resultPath = context.GetOption("result"); @@ -326,22 +529,60 @@ public partial class App : Application } private static async Task TryActivateExistingInstanceAsync() + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + return activation?.Accepted == true; + } + + private static async Task TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout) { try { using var ipcClient = new LanMountainDesktopIpcClient(); - await ipcClient.ConnectAsync().ConfigureAwait(false); + var connectTask = ipcClient.ConnectAsync(); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != connectTask) + { + return null; + } + + await connectTask.ConfigureAwait(false); if (!ipcClient.IsConnected) { - return false; + return null; } var shellProxy = ipcClient.CreateProxy(); - return await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false); + var activationTask = shellProxy.ActivateMainWindowWithStatusAsync(); + completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != activationTask) + { + return null; + } + + return await activationTask.ConfigureAwait(false); } catch (Exception ex) { Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); + return null; + } + } + + private static bool TryGetLiveProcess(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { return false; } } diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 7a92b0b..57e6ac7 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher; @@ -20,6 +21,13 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(SnapshotMetadata))] [JsonSerializable(typeof(AppVersionInfo))] [JsonSerializable(typeof(StartupProgressMessage))] +[JsonSerializable(typeof(LauncherCoordinatorRequest))] +[JsonSerializable(typeof(LauncherCoordinatorResponse))] +[JsonSerializable(typeof(LauncherCoordinatorStatus))] +[JsonSerializable(typeof(PublicShellStatus))] +[JsonSerializable(typeof(PublicTrayStatus))] +[JsonSerializable(typeof(PublicTaskbarStatus))] +[JsonSerializable(typeof(PublicShellActivationResult))] [JsonSerializable(typeof(LauncherResult))] [JsonSerializable(typeof(HostDiscoveryConfig))] [JsonSerializable(typeof(PluginManifest))] diff --git a/LanMountainDesktop.Launcher/Models/LauncherCoordinatorMessages.cs b/LanMountainDesktop.Launcher/Models/LauncherCoordinatorMessages.cs new file mode 100644 index 0000000..5b829b2 --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/LauncherCoordinatorMessages.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Models; + +internal static class LauncherCoordinatorCommands +{ + public const string Attach = "attach"; + public const string ActivateDesktop = "activate-desktop"; + public const string GetStatus = "get-status"; +} + +internal sealed class LauncherCoordinatorRequest +{ + [JsonPropertyName("requestId")] + public string RequestId { get; init; } = Guid.NewGuid().ToString("N"); + + [JsonPropertyName("command")] + public string Command { get; init; } = LauncherCoordinatorCommands.Attach; + + [JsonPropertyName("launcherPid")] + public int LauncherPid { get; init; } = Environment.ProcessId; + + [JsonPropertyName("launchSource")] + public string LaunchSource { get; init; } = string.Empty; + + [JsonPropertyName("successPolicy")] + public string SuccessPolicy { get; init; } = string.Empty; +} + +internal sealed class LauncherCoordinatorResponse +{ + [JsonPropertyName("accepted")] + public bool Accepted { get; init; } + + [JsonPropertyName("code")] + public string Code { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + [JsonPropertyName("status")] + public LauncherCoordinatorStatus? Status { get; init; } + + [JsonPropertyName("activationResult")] + public PublicShellActivationResult? ActivationResult { get; init; } +} + +internal sealed class LauncherCoordinatorStatus +{ + [JsonPropertyName("attemptId")] + public string AttemptId { get; init; } = string.Empty; + + [JsonPropertyName("coordinatorPid")] + public int CoordinatorPid { get; init; } = Environment.ProcessId; + + [JsonPropertyName("hostPid")] + public int HostPid { get; init; } + + [JsonPropertyName("hostProcessAlive")] + public bool HostProcessAlive { get; init; } + + [JsonPropertyName("launchSource")] + public string LaunchSource { get; init; } = string.Empty; + + [JsonPropertyName("successPolicy")] + public string SuccessPolicy { get; init; } = string.Empty; + + [JsonPropertyName("lastObservedStage")] + public StartupStage LastObservedStage { get; init; } = StartupStage.Initializing; + + [JsonPropertyName("lastObservedMessage")] + public string LastObservedMessage { get; init; } = string.Empty; + + [JsonPropertyName("publicIpcConnected")] + public bool PublicIpcConnected { get; init; } + + [JsonPropertyName("state")] + public string State { get; init; } = string.Empty; + + [JsonPropertyName("softTimeoutShown")] + public bool SoftTimeoutShown { get; init; } + + [JsonPropertyName("completed")] + public bool Completed { get; init; } + + [JsonPropertyName("succeeded")] + public bool Succeeded { get; init; } + + [JsonPropertyName("shellStatus")] + public PublicShellStatus? ShellStatus { get; init; } + + [JsonPropertyName("updatedAtUtc")] + public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs index 1105324..059184a 100644 --- a/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs +++ b/LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs @@ -20,12 +20,21 @@ internal sealed class StartupAttemptRecord [JsonPropertyName("hostPid")] public int HostPid { get; set; } + [JsonPropertyName("coordinatorPid")] + public int CoordinatorPid { get; set; } + + [JsonPropertyName("coordinatorPipeName")] + public string CoordinatorPipeName { get; set; } = string.Empty; + [JsonPropertyName("startedAtUtc")] public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow; [JsonPropertyName("updatedAtUtc")] public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + [JsonPropertyName("heartbeatAtUtc")] + public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow; + [JsonPropertyName("launchSource")] public string LaunchSource { get; set; } = string.Empty; @@ -41,6 +50,15 @@ internal sealed class StartupAttemptRecord [JsonPropertyName("ipcConnected")] public bool IpcConnected { get; set; } + [JsonPropertyName("publicIpcConnected")] + public bool PublicIpcConnected { get; set; } + + [JsonPropertyName("shellStatus")] + public string ShellStatus { get; set; } = string.Empty; + + [JsonPropertyName("reservedBeforeHostStart")] + public bool ReservedBeforeHostStart { get; set; } + [JsonPropertyName("state")] public StartupAttemptState State { get; set; } = StartupAttemptState.Pending; } diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcClient.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcClient.cs new file mode 100644 index 0000000..6cc1475 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcClient.cs @@ -0,0 +1,111 @@ +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services.Ipc; + +internal sealed class LauncherCoordinatorIpcClient +{ + private const int LengthPrefixSize = 4; + private const int MaxPayloadLength = 1024 * 1024; + + public async Task SendAsync( + string pipeName, + LauncherCoordinatorRequest request, + TimeSpan timeout) + { + if (string.IsNullOrWhiteSpace(pipeName)) + { + return null; + } + + using var timeoutCts = new CancellationTokenSource(timeout); + try + { + await using var client = new NamedPipeClientStream( + ".", + pipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous); + + await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false); + return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + catch (TimeoutException) + { + return null; + } + catch (Exception ex) + { + Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}"); + return null; + } + } + + private static async Task WriteRequestAsync( + Stream stream, + LauncherCoordinatorRequest request, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest); + var payload = Encoding.UTF8.GetBytes(json); + await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadResponseAsync( + Stream stream, + CancellationToken cancellationToken) + { + var lengthBuffer = new byte[LengthPrefixSize]; + if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var payloadLength = BitConverter.ToInt32(lengthBuffer, 0); + if (payloadLength <= 0 || payloadLength > MaxPayloadLength) + { + return null; + } + + var payload = new byte[payloadLength]; + if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false)) + { + return null; + } + + return JsonSerializer.Deserialize( + Encoding.UTF8.GetString(payload), + AppJsonContext.Default.LauncherCoordinatorResponse); + } + + private static async Task ReadExactAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { + var totalRead = 0; + while (totalRead < buffer.Length) + { + var read = await stream + .ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return false; + } + + totalRead += read; + } + + return true; + } +} diff --git a/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs new file mode 100644 index 0000000..4a2c08f --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/Ipc/LauncherCoordinatorIpcServer.cs @@ -0,0 +1,235 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.IO.Pipes; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services.Ipc; + +internal sealed class LauncherCoordinatorIpcServer : IDisposable +{ + private const int LengthPrefixSize = 4; + private const int MaxPayloadLength = 1024 * 1024; + private readonly string _pipeName; + private readonly Func> _requestHandler; + private readonly Action _heartbeatHandler; + private readonly CancellationTokenSource _cts = new(); + private readonly object _statusGate = new(); + private LauncherCoordinatorStatus _status; + private Task? _listenTask; + private Task? _heartbeatTask; + + public LauncherCoordinatorIpcServer( + string pipeName, + LauncherCoordinatorStatus initialStatus, + Func> requestHandler, + Action heartbeatHandler) + { + _pipeName = pipeName; + _status = initialStatus; + _requestHandler = requestHandler; + _heartbeatHandler = heartbeatHandler; + } + + public static string CreatePipeName() + { + var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}"; + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant())); + return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}"; + } + + public void Start() + { + _listenTask ??= Task.Run(ListenLoopAsync); + _heartbeatTask ??= Task.Run(HeartbeatLoopAsync); + } + + public LauncherCoordinatorStatus GetStatus() + { + lock (_statusGate) + { + return _status; + } + } + + public void UpdateStatus(LauncherCoordinatorStatus status) + { + lock (_statusGate) + { + _status = status; + } + } + + public void Dispose() + { + _cts.Cancel(); + + try + { + _listenTask?.Wait(TimeSpan.FromSeconds(1)); + _heartbeatTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + } + + _cts.Dispose(); + } + + private async Task ListenLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + NamedPipeServerStream? server = null; + try + { + server = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + 8, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false); + var connectedServer = server; + _ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token); + server = null; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}"); + try + { + await Task.Delay(250, _cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + finally + { + server?.Dispose(); + } + } + } + + private async Task HeartbeatLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + try + { + _heartbeatHandler(GetStatus()); + await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}"); + } + } + } + + private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken) + { + try + { + var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false); + var status = GetStatus(); + var response = request is null + ? new LauncherCoordinatorResponse + { + Accepted = false, + Code = "invalid_request", + Message = "Launcher coordinator request was invalid.", + Status = status + } + : await _requestHandler(request, status).ConfigureAwait(false); + + await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}"); + } + finally + { + try + { + server.Dispose(); + } + catch + { + } + } + } + + private static async Task ReadRequestAsync( + Stream stream, + CancellationToken cancellationToken) + { + var lengthBuffer = new byte[LengthPrefixSize]; + if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var payloadLength = BitConverter.ToInt32(lengthBuffer, 0); + if (payloadLength <= 0 || payloadLength > MaxPayloadLength) + { + return null; + } + + var payload = new byte[payloadLength]; + if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false)) + { + return null; + } + + return JsonSerializer.Deserialize( + Encoding.UTF8.GetString(payload), + AppJsonContext.Default.LauncherCoordinatorRequest); + } + + private static async Task WriteResponseAsync( + Stream stream, + LauncherCoordinatorResponse response, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse); + var payload = Encoding.UTF8.GetBytes(json); + await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadExactAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { + var totalRead = 0; + while (totalRead < buffer.Length) + { + var read = await stream + .ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return false; + } + + totalRead += read; + } + + return true; + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index c1eb343..f5e9932 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Services.Ipc; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC; @@ -31,6 +32,7 @@ internal sealed class LauncherFlowCoordinator private readonly UpdateEngineService _updateEngine; private readonly PluginInstallerService _pluginInstallerService; private readonly StartupAttemptRegistry _startupAttemptRegistry; + private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -38,17 +40,25 @@ internal sealed class LauncherFlowCoordinator DeploymentLocator deploymentLocator, OobeStateService oobeStateService, UpdateEngineService updateEngine, - PluginInstallerService pluginInstallerService) + PluginInstallerService pluginInstallerService, + StartupAttemptRegistry? startupAttemptRegistry = null, + LauncherCoordinatorIpcServer? coordinatorIpcServer = null) { _context = context; _deploymentLocator = deploymentLocator; _oobeStateService = oobeStateService; _updateEngine = updateEngine; _pluginInstallerService = pluginInstallerService; - _startupAttemptRegistry = new StartupAttemptRegistry(); + _startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry(); + _coordinatorIpcServer = coordinatorIpcServer; _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; } + public static string ResolveSuccessPolicyKey(CommandContext context) + { + return new StartupSuccessTracker(context).PolicyKey; + } + public async Task RunAsync(SplashWindow? existingSplashWindow = null) { try @@ -98,6 +108,44 @@ internal sealed class LauncherFlowCoordinator var softTimeoutShown = false; var attachedToExistingAttempt = false; StartupAttemptRecord? trackedAttempt = null; + PublicShellStatus? shellStatus = null; + + void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false) + { + if (_coordinatorIpcServer is null) + { + return; + } + + trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt; + var hostPid = trackedAttempt?.HostPid ?? 0; + var hostProcessAlive = hostProcessAliveOverride ?? + (hostPid > 0 && TryGetLiveProcess(hostPid, out _)); + var status = new LauncherCoordinatorStatus + { + AttemptId = trackedAttempt?.AttemptId ?? string.Empty, + CoordinatorPid = Environment.ProcessId, + HostPid = hostPid, + HostProcessAlive = hostProcessAlive, + LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource, + SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey, + LastObservedStage = lastStage, + LastObservedMessage = lastStageMessage, + PublicIpcConnected = ipcConnected, + State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(), + SoftTimeoutShown = softTimeoutShown, + Completed = completed, + Succeeded = succeeded, + ShellStatus = shellStatus, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; + + _coordinatorIpcServer.UpdateStatus(status); + _startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status); + } + + trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt(); + PublishCoordinatorStatus(); var loadingState = new LoadingStateMessage(); EventHandler? splashClosedHandler = null; @@ -135,6 +183,7 @@ internal sealed class LauncherFlowCoordinator reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString()); loadingDetailsWindow?.UpdateLoadingState(loadingState); _startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true); + PublishCoordinatorStatus(); if (startupSuccessTracker.TryResolve(message.Stage, out var successState)) { @@ -171,6 +220,51 @@ internal sealed class LauncherFlowCoordinator try { + if (ShouldProbeExistingHostBeforeLaunch(_context)) + { + var existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900)) + .ConfigureAwait(false); + if (existingActivation is not null) + { + ipcConnected = true; + shellStatus = existingActivation.Status; + lastStage = existingActivation.Accepted + ? StartupStage.ActivationRedirected + : StartupStage.ActivationFailed; + lastStageMessage = existingActivation.Message; + if (existingActivation.Accepted) + { + _startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage); + } + else + { + _startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage); + } + + PublishCoordinatorStatus( + hostProcessAliveOverride: true, + completed: true, + succeeded: existingActivation.Accepted); + windowsClosingByCoordinator = true; + await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); + return BuildResult( + success: existingActivation.Accepted, + stage: "launch", + code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", + message: existingActivation.Message, + details: MergeDetails( + launcherContextDetails, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["publicIpcConnected"] = "true", + ["existingHostPid"] = existingActivation.Status.ProcessId.ToString(), + ["existingShellState"] = existingActivation.Status.ShellState, + ["existingTrayState"] = existingActivation.Status.Tray.State, + ["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString() + })); + } + } + reporter.Report("update", "Checking updates..."); var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); if (!updateResult.Success) @@ -212,6 +306,7 @@ internal sealed class LauncherFlowCoordinator ? "Attached to the existing startup attempt." : attachableAttempt.LastObservedMessage; reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage); + PublishCoordinatorStatus(hostProcessAliveOverride: true); if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState)) { @@ -318,12 +413,19 @@ internal sealed class LauncherFlowCoordinator if (!attachedToExistingAttempt) { - trackedAttempt = _startupAttemptRegistry.StartOwnedAttempt( - launchOutcome.Process.Id, - _context.LaunchSource, - startupSuccessTracker.PolicyKey, - lastStage, - lastStageMessage); + var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt(); + trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true } + ? _startupAttemptRegistry.AssignOwnedHostProcess( + launchOutcome.Process.Id, + lastStage, + lastStageMessage) + : _startupAttemptRegistry.StartOwnedAttempt( + launchOutcome.Process.Id, + _context.LaunchSource, + startupSuccessTracker.PolicyKey, + lastStage, + lastStageMessage); + PublishCoordinatorStatus(hostProcessAliveOverride: true); } var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false); @@ -335,6 +437,8 @@ internal sealed class LauncherFlowCoordinator { ipcConnected = true; _startupAttemptRegistry.MarkOwnedIpcConnected(); + shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); + PublishCoordinatorStatus(hostProcessAliveOverride: true); } Dictionary ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false) @@ -368,6 +472,7 @@ internal sealed class LauncherFlowCoordinator var successState = await successTcs.Task.ConfigureAwait(false); windowsClosingByCoordinator = true; _startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message); + PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: true, @@ -392,6 +497,7 @@ internal sealed class LauncherFlowCoordinator if (exitCode == HostExitCodes.SecondaryActivationSucceeded) { _startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance."); + PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: true, @@ -407,6 +513,7 @@ internal sealed class LauncherFlowCoordinator } _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: false, @@ -435,6 +542,8 @@ internal sealed class LauncherFlowCoordinator { ipcConnected = true; _startupAttemptRegistry.MarkOwnedIpcConnected(); + shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); + PublishCoordinatorStatus(hostProcessAliveOverride: true); } nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5); @@ -453,6 +562,7 @@ internal sealed class LauncherFlowCoordinator SoftTimeoutDetailsMessage, trackedAttempt?.StartedAtUtc ?? startedAt); loadingDetailsWindow?.UpdateLoadingState(loadingState); + PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited); } if (now >= hardTimeoutAt) @@ -491,6 +601,8 @@ internal sealed class LauncherFlowCoordinator { ipcConnected = true; _startupAttemptRegistry.MarkOwnedIpcConnected(); + shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); + PublishCoordinatorStatus(hostProcessAliveOverride: true); } } @@ -506,6 +618,8 @@ internal sealed class LauncherFlowCoordinator { windowsClosingByCoordinator = true; _startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message); + shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false); + PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: true, @@ -520,6 +634,7 @@ internal sealed class LauncherFlowCoordinator windowsClosingByCoordinator = true; _startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason); + PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); return BuildResult( success: false, @@ -1200,6 +1315,11 @@ internal sealed class LauncherFlowCoordinator LanMountainDesktopIpcClient ipcClient, TimeSpan timeout) { + if (ipcClient.IsConnected) + { + return true; + } + var connectTask = ipcClient.ConnectAsync(); var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); if (completedTask != connectTask) @@ -1211,6 +1331,59 @@ internal sealed class LauncherFlowCoordinator return true; } + private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context) + { + if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (context.IsPreviewCommand || context.IsMaintenanceCommand) + { + return false; + } + + return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase); + } + + private static async Task TryActivateExistingHostWithStatusAsync( + LanMountainDesktopIpcClient ipcClient, + TimeSpan timeout) + { + try + { + var connected = ipcClient.IsConnected || + await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false); + if (!connected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Existing host activation probe failed: {ex.Message}"); + return null; + } + } + + private static async Task TryGetPublicShellStatusAsync( + LanMountainDesktopIpcClient ipcClient) + { + try + { + var shellProxy = ipcClient.CreateProxy(); + return await shellProxy.GetShellStatusAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to query public shell status: {ex.Message}"); + return null; + } + } + private static async Task TryRecoverWithPublicActivationAsync( LanMountainDesktopIpcClient ipcClient, Process hostProcess, @@ -1305,8 +1478,14 @@ internal sealed class LauncherFlowCoordinator details["startupAttemptState"] = trackedAttempt.State.ToString(); details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O"); details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O"); + details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O"); details["successPolicy"] = trackedAttempt.SuccessPolicy; details["hostPid"] = trackedAttempt.HostPid.ToString(); + details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString(); + details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName; + details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString(); + details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString(); + details["shellStatus"] = trackedAttempt.ShellStatus; } return details; diff --git a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs index ffd08d0..57d21b3 100644 --- a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs +++ b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs @@ -9,6 +9,7 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class StartupAttemptRegistry { + private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10); private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true @@ -45,12 +46,14 @@ internal sealed class StartupAttemptRegistry { AttemptId = Guid.NewGuid().ToString("N"), HostPid = hostPid, + CoordinatorPid = Environment.ProcessId, LaunchSource = launchSource, SuccessPolicy = successPolicy, LastObservedStage = stage, LastObservedMessage = message ?? string.Empty, StartedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, + HeartbeatAtUtc = DateTimeOffset.UtcNow, State = StartupAttemptState.Pending }; @@ -63,6 +66,148 @@ internal sealed class StartupAttemptRegistry return Clone(record); } + public bool TryReserveCoordinator( + string launchSource, + string successPolicy, + string coordinatorPipeName, + out StartupAttemptRecord reservedAttempt, + out StartupAttemptRecord? activeCoordinatorAttempt) + { + StartupAttemptRecord? reserved = null; + StartupAttemptRecord? active = null; + + ExecuteWithLock(() => + { + var existing = LoadUnsafe(); + if (existing is not null && IsCoordinatorLive(existing)) + { + active = Clone(existing); + return; + } + + if (existing is not null && IsRecoverableCoordinatorAttempt(existing)) + { + existing.CoordinatorPid = Environment.ProcessId; + existing.CoordinatorPipeName = coordinatorPipeName; + existing.HeartbeatAtUtc = DateTimeOffset.UtcNow; + existing.UpdatedAtUtc = DateTimeOffset.UtcNow; + if (existing.HostPid <= 0) + { + existing.ReservedBeforeHostStart = true; + } + + if (existing.State == StartupAttemptState.DetachedWaiting) + { + existing.State = StartupAttemptState.SoftTimeout; + } + + _ownedAttemptId = existing.AttemptId; + SaveUnsafe(existing); + reserved = Clone(existing); + return; + } + + var now = DateTimeOffset.UtcNow; + var record = new StartupAttemptRecord + { + AttemptId = Guid.NewGuid().ToString("N"), + HostPid = 0, + CoordinatorPid = Environment.ProcessId, + CoordinatorPipeName = coordinatorPipeName, + LaunchSource = launchSource, + SuccessPolicy = successPolicy, + LastObservedStage = StartupStage.Initializing, + LastObservedMessage = "Launcher coordinator reserved startup ownership.", + StartedAtUtc = now, + UpdatedAtUtc = now, + HeartbeatAtUtc = now, + ReservedBeforeHostStart = true, + State = StartupAttemptState.Pending + }; + + _ownedAttemptId = record.AttemptId; + SaveUnsafe(record); + reserved = Clone(record); + }); + + reservedAttempt = reserved ?? new StartupAttemptRecord(); + activeCoordinatorAttempt = active; + return reserved is not null; + } + + public StartupAttemptRecord? GetOwnedAttempt() + { + StartupAttemptRecord? result = null; + if (string.IsNullOrWhiteSpace(_ownedAttemptId)) + { + return null; + } + + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal)) + { + result = Clone(record); + } + }); + + return result; + } + + public StartupAttemptRecord? TryGetLiveCoordinatorAttempt() + { + StartupAttemptRecord? result = null; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is not null && IsCoordinatorLive(record)) + { + result = Clone(record); + } + }); + + return result; + } + + public StartupAttemptRecord? TryGetLatestAttempt() + { + StartupAttemptRecord? result = null; + ExecuteWithLock(() => + { + var record = LoadUnsafe(); + if (record is not null) + { + result = Clone(record); + } + }); + + return result; + } + + public StartupAttemptRecord AssignOwnedHostProcess( + int hostPid, + StartupStage stage, + string? message) + { + StartupAttemptRecord? result = null; + UpdateOwned(record => + { + record.HostPid = hostPid; + record.LastObservedStage = stage; + record.LastObservedMessage = message ?? record.LastObservedMessage; + record.ReservedBeforeHostStart = false; + result = Clone(record); + }); + + return result ?? StartOwnedAttempt( + hostPid, + string.Empty, + string.Empty, + stage, + message); + } + public bool AdoptAttempt(string attemptId) { if (string.IsNullOrWhiteSpace(attemptId)) @@ -120,7 +265,11 @@ internal sealed class StartupAttemptRegistry public void MarkOwnedIpcConnected() { - UpdateOwned(record => record.IpcConnected = true); + UpdateOwned(record => + { + record.IpcConnected = true; + record.PublicIpcConnected = true; + }); } public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected) @@ -132,10 +281,25 @@ internal sealed class StartupAttemptRegistry if (ipcConnected) { record.IpcConnected = true; + record.PublicIpcConnected = true; } }); } + public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status) + { + UpdateOwned(record => + { + record.CoordinatorPid = Environment.ProcessId; + record.HeartbeatAtUtc = DateTimeOffset.UtcNow; + record.LastObservedStage = status.LastObservedStage; + record.LastObservedMessage = status.LastObservedMessage; + record.IpcConnected = status.PublicIpcConnected; + record.PublicIpcConnected = status.PublicIpcConnected; + record.ShellStatus = status.ShellStatus?.ShellState ?? status.State; + }); + } + public void MarkOwnedSoftTimeout(string? message) { UpdateOwned(record => @@ -267,6 +431,38 @@ internal sealed class StartupAttemptRegistry return TryGetLiveProcess(record.HostPid, out _); } + private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record) + { + if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)) + { + return false; + } + + if (record.HostPid <= 0) + { + return true; + } + + return TryGetLiveProcess(record.HostPid, out _); + } + + private static bool IsCoordinatorLive(StartupAttemptRecord record) + { + if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)) + { + return false; + } + + if (record.CoordinatorPid <= 0 || + string.IsNullOrWhiteSpace(record.CoordinatorPipeName) || + DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout) + { + return false; + } + + return TryGetLiveProcess(record.CoordinatorPid, out _); + } + private static bool TryGetLiveProcess(int processId, out Process? process) { process = null; @@ -300,13 +496,19 @@ internal sealed class StartupAttemptRegistry { AttemptId = record.AttemptId, HostPid = record.HostPid, + CoordinatorPid = record.CoordinatorPid, + CoordinatorPipeName = record.CoordinatorPipeName, StartedAtUtc = record.StartedAtUtc, UpdatedAtUtc = record.UpdatedAtUtc, + HeartbeatAtUtc = record.HeartbeatAtUtc, LaunchSource = record.LaunchSource, SuccessPolicy = record.SuccessPolicy, LastObservedStage = record.LastObservedStage, LastObservedMessage = record.LastObservedMessage, IpcConnected = record.IpcConnected, + PublicIpcConnected = record.PublicIpcConnected, + ShellStatus = record.ShellStatus, + ReservedBeforeHostStart = record.ReservedBeforeHostStart, State = record.State }; } diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs index 6febdfe..14ab210 100644 --- a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs +++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs @@ -5,8 +5,16 @@ namespace LanMountainDesktop.Shared.IPC.Abstractions.Services; [IpcPublic(IgnoresIpcException = true)] public interface IPublicShellControlService { + Task GetShellStatusAsync(); + Task ActivateMainWindowAsync(); + Task ActivateMainWindowWithStatusAsync(); + + Task EnsureTrayReadyAsync(); + + Task EnsureTaskbarEntryAsync(); + Task OpenSettingsAsync(string? pageTag = null); Task RestartAsync(); diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/PublicShellStatus.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/PublicShellStatus.cs new file mode 100644 index 0000000..f10eb53 --- /dev/null +++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/PublicShellStatus.cs @@ -0,0 +1,36 @@ +namespace LanMountainDesktop.Shared.IPC.Abstractions.Services; + +public sealed record PublicShellStatus( + int ProcessId, + DateTimeOffset StartedAtUtc, + string LaunchSource, + string ShellState, + bool MainWindowCreated, + bool MainWindowVisible, + bool MainWindowOpened, + bool DesktopVisible, + bool PublicIpcReady, + PublicTrayStatus Tray, + PublicTaskbarStatus Taskbar); + +public sealed record PublicTrayStatus( + string State, + bool IsReady, + bool HasIcon, + bool HasMenu, + bool IsVisible, + int ConsecutiveRecoveryFailures); + +public sealed record PublicTaskbarStatus( + bool RequestedBySettings, + bool MainWindowExists, + bool MainWindowShowInTaskbar, + bool MainWindowVisible, + bool MainWindowMinimized, + bool IsUsable); + +public sealed record PublicShellActivationResult( + bool Accepted, + string Code, + string Message, + PublicShellStatus Status); diff --git a/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs b/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs new file mode 100644 index 0000000..7146c6c --- /dev/null +++ b/LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Nodes; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Services; +using LanMountainDesktop.Shared.Contracts.Launcher; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class LauncherCoordinatorRegistryTests +{ + [Fact] + public void TryReserveCoordinator_WhenActiveCoordinatorExists_ReturnsActiveAttempt() + { + using var temp = TemporaryAttemptState.Create(); + var firstRegistry = new StartupAttemptRegistry(temp.StatePath); + var secondRegistry = new StartupAttemptRegistry(temp.StatePath); + + Assert.True(firstRegistry.TryReserveCoordinator( + "normal", + "Foreground", + "pipe-a", + out var firstAttempt, + out var firstActive)); + Assert.Null(firstActive); + + Assert.False(secondRegistry.TryReserveCoordinator( + "normal", + "Foreground", + "pipe-b", + out _, + out var secondActive)); + + Assert.NotNull(secondActive); + Assert.Equal(firstAttempt.AttemptId, secondActive.AttemptId); + Assert.Equal("pipe-a", secondActive.CoordinatorPipeName); + Assert.Equal(Environment.ProcessId, secondActive.CoordinatorPid); + } + + [Fact] + public void TryReserveCoordinator_WhenHeartbeatIsStale_TakesOverAttempt() + { + using var temp = TemporaryAttemptState.Create(); + var firstRegistry = new StartupAttemptRegistry(temp.StatePath); + var secondRegistry = new StartupAttemptRegistry(temp.StatePath); + + Assert.True(firstRegistry.TryReserveCoordinator( + "normal", + "Foreground", + "pipe-a", + out var firstAttempt, + out _)); + temp.SetHeartbeat(DateTimeOffset.UtcNow.AddSeconds(-30)); + + Assert.True(secondRegistry.TryReserveCoordinator( + "normal", + "Foreground", + "pipe-b", + out var reservedAttempt, + out var activeAttempt)); + + Assert.Null(activeAttempt); + Assert.Equal(firstAttempt.AttemptId, reservedAttempt.AttemptId); + Assert.Equal("pipe-b", reservedAttempt.CoordinatorPipeName); + } + + [Fact] + public void AssignOwnedHostProcess_ClearsReservedBeforeHostStart() + { + using var temp = TemporaryAttemptState.Create(); + var registry = new StartupAttemptRegistry(temp.StatePath); + + Assert.True(registry.TryReserveCoordinator( + "normal", + "Foreground", + "pipe-a", + out var reservedAttempt, + out _)); + Assert.True(reservedAttempt.ReservedBeforeHostStart); + + var assignedAttempt = registry.AssignOwnedHostProcess( + Environment.ProcessId, + StartupStage.Initializing, + "host assigned"); + + Assert.Equal(Environment.ProcessId, assignedAttempt.HostPid); + Assert.False(assignedAttempt.ReservedBeforeHostStart); + } + + private sealed class TemporaryAttemptState : IDisposable + { + private TemporaryAttemptState(string directory) + { + Directory = directory; + StatePath = Path.Combine(directory, "startup-attempt.json"); + } + + public string Directory { get; } + + public string StatePath { get; } + + public static TemporaryAttemptState Create() + { + var directory = Path.Combine( + Path.GetTempPath(), + "LanMountainDesktop.LauncherCoordinatorTests", + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(directory); + return new TemporaryAttemptState(directory); + } + + public void SetHeartbeat(DateTimeOffset heartbeatAtUtc) + { + var node = JsonNode.Parse(File.ReadAllText(StatePath))!.AsObject(); + node["heartbeatAtUtc"] = heartbeatAtUtc.ToString("O"); + File.WriteAllText(StatePath, node.ToJsonString()); + } + + public void Dispose() + { + if (System.IO.Directory.Exists(Directory)) + { + System.IO.Directory.Delete(Directory, recursive: true); + } + } + } +} diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 867ce62..ba55861 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -71,6 +71,7 @@ public partial class App : Application private ShutdownIntent _shutdownIntent; private DesktopTrayService? _desktopTrayService; + private DispatcherTimer? _shellRecoveryTimer; private PluginRuntimeService? _pluginRuntimeService; private MainWindow? _mainWindow; private TransparentOverlayWindow? _transparentOverlayWindow; @@ -478,6 +479,7 @@ public partial class App : Application private void InitializeTrayIcon() { EnsureDesktopTrayService(); + _desktopTrayService?.StartWatchdog(); _trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true; if (_trayInitialized) { @@ -525,14 +527,67 @@ public partial class App : Application OnTrayRestartClick, OnTrayExitClick); _desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged; + _desktopTrayService.StartWatchdog(); + EnsureShellRecoveryWatchdog(); + } + + private void EnsureShellRecoveryWatchdog() + { + _shellRecoveryTimer ??= new DispatcherTimer( + TimeSpan.FromSeconds(10), + DispatcherPriority.Background, + OnShellRecoveryWatchdogTick); + + if (!_shellRecoveryTimer.IsEnabled) + { + _shellRecoveryTimer.Start(); + } + } + + private void StopShellRecoveryWatchdog() + { + if (_shellRecoveryTimer?.IsEnabled == true) + { + _shellRecoveryTimer.Stop(); + } + } + + private void OnShellRecoveryWatchdogTick(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + if (_shutdownIntent != ShutdownIntent.None) + { + return; + } + + EnsureTrayReady("ShellRecoveryWatchdog"); + + if (!ShouldShowMainWindowInTaskbar()) + { + return; + } + + if (_desktopShellState != DesktopShellState.ForegroundDesktop) + { + EnsureTaskbarEntry("ShellRecoveryWatchdog"); + return; + } + + if (_mainWindow is not null && _mainWindow.IsVisible && !_mainWindow.ShowInTaskbar) + { + _mainWindow.ShowInTaskbar = true; + } } private bool EnsureTrayReady(string reason) { EnsureDesktopTrayService(); + var wasReady = _trayInitialized; var ready = _desktopTrayService?.EnsureReady(reason) == true; _trayInitialized = ready; - if (ready) + if (ready && !wasReady) { ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready."); } @@ -544,9 +599,25 @@ public partial class App : Application { _trayInitialized = state == TrayAvailabilityState.Ready; - if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly) + if (state != TrayAvailabilityState.Failed) + { + return; + } + + if (_desktopShellState == DesktopShellState.TrayOnly) { RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed"); + return; + } + + var foregroundVisible = _mainWindow?.IsVisible == true && + _mainWindow.WindowState != WindowState.Minimized; + var taskbarUsable = BuildPublicTaskbarStatus().IsUsable; + if (!foregroundVisible && + !taskbarUsable && + (_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0) >= 3) + { + RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityRepeatedFailure"); } } @@ -736,7 +807,6 @@ public partial class App : Application mainWindow.PlayEnterAnimation(); }, DispatcherPriority.Background); - _desktopTrayService?.StopWatchdog(); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}"); AppLogger.Info( "DesktopShell", @@ -864,6 +934,23 @@ public partial class App : Application { RefreshFusedDesktopMenuItemVisibility(); } + + var showInTaskbarChanged = + refreshAll || + changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparer.OrdinalIgnoreCase); + + if (showInTaskbarChanged) + { + EnsureTrayReady("SettingsChanged"); + if (ShouldShowMainWindowInTaskbar()) + { + EnsureTaskbarEntry("SettingsChanged"); + } + else if (_mainWindow is not null && _mainWindow.IsVisible) + { + _mainWindow.ShowInTaskbar = false; + } + } }, DispatcherPriority.Background); } @@ -980,6 +1067,7 @@ public partial class App : Application } _exitCleanupCompleted = true; + StopShellRecoveryWatchdog(); _settingsFacade.Settings.Changed -= OnSettingsChanged; _appearanceThemeService.Changed -= OnAppearanceThemeChanged; @@ -1158,7 +1246,6 @@ public partial class App : Application case RestartPresentationMode.Minimized: mainWindow.ShowInTaskbar = true; mainWindow.WindowState = WindowState.Minimized; - _desktopTrayService?.StopWatchdog(); SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation"); ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready."); return true; @@ -1300,6 +1387,24 @@ public partial class App : Application { try { + if (ShouldShowMainWindowInTaskbar()) + { + EnsureTrayReady($"TaskbarBackground:{source}"); + mainWindow.ShowInTaskbar = true; + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + + mainWindow.WindowState = WindowState.Minimized; + SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, source); + ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar."); + AppLogger.Info( + "DesktopShell", + $"Main window minimized to taskbar because taskbar entry is enabled. Source='{source}'."); + return; + } + if (!EnsureTrayReady($"HideToTray:{source}")) { RecoverFromTrayUnavailable(mainWindow, source); @@ -1308,7 +1413,6 @@ public partial class App : Application mainWindow.ShowInTaskbar = false; mainWindow.Hide(); - _desktopTrayService?.StartWatchdog(); SetDesktopShellState(DesktopShellState.TrayOnly, source); ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready."); AppLogger.Info( @@ -1345,7 +1449,6 @@ public partial class App : Application } mainWindow.WindowState = WindowState.Minimized; - _desktopTrayService?.StopWatchdog(); SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}"); ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback."); return; @@ -1373,7 +1476,6 @@ public partial class App : Application mainWindow.Activate(); mainWindow.Topmost = true; mainWindow.Topmost = false; - _desktopTrayService?.StopWatchdog(); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}"); ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable."); } @@ -1383,6 +1485,48 @@ public partial class App : Application return _settingsFacade.Settings.LoadSnapshot(SettingsScope.App).ShowInTaskbar; } + private bool EnsureTaskbarEntry(string source) + { + if (!ShouldShowMainWindowInTaskbar()) + { + return false; + } + + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + AppLogger.Warn("DesktopShell", $"Taskbar repair skipped because desktop lifetime is unavailable. Source='{source}'."); + return false; + } + + try + { + var mainWindow = GetOrCreateMainWindow(desktop, $"TaskbarRepair:{source}"); + mainWindow.ShowInTaskbar = true; + + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + + if (_desktopShellState != DesktopShellState.ForegroundDesktop) + { + mainWindow.WindowState = WindowState.Minimized; + SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TaskbarRepair:{source}"); + ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar repair."); + } + + AppLogger.Info( + "DesktopShell", + $"Taskbar entry ensured. Source='{source}'; IsVisible={mainWindow.IsVisible}; ShowInTaskbar={mainWindow.ShowInTaskbar}; WindowState='{mainWindow.WindowState}'."); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("DesktopShell", $"Failed to ensure taskbar entry. Source='{source}'.", ex); + return false; + } + } + private void SetDesktopShellState(DesktopShellState state, string source) { if (_desktopShellState == state) @@ -1434,7 +1578,72 @@ public partial class App : Application internal bool TryActivateMainWindowFromExternalIpc(string source) { - return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source); + return TryActivateMainWindowWithStatusFromExternalIpc(source).Accepted; + } + + internal PublicShellActivationResult TryActivateMainWindowWithStatusFromExternalIpc(string source) + { + 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); + } + + internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source) + { + EnsureTrayReady($"ExternalIpc:{source}"); + return BuildPublicTrayStatus(); + } + + internal PublicTaskbarStatus EnsureTaskbarEntryFromExternalIpc(string source) + { + EnsureTaskbarEntry($"ExternalIpc:{source}"); + return BuildPublicTaskbarStatus(); + } + + internal PublicShellStatus GetPublicShellStatus() + { + return new PublicShellStatus( + Environment.ProcessId, + _startupAt, + _launchSource, + _desktopShellState.ToString(), + _mainWindow is not null && !_mainWindowClosed, + _mainWindow?.IsVisible == true, + _mainWindowOpened, + _mainWindow?.IsVisible == true && _mainWindow.WindowState != WindowState.Minimized, + _publicIpcHostService is not null, + BuildPublicTrayStatus(), + BuildPublicTaskbarStatus()); + } + + private PublicTrayStatus BuildPublicTrayStatus() + { + return new PublicTrayStatus( + _desktopTrayService?.State.ToString() ?? TrayAvailabilityState.Unavailable.ToString(), + _desktopTrayService?.IsReady == true, + _desktopTrayService?.HasIcon == true, + _desktopTrayService?.HasMenu == true, + _desktopTrayService?.IsVisible == true, + _desktopTrayService?.ConsecutiveRecoveryFailures ?? 0); + } + + private PublicTaskbarStatus BuildPublicTaskbarStatus() + { + var requested = ShouldShowMainWindowInTaskbar(); + var mainWindowExists = _mainWindow is not null && !_mainWindowClosed; + var showInTaskbar = _mainWindow?.ShowInTaskbar == true; + var visible = _mainWindow?.IsVisible == true; + var minimized = _mainWindow?.WindowState == WindowState.Minimized; + + return new PublicTaskbarStatus( + requested, + mainWindowExists, + showInTaskbar, + visible, + minimized, + requested && mainWindowExists && showInTaskbar && visible); } private void InitializePublicIpc() diff --git a/LanMountainDesktop/Services/DesktopTrayService.cs b/LanMountainDesktop/Services/DesktopTrayService.cs index 4c86823..d1ec7db 100644 --- a/LanMountainDesktop/Services/DesktopTrayService.cs +++ b/LanMountainDesktop/Services/DesktopTrayService.cs @@ -34,6 +34,7 @@ internal sealed class DesktopTrayService : IDisposable private NativeMenuItem? _restartMenuItem; private NativeMenuItem? _exitMenuItem; private int _consecutiveRecoveryFailures; + private bool _disposed; public DesktopTrayService( Application application, @@ -63,6 +64,14 @@ internal sealed class DesktopTrayService : IDisposable public bool IsReady => State == TrayAvailabilityState.Ready; + public bool HasIcon => _trayIcon?.Icon is not null; + + public bool HasMenu => _trayIcon?.Menu is not null; + + public bool IsVisible => _trayIcon?.IsVisible == true; + + public int ConsecutiveRecoveryFailures => _consecutiveRecoveryFailures; + public event Action? StateChanged; public bool EnsureReady(string reason) @@ -105,6 +114,7 @@ internal sealed class DesktopTrayService : IDisposable public void Dispose() { + _disposed = true; StopWatchdog(); try @@ -126,7 +136,7 @@ internal sealed class DesktopTrayService : IDisposable _ = sender; _ = e; - if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed) + if (_disposed || State == TrayAvailabilityState.Unavailable) { return; } @@ -256,6 +266,11 @@ internal sealed class DesktopTrayService : IDisposable { if (State == state) { + if (state == TrayAvailabilityState.Failed) + { + StateChanged?.Invoke(state); + } + return; } diff --git a/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs b/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs index bad011c..c0e21d9 100644 --- a/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs +++ b/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs @@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc; internal sealed class PublicShellControlService : IPublicShellControlService { + public Task GetShellStatusAsync() + { + return Dispatcher.UIThread.InvokeAsync(() => + { + return (Application.Current as App)?.GetPublicShellStatus() + ?? CreateUnavailableStatus(); + }).GetTask(); + } + public Task ActivateMainWindowAsync() { return Dispatcher.UIThread.InvokeAsync(() => @@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService }).GetTask(); } + public Task ActivateMainWindowWithStatusAsync() + { + return Dispatcher.UIThread.InvokeAsync(() => + { + return (Application.Current as App)?.TryActivateMainWindowWithStatusFromExternalIpc("PublicIpc") + ?? new PublicShellActivationResult( + false, + "app_unavailable", + "Application instance is not available.", + CreateUnavailableStatus()); + }).GetTask(); + } + + public Task EnsureTrayReadyAsync() + { + return Dispatcher.UIThread.InvokeAsync(() => + { + return (Application.Current as App)?.EnsureTrayReadyFromExternalIpc("PublicIpc") + ?? new PublicTrayStatus("Unavailable", false, false, false, false, 0); + }).GetTask(); + } + + public Task EnsureTaskbarEntryAsync() + { + return Dispatcher.UIThread.InvokeAsync(() => + { + return (Application.Current as App)?.EnsureTaskbarEntryFromExternalIpc("PublicIpc") + ?? new PublicTaskbarStatus(false, false, false, false, false, false); + }).GetTask(); + } + public Task OpenSettingsAsync(string? pageTag = null) { return Dispatcher.UIThread.InvokeAsync(() => @@ -44,4 +84,20 @@ internal sealed class PublicShellControlService : IPublicShellControlService Source: "PublicIpc", Reason: "External IPC requested exit.")) == true); } + + private static PublicShellStatus CreateUnavailableStatus() + { + return new PublicShellStatus( + Environment.ProcessId, + DateTimeOffset.UtcNow, + "unknown", + "Unavailable", + false, + false, + false, + false, + false, + new PublicTrayStatus("Unavailable", false, false, false, false, 0), + new PublicTaskbarStatus(false, false, false, false, false, false)); + } } diff --git a/docs/EXTERNAL_IPC_ARCHITECTURE.md b/docs/EXTERNAL_IPC_ARCHITECTURE.md index fcdf8fa..e966f21 100644 --- a/docs/EXTERNAL_IPC_ARCHITECTURE.md +++ b/docs/EXTERNAL_IPC_ARCHITECTURE.md @@ -40,7 +40,7 @@ Current built-in `[IpcPublic]` contracts: - `IPublicAppInfoService` - Returns application metadata such as version, codename, process id, pipe name, and startup time. - `IPublicShellControlService` - - Allows external .NET clients to activate the shell, open settings, request restart, and request exit. + - Allows external .NET clients to query shell status, activate the shell, repair tray readiness, repair taskbar entry visibility, open settings, request restart, and request exit. - `IPublicPluginCatalogService` - Returns the merged public IPC catalog snapshot exposed by Host. @@ -77,6 +77,8 @@ Launcher no longer depends on the previous custom named-pipe length-prefixed pro This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators. +Launcher-to-launcher de-duplication is intentionally separate from Host Public IPC. The active Launcher coordinator uses a per-user local pipe and `startup-attempt.json` heartbeat so secondary Launchers attach to the coordinator before any host process can be started twice. + ## Plugin Public IPC Contribution Model Plugins can contribute new external IPC services in two ways: diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index db745d0..ceb8f72 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -569,3 +569,7 @@ Launcher now consumes Host startup telemetry from the unified public IPC stack: - Launcher connects through `LanMountainDesktopIpcClient` The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path. + +## Coordinator Guard + +Launcher also owns a small per-user local coordinator used only between Launcher processes. It reserves `startup-attempt.json` before host launch, publishes a heartbeat, and exposes a local coordinator pipe for secondary Launchers. A secondary Launcher must attach to that coordinator or activate the existing Host through Public IPC instead of starting another Host process. See [Launcher Coordinator](LAUNCHER_COORDINATOR.md). diff --git a/docs/LAUNCHER_COORDINATOR.md b/docs/LAUNCHER_COORDINATOR.md new file mode 100644 index 0000000..ea79721 --- /dev/null +++ b/docs/LAUNCHER_COORDINATOR.md @@ -0,0 +1,31 @@ +# Launcher Coordinator + +LanMountainDesktop Launcher uses a per-user coordinator to prevent duplicate host startup. + +## Rules + +- A Launcher reserves `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before starting the host. +- The active record stores coordinator pid, coordinator pipe name, heartbeat, host pid, Public IPC state, and shell status. +- Only the active coordinator may start the host process. +- Secondary Launchers attach to the coordinator and request desktop activation. +- A coordinator is considered live while its pid exists and its heartbeat is newer than `10s`. +- Normal launch probes Host Public IPC first; if the host is already running, Launcher activates it and exits. + +## Tray And Taskbar + +- Tray icon and tray menu are mandatory and are not controlled by user settings. +- Tray watchdog starts with the shell and runs until process exit. +- `ShowInTaskbar=true` affects only the main-window taskbar entry. +- When `ShowInTaskbar=true`, background mode uses a minimized taskbar entry while keeping tray visible. +- Pure `TrayOnly` is allowed only when `ShowInTaskbar=false` and tray is ready. + +## Public Shell IPC + +Launcher and external callers can use: + +- `GetShellStatusAsync()` +- `ActivateMainWindowWithStatusAsync()` +- `EnsureTrayReadyAsync()` +- `EnsureTaskbarEntryAsync()` + +These APIs report process, shell, tray, taskbar, and activation state separately so callers do not infer health from window visibility alone.