Add launcher coordinator IPC and startup reservation

Introduce a launcher coordinator to reserve startup ownership and prevent duplicate host launches. Adds a NamedPipe-based IPC server/client (LauncherCoordinatorIpcServer/Client), coordinator messages/models, and PublicShellStatus/activation types for richer shell reporting. Enhances StartupAttemptRecord and StartupAttemptRegistry to track coordinator pid/pipe, heartbeat, reserved-before-host-start, and public IPC status, plus new reservation/heartbeat APIs and takeover logic. Wire coordinator into App and LauncherFlowCoordinator to attach secondary launchers, publish coordinator status, probe existing hosts, and include more detailed launch result details. Also adds unit tests and docs describing coordinator and startup visuals behavior.
This commit is contained in:
lincube
2026-04-23 09:45:05 +08:00
parent 33591a0a63
commit 927dc8d1fd
19 changed files with 1637 additions and 23 deletions

View File

@@ -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.

View File

@@ -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".

View File

@@ -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<LauncherResult> 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<string, string>(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<LauncherCoordinatorResponse> 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<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
var details = new Dictionary<string, string>(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<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> 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<IPublicShellControlService>();
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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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<LauncherCoordinatorResponse?> 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<LauncherCoordinatorResponse?> 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<bool> 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;
}
}

View File

@@ -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<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
private readonly Action<LauncherCoordinatorStatus> _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<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
Action<LauncherCoordinatorStatus> 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<LauncherCoordinatorRequest?> 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<bool> 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;
}
}

View File

@@ -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<IOobeStep> _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<LauncherResult> 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<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()
}));
}
}
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(
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<string, string> 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<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
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.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Existing host activation probe failed: {ex.Message}");
return null;
}
}
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
LanMountainDesktopIpcClient ipcClient)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
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<StartupSuccessState?> 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;

View File

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

View File

@@ -5,8 +5,16 @@ namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicShellControlService
{
Task<PublicShellStatus> GetShellStatusAsync();
Task<bool> ActivateMainWindowAsync();
Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync();
Task<PublicTrayStatus> EnsureTrayReadyAsync();
Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync();
Task<bool> OpenSettingsAsync(string? pageTag = null);
Task<bool> RestartAsync();

View File

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

View File

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

View File

@@ -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<AppSettingsSnapshot>(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()

View File

@@ -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<TrayAvailabilityState>? 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;
}

View File

@@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc;
internal sealed class PublicShellControlService : IPublicShellControlService
{
public Task<PublicShellStatus> GetShellStatusAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.GetPublicShellStatus()
?? CreateUnavailableStatus();
}).GetTask();
}
public Task<bool> ActivateMainWindowAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
@@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService
}).GetTask();
}
public Task<PublicShellActivationResult> 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<PublicTrayStatus> EnsureTrayReadyAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.EnsureTrayReadyFromExternalIpc("PublicIpc")
?? new PublicTrayStatus("Unavailable", false, false, false, false, 0);
}).GetTask();
}
public Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.EnsureTaskbarEntryFromExternalIpc("PublicIpc")
?? new PublicTaskbarStatus(false, false, false, false, false, false);
}).GetTask();
}
public Task<bool> 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));
}
}

View File

@@ -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:

View File

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

View File

@@ -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.