mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927dc8d1fd | ||
|
|
33591a0a63 |
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Launcher Slow-Startup And Startup Visual Addendum
|
||||
|
||||
## New startup timing contract
|
||||
|
||||
- `30s` is a soft timeout, not a failure threshold.
|
||||
- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process.
|
||||
- `120s` is the hard timeout.
|
||||
- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`.
|
||||
|
||||
## Startup attempt de-duplication
|
||||
|
||||
- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`.
|
||||
- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again.
|
||||
- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run.
|
||||
|
||||
## Startup visual modes
|
||||
|
||||
- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade.
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`.
|
||||
|
||||
## UX safeguards
|
||||
|
||||
- If the host process is still alive at failure time, the failure dialog must prefer:
|
||||
- `Activate`
|
||||
- `Wait`
|
||||
- `Open Logs`
|
||||
- `Exit`
|
||||
- Retry is only valid when Launcher is not about to create a duplicate desktop process.
|
||||
|
||||
## Launcher coordinator guard
|
||||
|
||||
- Startup attempts are now reserved before host launch, so concurrent Launchers cannot all reach `Process.Start()`.
|
||||
- A live coordinator is identified by `CoordinatorPid`, `CoordinatorPipeName`, and a heartbeat newer than `10s`.
|
||||
- Secondary Launchers send `activate-desktop` or `attach` to the coordinator pipe and then exit with the coordinator status.
|
||||
- If Host Public IPC is already available during a normal launch, Launcher activates the existing desktop and does not start a new host process.
|
||||
- Public shell status now reports tray readiness and taskbar-entry usability separately, allowing Launcher to distinguish "running but hidden" from "not recoverable".
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -5,7 +6,11 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
@@ -52,7 +57,7 @@ public partial class App : Application
|
||||
}
|
||||
else
|
||||
{
|
||||
var splashWindow = new SplashWindow();
|
||||
var splashWindow = CreateSplashWindow();
|
||||
splashWindow.Show();
|
||||
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
|
||||
}
|
||||
@@ -68,7 +73,7 @@ public partial class App : Application
|
||||
case "preview-splash":
|
||||
{
|
||||
Logger.Info("Preview command: splash.");
|
||||
var splashWindow = new SplashWindow();
|
||||
var splashWindow = CreateSplashWindow();
|
||||
splashWindow.SetDebugMode(true);
|
||||
splashWindow.Show();
|
||||
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
|
||||
@@ -112,6 +117,12 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve();
|
||||
return new SplashWindow(preferences.Mode);
|
||||
}
|
||||
|
||||
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
|
||||
{
|
||||
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
|
||||
@@ -172,53 +183,281 @@ public partial class App : Application
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
|
||||
|
||||
try
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
new PluginInstallerService());
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
try
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
new PluginInstallerService(),
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success &&
|
||||
result.Code is not "host_not_found" &&
|
||||
(string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", "Connecting to the active launcher...");
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = response.Accepted,
|
||||
Stage = "launch",
|
||||
Code = response.Code,
|
||||
Message = response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = activation.Accepted,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
@@ -238,15 +477,31 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ShowFailureWindowAsync(LauncherResult result)
|
||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
@@ -259,16 +514,76 @@ public partial class App : Application
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return;
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
@@ -20,6 +21,13 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorResponse))]
|
||||
[JsonSerializable(typeof(LauncherCoordinatorStatus))]
|
||||
[JsonSerializable(typeof(PublicShellStatus))]
|
||||
[JsonSerializable(typeof(PublicTrayStatus))]
|
||||
[JsonSerializable(typeof(PublicTaskbarStatus))]
|
||||
[JsonSerializable(typeof(PublicShellActivationResult))]
|
||||
[JsonSerializable(typeof(LauncherResult))]
|
||||
[JsonSerializable(typeof(HostDiscoveryConfig))]
|
||||
[JsonSerializable(typeof(PluginManifest))]
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- Avalonia 资源文件 -->
|
||||
<AvaloniaResource Include="Assets\logo.ico" />
|
||||
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
64
LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs
Normal file
64
LanMountainDesktop.Launcher/Models/StartupAttemptRecord.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal enum StartupAttemptState
|
||||
{
|
||||
Pending,
|
||||
SoftTimeout,
|
||||
DetachedWaiting,
|
||||
Succeeded,
|
||||
Failed
|
||||
}
|
||||
|
||||
internal sealed class StartupAttemptRecord
|
||||
{
|
||||
[JsonPropertyName("attemptId")]
|
||||
public string AttemptId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("hostPid")]
|
||||
public int HostPid { get; set; }
|
||||
|
||||
[JsonPropertyName("coordinatorPid")]
|
||||
public int CoordinatorPid { get; set; }
|
||||
|
||||
[JsonPropertyName("coordinatorPipeName")]
|
||||
public string CoordinatorPipeName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startedAtUtc")]
|
||||
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("updatedAtUtc")]
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("heartbeatAtUtc")]
|
||||
public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("launchSource")]
|
||||
public string LaunchSource { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("successPolicy")]
|
||||
public string SuccessPolicy { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastObservedStage")]
|
||||
public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing;
|
||||
|
||||
[JsonPropertyName("lastObservedMessage")]
|
||||
public string LastObservedMessage { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ipcConnected")]
|
||||
public bool IpcConnected { get; set; }
|
||||
|
||||
[JsonPropertyName("publicIpcConnected")]
|
||||
public bool PublicIpcConnected { get; set; }
|
||||
|
||||
[JsonPropertyName("shellStatus")]
|
||||
public string ShellStatus { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reservedBeforeHostStart")]
|
||||
public bool ReservedBeforeHostStart { get; set; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
|
||||
}
|
||||
@@ -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 Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
@@ -10,6 +11,11 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120);
|
||||
private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
|
||||
private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行,Launcher 会继续等待,不会重复启动。";
|
||||
|
||||
private static readonly string[] LauncherOnlyOptions =
|
||||
[
|
||||
"debug", "show-loading-details", "plugins-dir", "source", "result",
|
||||
@@ -25,6 +31,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly StartupAttemptRegistry _startupAttemptRegistry;
|
||||
private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
@@ -32,16 +40,25 @@ internal sealed class LauncherFlowCoordinator
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
PluginInstallerService pluginInstallerService,
|
||||
StartupAttemptRegistry? startupAttemptRegistry = null,
|
||||
LauncherCoordinatorIpcServer? coordinatorIpcServer = null)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry();
|
||||
_coordinatorIpcServer = coordinatorIpcServer;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
|
||||
}
|
||||
|
||||
public static string ResolveSuccessPolicyKey(CommandContext context)
|
||||
{
|
||||
return new StartupSuccessTracker(context).PolicyKey;
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync(SplashWindow? existingSplashWindow = null)
|
||||
{
|
||||
try
|
||||
@@ -66,6 +83,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
var windowsClosingByCoordinator = false;
|
||||
var versionInfo = _deploymentLocator.GetVersionInfo();
|
||||
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
var reporter = (ISplashStageReporter)splashWindow;
|
||||
@@ -85,8 +103,63 @@ internal sealed class LauncherFlowCoordinator
|
||||
var lastStage = StartupStage.Initializing;
|
||||
var lastStageMessage = "launcher-started";
|
||||
var startupSuccessTracker = new StartupSuccessTracker(_context);
|
||||
var activationFailureReason = string.Empty;
|
||||
var ipcConnected = false;
|
||||
var softTimeoutShown = false;
|
||||
var attachedToExistingAttempt = false;
|
||||
StartupAttemptRecord? trackedAttempt = null;
|
||||
PublicShellStatus? shellStatus = null;
|
||||
|
||||
void PublishCoordinatorStatus(bool? hostProcessAliveOverride = null, bool completed = false, bool succeeded = false)
|
||||
{
|
||||
if (_coordinatorIpcServer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt() ?? trackedAttempt;
|
||||
var hostPid = trackedAttempt?.HostPid ?? 0;
|
||||
var hostProcessAlive = hostProcessAliveOverride ??
|
||||
(hostPid > 0 && TryGetLiveProcess(hostPid, out _));
|
||||
var status = new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = trackedAttempt?.AttemptId ?? string.Empty,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = hostPid,
|
||||
HostProcessAlive = hostProcessAlive,
|
||||
LaunchSource = trackedAttempt?.LaunchSource ?? _context.LaunchSource,
|
||||
SuccessPolicy = trackedAttempt?.SuccessPolicy ?? startupSuccessTracker.PolicyKey,
|
||||
LastObservedStage = lastStage,
|
||||
LastObservedMessage = lastStageMessage,
|
||||
PublicIpcConnected = ipcConnected,
|
||||
State = trackedAttempt?.State.ToString() ?? StartupAttemptState.Pending.ToString(),
|
||||
SoftTimeoutShown = softTimeoutShown,
|
||||
Completed = completed,
|
||||
Succeeded = succeeded,
|
||||
ShellStatus = shellStatus,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_coordinatorIpcServer.UpdateStatus(status);
|
||||
_startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat(status);
|
||||
}
|
||||
|
||||
trackedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
||||
PublishCoordinatorStatus();
|
||||
|
||||
var loadingState = new LoadingStateMessage();
|
||||
EventHandler? splashClosedHandler = null;
|
||||
splashClosedHandler = (_, _) =>
|
||||
{
|
||||
if (windowsClosingByCoordinator)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
|
||||
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
|
||||
};
|
||||
splashWindow.Closed += splashClosedHandler;
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
||||
{
|
||||
@@ -94,8 +167,9 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
try
|
||||
{
|
||||
ipcConnected = true;
|
||||
lastStage = message.Stage;
|
||||
lastStageMessage = message.Message ?? string.Empty;
|
||||
lastStageMessage = message.Message ?? message.Stage.ToString();
|
||||
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
|
||||
|
||||
loadingState = loadingState with
|
||||
@@ -108,6 +182,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
|
||||
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
|
||||
PublishCoordinatorStatus();
|
||||
|
||||
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
|
||||
{
|
||||
@@ -116,6 +192,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
|
||||
if (message.Stage == StartupStage.ActivationFailed)
|
||||
{
|
||||
activationFailureReason = message.Message ?? "activation_failed";
|
||||
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
|
||||
}
|
||||
}
|
||||
@@ -143,6 +220,51 @@ internal sealed class LauncherFlowCoordinator
|
||||
|
||||
try
|
||||
{
|
||||
if (ShouldProbeExistingHostBeforeLaunch(_context))
|
||||
{
|
||||
var existingActivation = await TryActivateExistingHostWithStatusAsync(ipcClient, TimeSpan.FromMilliseconds(900))
|
||||
.ConfigureAwait(false);
|
||||
if (existingActivation is not null)
|
||||
{
|
||||
ipcConnected = true;
|
||||
shellStatus = existingActivation.Status;
|
||||
lastStage = existingActivation.Accepted
|
||||
? StartupStage.ActivationRedirected
|
||||
: StartupStage.ActivationFailed;
|
||||
lastStageMessage = existingActivation.Message;
|
||||
if (existingActivation.Accepted)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(lastStage, lastStageMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, lastStageMessage);
|
||||
}
|
||||
|
||||
PublishCoordinatorStatus(
|
||||
hostProcessAliveOverride: true,
|
||||
completed: true,
|
||||
succeeded: existingActivation.Accepted);
|
||||
windowsClosingByCoordinator = true;
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: existingActivation.Accepted,
|
||||
stage: "launch",
|
||||
code: existingActivation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
message: existingActivation.Message,
|
||||
details: MergeDetails(
|
||||
launcherContextDetails,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["publicIpcConnected"] = "true",
|
||||
["existingHostPid"] = existingActivation.Status.ProcessId.ToString(),
|
||||
["existingShellState"] = existingActivation.Status.ShellState,
|
||||
["existingTrayState"] = existingActivation.Status.Tray.State,
|
||||
["existingTaskbarUsable"] = existingActivation.Status.Taskbar.IsUsable.ToString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
reporter.Report("update", "Checking updates...");
|
||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
@@ -170,7 +292,91 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
|
||||
reporter.Report("launch", "Launching desktop...");
|
||||
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
||||
var launchOutcome = default(HostLaunchOutcome);
|
||||
var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey);
|
||||
if (attachableAttempt is not null &&
|
||||
_startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) &&
|
||||
TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess))
|
||||
{
|
||||
trackedAttempt = attachableAttempt;
|
||||
attachedToExistingAttempt = true;
|
||||
ipcConnected = attachableAttempt.IpcConnected;
|
||||
lastStage = attachableAttempt.LastObservedStage;
|
||||
lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage)
|
||||
? "Attached to the existing startup attempt."
|
||||
: attachableAttempt.LastObservedMessage;
|
||||
reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
|
||||
if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState))
|
||||
{
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: attachedSuccessState.Code,
|
||||
message: attachedSuccessState.Message,
|
||||
details: MergeDetails(
|
||||
launcherContextDetails,
|
||||
BuildAttemptDetails(
|
||||
trackedAttempt,
|
||||
attachedToExistingAttempt,
|
||||
ipcConnected,
|
||||
hostProcessAlive: true,
|
||||
lastStage,
|
||||
lastStageMessage,
|
||||
activationFailureReason,
|
||||
softTimeoutShown: false,
|
||||
recoveryActivationAttempted: false)));
|
||||
}
|
||||
|
||||
if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
softTimeoutShown = true;
|
||||
reporter.Report("delayed", SoftTimeoutStatusMessage);
|
||||
loadingState = BuildDelayedLoadingState(
|
||||
loadingState,
|
||||
SoftTimeoutStatusMessage,
|
||||
SoftTimeoutDetailsMessage,
|
||||
trackedAttempt.StartedAtUtc);
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
}
|
||||
|
||||
launchOutcome = HostLaunchOutcome.FromProcess(
|
||||
attachedProcess!,
|
||||
BuildResult(
|
||||
true,
|
||||
"launchHost",
|
||||
"attached_attempt",
|
||||
"Attached to an existing startup attempt.",
|
||||
BuildAttemptDetails(
|
||||
trackedAttempt,
|
||||
attachedToExistingAttempt,
|
||||
ipcConnected,
|
||||
hostProcessAlive: true,
|
||||
lastStage,
|
||||
lastStageMessage,
|
||||
activationFailureReason,
|
||||
softTimeoutShown,
|
||||
recoveryActivationAttempted: false)),
|
||||
BuildAttemptDetails(
|
||||
trackedAttempt,
|
||||
attachedToExistingAttempt,
|
||||
ipcConnected,
|
||||
hostProcessAlive: true,
|
||||
lastStage,
|
||||
lastStageMessage,
|
||||
activationFailureReason,
|
||||
softTimeoutShown,
|
||||
recoveryActivationAttempted: false));
|
||||
}
|
||||
else
|
||||
{
|
||||
launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!launchOutcome.Result.Success)
|
||||
{
|
||||
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
|
||||
@@ -189,7 +395,37 @@ internal sealed class LauncherFlowCoordinator
|
||||
stage: "launch",
|
||||
code: "host_start_failed",
|
||||
message: "Host launch did not create a process.",
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
details: MergeDetails(
|
||||
launcherContextDetails,
|
||||
MergeDetails(
|
||||
launchOutcome.Details,
|
||||
BuildAttemptDetails(
|
||||
trackedAttempt,
|
||||
attachedToExistingAttempt,
|
||||
ipcConnected,
|
||||
hostProcessAlive: false,
|
||||
lastStage,
|
||||
lastStageMessage,
|
||||
activationFailureReason,
|
||||
softTimeoutShown,
|
||||
recoveryActivationAttempted: false))));
|
||||
}
|
||||
|
||||
if (!attachedToExistingAttempt)
|
||||
{
|
||||
var reservedAttempt = _startupAttemptRegistry.GetOwnedAttempt();
|
||||
trackedAttempt = reservedAttempt is { ReservedBeforeHostStart: true }
|
||||
? _startupAttemptRegistry.AssignOwnedHostProcess(
|
||||
launchOutcome.Process.Id,
|
||||
lastStage,
|
||||
lastStageMessage)
|
||||
: _startupAttemptRegistry.StartOwnedAttempt(
|
||||
launchOutcome.Process.Id,
|
||||
_context.LaunchSource,
|
||||
startupSuccessTracker.PolicyKey,
|
||||
lastStage,
|
||||
lastStageMessage);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
@@ -197,68 +433,182 @@ internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||
}
|
||||
else
|
||||
{
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
|
||||
{
|
||||
return MergeDetails(
|
||||
launcherContextDetails,
|
||||
MergeDetails(
|
||||
launchOutcome.Details,
|
||||
BuildAttemptDetails(
|
||||
trackedAttempt,
|
||||
attachedToExistingAttempt,
|
||||
ipcConnected,
|
||||
hostProcessAlive,
|
||||
lastStage,
|
||||
lastStageMessage,
|
||||
activationFailureReason,
|
||||
softTimeoutShown,
|
||||
recoveryActivationAttempted)));
|
||||
}
|
||||
|
||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||
var completedTask = await Task.WhenAny(
|
||||
successTcs.Task,
|
||||
activationFailedTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
|
||||
var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
|
||||
var softTimeoutAt = startedAt + StartupSoftTimeout;
|
||||
var hardTimeoutAt = startedAt + StartupHardTimeout;
|
||||
var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
|
||||
if (completedTask == successTcs.Task)
|
||||
while (true)
|
||||
{
|
||||
var successState = await successTcs.Task.ConfigureAwait(false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: successState.Code,
|
||||
message: successState.Message,
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
if (completedTask == activationFailedTcs.Task)
|
||||
{
|
||||
Logger.Warn($"Activation failure received before desktop visibility. Reason='{await activationFailedTcs.Task.ConfigureAwait(false)}'.");
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
if (successTcs.Task.IsCompleted)
|
||||
{
|
||||
var successState = await successTcs.Task.ConfigureAwait(false);
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
|
||||
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: successState.Code,
|
||||
message: successState.Message,
|
||||
details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
|
||||
}
|
||||
|
||||
if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason))
|
||||
{
|
||||
activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
|
||||
Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
|
||||
}
|
||||
|
||||
if (processExitTask.IsCompleted)
|
||||
{
|
||||
var exitCode = launchOutcome.Process.ExitCode;
|
||||
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
||||
|
||||
windowsClosingByCoordinator = true;
|
||||
if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
|
||||
{
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: "activation_redirected",
|
||||
message: "Host redirected activation to the existing desktop instance.",
|
||||
details: MergeDetails(
|
||||
ComposeLaunchDetails(hostProcessAlive: false),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: false, completed: true, succeeded: false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||||
? "activation_failed"
|
||||
: "host_exited_early",
|
||||
message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
|
||||
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
|
||||
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
||||
details: MergeDetails(
|
||||
ComposeLaunchDetails(hostProcessAlive: false),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (!ipcConnected &&
|
||||
!launchOutcome.Process.HasExited &&
|
||||
now >= nextReconnectAttemptAt)
|
||||
{
|
||||
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
|
||||
if (connected)
|
||||
{
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
}
|
||||
|
||||
if (!softTimeoutShown &&
|
||||
now >= softTimeoutAt &&
|
||||
(!launchOutcome.Process.HasExited || ipcConnected))
|
||||
{
|
||||
softTimeoutShown = true;
|
||||
_startupAttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage);
|
||||
reporter.Report("delayed", SoftTimeoutStatusMessage);
|
||||
loadingState = BuildDelayedLoadingState(
|
||||
loadingState,
|
||||
SoftTimeoutStatusMessage,
|
||||
SoftTimeoutDetailsMessage,
|
||||
trackedAttempt?.StartedAtUtc ?? startedAt);
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: !launchOutcome.Process.HasExited);
|
||||
}
|
||||
|
||||
if (now >= hardTimeoutAt)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var nextCheckpointAt = hardTimeoutAt;
|
||||
if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt)
|
||||
{
|
||||
nextCheckpointAt = softTimeoutAt;
|
||||
}
|
||||
|
||||
var delay = nextCheckpointAt - now;
|
||||
if (delay > TimeSpan.FromSeconds(1))
|
||||
{
|
||||
delay = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
else if (delay < TimeSpan.FromMilliseconds(100))
|
||||
{
|
||||
delay = TimeSpan.FromMilliseconds(100);
|
||||
}
|
||||
|
||||
await Task.WhenAny(
|
||||
successTcs.Task,
|
||||
activationFailedTcs.Task,
|
||||
processExitTask,
|
||||
Task.Delay(delay)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (completedTask == processExitTask)
|
||||
var recoveryActivationAttempted = false;
|
||||
if (!connected && !launchOutcome.Process.HasExited)
|
||||
{
|
||||
var exitCode = launchOutcome.Process.ExitCode;
|
||||
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
|
||||
|
||||
if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
|
||||
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
if (connected)
|
||||
{
|
||||
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
|
||||
if (retryOutcome is not null)
|
||||
{
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
|
||||
}
|
||||
ipcConnected = true;
|
||||
_startupAttemptRegistry.MarkOwnedIpcConnected();
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
PublishCoordinatorStatus(hostProcessAliveOverride: true);
|
||||
}
|
||||
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early",
|
||||
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
|
||||
? "Host redirected activation to the existing desktop instance."
|
||||
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["exitCode"] = exitCode.ToString()
|
||||
})));
|
||||
}
|
||||
|
||||
if (connected && !launchOutcome.Process.HasExited)
|
||||
{
|
||||
recoveryActivationAttempted = true;
|
||||
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
|
||||
ipcClient,
|
||||
launchOutcome.Process,
|
||||
@@ -266,48 +616,60 @@ internal sealed class LauncherFlowCoordinator
|
||||
startupSuccessTracker).ConfigureAwait(false);
|
||||
if (recoveryOutcome is not null)
|
||||
{
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
|
||||
shellStatus = await TryGetPublicShellStatusAsync(ipcClient).ConfigureAwait(false);
|
||||
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: true);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: true,
|
||||
stage: "launch",
|
||||
code: recoveryOutcome.Code,
|
||||
message: recoveryOutcome.Message,
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["recoveryActivationAttempted"] = bool.TrueString
|
||||
})));
|
||||
details: ComposeLaunchDetails(
|
||||
!launchOutcome.Process.HasExited,
|
||||
recoveryActivationAttempted: true));
|
||||
}
|
||||
}
|
||||
|
||||
windowsClosingByCoordinator = true;
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
|
||||
PublishCoordinatorStatus(!launchOutcome.Process.HasExited, completed: true, succeeded: false);
|
||||
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
|
||||
return BuildResult(
|
||||
success: false,
|
||||
stage: "launch",
|
||||
code: "desktop_not_visible",
|
||||
message: "Host process started, but it never reached the required startup state within 30 seconds.",
|
||||
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
|
||||
{
|
||||
["ipcStage"] = lastStage.ToString(),
|
||||
["ipcMessage"] = lastStageMessage
|
||||
})));
|
||||
message: "Host process started, but it never reached the required startup state within 120 seconds.",
|
||||
details: ComposeLaunchDetails(
|
||||
!launchOutcome.Process.HasExited,
|
||||
recoveryActivationAttempted));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
if (splashClosedHandler is not null)
|
||||
{
|
||||
try
|
||||
splashWindow.Closed -= splashClosedHandler;
|
||||
}
|
||||
|
||||
if (!windowsClosingByCoordinator)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
try
|
||||
{
|
||||
splashWindow.Close();
|
||||
Logger.Info("Splash window closed in coordinator cleanup.");
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
Logger.Info("Splash window closed in coordinator cleanup.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close splash window during coordinator cleanup.", ex);
|
||||
}
|
||||
});
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close splash window during coordinator cleanup.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -373,20 +735,17 @@ internal sealed class LauncherFlowCoordinator
|
||||
|
||||
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to dismiss splash window.", ex);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (splashWindow.IsVisible && splashWindow.IsLoaded)
|
||||
{
|
||||
splashWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close splash window.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
@@ -672,6 +1031,7 @@ internal sealed class LauncherFlowCoordinator
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.ConfigureForHostNotFound();
|
||||
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
|
||||
errorWindow.Show();
|
||||
Logger.Warn("Host not found. Showing error window.");
|
||||
@@ -955,6 +1315,11 @@ internal sealed class LauncherFlowCoordinator
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
if (ipcClient.IsConnected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
@@ -966,6 +1331,59 @@ internal sealed class LauncherFlowCoordinator
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ShouldProbeExistingHostBeforeLaunch(CommandContext context)
|
||||
{
|
||||
if (!string.Equals(context.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.IsPreviewCommand || context.IsMaintenanceCommand)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingHostWithStatusAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connected = ipcClient.IsConnected ||
|
||||
await TryConnectToPublicIpcAsync(ipcClient, timeout).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
return await shellProxy.ActivateMainWindowWithStatusAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Existing host activation probe failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PublicShellStatus?> TryGetPublicShellStatusAsync(
|
||||
LanMountainDesktopIpcClient ipcClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
return await shellProxy.GetShellStatusAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to query public shell status: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
Process hostProcess,
|
||||
@@ -1000,6 +1418,100 @@ internal sealed class LauncherFlowCoordinator
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LoadingStateMessage BuildDelayedLoadingState(
|
||||
LoadingStateMessage loadingState,
|
||||
string summaryMessage,
|
||||
string detailMessage,
|
||||
DateTimeOffset startedAtUtc)
|
||||
{
|
||||
var delayedItems = loadingState.ActiveItems
|
||||
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
delayedItems.Insert(0, new LoadingItem
|
||||
{
|
||||
Id = "launcher-soft-timeout",
|
||||
Type = LoadingItemType.System,
|
||||
Name = "Startup still in progress",
|
||||
Description = detailMessage,
|
||||
State = LoadingState.Delayed,
|
||||
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
|
||||
Message = detailMessage,
|
||||
StartTime = startedAtUtc
|
||||
});
|
||||
|
||||
return loadingState with
|
||||
{
|
||||
ActiveItems = delayedItems,
|
||||
Message = summaryMessage,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildAttemptDetails(
|
||||
StartupAttemptRecord? trackedAttempt,
|
||||
bool attachedToExistingAttempt,
|
||||
bool ipcConnected,
|
||||
bool hostProcessAlive,
|
||||
StartupStage lastStage,
|
||||
string lastStageMessage,
|
||||
string? activationFailureReason,
|
||||
bool softTimeoutShown,
|
||||
bool recoveryActivationAttempted)
|
||||
{
|
||||
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["hostProcessAlive"] = hostProcessAlive.ToString(),
|
||||
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
|
||||
["ipcConnected"] = ipcConnected.ToString(),
|
||||
["ipcStage"] = lastStage.ToString(),
|
||||
["ipcMessage"] = lastStageMessage,
|
||||
["activationFailureReason"] = activationFailureReason ?? string.Empty,
|
||||
["softTimeoutShown"] = softTimeoutShown.ToString(),
|
||||
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
|
||||
};
|
||||
|
||||
if (trackedAttempt is not null)
|
||||
{
|
||||
details["startupAttemptId"] = trackedAttempt.AttemptId;
|
||||
details["startupAttemptState"] = trackedAttempt.State.ToString();
|
||||
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
|
||||
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
|
||||
details["startupAttemptHeartbeatAtUtc"] = trackedAttempt.HeartbeatAtUtc.ToString("O");
|
||||
details["successPolicy"] = trackedAttempt.SuccessPolicy;
|
||||
details["hostPid"] = trackedAttempt.HostPid.ToString();
|
||||
details["coordinatorPid"] = trackedAttempt.CoordinatorPid.ToString();
|
||||
details["coordinatorPipeName"] = trackedAttempt.CoordinatorPipeName;
|
||||
details["reservedBeforeHostStart"] = trackedAttempt.ReservedBeforeHostStart.ToString();
|
||||
details["publicIpcConnected"] = trackedAttempt.PublicIpcConnected.ToString();
|
||||
details["shellStatus"] = trackedAttempt.ShellStatus;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId, out Process? process)
|
||||
{
|
||||
process = null;
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
process?.Dispose();
|
||||
process = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private enum HostStartMode
|
||||
{
|
||||
ShellExecute,
|
||||
@@ -1048,6 +1560,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
private bool _trayReady;
|
||||
private bool _backgroundReady;
|
||||
|
||||
public string PolicyKey => _policy.ToString();
|
||||
|
||||
public StartupSuccessTracker(CommandContext context)
|
||||
{
|
||||
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
|
||||
|
||||
515
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
515
LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs
Normal file
@@ -0,0 +1,515 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class StartupAttemptRegistry
|
||||
{
|
||||
private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _statePath;
|
||||
private readonly string _mutexName;
|
||||
private string? _ownedAttemptId;
|
||||
|
||||
public StartupAttemptRegistry()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
".launcher",
|
||||
"state",
|
||||
"startup-attempt.json"))
|
||||
{
|
||||
}
|
||||
|
||||
internal StartupAttemptRegistry(string statePath)
|
||||
{
|
||||
_statePath = statePath;
|
||||
_mutexName = $"LanMountainDesktop.Launcher.StartupAttempt.{ComputePathHash(statePath)}";
|
||||
}
|
||||
|
||||
public StartupAttemptRecord StartOwnedAttempt(
|
||||
int hostPid,
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = hostPid,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = stage,
|
||||
LastObservedMessage = message ?? string.Empty,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
HeartbeatAtUtc = DateTimeOffset.UtcNow,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
SaveUnsafe(record);
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
});
|
||||
|
||||
return Clone(record);
|
||||
}
|
||||
|
||||
public bool TryReserveCoordinator(
|
||||
string launchSource,
|
||||
string successPolicy,
|
||||
string coordinatorPipeName,
|
||||
out StartupAttemptRecord reservedAttempt,
|
||||
out StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
StartupAttemptRecord? reserved = null;
|
||||
StartupAttemptRecord? active = null;
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var existing = LoadUnsafe();
|
||||
if (existing is not null && IsCoordinatorLive(existing))
|
||||
{
|
||||
active = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing is not null && IsRecoverableCoordinatorAttempt(existing))
|
||||
{
|
||||
existing.CoordinatorPid = Environment.ProcessId;
|
||||
existing.CoordinatorPipeName = coordinatorPipeName;
|
||||
existing.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
if (existing.HostPid <= 0)
|
||||
{
|
||||
existing.ReservedBeforeHostStart = true;
|
||||
}
|
||||
|
||||
if (existing.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
existing.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
_ownedAttemptId = existing.AttemptId;
|
||||
SaveUnsafe(existing);
|
||||
reserved = Clone(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = Guid.NewGuid().ToString("N"),
|
||||
HostPid = 0,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
CoordinatorPipeName = coordinatorPipeName,
|
||||
LaunchSource = launchSource,
|
||||
SuccessPolicy = successPolicy,
|
||||
LastObservedStage = StartupStage.Initializing,
|
||||
LastObservedMessage = "Launcher coordinator reserved startup ownership.",
|
||||
StartedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
HeartbeatAtUtc = now,
|
||||
ReservedBeforeHostStart = true,
|
||||
State = StartupAttemptState.Pending
|
||||
};
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
SaveUnsafe(record);
|
||||
reserved = Clone(record);
|
||||
});
|
||||
|
||||
reservedAttempt = reserved ?? new StartupAttemptRecord();
|
||||
activeCoordinatorAttempt = active;
|
||||
return reserved is not null;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? GetOwnedAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLiveCoordinatorAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null && IsCoordinatorLive(record))
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetLatestAttempt()
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is not null)
|
||||
{
|
||||
result = Clone(record);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord AssignOwnedHostProcess(
|
||||
int hostPid,
|
||||
StartupStage stage,
|
||||
string? message)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.HostPid = hostPid;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
record.ReservedBeforeHostStart = false;
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result ?? StartOwnedAttempt(
|
||||
hostPid,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
stage,
|
||||
message);
|
||||
}
|
||||
|
||||
public bool AdoptAttempt(string attemptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var adopted = false;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, attemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsAttachable(record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ownedAttemptId = record.AttemptId;
|
||||
if (record.State == StartupAttemptState.DetachedWaiting)
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
}
|
||||
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
adopted = true;
|
||||
});
|
||||
|
||||
return adopted;
|
||||
}
|
||||
|
||||
public StartupAttemptRecord? TryGetAttachableAttempt(string launchSource, string successPolicy)
|
||||
{
|
||||
StartupAttemptRecord? result = null;
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null ||
|
||||
!IsAttachable(record) ||
|
||||
!string.Equals(record.LaunchSource, launchSource, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(record.SuccessPolicy, successPolicy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result = Clone(record);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void MarkOwnedIpcConnected()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? string.Empty;
|
||||
if (ipcConnected)
|
||||
{
|
||||
record.IpcConnected = true;
|
||||
record.PublicIpcConnected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateOwnedCoordinatorHeartbeat(LauncherCoordinatorStatus status)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.CoordinatorPid = Environment.ProcessId;
|
||||
record.HeartbeatAtUtc = DateTimeOffset.UtcNow;
|
||||
record.LastObservedStage = status.LastObservedStage;
|
||||
record.LastObservedMessage = status.LastObservedMessage;
|
||||
record.IpcConnected = status.PublicIpcConnected;
|
||||
record.PublicIpcConnected = status.PublicIpcConnected;
|
||||
record.ShellStatus = status.ShellStatus?.ShellState ?? status.State;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSoftTimeout(string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.SoftTimeout;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedDetachedWaiting()
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
|
||||
{
|
||||
record.State = StartupAttemptState.DetachedWaiting;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedSucceeded(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Succeeded;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkOwnedFailed(StartupStage stage, string? message)
|
||||
{
|
||||
UpdateOwned(record =>
|
||||
{
|
||||
record.State = StartupAttemptState.Failed;
|
||||
record.LastObservedStage = stage;
|
||||
record.LastObservedMessage = message ?? record.LastObservedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateOwned(Action<StartupAttemptRecord> update)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteWithLock(() =>
|
||||
{
|
||||
var record = LoadUnsafe();
|
||||
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
update(record);
|
||||
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
SaveUnsafe(record);
|
||||
});
|
||||
}
|
||||
|
||||
private void ExecuteWithLock(Action action)
|
||||
{
|
||||
using var mutex = new Mutex(false, _mutexName);
|
||||
var hasHandle = false;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
hasHandle = true;
|
||||
}
|
||||
|
||||
if (!hasHandle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (hasHandle)
|
||||
{
|
||||
mutex.ReleaseMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StartupAttemptRecord? LoadUnsafe()
|
||||
{
|
||||
if (!File.Exists(_statePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_statePath);
|
||||
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveUnsafe(StartupAttemptRecord record)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_statePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
|
||||
}
|
||||
|
||||
private static bool IsAttachable(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsRecoverableCoordinatorAttempt(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.HostPid <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.HostPid, out _);
|
||||
}
|
||||
|
||||
private static bool IsCoordinatorLive(StartupAttemptRecord record)
|
||||
{
|
||||
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.CoordinatorPid <= 0 ||
|
||||
string.IsNullOrWhiteSpace(record.CoordinatorPipeName) ||
|
||||
DateTimeOffset.UtcNow - record.HeartbeatAtUtc > CoordinatorHeartbeatTimeout)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetLiveProcess(record.CoordinatorPid, out _);
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId, out Process? process)
|
||||
{
|
||||
process = null;
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
process?.Dispose();
|
||||
process = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputePathHash(string statePath)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(statePath.ToLowerInvariant()));
|
||||
return Convert.ToHexString(bytes[..8]);
|
||||
}
|
||||
|
||||
private static StartupAttemptRecord Clone(StartupAttemptRecord record)
|
||||
{
|
||||
return new StartupAttemptRecord
|
||||
{
|
||||
AttemptId = record.AttemptId,
|
||||
HostPid = record.HostPid,
|
||||
CoordinatorPid = record.CoordinatorPid,
|
||||
CoordinatorPipeName = record.CoordinatorPipeName,
|
||||
StartedAtUtc = record.StartedAtUtc,
|
||||
UpdatedAtUtc = record.UpdatedAtUtc,
|
||||
HeartbeatAtUtc = record.HeartbeatAtUtc,
|
||||
LaunchSource = record.LaunchSource,
|
||||
SuccessPolicy = record.SuccessPolicy,
|
||||
LastObservedStage = record.LastObservedStage,
|
||||
LastObservedMessage = record.LastObservedMessage,
|
||||
IpcConnected = record.IpcConnected,
|
||||
PublicIpcConnected = record.PublicIpcConnected,
|
||||
ShellStatus = record.ShellStatus,
|
||||
ReservedBeforeHostStart = record.ReservedBeforeHostStart,
|
||||
State = record.State
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,102 +3,96 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="520"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||
x:DataType="views:ErrorWindow"
|
||||
Title="阑山桌面"
|
||||
Width="520"
|
||||
Height="280"
|
||||
Title="LanMountain Desktop"
|
||||
Width="560"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Background="#111318"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:ErrorWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<!-- Fluent Design 风格对话框布局 -->
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 主内容区域:左侧图标 + 右侧文字 -->
|
||||
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- 左侧:错误图标(可点击进入调试模式) -->
|
||||
<Grid Grid.Row="0"
|
||||
Margin="24"
|
||||
ColumnDefinitions="Auto,*">
|
||||
<Border x:Name="ErrorIconBorder"
|
||||
Grid.Column="0"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0,4,16,0"
|
||||
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||
CornerRadius="24"
|
||||
Width="52"
|
||||
Height="52"
|
||||
Margin="0,4,18,0"
|
||||
Background="#2B161A"
|
||||
CornerRadius="26"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock Text=""
|
||||
<TextBlock Text="!"
|
||||
FontSize="24"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
FontWeight="Bold"
|
||||
Foreground="#FFB4AB"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<!-- 右侧:标题 + 内容 -->
|
||||
<StackPanel Grid.Column="1" Spacing="8">
|
||||
<!-- 标题 -->
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="10">
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="启动失败"
|
||||
FontSize="18"
|
||||
Text="Launcher could not confirm startup"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
Foreground="#F6F7FB"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock x:Name="ErrorMessageText"
|
||||
Text="找不到阑山桌面应用程序。"
|
||||
Text="LanMountain Desktop did not reach the expected startup state."
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Foreground="#D2D7E1"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="20"/>
|
||||
|
||||
<!-- 建议信息 -->
|
||||
LineHeight="22" />
|
||||
|
||||
<TextBlock x:Name="SuggestionText"
|
||||
Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||
Foreground="#9BA5B7"
|
||||
TextWrapping="Wrap"
|
||||
LineHeight="18"
|
||||
Margin="0,4,0,0"/>
|
||||
LineHeight="20" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:按钮区域 -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Padding="24,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
Padding="24,16"
|
||||
Background="#171A21">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="OpenLogButton"
|
||||
Grid.Column="0"
|
||||
Content="打开日志"
|
||||
Width="100"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
HorizontalAlignment="Left"/>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button x:Name="ExitButton"
|
||||
Content="退出"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"/>
|
||||
<Button x:Name="RetryButton"
|
||||
Content="重试"
|
||||
Width="80"
|
||||
Height="32"
|
||||
FontSize="13"
|
||||
Theme="{DynamicResource AccentButtonTheme}"/>
|
||||
</StackPanel>
|
||||
Content="Open Logs"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
HorizontalAlignment="Left" />
|
||||
|
||||
<Button x:Name="SecondaryActionButton"
|
||||
Grid.Column="1"
|
||||
Content="Wait"
|
||||
MinWidth="108"
|
||||
Height="34"
|
||||
IsVisible="False" />
|
||||
|
||||
<Button x:Name="ExitButton"
|
||||
Grid.Column="2"
|
||||
Content="Exit"
|
||||
MinWidth="90"
|
||||
Height="34" />
|
||||
|
||||
<Button x:Name="PrimaryActionButton"
|
||||
Grid.Column="3"
|
||||
Content="Retry"
|
||||
MinWidth="108"
|
||||
Height="34" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -1,542 +1,365 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||
/// </summary>
|
||||
public partial class ErrorWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||
private int _iconClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugMode = false;
|
||||
private string? _customHostPath;
|
||||
|
||||
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _iconClickCount;
|
||||
private bool _isDebugMode;
|
||||
private bool _devModeEnabled;
|
||||
private string? _customHostPath;
|
||||
private ErrorWindowResult _primaryAction = ErrorWindowResult.Retry;
|
||||
private ErrorWindowResult? _secondaryAction;
|
||||
|
||||
public ErrorWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 先加载保存的状态
|
||||
_devModeEnabled = LoadDevModeStateInternal();
|
||||
_customHostPath = LoadCustomHostPathInternal();
|
||||
|
||||
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好
|
||||
this.Loaded += OnWindowLoaded;
|
||||
this.Opened += OnWindowOpened;
|
||||
Loaded += OnWindowLoaded;
|
||||
Closed += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件 - 视觉树已准备好
|
||||
/// </summary>
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window loaded, initializing components...");
|
||||
InitializeComponents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口打开事件
|
||||
/// </summary>
|
||||
private void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Window opened and visible");
|
||||
}
|
||||
|
||||
private void InitializeComponents()
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] Initializing components...");
|
||||
|
||||
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||
if (errorIconBorder is not null)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
Console.WriteLine("[ErrorWindow] ErrorIconBorder event bound successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ErrorIconBorder!");
|
||||
}
|
||||
|
||||
// 按钮事件
|
||||
var retryButton = this.FindControl<Button>("RetryButton");
|
||||
var exitButton = this.FindControl<Button>("ExitButton");
|
||||
var openLogButton = this.FindControl<Button>("OpenLogButton");
|
||||
|
||||
if (retryButton is not null)
|
||||
{
|
||||
retryButton.Click += OnRetryClick;
|
||||
Console.WriteLine("[ErrorWindow] RetryButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find RetryButton!");
|
||||
}
|
||||
|
||||
if (exitButton is not null)
|
||||
{
|
||||
exitButton.Click += OnExitClick;
|
||||
Console.WriteLine("[ErrorWindow] ExitButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find ExitButton!");
|
||||
}
|
||||
|
||||
if (openLogButton is not null)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
Console.WriteLine("[ErrorWindow] OpenLogButton event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Failed to find OpenLogButton!");
|
||||
}
|
||||
|
||||
Console.WriteLine("[ErrorWindow] Components initialization completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置错误消息
|
||||
/// </summary>
|
||||
public void SetErrorMessage(string message)
|
||||
{
|
||||
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||
if (errorText is not null)
|
||||
if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
|
||||
{
|
||||
errorText.Text = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
_isDebugMode = isDebugMode;
|
||||
var titleText = this.FindControl<TextBlock>("TitleText");
|
||||
if (titleText is not null && isDebugMode)
|
||||
if (isDebugMode && this.FindControl<TextBlock>("TitleText") is { } titleText)
|
||||
{
|
||||
titleText.Text = "[调试模式] 错误页面";
|
||||
titleText.Text = "[Debug] Launcher error";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户选择的主程序路径
|
||||
/// </summary>
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用了开发模式
|
||||
/// </summary>
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 等待用户选择
|
||||
/// </summary>
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||
public void ConfigureForHostNotFound()
|
||||
{
|
||||
return _completionSource.Task;
|
||||
ApplyActionLayout(
|
||||
title: "Launcher could not find the desktop executable",
|
||||
suggestion: "Pick another executable in debug mode, inspect logs, or retry after fixing the deployment path.",
|
||||
primaryLabel: "Retry",
|
||||
primaryAction: ErrorWindowResult.Retry,
|
||||
secondaryLabel: null,
|
||||
secondaryAction: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||
/// </summary>
|
||||
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||
public void ConfigureForGenericFailure(bool allowRetry)
|
||||
{
|
||||
ApplyActionLayout(
|
||||
title: "Launcher could not confirm startup",
|
||||
suggestion: allowRetry
|
||||
? "Inspect logs, then retry once the previous startup attempt has fully finished."
|
||||
: "Inspect logs or exit. Launcher will avoid creating another desktop process while the old one is still running.",
|
||||
primaryLabel: allowRetry ? "Retry" : "Activate",
|
||||
primaryAction: allowRetry ? ErrorWindowResult.Retry : ErrorWindowResult.ActivateExisting,
|
||||
secondaryLabel: allowRetry ? null : "Wait",
|
||||
secondaryAction: allowRetry ? null : ErrorWindowResult.ContinueWaiting);
|
||||
}
|
||||
|
||||
public void ConfigureForRunningHostFailure(int? hostPid)
|
||||
{
|
||||
var pidHint = hostPid is > 0 ? $" Current host PID: {hostPid}." : string.Empty;
|
||||
ApplyActionLayout(
|
||||
title: "Startup is still pending",
|
||||
suggestion: $"The desktop process is still running, so Launcher will not start a second instance.{pidHint}",
|
||||
primaryLabel: "Activate",
|
||||
primaryAction: ErrorWindowResult.ActivateExisting,
|
||||
secondaryLabel: "Wait",
|
||||
secondaryAction: ErrorWindowResult.ContinueWaiting);
|
||||
}
|
||||
|
||||
public string? GetCustomHostPath() => _customHostPath;
|
||||
|
||||
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||
|
||||
public Task<ErrorWindowResult> WaitForChoiceAsync() => _completionSource.Task;
|
||||
|
||||
public static bool CheckDevModeEnabled() => LoadDevModeStateInternal();
|
||||
|
||||
public static string? GetSavedCustomHostPath() => LoadCustomHostPathInternal();
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (this.FindControl<Border>("ErrorIconBorder") is { } errorIconBorder)
|
||||
{
|
||||
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryActionButton)
|
||||
{
|
||||
primaryActionButton.Click += OnPrimaryActionClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryActionButton)
|
||||
{
|
||||
secondaryActionButton.Click += OnSecondaryActionClick;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("ExitButton") is { } exitButton)
|
||||
{
|
||||
exitButton.Click += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("OpenLogButton") is { } openLogButton)
|
||||
{
|
||||
openLogButton.Click += OnOpenLogClick;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyActionLayout(
|
||||
string title,
|
||||
string suggestion,
|
||||
string primaryLabel,
|
||||
ErrorWindowResult primaryAction,
|
||||
string? secondaryLabel,
|
||||
ErrorWindowResult? secondaryAction)
|
||||
{
|
||||
_primaryAction = primaryAction;
|
||||
_secondaryAction = secondaryAction;
|
||||
|
||||
if (this.FindControl<TextBlock>("TitleText") is { } titleText && !_isDebugMode)
|
||||
{
|
||||
titleText.Text = title;
|
||||
}
|
||||
|
||||
if (this.FindControl<TextBlock>("SuggestionText") is { } suggestionText)
|
||||
{
|
||||
suggestionText.Text = suggestion;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("PrimaryActionButton") is { } primaryButton)
|
||||
{
|
||||
primaryButton.Content = primaryLabel;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("SecondaryActionButton") is { } secondaryButton)
|
||||
{
|
||||
secondaryButton.IsVisible = !string.IsNullOrWhiteSpace(secondaryLabel);
|
||||
secondaryButton.Content = secondaryLabel ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPrimaryActionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(_primaryAction);
|
||||
}
|
||||
|
||||
private void OnSecondaryActionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(_secondaryAction ?? ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
private void OnErrorIconClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_iconClickCount++;
|
||||
|
||||
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||
{
|
||||
EnterDebugMode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入调试模式 - 显示调试窗口
|
||||
/// </summary>
|
||||
private async void EnterDebugMode()
|
||||
{
|
||||
_isDebugMode = true;
|
||||
|
||||
// 创建并显示调试窗口
|
||||
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅调试窗口关闭事件
|
||||
debugWindow.Closed += (s, e) =>
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
// 更新状态
|
||||
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||
_customHostPath = debugWindow.SelectedHostPath;
|
||||
|
||||
// 保存开发模式状态和自定义路径
|
||||
SaveDevModeStateInternal(_devModeEnabled);
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
|
||||
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||
if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
|
||||
{
|
||||
ScanDevPaths();
|
||||
// 扫描到路径后也保存
|
||||
if (!string.IsNullOrEmpty(_customHostPath))
|
||||
{
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
SaveCustomHostPathInternal(_customHostPath);
|
||||
}
|
||||
|
||||
_isDebugMode = false;
|
||||
_iconClickCount = 0;
|
||||
};
|
||||
|
||||
await debugWindow.ShowDialog(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描开发路径
|
||||
/// </summary>
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_customHostPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置存储的基础目录
|
||||
/// </summary>
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用 LocalApplicationData(用户状态)
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
{
|
||||
var configDir = Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// LocalApplicationData 不可用,回退到 Launcher 所在目录
|
||||
}
|
||||
|
||||
// 回退方案:使用 Launcher 所在目录
|
||||
try
|
||||
{
|
||||
var launcherDir = AppContext.BaseDirectory;
|
||||
var configDir = Path.Combine(launcherDir, ".launcher");
|
||||
return configDir;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 最后的兜底:使用当前目录
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保配置目录存在
|
||||
/// </summary>
|
||||
private static bool EnsureConfigDirectory(string dirPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dirPath))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath);
|
||||
Console.WriteLine($"[ErrorWindow] Created config directory: {dirPath}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to create config directory: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save dev mode: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state saved: {enabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save dev mode state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载开发模式状态(内部方法)
|
||||
/// </summary>
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var devModeFile = Path.Combine(configDir, "devmode.config");
|
||||
|
||||
if (File.Exists(devModeFile))
|
||||
{
|
||||
var content = File.ReadAllText(devModeFile).Trim();
|
||||
var enabled = content == "1";
|
||||
Console.WriteLine($"[ErrorWindow] Dev mode state loaded: {enabled}");
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load dev mode state: {ex.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static void SaveCustomHostPathInternal(string? path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (!EnsureConfigDirectory(configDir))
|
||||
{
|
||||
Console.Error.WriteLine("[ErrorWindow] Cannot save custom path: config directory unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
File.WriteAllText(hostPathFile, path ?? string.Empty);
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path saved: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to save custom host path: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载自定义主程序路径(内部方法)
|
||||
/// </summary>
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
var hostPathFile = Path.Combine(configDir, "custom-host-path.config");
|
||||
|
||||
if (File.Exists(hostPathFile))
|
||||
{
|
||||
var content = File.ReadAllText(hostPathFile).Trim();
|
||||
// 验证路径是否仍然有效
|
||||
if (!string.IsNullOrEmpty(content) && File.Exists(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path loaded: {content}");
|
||||
return content;
|
||||
}
|
||||
|
||||
// 路径已失效,清理配置文件
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
Console.WriteLine($"[ErrorWindow] Custom host path is no longer valid: {content}");
|
||||
try
|
||||
{
|
||||
File.Delete(hostPathFile);
|
||||
Console.WriteLine("[ErrorWindow] Cleared invalid custom host path");
|
||||
}
|
||||
catch (Exception clearEx)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to clear invalid host path: {clearEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to load custom host path: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static bool CheckDevModeEnabled()
|
||||
{
|
||||
return LoadDevModeStateInternal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取保存的自定义主程序路径(静态方法,启动时调用)
|
||||
/// </summary>
|
||||
public static string? GetSavedCustomHostPath()
|
||||
{
|
||||
return LoadCustomHostPathInternal();
|
||||
}
|
||||
|
||||
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||
}
|
||||
|
||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开日志文件
|
||||
/// </summary>
|
||||
private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = Logger.GetLogFilePath();
|
||||
|
||||
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
|
||||
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
|
||||
{
|
||||
// 如果没有日志文件,打开日志目录
|
||||
var logDir = Path.GetDirectoryName(logFilePath);
|
||||
if (!string.IsNullOrEmpty(logDir) && Directory.Exists(logDir))
|
||||
{
|
||||
OpenFolder(logDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试打开配置目录
|
||||
var configDir = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDir))
|
||||
{
|
||||
OpenFolder(configDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[ErrorWindow] No log file or directory available");
|
||||
}
|
||||
}
|
||||
OpenPath(logFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}");
|
||||
OpenFile(logFilePath);
|
||||
var logDirectory = !string.IsNullOrWhiteSpace(logFilePath)
|
||||
? Path.GetDirectoryName(logFilePath)
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(logDirectory) && Directory.Exists(logDirectory))
|
||||
{
|
||||
OpenPath(logDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
var configDirectory = GetConfigBaseDirectory();
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
OpenPath(configDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open log: {ex.Message}");
|
||||
Debug.WriteLine($"[ErrorWindow] Failed to open log path: {ex}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ScanDevPaths()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var candidatePaths = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable)
|
||||
};
|
||||
|
||||
foreach (var candidate in candidatePaths.Select(Path.GetFullPath).Distinct())
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
_customHostPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件
|
||||
/// </summary>
|
||||
private static void OpenFile(string filePath)
|
||||
private static void OpenPath(string path)
|
||||
{
|
||||
try
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{filePath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
|
||||
Process.Start("open", path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件夹
|
||||
/// </summary>
|
||||
private static void OpenFolder(string folderPath)
|
||||
private static string GetConfigBaseDirectory()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, ".launcher");
|
||||
}
|
||||
|
||||
private static string GetDevModePath() => Path.Combine(GetConfigBaseDirectory(), "dev-mode.flag");
|
||||
|
||||
private static string GetCustomHostPathFile() => Path.Combine(GetConfigBaseDirectory(), "custom-host-path.txt");
|
||||
|
||||
private static bool LoadDevModeStateInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{folderPath}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Process.Start("open", folderPath);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Process.Start("xdg-open", folderPath);
|
||||
}
|
||||
return File.Exists(GetDevModePath()) &&
|
||||
bool.TryParse(File.ReadAllText(GetDevModePath()).Trim(), out var enabled) &&
|
||||
enabled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveDevModeStateInternal(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetConfigBaseDirectory());
|
||||
File.WriteAllText(GetDevModePath(), enabled.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string? LoadCustomHostPathInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pathFile = GetCustomHostPathFile();
|
||||
if (!File.Exists(pathFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var savedPath = File.ReadAllText(pathFile).Trim();
|
||||
return string.IsNullOrWhiteSpace(savedPath) ? null : savedPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveCustomHostPathInternal(string? customHostPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetConfigBaseDirectory());
|
||||
File.WriteAllText(GetCustomHostPathFile(), customHostPath ?? string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 错误窗口用户选择结果
|
||||
/// </summary>
|
||||
public enum ErrorWindowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重试
|
||||
/// </summary>
|
||||
Retry,
|
||||
|
||||
/// <summary>
|
||||
/// 退出
|
||||
/// </summary>
|
||||
Exit
|
||||
Exit,
|
||||
ActivateExisting,
|
||||
ContinueWaiting
|
||||
}
|
||||
|
||||
@@ -3,85 +3,92 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
x:DataType="views:SplashWindow"
|
||||
Title="LanMountain Desktop"
|
||||
Width="480"
|
||||
Height="320"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
Background="#0B0B0B"
|
||||
TransparencyLevelHint="None"
|
||||
Icon="/Assets/logo.ico">
|
||||
<Design.DataContext>
|
||||
<views:SplashWindow />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid>
|
||||
<!-- 左上角:应用名称 -->
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="24,24,0,0"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
|
||||
<!-- 底部区域:进度条和状态 -->
|
||||
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 第一行:左下角版本信息,右下角阶段文字 -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) -->
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Opacity="0.8"
|
||||
Text="1.0.0 (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<!-- 右下角:阶段文字 -->
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Opacity="0.8"
|
||||
Text="Initializing..." />
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
Background="#0B0B0B">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid x:Name="CompactHero"
|
||||
Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="LanMountain Desktop"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="#F6F7FB" />
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="FullscreenHero"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="24">
|
||||
<Border Width="240"
|
||||
Height="240"
|
||||
Background="Transparent">
|
||||
<Image Source="/Assets/logo_nightly.png"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="LanMountain Desktop"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#F6F7FB" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 底部:进度条 -->
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Padding="24,18,24,24"
|
||||
Background="Transparent">
|
||||
<Grid RowDefinitions="Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Border x:Name="VersionTextBorder"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock x:Name="VersionText"
|
||||
FontSize="11"
|
||||
Foreground="#B9C0CC"
|
||||
Text="0.0.0-dev (Administrate)" />
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusText"
|
||||
Grid.Column="1"
|
||||
FontSize="11"
|
||||
Foreground="#B9C0CC"
|
||||
HorizontalAlignment="Right"
|
||||
Text="Initializing..." />
|
||||
</Grid>
|
||||
|
||||
<ProgressBar x:Name="ProgressIndicator"
|
||||
Grid.Row="1"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="0"
|
||||
Height="4"
|
||||
IsIndeterminate="False"
|
||||
Foreground="#F6F7FB"
|
||||
Background="#2C313D" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,88 +1,266 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 启动画面窗口 - 简洁设计
|
||||
/// </summary>
|
||||
public partial class SplashWindow : Window, ISplashStageReporter
|
||||
{
|
||||
private int _versionTextClickCount = 0;
|
||||
private const int DebugModeClickThreshold = 5;
|
||||
private bool _isDebugModeOpened = false;
|
||||
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
|
||||
|
||||
private readonly StartupVisualMode _mode;
|
||||
private int _versionTextClickCount;
|
||||
private bool _isDebugModeOpened;
|
||||
private bool _isOpened;
|
||||
private bool _layoutConfigured;
|
||||
private bool _dismissed;
|
||||
private PixelPoint _targetPosition;
|
||||
private PixelPoint _slideHiddenPosition;
|
||||
|
||||
public SplashWindow()
|
||||
: this(StartupVisualMode.Fade)
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
// 延迟到窗口加载完成后再绑定事件
|
||||
this.Loaded += OnWindowLoaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口加载完成事件
|
||||
/// </summary>
|
||||
public SplashWindow(StartupVisualMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Window loaded, binding events...");
|
||||
|
||||
// 绑定版本文本点击事件(隐藏功能:点击5次打开开发者界面)
|
||||
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
|
||||
if (versionTextBorder is not null)
|
||||
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
|
||||
{
|
||||
versionTextBorder.PointerPressed += OnVersionTextClick;
|
||||
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
|
||||
versionBorder.PointerPressed += OnVersionTextClick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 版本文本点击事件 - 连续点击5次打开开发者界面(隐藏功能)
|
||||
/// </summary>
|
||||
private async void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isOpened = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
Opacity = 0d;
|
||||
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Opacity = 1d;
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DismissAsync()
|
||||
{
|
||||
if (_dismissed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dismissed = true;
|
||||
ConfigureForVisualMode();
|
||||
|
||||
if (_mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
var from = Position;
|
||||
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
|
||||
}
|
||||
else if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (IsVisible)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = stage;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message) &&
|
||||
this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (this.FindControl<TextBlock>("VersionText") is { } versionText)
|
||||
{
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
if (!isDebugMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatus("[Debug Mode] Splash Preview");
|
||||
}
|
||||
|
||||
private void ConfigureForVisualMode()
|
||||
{
|
||||
if (_layoutConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutConfigured = true;
|
||||
var compactHero = this.FindControl<Grid>("CompactHero");
|
||||
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
|
||||
|
||||
if (_mode == StartupVisualMode.Fade)
|
||||
{
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
|
||||
Width = 480;
|
||||
Height = 320;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
compactHero?.SetCurrentValue(IsVisibleProperty, false);
|
||||
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
|
||||
Background = Brushes.Black;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
Width = workingArea.Width / scale;
|
||||
Height = workingArea.Height / scale;
|
||||
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
|
||||
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
|
||||
Position = _mode == StartupVisualMode.SlideSplash
|
||||
? _slideHiddenPosition
|
||||
: _targetPosition;
|
||||
}
|
||||
|
||||
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isDebugModeOpened) return;
|
||||
|
||||
_versionTextClickCount++;
|
||||
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
|
||||
if (_isDebugModeOpened)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_versionTextClickCount++;
|
||||
if (_versionTextClickCount >= DebugModeClickThreshold)
|
||||
{
|
||||
OpenDebugWindow();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开开发者调试窗口
|
||||
/// </summary>
|
||||
private async void OpenDebugWindow()
|
||||
{
|
||||
_isDebugModeOpened = true;
|
||||
Console.WriteLine("[SplashWindow] Opening debug window...");
|
||||
|
||||
try
|
||||
{
|
||||
// 加载保存的状态
|
||||
var devModeEnabled = ErrorWindow.CheckDevModeEnabled();
|
||||
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
|
||||
|
||||
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath)
|
||||
var debugWindow = new ErrorDebugWindow(
|
||||
ErrorWindow.CheckDevModeEnabled(),
|
||||
ErrorWindow.GetSavedCustomHostPath())
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
// 订阅窗口关闭事件以保存状态
|
||||
debugWindow.Closed += (s, e) =>
|
||||
debugWindow.Closed += (_, _) =>
|
||||
{
|
||||
Console.WriteLine("[SplashWindow] Debug window closed");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
};
|
||||
@@ -91,160 +269,75 @@ public partial class SplashWindow : Window, ISplashStageReporter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}");
|
||||
Debug.WriteLine($"[SplashWindow] Failed to open debug window: {ex}");
|
||||
_isDebugModeOpened = false;
|
||||
_versionTextClickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度和状态
|
||||
/// </summary>
|
||||
public void Report(string stage, string message)
|
||||
private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
await AnimateAsync(progress =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found: StatusText={statusText != null}, ProgressIndicator={progressIndicator != null}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态文本
|
||||
statusText.Text = message;
|
||||
|
||||
// 根据阶段更新进度
|
||||
var progress = ResolveProgress(stage);
|
||||
if (progress > 0)
|
||||
{
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = progress;
|
||||
}
|
||||
else
|
||||
{
|
||||
progressIndicator.IsIndeterminate = true;
|
||||
}
|
||||
});
|
||||
Opacity = from + ((to - from) * progress);
|
||||
}, duration, EaseOutCubic).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度(0-100)
|
||||
/// </summary>
|
||||
public void UpdateProgress(int percent, string? message = null)
|
||||
private async Task AnimateWindowPositionAsync(
|
||||
PixelPoint from,
|
||||
PixelPoint to,
|
||||
TimeSpan duration,
|
||||
Func<double, double> easing)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
await AnimateAsync(progress =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in UpdateProgress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
statusText.Text = message;
|
||||
}
|
||||
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||
});
|
||||
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
|
||||
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
|
||||
Position = new PixelPoint(currentX, currentY);
|
||||
}, duration, easing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新状态文本
|
||||
/// </summary>
|
||||
public void UpdateStatus(string message)
|
||||
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
|
||||
return;
|
||||
}
|
||||
statusText.Text = message;
|
||||
});
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
while (stopwatch.Elapsed < duration)
|
||||
{
|
||||
var raw = stopwatch.Elapsed.TotalMilliseconds / duration.TotalMilliseconds;
|
||||
var progress = easing(Math.Clamp(raw, 0d, 1d));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(progress));
|
||||
await Task.Delay(16).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => update(1d));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告阶段和进度(0-100)
|
||||
/// </summary>
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
|
||||
|
||||
if (statusText is null || progressIndicator is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage");
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.Text = stage;
|
||||
progressIndicator.IsIndeterminate = false;
|
||||
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置版本和开发代号
|
||||
/// </summary>
|
||||
public void SetVersionInfo(string version, string codename)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var versionText = this.FindControl<TextBlock>("VersionText");
|
||||
if (versionText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
|
||||
return;
|
||||
}
|
||||
versionText.Text = $"{version} ({codename})";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置调试模式
|
||||
/// </summary>
|
||||
public void SetDebugMode(bool isDebugMode)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var statusText = this.FindControl<TextBlock>("StatusText");
|
||||
if (statusText is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
|
||||
return;
|
||||
}
|
||||
if (isDebugMode)
|
||||
{
|
||||
statusText.Text = "[Debug Mode] Splash Preview";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据阶段名称解析进度值
|
||||
/// </summary>
|
||||
private static int ResolveProgress(string stage)
|
||||
{
|
||||
return stage.ToLowerInvariant() switch
|
||||
{
|
||||
"initializing" => 10,
|
||||
"settings" => 25,
|
||||
"update" => 30,
|
||||
"plugins" => 50,
|
||||
"launch" => 70,
|
||||
"ui" => 65,
|
||||
"shell" => 80,
|
||||
"activation" => 90,
|
||||
"ready" => 100,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static double EaseOutCubic(double value)
|
||||
{
|
||||
var inverse = 1d - value;
|
||||
return 1d - (inverse * inverse * inverse);
|
||||
}
|
||||
|
||||
private static double EaseInCubic(double value) => value * value * value;
|
||||
}
|
||||
|
||||
@@ -1,231 +1,85 @@
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
public enum LoadingItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统初始化
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// 设置加载
|
||||
/// </summary>
|
||||
Settings,
|
||||
|
||||
/// <summary>
|
||||
/// 插件
|
||||
/// </summary>
|
||||
Plugin,
|
||||
|
||||
/// <summary>
|
||||
/// 组件
|
||||
/// </summary>
|
||||
Component,
|
||||
|
||||
/// <summary>
|
||||
/// 资源
|
||||
/// </summary>
|
||||
Resource,
|
||||
|
||||
/// <summary>
|
||||
/// 数据
|
||||
/// </summary>
|
||||
Data,
|
||||
|
||||
/// <summary>
|
||||
/// 网络请求
|
||||
/// </summary>
|
||||
Network,
|
||||
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态
|
||||
/// </summary>
|
||||
public enum LoadingState
|
||||
{
|
||||
/// <summary>
|
||||
/// 等待中
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// 进行中
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// 已完成
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// 失败
|
||||
/// </summary>
|
||||
Delayed,
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// 已取消
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// 超时
|
||||
/// </summary>
|
||||
Timeout
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载项信息
|
||||
/// </summary>
|
||||
public record LoadingItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载项唯一标识
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项类型
|
||||
/// </summary>
|
||||
|
||||
public LoadingItemType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项名称
|
||||
/// </summary>
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 加载项描述
|
||||
/// </summary>
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前状态
|
||||
/// </summary>
|
||||
|
||||
public LoadingState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度百分比 (0-100)
|
||||
/// </summary>
|
||||
|
||||
public int ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(当 State 为 Failed 时)
|
||||
/// </summary>
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset? StartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset? EndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预计剩余时间(秒)
|
||||
/// </summary>
|
||||
|
||||
public int? EstimatedRemainingSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem>? Children { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 额外数据
|
||||
/// </summary>
|
||||
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载状态更新消息
|
||||
/// </summary>
|
||||
public record LoadingStateMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前启动阶段
|
||||
/// </summary>
|
||||
public StartupStage Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 整体进度百分比 (0-100)
|
||||
/// </summary>
|
||||
|
||||
public int OverallProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前活动的加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem> ActiveItems { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 已完成的加载项数量
|
||||
/// </summary>
|
||||
|
||||
public int CompletedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总加载项数量
|
||||
/// </summary>
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态消息
|
||||
/// </summary>
|
||||
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否有错误
|
||||
/// </summary>
|
||||
|
||||
public bool HasErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误消息列表
|
||||
/// </summary>
|
||||
|
||||
public List<string>? ErrorMessages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 时间戳
|
||||
/// </summary>
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 详细的加载进度消息(用于实时更新)
|
||||
/// </summary>
|
||||
public record DetailedProgressMessage : StartupProgressMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前加载项
|
||||
/// </summary>
|
||||
public LoadingItem? CurrentItem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有加载项
|
||||
/// </summary>
|
||||
|
||||
public List<LoadingItem>? AllItems { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为主要更新
|
||||
/// </summary>
|
||||
|
||||
public bool IsMajorUpdate { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
public enum StartupVisualMode
|
||||
{
|
||||
Fade,
|
||||
StaticSplash,
|
||||
SlideSplash
|
||||
}
|
||||
|
||||
public readonly record struct StartupVisualPreferences(
|
||||
bool EnableFadeTransition,
|
||||
bool EnableSlideTransition)
|
||||
{
|
||||
public static StartupVisualPreferences Default => new(true, false);
|
||||
|
||||
public StartupVisualPreferences Normalize()
|
||||
{
|
||||
if (EnableSlideTransition)
|
||||
{
|
||||
return new StartupVisualPreferences(false, true);
|
||||
}
|
||||
|
||||
return new StartupVisualPreferences(EnableFadeTransition, false);
|
||||
}
|
||||
|
||||
public StartupVisualMode Mode => Normalize() switch
|
||||
{
|
||||
{ EnableSlideTransition: true } => StartupVisualMode.SlideSplash,
|
||||
{ EnableFadeTransition: false } => StartupVisualMode.StaticSplash,
|
||||
_ => StartupVisualMode.Fade
|
||||
};
|
||||
}
|
||||
|
||||
public static class StartupVisualPreferencesResolver
|
||||
{
|
||||
public static StartupVisualPreferences Resolve(string? settingsPath = null)
|
||||
{
|
||||
var resolvedPath = string.IsNullOrWhiteSpace(settingsPath)
|
||||
? GetDefaultSettingsPath()
|
||||
: settingsPath!;
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
return StartupVisualPreferences.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(resolvedPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
|
||||
var enableFade = TryGetBoolean(root, "enableFadeTransition") ?? true;
|
||||
var enableSlide = TryGetBoolean(root, "enableSlideTransition") ?? false;
|
||||
return FromFlags(enableFade, enableSlide);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StartupVisualPreferences.Default;
|
||||
}
|
||||
}
|
||||
|
||||
public static StartupVisualPreferences FromFlags(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
return new StartupVisualPreferences(enableFadeTransition, enableSlideTransition).Normalize();
|
||||
}
|
||||
|
||||
public static string GetDefaultSettingsPath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(appData, "LanMountainDesktop", "settings.json");
|
||||
}
|
||||
|
||||
private static bool? TryGetBoolean(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String when bool.TryParse(property.GetString(), out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,16 @@ namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicShellControlService
|
||||
{
|
||||
Task<PublicShellStatus> GetShellStatusAsync();
|
||||
|
||||
Task<bool> ActivateMainWindowAsync();
|
||||
|
||||
Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync();
|
||||
|
||||
Task<PublicTrayStatus> EnsureTrayReadyAsync();
|
||||
|
||||
Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync();
|
||||
|
||||
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||
|
||||
Task<bool> RestartAsync();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
LanMountainDesktop.Tests/StartupVisualPreferencesTests.cs
Normal file
57
LanMountainDesktop.Tests/StartupVisualPreferencesTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StartupVisualPreferencesTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromFlags_WhenSlideEnabled_DisablesFadeAndUsesSlideMode()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.FromFlags(
|
||||
enableFadeTransition: true,
|
||||
enableSlideTransition: true);
|
||||
|
||||
Assert.False(preferences.EnableFadeTransition);
|
||||
Assert.True(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.SlideSplash, preferences.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlags_WhenFadeDisabledAndSlideDisabled_UsesStaticSplashMode()
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.FromFlags(
|
||||
enableFadeTransition: false,
|
||||
enableSlideTransition: false);
|
||||
|
||||
Assert.False(preferences.EnableFadeTransition);
|
||||
Assert.False(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.StaticSplash, preferences.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhenFadeSettingMissing_DefaultsToFadeEnabled()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.StartupVisualTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var settingsPath = Path.Combine(tempDirectory, "settings.json");
|
||||
File.WriteAllText(settingsPath, """
|
||||
{
|
||||
"enableSlideTransition": false
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var preferences = StartupVisualPreferencesResolver.Resolve(settingsPath);
|
||||
|
||||
Assert.True(preferences.EnableFadeTransition);
|
||||
Assert.False(preferences.EnableSlideTransition);
|
||||
Assert.Equal(StartupVisualMode.Fade, preferences.Mode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ public partial class App : Application
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
|
||||
private DesktopTrayService? _desktopTrayService;
|
||||
private DispatcherTimer? _shellRecoveryTimer;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
@@ -478,6 +479,7 @@ public partial class App : Application
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
EnsureDesktopTrayService();
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
|
||||
if (_trayInitialized)
|
||||
{
|
||||
@@ -525,14 +527,67 @@ public partial class App : Application
|
||||
OnTrayRestartClick,
|
||||
OnTrayExitClick);
|
||||
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
|
||||
_desktopTrayService.StartWatchdog();
|
||||
EnsureShellRecoveryWatchdog();
|
||||
}
|
||||
|
||||
private void EnsureShellRecoveryWatchdog()
|
||||
{
|
||||
_shellRecoveryTimer ??= new DispatcherTimer(
|
||||
TimeSpan.FromSeconds(10),
|
||||
DispatcherPriority.Background,
|
||||
OnShellRecoveryWatchdogTick);
|
||||
|
||||
if (!_shellRecoveryTimer.IsEnabled)
|
||||
{
|
||||
_shellRecoveryTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopShellRecoveryWatchdog()
|
||||
{
|
||||
if (_shellRecoveryTimer?.IsEnabled == true)
|
||||
{
|
||||
_shellRecoveryTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnShellRecoveryWatchdogTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_shutdownIntent != ShutdownIntent.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureTrayReady("ShellRecoveryWatchdog");
|
||||
|
||||
if (!ShouldShowMainWindowInTaskbar())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_desktopShellState != DesktopShellState.ForegroundDesktop)
|
||||
{
|
||||
EnsureTaskbarEntry("ShellRecoveryWatchdog");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mainWindow is not null && _mainWindow.IsVisible && !_mainWindow.ShowInTaskbar)
|
||||
{
|
||||
_mainWindow.ShowInTaskbar = true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnsureTrayReady(string reason)
|
||||
{
|
||||
EnsureDesktopTrayService();
|
||||
var wasReady = _trayInitialized;
|
||||
var ready = _desktopTrayService?.EnsureReady(reason) == true;
|
||||
_trayInitialized = ready;
|
||||
if (ready)
|
||||
if (ready && !wasReady)
|
||||
{
|
||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||
}
|
||||
@@ -544,9 +599,25 @@ public partial class App : Application
|
||||
{
|
||||
_trayInitialized = state == TrayAvailabilityState.Ready;
|
||||
|
||||
if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly)
|
||||
if (state != TrayAvailabilityState.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_desktopShellState == DesktopShellState.TrayOnly)
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
|
||||
return;
|
||||
}
|
||||
|
||||
var foregroundVisible = _mainWindow?.IsVisible == true &&
|
||||
_mainWindow.WindowState != WindowState.Minimized;
|
||||
var taskbarUsable = BuildPublicTaskbarStatus().IsUsable;
|
||||
if (!foregroundVisible &&
|
||||
!taskbarUsable &&
|
||||
(_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0) >= 3)
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityRepeatedFailure");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +790,10 @@ public partial class App : Application
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
mainWindow.EnsureForegroundWindowLayout();
|
||||
|
||||
if (mainWindow.ShouldUseFullscreenWindow() &&
|
||||
mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
@@ -733,7 +807,6 @@ public partial class App : Application
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
@@ -861,6 +934,23 @@ public partial class App : Application
|
||||
{
|
||||
RefreshFusedDesktopMenuItemVisibility();
|
||||
}
|
||||
|
||||
var showInTaskbarChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (showInTaskbarChanged)
|
||||
{
|
||||
EnsureTrayReady("SettingsChanged");
|
||||
if (ShouldShowMainWindowInTaskbar())
|
||||
{
|
||||
EnsureTaskbarEntry("SettingsChanged");
|
||||
}
|
||||
else if (_mainWindow is not null && _mainWindow.IsVisible)
|
||||
{
|
||||
_mainWindow.ShowInTaskbar = false;
|
||||
}
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
@@ -977,6 +1067,7 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
_exitCleanupCompleted = true;
|
||||
StopShellRecoveryWatchdog();
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||
|
||||
@@ -1155,7 +1246,6 @@ public partial class App : Application
|
||||
case RestartPresentationMode.Minimized:
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
|
||||
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
return true;
|
||||
@@ -1297,6 +1387,24 @@ public partial class App : Application
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ShouldShowMainWindowInTaskbar())
|
||||
{
|
||||
EnsureTrayReady($"TaskbarBackground:{source}");
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, source);
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar.");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window minimized to taskbar because taskbar entry is enabled. Source='{source}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EnsureTrayReady($"HideToTray:{source}"))
|
||||
{
|
||||
RecoverFromTrayUnavailable(mainWindow, source);
|
||||
@@ -1305,7 +1413,6 @@ public partial class App : Application
|
||||
|
||||
mainWindow.ShowInTaskbar = false;
|
||||
mainWindow.Hide();
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
AppLogger.Info(
|
||||
@@ -1342,7 +1449,6 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
|
||||
return;
|
||||
@@ -1359,7 +1465,10 @@ public partial class App : Application
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
mainWindow.EnsureForegroundWindowLayout();
|
||||
|
||||
if (mainWindow.ShouldUseFullscreenWindow() &&
|
||||
mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
@@ -1367,7 +1476,6 @@ public partial class App : Application
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
|
||||
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
|
||||
}
|
||||
@@ -1377,6 +1485,48 @@ public partial class App : Application
|
||||
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
}
|
||||
|
||||
private bool EnsureTaskbarEntry(string source)
|
||||
{
|
||||
if (!ShouldShowMainWindowInTaskbar())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Taskbar repair skipped because desktop lifetime is unavailable. Source='{source}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, $"TaskbarRepair:{source}");
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (_desktopShellState != DesktopShellState.ForegroundDesktop)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TaskbarRepair:{source}");
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar repair.");
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Taskbar entry ensured. Source='{source}'; IsVisible={mainWindow.IsVisible}; ShowInTaskbar={mainWindow.ShowInTaskbar}; WindowState='{mainWindow.WindowState}'.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to ensure taskbar entry. Source='{source}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||
{
|
||||
if (_desktopShellState == state)
|
||||
@@ -1428,7 +1578,72 @@ public partial class App : Application
|
||||
|
||||
internal bool TryActivateMainWindowFromExternalIpc(string source)
|
||||
{
|
||||
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
return TryActivateMainWindowWithStatusFromExternalIpc(source).Accepted;
|
||||
}
|
||||
|
||||
internal PublicShellActivationResult TryActivateMainWindowWithStatusFromExternalIpc(string source)
|
||||
{
|
||||
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
var status = GetPublicShellStatus();
|
||||
return restored
|
||||
? new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status)
|
||||
: new PublicShellActivationResult(false, "activation_failed", "Desktop window activation failed.", status);
|
||||
}
|
||||
|
||||
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)
|
||||
{
|
||||
EnsureTrayReady($"ExternalIpc:{source}");
|
||||
return BuildPublicTrayStatus();
|
||||
}
|
||||
|
||||
internal PublicTaskbarStatus EnsureTaskbarEntryFromExternalIpc(string source)
|
||||
{
|
||||
EnsureTaskbarEntry($"ExternalIpc:{source}");
|
||||
return BuildPublicTaskbarStatus();
|
||||
}
|
||||
|
||||
internal PublicShellStatus GetPublicShellStatus()
|
||||
{
|
||||
return new PublicShellStatus(
|
||||
Environment.ProcessId,
|
||||
_startupAt,
|
||||
_launchSource,
|
||||
_desktopShellState.ToString(),
|
||||
_mainWindow is not null && !_mainWindowClosed,
|
||||
_mainWindow?.IsVisible == true,
|
||||
_mainWindowOpened,
|
||||
_mainWindow?.IsVisible == true && _mainWindow.WindowState != WindowState.Minimized,
|
||||
_publicIpcHostService is not null,
|
||||
BuildPublicTrayStatus(),
|
||||
BuildPublicTaskbarStatus());
|
||||
}
|
||||
|
||||
private PublicTrayStatus BuildPublicTrayStatus()
|
||||
{
|
||||
return new PublicTrayStatus(
|
||||
_desktopTrayService?.State.ToString() ?? TrayAvailabilityState.Unavailable.ToString(),
|
||||
_desktopTrayService?.IsReady == true,
|
||||
_desktopTrayService?.HasIcon == true,
|
||||
_desktopTrayService?.HasMenu == true,
|
||||
_desktopTrayService?.IsVisible == true,
|
||||
_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0);
|
||||
}
|
||||
|
||||
private PublicTaskbarStatus BuildPublicTaskbarStatus()
|
||||
{
|
||||
var requested = ShouldShowMainWindowInTaskbar();
|
||||
var mainWindowExists = _mainWindow is not null && !_mainWindowClosed;
|
||||
var showInTaskbar = _mainWindow?.ShowInTaskbar == true;
|
||||
var visible = _mainWindow?.IsVisible == true;
|
||||
var minimized = _mainWindow?.WindowState == WindowState.Minimized;
|
||||
|
||||
return new PublicTaskbarStatus(
|
||||
requested,
|
||||
mainWindowExists,
|
||||
showInTaskbar,
|
||||
visible,
|
||||
minimized,
|
||||
requested && mainWindowExists && showInTaskbar && visible);
|
||||
}
|
||||
|
||||
private void InitializePublicIpc()
|
||||
|
||||
@@ -152,6 +152,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
|
||||
public bool EnableFadeTransition { get; set; } = true;
|
||||
|
||||
public bool EnableSlideTransition { get; set; } = false;
|
||||
|
||||
public bool ShowInTaskbar { get; set; } = false;
|
||||
|
||||
@@ -34,6 +34,7 @@ internal sealed class DesktopTrayService : IDisposable
|
||||
private NativeMenuItem? _restartMenuItem;
|
||||
private NativeMenuItem? _exitMenuItem;
|
||||
private int _consecutiveRecoveryFailures;
|
||||
private bool _disposed;
|
||||
|
||||
public DesktopTrayService(
|
||||
Application application,
|
||||
@@ -63,6 +64,14 @@ internal sealed class DesktopTrayService : IDisposable
|
||||
|
||||
public bool IsReady => State == TrayAvailabilityState.Ready;
|
||||
|
||||
public bool HasIcon => _trayIcon?.Icon is not null;
|
||||
|
||||
public bool HasMenu => _trayIcon?.Menu is not null;
|
||||
|
||||
public bool IsVisible => _trayIcon?.IsVisible == true;
|
||||
|
||||
public int ConsecutiveRecoveryFailures => _consecutiveRecoveryFailures;
|
||||
|
||||
public event Action<TrayAvailabilityState>? StateChanged;
|
||||
|
||||
public bool EnsureReady(string reason)
|
||||
@@ -105,6 +114,7 @@ internal sealed class DesktopTrayService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
StopWatchdog();
|
||||
|
||||
try
|
||||
@@ -126,7 +136,7 @@ internal sealed class DesktopTrayService : IDisposable
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
|
||||
if (_disposed || State == TrayAvailabilityState.Unavailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -256,6 +266,11 @@ internal sealed class DesktopTrayService : IDisposable
|
||||
{
|
||||
if (State == state)
|
||||
{
|
||||
if (state == TrayAvailabilityState.Failed)
|
||||
{
|
||||
StateChanged?.Invoke(state);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
{
|
||||
public Task<PublicShellStatus> GetShellStatusAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.GetPublicShellStatus()
|
||||
?? CreateUnavailableStatus();
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> ActivateMainWindowAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
@@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.TryActivateMainWindowWithStatusFromExternalIpc("PublicIpc")
|
||||
?? new PublicShellActivationResult(
|
||||
false,
|
||||
"app_unavailable",
|
||||
"Application instance is not available.",
|
||||
CreateUnavailableStatus());
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicTrayStatus> EnsureTrayReadyAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.EnsureTrayReadyFromExternalIpc("PublicIpc")
|
||||
?? new PublicTrayStatus("Unavailable", false, false, false, false, 0);
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.EnsureTaskbarEntryFromExternalIpc("PublicIpc")
|
||||
?? new PublicTaskbarStatus(false, false, false, false, false, false);
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> OpenSettingsAsync(string? pageTag = null)
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
@@ -44,4 +84,20 @@ internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
Source: "PublicIpc",
|
||||
Reason: "External IPC requested exit.")) == true);
|
||||
}
|
||||
|
||||
private static PublicShellStatus CreateUnavailableStatus()
|
||||
{
|
||||
return new PublicShellStatus(
|
||||
Environment.ProcessId,
|
||||
DateTimeOffset.UtcNow,
|
||||
"unknown",
|
||||
"Unavailable",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
new PublicTrayStatus("Unavailable", false, false, false, false, 0),
|
||||
new PublicTaskbarStatus(false, false, false, false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
@@ -201,7 +202,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? RenderModes[0];
|
||||
EnableSlideTransition = appSnapshot.EnableSlideTransition;
|
||||
ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition);
|
||||
ShowInTaskbar = appSnapshot.ShowInTaskbar;
|
||||
_isInitializing = false;
|
||||
|
||||
@@ -235,9 +236,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)))
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFadeTransition)))
|
||||
{
|
||||
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
ApplyTransitionPreferences(snapshot.EnableFadeTransition, snapshot.EnableSlideTransition);
|
||||
}
|
||||
|
||||
if (changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar)))
|
||||
@@ -263,6 +266,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableFadeTransition = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _enableSlideTransition;
|
||||
|
||||
@@ -271,6 +277,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
|
||||
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
|
||||
|
||||
public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition;
|
||||
|
||||
public string FadeTransitionDescription => EnableSlideTransition
|
||||
? "滑动模式已启用,淡入淡出不可同时使用。"
|
||||
: "启用后,启动与恢复过程使用淡入淡出效果。";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageTitle = string.Empty;
|
||||
|
||||
@@ -372,8 +384,22 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
|
||||
partial void OnEnableSlideTransitionChanged(bool value)
|
||||
{
|
||||
if (_isInitializing) return;
|
||||
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTransitionPreferences(EnableFadeTransition, value);
|
||||
}
|
||||
|
||||
partial void OnEnableFadeTransitionChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveTransitionPreferences(value, EnableSlideTransition);
|
||||
}
|
||||
|
||||
partial void OnShowInTaskbarChanged(bool value)
|
||||
@@ -394,6 +420,35 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
|
||||
}
|
||||
|
||||
private void SaveTransitionPreferences(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
var normalized = StartupVisualPreferencesResolver.FromFlags(enableFadeTransition, enableSlideTransition);
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
snapshot.EnableFadeTransition = normalized.EnableFadeTransition;
|
||||
snapshot.EnableSlideTransition = normalized.EnableSlideTransition;
|
||||
ApplyTransitionPreferences(normalized.EnableFadeTransition, normalized.EnableSlideTransition);
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.EnableFadeTransition),
|
||||
nameof(AppSettingsSnapshot.EnableSlideTransition)
|
||||
]);
|
||||
}
|
||||
|
||||
private void ApplyTransitionPreferences(bool enableFadeTransition, bool enableSlideTransition)
|
||||
{
|
||||
var normalized = StartupVisualPreferencesResolver.FromFlags(enableFadeTransition, enableSlideTransition);
|
||||
var wasInitializing = _isInitializing;
|
||||
_isInitializing = true;
|
||||
EnableFadeTransition = normalized.EnableFadeTransition;
|
||||
EnableSlideTransition = normalized.EnableSlideTransition;
|
||||
_isInitializing = wasInitializing;
|
||||
OnPropertyChanged(nameof(IsFadeTransitionToggleEnabled));
|
||||
OnPropertyChanged(nameof(FadeTransitionDescription));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateLanguageOptions()
|
||||
{
|
||||
return
|
||||
|
||||
@@ -79,6 +79,7 @@ public partial class MainWindow
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
@@ -690,6 +691,7 @@ public partial class MainWindow
|
||||
StatusBarShadowColor = _statusBarShadowColor,
|
||||
StatusBarShadowOpacity = _statusBarShadowOpacity,
|
||||
EnableThreeFingerSwipe = existingSnapshot.EnableThreeFingerSwipe,
|
||||
EnableFadeTransition = existingSnapshot.EnableFadeTransition,
|
||||
EnableSlideTransition = existingSnapshot.EnableSlideTransition,
|
||||
ShowInTaskbar = existingSnapshot.ShowInTaskbar,
|
||||
EnableFusedDesktop = existingSnapshot.EnableFusedDesktop,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -23,6 +24,7 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
@@ -134,6 +136,8 @@ public partial class MainWindow : Window
|
||||
private string _gridSpacingPreset = "Relaxed";
|
||||
private bool _isSlideAnimationActive;
|
||||
private TranslateTransform? _desktopPageSlideTransform;
|
||||
private PixelPoint? _preparedWindowTargetPosition;
|
||||
private PixelPoint? _preparedWindowHiddenPosition;
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private bool _statusBarClockTransparentBackground;
|
||||
@@ -862,24 +866,45 @@ public partial class MainWindow : Window
|
||||
return _desktopPageSlideTransform;
|
||||
}
|
||||
|
||||
internal bool ShouldUseFullscreenWindow()
|
||||
{
|
||||
return GetStartupVisualPreferences().Mode != StartupVisualMode.SlideSplash;
|
||||
}
|
||||
|
||||
internal void EnsureForegroundWindowLayout()
|
||||
{
|
||||
if (!IsSlideTransitionEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var layout = ResolveWindowAnimationLayout();
|
||||
ApplyWindowAnimationLayout(layout);
|
||||
Position = layout.VisiblePosition;
|
||||
}
|
||||
|
||||
private async void SlideOutAndMinimizeAsync()
|
||||
{
|
||||
_isSlideAnimationActive = true;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
|
||||
var useSlide = IsSlideTransitionEnabled();
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
WindowAnimationLayout? slideLayout = null;
|
||||
|
||||
if (useSlide)
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
slideTransform.X = Bounds.Width;
|
||||
slideLayout = ResolveWindowAnimationLayout();
|
||||
ApplyWindowAnimationLayout(slideLayout.Value);
|
||||
await AnimateWindowPositionAsync(
|
||||
Position,
|
||||
slideLayout.Value.HiddenPosition,
|
||||
FluttermotionToken.Intro).ConfigureAwait(false);
|
||||
}
|
||||
else if (preferences.Mode == StartupVisualMode.Fade)
|
||||
{
|
||||
DesktopPage.Opacity = 0;
|
||||
await Task.Delay(FluttermotionToken.Page);
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 0;
|
||||
|
||||
await Task.Delay(useSlide
|
||||
? FluttermotionToken.Intro
|
||||
: FluttermotionToken.Page);
|
||||
|
||||
if (!_isSlideAnimationActive)
|
||||
{
|
||||
@@ -900,44 +925,63 @@ public partial class MainWindow : Window
|
||||
WindowState = WindowState.Minimized;
|
||||
}
|
||||
|
||||
slideTransform.X = 0;
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
if (slideLayout is { } layout)
|
||||
{
|
||||
Position = layout.VisiblePosition;
|
||||
}
|
||||
}
|
||||
|
||||
public void PrepareEnterAnimation()
|
||||
{
|
||||
_isSlideAnimationActive = false;
|
||||
|
||||
var useSlide = IsSlideTransitionEnabled();
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
_preparedWindowTargetPosition = null;
|
||||
_preparedWindowHiddenPosition = null;
|
||||
|
||||
var savedTransitions = DesktopPage.Transitions;
|
||||
DesktopPage.Transitions = null;
|
||||
|
||||
DesktopPage.Opacity = 0;
|
||||
|
||||
if (useSlide)
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash)
|
||||
{
|
||||
var screen = Screens.ScreenFromVisual(this);
|
||||
var scale = screen?.Scaling ?? 1d;
|
||||
var screenWidthDip = screen is null
|
||||
? 1920d
|
||||
: screen.WorkingArea.Width / Math.Max(scale, 0.01d);
|
||||
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidthDip;
|
||||
var layout = ResolveWindowAnimationLayout();
|
||||
_preparedWindowTargetPosition = layout.VisiblePosition;
|
||||
_preparedWindowHiddenPosition = layout.HiddenPosition;
|
||||
ApplyWindowAnimationLayout(layout);
|
||||
Position = layout.HiddenPosition;
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Transitions = savedTransitions;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
if (preferences.Mode == StartupVisualMode.Fade)
|
||||
{
|
||||
var savedTransitions = DesktopPage.Transitions;
|
||||
DesktopPage.Transitions = null;
|
||||
DesktopPage.Opacity = 0;
|
||||
DesktopPage.Transitions = savedTransitions;
|
||||
DesktopPage.IsHitTestVisible = false;
|
||||
_isSlideAnimationActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 1;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
}
|
||||
|
||||
public void PlayEnterAnimation()
|
||||
{
|
||||
var slideTransform = GetDesktopPageSlideTransform();
|
||||
var preferences = GetStartupVisualPreferences();
|
||||
if (preferences.Mode == StartupVisualMode.SlideSplash &&
|
||||
_preparedWindowTargetPosition is { } targetPosition &&
|
||||
_preparedWindowHiddenPosition is { } hiddenPosition)
|
||||
{
|
||||
_ = PlayWindowEnterAnimationAsync(hiddenPosition, targetPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopPage.Opacity = 1;
|
||||
slideTransform.X = 0;
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
}
|
||||
@@ -949,10 +993,67 @@ public partial class MainWindow : Window
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return snapshot.EnableSlideTransition;
|
||||
return GetStartupVisualPreferences().Mode == StartupVisualMode.SlideSplash;
|
||||
}
|
||||
|
||||
private StartupVisualPreferences GetStartupVisualPreferences()
|
||||
{
|
||||
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return StartupVisualPreferencesResolver.FromFlags(
|
||||
snapshot.EnableFadeTransition,
|
||||
snapshot.EnableSlideTransition);
|
||||
}
|
||||
|
||||
private WindowAnimationLayout ResolveWindowAnimationLayout()
|
||||
{
|
||||
var screen = Screens.ScreenFromVisual(this) ?? Screens.Primary ?? Screens.All.FirstOrDefault();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scaling = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
||||
|
||||
return new WindowAnimationLayout(
|
||||
new PixelPoint(workingArea.X, workingArea.Y),
|
||||
new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y),
|
||||
new Size(workingArea.Width / scaling, workingArea.Height / scaling));
|
||||
}
|
||||
|
||||
private void ApplyWindowAnimationLayout(WindowAnimationLayout layout)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
Width = layout.WindowSize.Width;
|
||||
Height = layout.WindowSize.Height;
|
||||
}
|
||||
|
||||
private async Task PlayWindowEnterAnimationAsync(PixelPoint hiddenPosition, PixelPoint targetPosition)
|
||||
{
|
||||
Position = hiddenPosition;
|
||||
await AnimateWindowPositionAsync(hiddenPosition, targetPosition, FluttermotionToken.Intro);
|
||||
DesktopPage.IsHitTestVisible = true;
|
||||
_isSlideAnimationActive = false;
|
||||
}
|
||||
|
||||
private async Task AnimateWindowPositionAsync(PixelPoint from, PixelPoint to, TimeSpan duration)
|
||||
{
|
||||
var totalMilliseconds = Math.Max(duration.TotalMilliseconds, 1d);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (stopwatch.Elapsed < duration)
|
||||
{
|
||||
var progress = Math.Clamp(stopwatch.Elapsed.TotalMilliseconds / totalMilliseconds, 0d, 1d);
|
||||
var eased = 1d - Math.Pow(1d - progress, 3d);
|
||||
var x = (int)Math.Round(from.X + ((to.X - from.X) * eased));
|
||||
var y = (int)Math.Round(from.Y + ((to.Y - from.Y) * eased));
|
||||
Position = new PixelPoint(x, y);
|
||||
await Task.Delay(16).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Position = to;
|
||||
}
|
||||
|
||||
private readonly record struct WindowAnimationLayout(
|
||||
PixelPoint VisiblePosition,
|
||||
PixelPoint HiddenPosition,
|
||||
Size WindowSize);
|
||||
|
||||
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property != WindowStateProperty)
|
||||
@@ -966,10 +1067,17 @@ public partial class MainWindow : Window
|
||||
if (oldState == WindowState.Minimized && newState != WindowState.Minimized)
|
||||
{
|
||||
PrepareEnterAnimation();
|
||||
|
||||
if (newState != WindowState.FullScreen)
|
||||
|
||||
if (ShouldUseFullscreenWindow())
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
if (newState != WindowState.FullScreen)
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
}
|
||||
else if (newState == WindowState.Minimized)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
@@ -980,7 +1088,8 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (newState is WindowState.Minimized or WindowState.FullScreen)
|
||||
if (newState == WindowState.Minimized ||
|
||||
(ShouldUseFullscreenWindow() && newState == WindowState.FullScreen))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -999,7 +1108,10 @@ public partial class MainWindow : Window
|
||||
|
||||
if (WindowState is not (WindowState.Minimized or WindowState.FullScreen))
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
if (ShouldUseFullscreenWindow())
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<!-- 区域设置分组 -->
|
||||
<controls:IconText Icon="Globe"
|
||||
Text="{Binding BasicHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
@@ -76,7 +75,6 @@
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
<!-- 运行时设置分组 -->
|
||||
<controls:IconText Icon="DeveloperBoard"
|
||||
Text="{Binding RuntimeHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
@@ -106,8 +104,20 @@
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="滑入滑出过渡效果"
|
||||
Description="启用后,进入和退出桌面时使用滑入滑出动画(仅 Windows)"
|
||||
<ui:SettingsExpander Header="淡入淡出效果"
|
||||
Description="{Binding FadeTransitionDescription}"
|
||||
IsVisible="{Binding IsSlideTransitionAvailable}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowUpload" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding EnableFadeTransition}"
|
||||
IsEnabled="{Binding IsFadeTransitionToggleEnabled}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="启动滑入滑出效果"
|
||||
Description="启用后,启动和恢复时从屏幕右侧边缘滑入或滑出,仅 Windows 可用。"
|
||||
IsVisible="{Binding IsSlideTransitionAvailable}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowRight" />
|
||||
@@ -118,7 +128,7 @@
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Header="桌面主窗口在任务栏显示图标"
|
||||
Description="仅控制桌面主窗口在系统任务栏中的图标显示;不会影响设置窗口,设置窗口打开时始终保留独立任务栏图标">
|
||||
Description="仅控制桌面主窗口在系统任务栏中的图标显示,不影响设置窗口。">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Window" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
|
||||
@@ -40,7 +40,7 @@ Current built-in `[IpcPublic]` contracts:
|
||||
- `IPublicAppInfoService`
|
||||
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
||||
- `IPublicShellControlService`
|
||||
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
|
||||
- Allows external .NET clients to query shell status, activate the shell, repair tray readiness, repair taskbar entry visibility, open settings, request restart, and request exit.
|
||||
- `IPublicPluginCatalogService`
|
||||
- Returns the merged public IPC catalog snapshot exposed by Host.
|
||||
|
||||
@@ -77,6 +77,8 @@ Launcher no longer depends on the previous custom named-pipe length-prefixed pro
|
||||
|
||||
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
|
||||
|
||||
Launcher-to-launcher de-duplication is intentionally separate from Host Public IPC. The active Launcher coordinator uses a per-user local pipe and `startup-attempt.json` heartbeat so secondary Launchers attach to the coordinator before any host process can be started twice.
|
||||
|
||||
## Plugin Public IPC Contribution Model
|
||||
|
||||
Plugins can contribute new external IPC services in two ways:
|
||||
|
||||
@@ -569,3 +569,7 @@ Launcher now consumes Host startup telemetry from the unified public IPC stack:
|
||||
- Launcher connects through `LanMountainDesktopIpcClient`
|
||||
|
||||
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
|
||||
|
||||
## Coordinator Guard
|
||||
|
||||
Launcher also owns a small per-user local coordinator used only between Launcher processes. It reserves `startup-attempt.json` before host launch, publishes a heartbeat, and exposes a local coordinator pipe for secondary Launchers. A secondary Launcher must attach to that coordinator or activate the existing Host through Public IPC instead of starting another Host process. See [Launcher Coordinator](LAUNCHER_COORDINATOR.md).
|
||||
|
||||
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.
|
||||
28
docs/LAUNCHER_STARTUP_VISUALS.md
Normal file
28
docs/LAUNCHER_STARTUP_VISUALS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Launcher Startup Visuals
|
||||
|
||||
This supplement records the startup rules that are shared by the launcher and the desktop host.
|
||||
|
||||
## Timeout behavior
|
||||
|
||||
- `30 seconds` is a soft timeout.
|
||||
- Soft timeout means `still starting`, not `failed`.
|
||||
- When the host process is alive or Public IPC is connected, Launcher keeps waiting and avoids launching another host process.
|
||||
- `120 seconds` is the hard timeout for `desktop_not_visible`.
|
||||
|
||||
## Visual mode resolution
|
||||
|
||||
- `EnableSlideTransition = true` resolves to `SlideSplash` and forces `EnableFadeTransition = false`.
|
||||
- `EnableSlideTransition = false` and `EnableFadeTransition = false` resolves to `StaticSplash`.
|
||||
- `EnableSlideTransition = false` and `EnableFadeTransition = true` resolves to `Fade`.
|
||||
|
||||
## Fullscreen splash rules
|
||||
|
||||
- Fullscreen splash uses the shared `logo_nightly.png` asset.
|
||||
- Slide splash enters from the right edge of the target screen and exits back to the right edge.
|
||||
- Static splash uses the same fullscreen black surface without motion.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- Closing Launcher during startup does not cancel the startup attempt.
|
||||
- Relaunching Launcher attaches to the active attempt instead of spawning a second desktop process.
|
||||
- If a host process is still alive during failure handling, Launcher offers activation or continued waiting before any retry.
|
||||
Reference in New Issue
Block a user