mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
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:
@@ -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.
|
||||||
@@ -27,3 +27,11 @@
|
|||||||
- `Open Logs`
|
- `Open Logs`
|
||||||
- `Exit`
|
- `Exit`
|
||||||
- Retry is only valid when Launcher is not about to create a duplicate desktop process.
|
- 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".
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
@@ -5,6 +6,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
@@ -183,6 +185,36 @@ public partial class App : Application
|
|||||||
LauncherResult result;
|
LauncherResult result;
|
||||||
SplashWindow? currentSplashWindow = splashWindow;
|
SplashWindow? currentSplashWindow = splashWindow;
|
||||||
var appRoot = Commands.ResolveAppRoot(context);
|
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)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -199,7 +231,9 @@ public partial class App : Application
|
|||||||
deploymentLocator,
|
deploymentLocator,
|
||||||
new OobeStateService(appRoot),
|
new OobeStateService(appRoot),
|
||||||
new UpdateEngineService(deploymentLocator),
|
new UpdateEngineService(deploymentLocator),
|
||||||
new PluginInstallerService());
|
new PluginInstallerService(),
|
||||||
|
startupAttemptRegistry,
|
||||||
|
coordinatorServer);
|
||||||
|
|
||||||
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
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);
|
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)
|
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||||
{
|
{
|
||||||
var resultPath = context.GetOption("result");
|
var resultPath = context.GetOption("result");
|
||||||
@@ -326,22 +529,60 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
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
|
try
|
||||||
{
|
{
|
||||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
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)
|
if (!ipcClient.IsConnected)
|
||||||
{
|
{
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
|||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Services;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher;
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
@@ -20,6 +21,13 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||||
[JsonSerializable(typeof(AppVersionInfo))]
|
[JsonSerializable(typeof(AppVersionInfo))]
|
||||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
[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(LauncherResult))]
|
||||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||||
[JsonSerializable(typeof(PluginManifest))]
|
[JsonSerializable(typeof(PluginManifest))]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,12 +20,21 @@ internal sealed class StartupAttemptRecord
|
|||||||
[JsonPropertyName("hostPid")]
|
[JsonPropertyName("hostPid")]
|
||||||
public int HostPid { get; set; }
|
public int HostPid { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("coordinatorPid")]
|
||||||
|
public int CoordinatorPid { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("coordinatorPipeName")]
|
||||||
|
public string CoordinatorPipeName { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonPropertyName("startedAtUtc")]
|
[JsonPropertyName("startedAtUtc")]
|
||||||
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
[JsonPropertyName("updatedAtUtc")]
|
[JsonPropertyName("updatedAtUtc")]
|
||||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
[JsonPropertyName("heartbeatAtUtc")]
|
||||||
|
public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
[JsonPropertyName("launchSource")]
|
[JsonPropertyName("launchSource")]
|
||||||
public string LaunchSource { get; set; } = string.Empty;
|
public string LaunchSource { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -41,6 +50,15 @@ internal sealed class StartupAttemptRecord
|
|||||||
[JsonPropertyName("ipcConnected")]
|
[JsonPropertyName("ipcConnected")]
|
||||||
public bool IpcConnected { get; set; }
|
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")]
|
[JsonPropertyName("state")]
|
||||||
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
|
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Shared.IPC;
|
using LanMountainDesktop.Shared.IPC;
|
||||||
@@ -31,6 +32,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
private readonly UpdateEngineService _updateEngine;
|
private readonly UpdateEngineService _updateEngine;
|
||||||
private readonly PluginInstallerService _pluginInstallerService;
|
private readonly PluginInstallerService _pluginInstallerService;
|
||||||
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||||
|
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||||
|
|
||||||
public LauncherFlowCoordinator(
|
public LauncherFlowCoordinator(
|
||||||
@@ -38,17 +40,25 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
DeploymentLocator deploymentLocator,
|
DeploymentLocator deploymentLocator,
|
||||||
OobeStateService oobeStateService,
|
OobeStateService oobeStateService,
|
||||||
UpdateEngineService updateEngine,
|
UpdateEngineService updateEngine,
|
||||||
PluginInstallerService pluginInstallerService)
|
PluginInstallerService pluginInstallerService,
|
||||||
|
StartupAttemptRegistry? startupAttemptRegistry = null,
|
||||||
|
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
_oobeStateService = oobeStateService;
|
_oobeStateService = oobeStateService;
|
||||||
_updateEngine = updateEngine;
|
_updateEngine = updateEngine;
|
||||||
_pluginInstallerService = pluginInstallerService;
|
_pluginInstallerService = pluginInstallerService;
|
||||||
_startupAttemptRegistry = new StartupAttemptRegistry();
|
_startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry();
|
||||||
|
_coordinatorIpcServer = coordinatorIpcServer;
|
||||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string ResolveSuccessPolicyKey(CommandContext context)
|
||||||
|
{
|
||||||
|
return new StartupSuccessTracker(context).PolicyKey;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -98,6 +108,44 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var softTimeoutShown = false;
|
var softTimeoutShown = false;
|
||||||
var attachedToExistingAttempt = false;
|
var attachedToExistingAttempt = false;
|
||||||
StartupAttemptRecord? trackedAttempt = null;
|
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();
|
var loadingState = new LoadingStateMessage();
|
||||||
EventHandler? splashClosedHandler = null;
|
EventHandler? splashClosedHandler = null;
|
||||||
@@ -135,6 +183,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
||||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
|
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
|
||||||
|
PublishCoordinatorStatus();
|
||||||
|
|
||||||
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
||||||
{
|
{
|
||||||
@@ -171,6 +220,51 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
try
|
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...");
|
reporter.Report("update", "Checking updates...");
|
||||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||||
if (!updateResult.Success)
|
if (!updateResult.Success)
|
||||||
@@ -212,6 +306,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
? "Attached to the existing startup attempt."
|
? "Attached to the existing startup attempt."
|
||||||
: attachableAttempt.LastObservedMessage;
|
: attachableAttempt.LastObservedMessage;
|
||||||
reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage);
|
reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||||
|
|
||||||
if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState))
|
if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState))
|
||||||
{
|
{
|
||||||
@@ -318,12 +413,19 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
if (!attachedToExistingAttempt)
|
if (!attachedToExistingAttempt)
|
||||||
{
|
{
|
||||||
trackedAttempt = _startupAttemptRegistry.StartOwnedAttempt(
|
var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
||||||
launchOutcome.Process.Id,
|
trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
|
||||||
_context.LaunchSource,
|
? _startupAttemptRegistry.AssignOwnedHostProcess(
|
||||||
startupSuccessTracker.PolicyKey,
|
launchOutcome.Process.Id,
|
||||||
lastStage,
|
lastStage,
|
||||||
lastStageMessage);
|
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);
|
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||||
@@ -335,6 +437,8 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
ipcConnected = true;
|
ipcConnected = true;
|
||||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||||
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
|
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
|
||||||
@@ -368,6 +472,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var successState = await successTcs.Task.ConfigureAwait(false);
|
var successState = await successTcs.Task.ConfigureAwait(false);
|
||||||
windowsClosingByCoordinator = true;
|
windowsClosingByCoordinator = true;
|
||||||
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
|
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
|
||||||
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: true,
|
success: true,
|
||||||
@@ -392,6 +497,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||||
{
|
{
|
||||||
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
|
_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);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: true,
|
success: true,
|
||||||
@@ -407,6 +513,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: false,
|
success: false,
|
||||||
@@ -435,6 +542,8 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
ipcConnected = true;
|
ipcConnected = true;
|
||||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||||
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||||
@@ -453,6 +562,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
SoftTimeoutDetailsMessage,
|
SoftTimeoutDetailsMessage,
|
||||||
trackedAttempt?.StartedAtUtc ?? startedAt);
|
trackedAttempt?.StartedAtUtc ?? startedAt);
|
||||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now >= hardTimeoutAt)
|
if (now >= hardTimeoutAt)
|
||||||
@@ -491,6 +601,8 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
ipcConnected = true;
|
ipcConnected = true;
|
||||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||||
|
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||||
|
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +618,8 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
{
|
{
|
||||||
windowsClosingByCoordinator = true;
|
windowsClosingByCoordinator = true;
|
||||||
_startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
|
_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);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: true,
|
success: true,
|
||||||
@@ -520,6 +634,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
windowsClosingByCoordinator = true;
|
windowsClosingByCoordinator = true;
|
||||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||||
|
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
||||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1200,6 +1315,11 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
LanMountainDesktopIpcClient ipcClient,
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
TimeSpan timeout)
|
TimeSpan timeout)
|
||||||
{
|
{
|
||||||
|
if (ipcClient.IsConnected)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var connectTask = ipcClient.ConnectAsync();
|
var connectTask = ipcClient.ConnectAsync();
|
||||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||||
if (completedTask != connectTask)
|
if (completedTask != connectTask)
|
||||||
@@ -1211,6 +1331,59 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
return true;
|
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(
|
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||||
LanMountainDesktopIpcClient ipcClient,
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
Process hostProcess,
|
Process hostProcess,
|
||||||
@@ -1305,8 +1478,14 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
||||||
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
||||||
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
||||||
|
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
|
||||||
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
||||||
details["hostPid"] = trackedAttempt.HostPid.ToString();
|
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;
|
return details;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace LanMountainDesktop.Launcher.Services;
|
|||||||
|
|
||||||
internal sealed class StartupAttemptRegistry
|
internal sealed class StartupAttemptRegistry
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
@@ -45,12 +46,14 @@ internal sealed class StartupAttemptRegistry
|
|||||||
{
|
{
|
||||||
AttemptId = Guid.NewGuid().ToString("N"),
|
AttemptId = Guid.NewGuid().ToString("N"),
|
||||||
HostPid = hostPid,
|
HostPid = hostPid,
|
||||||
|
CoordinatorPid = Environment.ProcessId,
|
||||||
LaunchSource = launchSource,
|
LaunchSource = launchSource,
|
||||||
SuccessPolicy = successPolicy,
|
SuccessPolicy = successPolicy,
|
||||||
LastObservedStage = stage,
|
LastObservedStage = stage,
|
||||||
LastObservedMessage = message ?? string.Empty,
|
LastObservedMessage = message ?? string.Empty,
|
||||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
HeartbeatAtUtc = DateTimeOffset.UtcNow,
|
||||||
State = StartupAttemptState.Pending
|
State = StartupAttemptState.Pending
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +66,148 @@ internal sealed class StartupAttemptRegistry
|
|||||||
return Clone(record);
|
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)
|
public bool AdoptAttempt(string attemptId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(attemptId))
|
if (string.IsNullOrWhiteSpace(attemptId))
|
||||||
@@ -120,7 +265,11 @@ internal sealed class StartupAttemptRegistry
|
|||||||
|
|
||||||
public void MarkOwnedIpcConnected()
|
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)
|
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
|
||||||
@@ -132,10 +281,25 @@ internal sealed class StartupAttemptRegistry
|
|||||||
if (ipcConnected)
|
if (ipcConnected)
|
||||||
{
|
{
|
||||||
record.IpcConnected = true;
|
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)
|
public void MarkOwnedSoftTimeout(string? message)
|
||||||
{
|
{
|
||||||
UpdateOwned(record =>
|
UpdateOwned(record =>
|
||||||
@@ -267,6 +431,38 @@ internal sealed class StartupAttemptRegistry
|
|||||||
return TryGetLiveProcess(record.HostPid, out _);
|
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)
|
private static bool TryGetLiveProcess(int processId, out Process? process)
|
||||||
{
|
{
|
||||||
process = null;
|
process = null;
|
||||||
@@ -300,13 +496,19 @@ internal sealed class StartupAttemptRegistry
|
|||||||
{
|
{
|
||||||
AttemptId = record.AttemptId,
|
AttemptId = record.AttemptId,
|
||||||
HostPid = record.HostPid,
|
HostPid = record.HostPid,
|
||||||
|
CoordinatorPid = record.CoordinatorPid,
|
||||||
|
CoordinatorPipeName = record.CoordinatorPipeName,
|
||||||
StartedAtUtc = record.StartedAtUtc,
|
StartedAtUtc = record.StartedAtUtc,
|
||||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||||
|
HeartbeatAtUtc = record.HeartbeatAtUtc,
|
||||||
LaunchSource = record.LaunchSource,
|
LaunchSource = record.LaunchSource,
|
||||||
SuccessPolicy = record.SuccessPolicy,
|
SuccessPolicy = record.SuccessPolicy,
|
||||||
LastObservedStage = record.LastObservedStage,
|
LastObservedStage = record.LastObservedStage,
|
||||||
LastObservedMessage = record.LastObservedMessage,
|
LastObservedMessage = record.LastObservedMessage,
|
||||||
IpcConnected = record.IpcConnected,
|
IpcConnected = record.IpcConnected,
|
||||||
|
PublicIpcConnected = record.PublicIpcConnected,
|
||||||
|
ShellStatus = record.ShellStatus,
|
||||||
|
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
|
||||||
State = record.State
|
State = record.State
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
|||||||
[IpcPublic(IgnoresIpcException = true)]
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
public interface IPublicShellControlService
|
public interface IPublicShellControlService
|
||||||
{
|
{
|
||||||
|
Task<PublicShellStatus> GetShellStatusAsync();
|
||||||
|
|
||||||
Task<bool> ActivateMainWindowAsync();
|
Task<bool> ActivateMainWindowAsync();
|
||||||
|
|
||||||
|
Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync();
|
||||||
|
|
||||||
|
Task<PublicTrayStatus> EnsureTrayReadyAsync();
|
||||||
|
|
||||||
|
Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync();
|
||||||
|
|
||||||
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||||
|
|
||||||
Task<bool> RestartAsync();
|
Task<bool> RestartAsync();
|
||||||
|
|||||||
@@ -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);
|
||||||
126
LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs
Normal file
126
LanMountainDesktop.Tests/LauncherCoordinatorRegistryTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ public partial class App : Application
|
|||||||
private ShutdownIntent _shutdownIntent;
|
private ShutdownIntent _shutdownIntent;
|
||||||
|
|
||||||
private DesktopTrayService? _desktopTrayService;
|
private DesktopTrayService? _desktopTrayService;
|
||||||
|
private DispatcherTimer? _shellRecoveryTimer;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||||
@@ -478,6 +479,7 @@ public partial class App : Application
|
|||||||
private void InitializeTrayIcon()
|
private void InitializeTrayIcon()
|
||||||
{
|
{
|
||||||
EnsureDesktopTrayService();
|
EnsureDesktopTrayService();
|
||||||
|
_desktopTrayService?.StartWatchdog();
|
||||||
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
|
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
|
||||||
if (_trayInitialized)
|
if (_trayInitialized)
|
||||||
{
|
{
|
||||||
@@ -525,14 +527,67 @@ public partial class App : Application
|
|||||||
OnTrayRestartClick,
|
OnTrayRestartClick,
|
||||||
OnTrayExitClick);
|
OnTrayExitClick);
|
||||||
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
|
_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)
|
private bool EnsureTrayReady(string reason)
|
||||||
{
|
{
|
||||||
EnsureDesktopTrayService();
|
EnsureDesktopTrayService();
|
||||||
|
var wasReady = _trayInitialized;
|
||||||
var ready = _desktopTrayService?.EnsureReady(reason) == true;
|
var ready = _desktopTrayService?.EnsureReady(reason) == true;
|
||||||
_trayInitialized = ready;
|
_trayInitialized = ready;
|
||||||
if (ready)
|
if (ready && !wasReady)
|
||||||
{
|
{
|
||||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||||
}
|
}
|
||||||
@@ -544,9 +599,25 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_trayInitialized = state == TrayAvailabilityState.Ready;
|
_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");
|
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();
|
mainWindow.PlayEnterAnimation();
|
||||||
}, DispatcherPriority.Background);
|
}, DispatcherPriority.Background);
|
||||||
|
|
||||||
_desktopTrayService?.StopWatchdog();
|
|
||||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"DesktopShell",
|
"DesktopShell",
|
||||||
@@ -864,6 +934,23 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
RefreshFusedDesktopMenuItemVisibility();
|
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);
|
}, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,6 +1067,7 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
_exitCleanupCompleted = true;
|
_exitCleanupCompleted = true;
|
||||||
|
StopShellRecoveryWatchdog();
|
||||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||||
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||||
|
|
||||||
@@ -1158,7 +1246,6 @@ public partial class App : Application
|
|||||||
case RestartPresentationMode.Minimized:
|
case RestartPresentationMode.Minimized:
|
||||||
mainWindow.ShowInTaskbar = true;
|
mainWindow.ShowInTaskbar = true;
|
||||||
mainWindow.WindowState = WindowState.Minimized;
|
mainWindow.WindowState = WindowState.Minimized;
|
||||||
_desktopTrayService?.StopWatchdog();
|
|
||||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
|
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
|
||||||
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
|
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||||
return true;
|
return true;
|
||||||
@@ -1300,6 +1387,24 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
try
|
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}"))
|
if (!EnsureTrayReady($"HideToTray:{source}"))
|
||||||
{
|
{
|
||||||
RecoverFromTrayUnavailable(mainWindow, source);
|
RecoverFromTrayUnavailable(mainWindow, source);
|
||||||
@@ -1308,7 +1413,6 @@ public partial class App : Application
|
|||||||
|
|
||||||
mainWindow.ShowInTaskbar = false;
|
mainWindow.ShowInTaskbar = false;
|
||||||
mainWindow.Hide();
|
mainWindow.Hide();
|
||||||
_desktopTrayService?.StartWatchdog();
|
|
||||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
|
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
@@ -1345,7 +1449,6 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.WindowState = WindowState.Minimized;
|
mainWindow.WindowState = WindowState.Minimized;
|
||||||
_desktopTrayService?.StopWatchdog();
|
|
||||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
|
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
|
||||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
|
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
|
||||||
return;
|
return;
|
||||||
@@ -1373,7 +1476,6 @@ public partial class App : Application
|
|||||||
mainWindow.Activate();
|
mainWindow.Activate();
|
||||||
mainWindow.Topmost = true;
|
mainWindow.Topmost = true;
|
||||||
mainWindow.Topmost = false;
|
mainWindow.Topmost = false;
|
||||||
_desktopTrayService?.StopWatchdog();
|
|
||||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
|
||||||
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
|
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;
|
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)
|
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||||
{
|
{
|
||||||
if (_desktopShellState == state)
|
if (_desktopShellState == state)
|
||||||
@@ -1434,7 +1578,72 @@ public partial class App : Application
|
|||||||
|
|
||||||
internal bool TryActivateMainWindowFromExternalIpc(string source)
|
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()
|
private void InitializePublicIpc()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
private NativeMenuItem? _restartMenuItem;
|
private NativeMenuItem? _restartMenuItem;
|
||||||
private NativeMenuItem? _exitMenuItem;
|
private NativeMenuItem? _exitMenuItem;
|
||||||
private int _consecutiveRecoveryFailures;
|
private int _consecutiveRecoveryFailures;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public DesktopTrayService(
|
public DesktopTrayService(
|
||||||
Application application,
|
Application application,
|
||||||
@@ -63,6 +64,14 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
|
|
||||||
public bool IsReady => State == TrayAvailabilityState.Ready;
|
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 event Action<TrayAvailabilityState>? StateChanged;
|
||||||
|
|
||||||
public bool EnsureReady(string reason)
|
public bool EnsureReady(string reason)
|
||||||
@@ -105,6 +114,7 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
StopWatchdog();
|
StopWatchdog();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -126,7 +136,7 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
_ = sender;
|
_ = sender;
|
||||||
_ = e;
|
_ = e;
|
||||||
|
|
||||||
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
|
if (_disposed || State == TrayAvailabilityState.Unavailable)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -256,6 +266,11 @@ internal sealed class DesktopTrayService : IDisposable
|
|||||||
{
|
{
|
||||||
if (State == state)
|
if (State == state)
|
||||||
{
|
{
|
||||||
|
if (state == TrayAvailabilityState.Failed)
|
||||||
|
{
|
||||||
|
StateChanged?.Invoke(state);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc;
|
|||||||
|
|
||||||
internal sealed class PublicShellControlService : IPublicShellControlService
|
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()
|
public Task<bool> ActivateMainWindowAsync()
|
||||||
{
|
{
|
||||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
@@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
|||||||
}).GetTask();
|
}).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)
|
public Task<bool> OpenSettingsAsync(string? pageTag = null)
|
||||||
{
|
{
|
||||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
@@ -44,4 +84,20 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
|||||||
Source: "PublicIpc",
|
Source: "PublicIpc",
|
||||||
Reason: "External IPC requested exit.")) == true);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Current built-in `[IpcPublic]` contracts:
|
|||||||
- `IPublicAppInfoService`
|
- `IPublicAppInfoService`
|
||||||
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
||||||
- `IPublicShellControlService`
|
- `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`
|
- `IPublicPluginCatalogService`
|
||||||
- Returns the merged public IPC catalog snapshot exposed by Host.
|
- 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.
|
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
|
## Plugin Public IPC Contribution Model
|
||||||
|
|
||||||
Plugins can contribute new external IPC services in two ways:
|
Plugins can contribute new external IPC services in two ways:
|
||||||
|
|||||||
@@ -569,3 +569,7 @@ Launcher now consumes Host startup telemetry from the unified public IPC stack:
|
|||||||
- Launcher connects through `LanMountainDesktopIpcClient`
|
- Launcher connects through `LanMountainDesktopIpcClient`
|
||||||
|
|
||||||
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
|
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).
|
||||||
|
|||||||
31
docs/LAUNCHER_COORDINATOR.md
Normal file
31
docs/LAUNCHER_COORDINATOR.md
Normal 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.
|
||||||
Reference in New Issue
Block a user