Compare commits

...

5 Commits

Author SHA1 Message Date
lincube
0085c66514 Introduce HostLaunchPlan and refine launch flow
Add HostLaunchPlan/HostLaunchPlanBuilder to encapsulate host path, package root, working dir, forwarded args and env; add unit tests for builder. Refactor LauncherFlowCoordinator to use HostLaunchPlan when starting hosts, improve IPC handling and startup logic (shorter soft/hard timeouts, more frequent reconnects and shell status polling, activation recovery via existing host). Move argument formatting and environment setup into the plan, include package/working/args metadata in start attempts. Update Commands to prefer ProcessPath for launcher base directory. App and Program: start single-instance activation listener earlier and harden ActivateMainWindow to handle shell initialization state and return richer activation status codes. SingleInstanceService: signal listener readiness (ManualResetEventSlim) and wait briefly when starting, and dispose it. Various logging and minor error handling improvements.
2026-04-23 23:07:37 +08:00
lincube
d4901e436f Add launcher debug settings, recovery & version fixes
Introduce a persistent LauncherDebugSettingsStore and wire it into ErrorWindow and SplashWindow so dev-mode and custom host path can be saved/loaded. Harden DeploymentLocator/FlexibleHostLocator to safely normalize and validate saved debug paths and log warnings for malformed values. Add a WaitingForShell startup state and recoverable-activation logic across App and LauncherFlowCoordinator (with registry updates) so Launcher can attach to an in-progress desktop shell rather than failing. Clean up ErrorDebugWindow UI/flow (WasAccepted flag, localization fixes, event wiring) and improve splash version population. Improve AppVersionProvider to trim surrounding quotes, robustly parse version.json via JsonDocument and read string properties; add unit tests for AppVersionProvider, DeploymentLocator and LauncherDebugSettingsStore. Also quote Exec commands in the csproj and harden scripts/Generate-VersionFile.ps1 (argument normalization, LiteralPath, error handling).
2026-04-23 19:04:39 +08:00
lincube
2d9391f930 Add HostShutdownGate and shutdown handling
Introduce HostShutdownGate to serialize and record the first host shutdown request (Restart preferred over later Exit). Add tests (HostShutdownGateTests) and a tray-menu spec describing shutdown requirements. Integrate the gate into App: expose IsShutdownInProgress, ignore tray/settings/component-library actions during shutdown, reuse/track the fused component library window, ensure edit-mode exit on failures, and close the library during shutdown. Add TrySubmitShutdown to commit shutdown intent, schedule forced termination, perform exit cleanup, and invoke desktop lifetime shutdown. Update HostApplicationLifecycleService to use the new TrySubmitShutdown flow for Exit/Restart. Harden DesktopTrayService.Dispose to clear icons and dispose the tray icon safely. These changes ensure irreversible shutdown commits, prevent UI reopening during shutdown, preserve restart intent, and avoid duplicate or conflicting shutdown actions.
2026-04-23 14:18:09 +08:00
lincube
927dc8d1fd Add launcher coordinator IPC and startup reservation
Introduce a launcher coordinator to reserve startup ownership and prevent duplicate host launches. Adds a NamedPipe-based IPC server/client (LauncherCoordinatorIpcServer/Client), coordinator messages/models, and PublicShellStatus/activation types for richer shell reporting. Enhances StartupAttemptRecord and StartupAttemptRegistry to track coordinator pid/pipe, heartbeat, reserved-before-host-start, and public IPC status, plus new reservation/heartbeat APIs and takeover logic. Wire coordinator into App and LauncherFlowCoordinator to attach secondary launchers, publish coordinator status, probe existing hosts, and include more detailed launch result details. Also adds unit tests and docs describing coordinator and startup visuals behavior.
2026-04-23 09:45:05 +08:00
lincube
33591a0a63 Add startup visual modes and attempt registry
Implement startup visual behavior, de-duplicate startup attempts, and improve failure UX.

Key changes:
- Add spec and docs for startup visuals and timing contract (.trae/specs and docs/LAUNCHER_STARTUP_VISUALS.md).
- Introduce StartupVisualPreferences contract and resolver; create SplashWindow via resolved mode.
- Add StartupAttemptRecord model and a file-backed StartupAttemptRegistry to persist and coordinate in-progress startup attempts (attach/adopt, soft/hard timeouts, IPC/connect state, lifecycle updates).
- Update LauncherFlowCoordinator to: adopt/attach to existing attempts, track IPC connection and soft/hard timeouts (30s/120s), show delayed UI state, attempt foreground recovery via public IPC, compose detailed launch result metadata, and mark registry states (soft timeout, detached waiting, succeeded, failed).
- Add TryActivateExistingInstanceAsync to attempt activating an existing desktop via IPC.
- Change failure flow: ShowFailureWindowAsync now returns user choice; ErrorWindow updated to present Activate/Wait/Open Logs/Exit semantics and new layouts/styles; improved button wiring and debug/dev mode handling.
- Add UI and resource tweaks (ErrorWindow and SplashWindow changes), project asset link for nightly logo, and unit tests for StartupVisualPreferences.

These changes prevent duplicate desktop processes during slow startups, provide clearer UX for delayed startups, and persist startup attempt state across Launcher invocations for safer recovery/attach behavior.
2026-04-23 09:03:35 +08:00
52 changed files with 5245 additions and 1386 deletions

View File

@@ -0,0 +1,29 @@
# Launcher Coordinator And Always-On Tray Addendum
## Launcher-to-launcher coordination
- Launcher reserves startup ownership in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before it starts the host process.
- The reserved record includes `CoordinatorPid`, `CoordinatorPipeName`, `HeartbeatAtUtc`, `PublicIpcConnected`, `ShellStatus`, and `ReservedBeforeHostStart`.
- Only the active coordinator may call `Process.Start()` for the host. Secondary Launchers attach to the coordinator pipe and request desktop activation or status.
- If the coordinator heartbeat is newer than `10s` and the coordinator pid is alive, a new Launcher must not take over.
- If the coordinator is stale, the next Launcher may take over the same pending attempt instead of creating a second host attempt.
- Normal launches probe Host Public IPC first. If a host is already running, Launcher activates that instance and exits without starting another host.
## Finer shell status
- Public shell IPC exposes `GetShellStatusAsync()`, `ActivateMainWindowWithStatusAsync()`, `EnsureTrayReadyAsync()`, and `EnsureTaskbarEntryAsync()`.
- `PublicShellStatus` separates process, shell state, main-window visibility, tray health, taskbar-entry health, and Public IPC readiness.
- Launcher success/failure details must include coordinator pid, attempt id, host pid, Public IPC status, tray state, and taskbar usability when available.
## Always-on tray and taskbar repair
- The tray icon and menu are mandatory application-liveness indicators and are not controlled by user settings.
- Tray watchdog starts during shell initialization and keeps running until application exit.
- `ShowInTaskbar=true` means hidden/background states prefer `MinimizedToTaskbar`; it never disables the tray.
- `ShowInTaskbar=false` is the only mode that may enter pure `TrayOnly`, and only after `TrayReady`.
- When taskbar entry is requested but missing, shell repair recreates or shows the main window minimized with `ShowInTaskbar=true` while keeping the tray visible.
## Regression coverage
- Unit tests cover active coordinator rejection, stale heartbeat takeover, and host-pid assignment after a reserved attempt.
- Manual QA still needs multi-process Launcher concurrency and real tray loss simulation on Windows.

View File

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

View File

@@ -0,0 +1,17 @@
# Tray Menu Shutdown Addendum
## Requirements
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.

View File

@@ -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,28 @@ public partial class App : Application
}
}
private static SplashWindow CreateSplashWindow()
{
var preferences = StartupVisualPreferencesResolver.Resolve();
var window = new SplashWindow(preferences.Mode);
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
@@ -172,53 +199,330 @@ 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);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: 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);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: 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)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
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,
ActivationResult = activation
};
}
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 bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
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 +542,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 +579,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;
}
}

View File

@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
@@ -20,6 +21,13 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
[JsonSerializable(typeof(LauncherCoordinatorResponse))]
[JsonSerializable(typeof(LauncherCoordinatorStatus))]
[JsonSerializable(typeof(PublicShellStatus))]
[JsonSerializable(typeof(PublicTrayStatus))]
[JsonSerializable(typeof(PublicTaskbarStatus))]
[JsonSerializable(typeof(PublicShellActivationResult))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]

View File

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

View File

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Models;
internal static class LauncherCoordinatorCommands
{
public const string Attach = "attach";
public const string ActivateDesktop = "activate-desktop";
public const string GetStatus = "get-status";
}
internal sealed class LauncherCoordinatorRequest
{
[JsonPropertyName("requestId")]
public string RequestId { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("command")]
public string Command { get; init; } = LauncherCoordinatorCommands.Attach;
[JsonPropertyName("launcherPid")]
public int LauncherPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
}
internal sealed class LauncherCoordinatorResponse
{
[JsonPropertyName("accepted")]
public bool Accepted { get; init; }
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("status")]
public LauncherCoordinatorStatus? Status { get; init; }
[JsonPropertyName("activationResult")]
public PublicShellActivationResult? ActivationResult { get; init; }
}
internal sealed class LauncherCoordinatorStatus
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; init; } = string.Empty;
[JsonPropertyName("coordinatorPid")]
public int CoordinatorPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("hostPid")]
public int HostPid { get; init; }
[JsonPropertyName("hostProcessAlive")]
public bool HostProcessAlive { get; init; }
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
[JsonPropertyName("lastObservedStage")]
public StartupStage LastObservedStage { get; init; } = StartupStage.Initializing;
[JsonPropertyName("lastObservedMessage")]
public string LastObservedMessage { get; init; } = string.Empty;
[JsonPropertyName("publicIpcConnected")]
public bool PublicIpcConnected { get; init; }
[JsonPropertyName("state")]
public string State { get; init; } = string.Empty;
[JsonPropertyName("softTimeoutShown")]
public bool SoftTimeoutShown { get; init; }
[JsonPropertyName("completed")]
public bool Completed { get; init; }
[JsonPropertyName("succeeded")]
public bool Succeeded { get; init; }
[JsonPropertyName("shellStatus")]
public PublicShellStatus? ShellStatus { get; init; }
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Models;
internal enum StartupAttemptState
{
Pending,
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed,
WaitingForShell
}
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;
}

View File

@@ -166,7 +166,10 @@ internal static class Commands
return Path.GetFullPath(configured);
}
var baseDir = AppContext.BaseDirectory;
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
? launcherDir
: AppContext.BaseDirectory);
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)

View File

@@ -204,12 +204,16 @@ internal sealed class DeploymentLocator
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
var fullSavedPath = Path.GetFullPath(savedCustomPath);
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
}
@@ -229,6 +233,21 @@ internal sealed class DeploymentLocator
return null;
}
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
{
try
{
fullSavedPath = Path.GetFullPath(savedPath);
return true;
}
catch (Exception ex)
{
fullSavedPath = string.Empty;
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
return false;
}
}
private static string? FindBestDeploymentHost(
string root,
string executable,
@@ -303,9 +322,17 @@ internal sealed class DeploymentLocator
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath) && File.Exists(savedCustomPath))
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
return savedCustomPath;
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
var devPath = ScanDevelopmentPaths(executable);

View File

@@ -560,6 +560,11 @@ namespace LanMountainDesktop.Launcher.Services;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}

View File

@@ -0,0 +1,199 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed record HostLaunchPlan(
string HostPath,
string PackageRoot,
string WorkingDirectory,
IReadOnlyList<string> Arguments,
IReadOnlyDictionary<string, string> EnvironmentVariables,
AppVersionInfo VersionInfo);
internal static class HostLaunchPlanBuilder
{
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
"app-root",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
public static HostLaunchPlan Build(
CommandContext context,
DeploymentLocator deploymentLocator,
HostResolutionResult resolution)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(deploymentLocator);
ArgumentNullException.ThrowIfNull(resolution);
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
{
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
}
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
var versionInfo = deploymentLocator.GetVersionInfo();
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo);
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
};
return new HostLaunchPlan(
hostPath,
packageRoot,
Directory.Exists(packageRoot)
? packageRoot
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
arguments,
environment,
versionInfo);
}
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
{
return string.Join(" ", arguments.Select(QuoteArgument));
}
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
{
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
? AppContext.BaseDirectory
: Path.GetFullPath(appRoot);
var hostDirectory = Path.GetDirectoryName(hostPath);
if (hostDirectory is not null &&
Directory.Exists(fullAppRoot) &&
IsAppDeploymentDirectory(hostDirectory) &&
IsParentOf(fullAppRoot, hostDirectory))
{
return fullAppRoot;
}
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
{
return fullAppRoot;
}
return hostDirectory ?? fullAppRoot;
}
private static IReadOnlyList<string> BuildForwardedArguments(
CommandContext context,
string packageRoot,
AppVersionInfo versionInfo)
{
var arguments = new List<string>();
for (var index = 0; index < context.RawArgs.Count; index++)
{
var arg = context.RawArgs[index];
if (index == 0 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (index == 1 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (arg.StartsWith("--", StringComparison.Ordinal))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
key = key[..equalsIndex];
}
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
{
if (equalsIndex < 0 &&
index + 1 < context.RawArgs.Count &&
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
{
index++;
}
continue;
}
}
arguments.Add(arg);
}
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
return arguments;
}
private static bool IsAppDeploymentDirectory(string path)
{
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
}
private static bool IsParentOf(string parent, string child)
{
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return childPath.StartsWith(
parentPath + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
}

View File

@@ -0,0 +1,111 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
public async Task<LauncherCoordinatorResponse?> SendAsync(
string pipeName,
LauncherCoordinatorRequest request,
TimeSpan timeout)
{
if (string.IsNullOrWhiteSpace(pipeName))
{
return null;
}
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
await using var client = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false);
return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (TimeoutException)
{
return null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}");
return null;
}
}
private static async Task WriteRequestAsync(
Stream stream,
LauncherCoordinatorRequest request,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<LauncherCoordinatorResponse?> ReadResponseAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorResponse);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -0,0 +1,235 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private readonly string _pipeName;
private readonly Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
private readonly Action<LauncherCoordinatorStatus> _heartbeatHandler;
private readonly CancellationTokenSource _cts = new();
private readonly object _statusGate = new();
private LauncherCoordinatorStatus _status;
private Task? _listenTask;
private Task? _heartbeatTask;
public LauncherCoordinatorIpcServer(
string pipeName,
LauncherCoordinatorStatus initialStatus,
Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
Action<LauncherCoordinatorStatus> heartbeatHandler)
{
_pipeName = pipeName;
_status = initialStatus;
_requestHandler = requestHandler;
_heartbeatHandler = heartbeatHandler;
}
public static string CreatePipeName()
{
var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant()));
return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}";
}
public void Start()
{
_listenTask ??= Task.Run(ListenLoopAsync);
_heartbeatTask ??= Task.Run(HeartbeatLoopAsync);
}
public LauncherCoordinatorStatus GetStatus()
{
lock (_statusGate)
{
return _status;
}
}
public void UpdateStatus(LauncherCoordinatorStatus status)
{
lock (_statusGate)
{
_status = status;
}
}
public void Dispose()
{
_cts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
_heartbeatTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
}
_cts.Dispose();
}
private async Task ListenLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
try
{
server = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
8,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
var connectedServer = server;
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
server = null;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}");
try
{
await Task.Delay(250, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
server?.Dispose();
}
}
}
private async Task HeartbeatLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
_heartbeatHandler(GetStatus());
await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}");
}
}
}
private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken)
{
try
{
var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false);
var status = GetStatus();
var response = request is null
? new LauncherCoordinatorResponse
{
Accepted = false,
Code = "invalid_request",
Message = "Launcher coordinator request was invalid.",
Status = status
}
: await _requestHandler(request, status).ConfigureAwait(false);
await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}");
}
finally
{
try
{
server.Dispose();
}
catch
{
}
}
}
private static async Task<LauncherCoordinatorRequest?> ReadRequestAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorRequest);
}
private static async Task WriteResponseAsync(
Stream stream,
LauncherCoordinatorResponse response,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -0,0 +1,124 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed record LauncherDebugSettings(bool DevModeEnabled, string? CustomHostPath);
internal static class LauncherDebugSettingsStore
{
private const string DevModeFileName = "dev-mode.flag";
private const string CustomHostPathFileName = "custom-host-path.txt";
private const string LegacyDevModeFileName = "devmode.config";
private const string LegacyCustomHostPathFileName = "custom-host-path.config";
internal static string? ConfigBaseDirectoryOverride { get; set; }
public static string ConfigBaseDirectory => ConfigBaseDirectoryOverride ?? ResolveConfigBaseDirectory();
public static LauncherDebugSettings Load()
{
return new LauncherDebugSettings(
LoadDevModeState(),
LoadCustomHostPath());
}
public static bool IsDevModeEnabled() => Load().DevModeEnabled;
public static string? GetSavedCustomHostPath() => Load().CustomHostPath;
public static void Save(LauncherDebugSettings settings)
{
try
{
Directory.CreateDirectory(ConfigBaseDirectory);
File.WriteAllText(GetPath(DevModeFileName), settings.DevModeEnabled.ToString());
File.WriteAllText(GetPath(CustomHostPathFileName), settings.CustomHostPath ?? string.Empty);
}
catch (Exception ex)
{
Logger.Warn($"Failed to save launcher debug settings: {ex.Message}");
}
}
public static void SaveDevModeState(bool enabled)
{
var current = Load();
Save(current with { DevModeEnabled = enabled });
}
public static void SaveCustomHostPath(string? customHostPath)
{
var current = Load();
Save(current with { CustomHostPath = customHostPath });
}
private static bool LoadDevModeState()
{
var newValue = TryReadText(GetPath(DevModeFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return TryParseDevMode(newValue);
}
var legacyValue = TryReadText(GetPath(LegacyDevModeFileName));
return !string.IsNullOrWhiteSpace(legacyValue) && TryParseDevMode(legacyValue);
}
private static string? LoadCustomHostPath()
{
var newValue = TryReadText(GetPath(CustomHostPathFileName));
if (!string.IsNullOrWhiteSpace(newValue))
{
return newValue.Trim();
}
var legacyValue = TryReadText(GetPath(LegacyCustomHostPathFileName));
return string.IsNullOrWhiteSpace(legacyValue) ? null : legacyValue.Trim();
}
private static bool TryParseDevMode(string value)
{
var normalized = value.Trim();
return normalized == "1" ||
normalized.Equals("true", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("on", StringComparison.OrdinalIgnoreCase);
}
private static string? TryReadText(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read launcher debug setting '{path}': {ex.Message}");
return null;
}
}
private static string GetPath(string fileName) => Path.Combine(ConfigBaseDirectory, fileName);
private static string ResolveConfigBaseDirectory()
{
try
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
}
catch
{
}
try
{
return Path.Combine(AppContext.BaseDirectory, ".launcher");
}
catch
{
return Path.Combine(Directory.GetCurrentDirectory(), ".launcher");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,540 @@
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 MarkOwnedWaitingForShell(string? message)
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
record.State = StartupAttemptState.WaitingForShell;
}
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 or
StartupAttemptState.WaitingForShell))
{
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 or
StartupAttemptState.WaitingForShell))
{
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 or
StartupAttemptState.WaitingForShell))
{
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
};
}
}

View File

@@ -5,52 +5,41 @@ using Avalonia.Platform.Storage;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误调试窗口 - 开发人员专用调试设置
/// </summary>
public partial class ErrorDebugWindow : Window
{
private string? _selectedHostPath;
private bool _isInitialized = false;
private bool _isInitialized;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled { get; private set; }
/// <summary>
/// 选择的主程序路径
/// </summary>
public bool WasAccepted { get; private set; }
public string? SelectedHostPath => _selectedHostPath;
public ErrorDebugWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化组件
this.Loaded += OnWindowLoaded;
Loaded += OnWindowLoaded;
}
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
public ErrorDebugWindow(bool devModeEnabled, string? initialPath)
: this()
{
IsDevModeEnabled = devModeEnabled;
_selectedHostPath = initialPath;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
if (_isInitialized) return;
if (_isInitialized)
{
return;
}
_isInitialized = true;
Console.WriteLine("[ErrorDebugWindow] Window loaded, initializing components...");
InitializeComponents();
// 设置初始值(在视觉树准备好后)
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsChecked = IsDevModeEnabled;
}
@@ -60,113 +49,72 @@ public partial class ErrorDebugWindow : Window
private void InitializeComponents()
{
// 开发模式开关
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
if (devModeToggle is not null)
if (this.FindControl<ToggleSwitch>("DevModeToggle") is { } devModeToggle)
{
devModeToggle.IsCheckedChanged += (s, e) =>
devModeToggle.IsCheckedChanged += (_, _) =>
{
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
Console.WriteLine($"[ErrorDebugWindow] DevMode changed to: {IsDevModeEnabled}");
};
Console.WriteLine("[ErrorDebugWindow] DevModeToggle event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find DevModeToggle!");
}
// 浏览按钮
var browseButton = this.FindControl<Button>("BrowseButton");
if (browseButton is not null)
if (this.FindControl<Button>("BrowseButton") is { } browseButton)
{
browseButton.Click += OnBrowseClick;
Console.WriteLine("[ErrorDebugWindow] BrowseButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find BrowseButton!");
}
// 确定按钮
var okButton = this.FindControl<Button>("OkButton");
if (okButton is not null)
if (this.FindControl<Button>("OkButton") is { } okButton)
{
okButton.Click += (s, e) => Close();
Console.WriteLine("[ErrorDebugWindow] OkButton event bound");
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find OkButton!");
}
// 取消按钮
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton is not null)
{
cancelButton.Click += (s, e) =>
okButton.Click += (_, _) =>
{
// 取消时恢复原始状态
IsDevModeEnabled = false;
_selectedHostPath = null;
Console.WriteLine("[ErrorDebugWindow] Cancel clicked, resetting state");
WasAccepted = true;
Close();
};
Console.WriteLine("[ErrorDebugWindow] CancelButton event bound");
}
else
if (this.FindControl<Button>("CancelButton") is { } cancelButton)
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find CancelButton!");
cancelButton.Click += (_, _) => Close();
}
Console.WriteLine("[ErrorDebugWindow] Components initialization completed");
}
/// <summary>
/// 浏览按钮点击
/// </summary>
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var storageProvider = StorageProvider;
if (storageProvider is null) return;
if (storageProvider is null)
{
return;
}
var options = new FilePickerOpenOptions
{
Title = "选择阑山桌面主程序",
Title = "Select LanMountainDesktop host executable",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("可执行文件")
FileTypeFilter =
[
new FilePickerFileType("Executable")
{
Patterns = OperatingSystem.IsWindows()
? new[] { "*.exe" }
: new[] { "*" }
? ["*.exe"]
: ["*"]
}
}
]
};
var result = await storageProvider.OpenFilePickerAsync(options);
if (result.Count > 0)
if (result.Count <= 0)
{
_selectedHostPath = result[0].Path.LocalPath;
Console.WriteLine($"[ErrorDebugWindow] Selected host path: {_selectedHostPath}");
UpdatePathDisplay(_selectedHostPath);
return;
}
_selectedHostPath = result[0].Path.LocalPath;
UpdatePathDisplay(_selectedHostPath);
}
/// <summary>
/// 更新路径显示
/// </summary>
private void UpdatePathDisplay(string? path)
{
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
if (pathTextBlock is not null)
if (this.FindControl<TextBlock>("PathTextBlock") is { } pathTextBlock)
{
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
}
else
{
Console.Error.WriteLine("[ErrorDebugWindow] Failed to find PathTextBlock!");
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "Not selected" : path;
}
}
}

View File

@@ -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="&#xEA39;"
<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>

View File

@@ -1,542 +1,314 @@
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 += (_, _) =>
{
// 更新状态
if (!debugWindow.WasAccepted)
{
_isDebugMode = false;
_iconClickCount = 0;
return;
}
_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);
}
}
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(_devModeEnabled, _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()
{
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);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
}
return LauncherDebugSettingsStore.ConfigBaseDirectory;
}
private static bool LoadDevModeStateInternal()
{
return LauncherDebugSettingsStore.IsDevModeEnabled();
}
private static string? LoadCustomHostPathInternal()
{
return LauncherDebugSettingsStore.GetSavedCustomHostPath();
}
}
/// <summary>
/// 错误窗口用户选择结果
/// </summary>
public enum ErrorWindowResult
{
/// <summary>
/// 重试
/// </summary>
Retry,
/// <summary>
/// 退出
/// </summary>
Exit
Exit,
ActivateExisting,
ContinueWaiting
}

View File

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

View File

@@ -1,88 +1,273 @@
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");
if (debugWindow.WasAccepted)
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(
debugWindow.IsDevModeEnabled,
debugWindow.SelectedHostPath));
}
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};
@@ -91,160 +276,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;
}

View File

@@ -108,7 +108,9 @@ public static class AppVersionProvider
return fallback;
}
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
var normalized = TrimSurroundingQuotes(rawValue)
.Split('+', 2, StringSplitOptions.TrimEntries)[0]
.Trim();
return string.IsNullOrWhiteSpace(normalized)
? fallback
: normalized;
@@ -116,9 +118,10 @@ public static class AppVersionProvider
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
{
return string.IsNullOrWhiteSpace(rawValue)
var normalized = TrimSurroundingQuotes(rawValue);
return string.IsNullOrWhiteSpace(normalized)
? fallback
: rawValue.Trim();
: normalized;
}
private static AppVersionInfo OverrideMissingParts(
@@ -158,17 +161,24 @@ public static class AppVersionProvider
try
{
var json = File.ReadAllText(versionFilePath);
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
using var document = JsonDocument.Parse(File.ReadAllText(versionFilePath));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
var version = ReadStringProperty(root, nameof(AppVersionInfo.Version));
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
var codename = ReadStringProperty(root, nameof(AppVersionInfo.Codename));
info = new AppVersionInfo
{
Version = NormalizeVersionText(parsedInfo.Version),
Codename = NormalizeCodename(parsedInfo.Codename)
Version = NormalizeVersionText(version),
Codename = NormalizeCodename(codename)
};
return true;
}
@@ -359,4 +369,43 @@ public static class AppVersionProvider
return null;
}
}
private static string? ReadStringProperty(JsonElement root, string propertyName)
{
foreach (var property in root.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
return property.Value.GetString();
}
}
return null;
}
private static string TrimSurroundingQuotes(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return string.Empty;
}
var normalized = rawValue.Trim();
while (normalized.Length >= 2)
{
var first = normalized[0];
var last = normalized[^1];
if ((first == '\'' && last == '\'') ||
(first == '"' && last == '"'))
{
normalized = normalized[1..^1].Trim();
continue;
}
break;
}
return normalized;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
public sealed record PublicShellStatus(
int ProcessId,
DateTimeOffset StartedAtUtc,
string LaunchSource,
string ShellState,
bool MainWindowCreated,
bool MainWindowVisible,
bool MainWindowOpened,
bool DesktopVisible,
bool PublicIpcReady,
PublicTrayStatus Tray,
PublicTaskbarStatus Taskbar);
public sealed record PublicTrayStatus(
string State,
bool IsReady,
bool HasIcon,
bool HasMenu,
bool IsVisible,
int ConsecutiveRecoveryFailures);
public sealed record PublicTaskbarStatus(
bool RequestedBySettings,
bool MainWindowExists,
bool MainWindowShowInTaskbar,
bool MainWindowVisible,
bool MainWindowMinimized,
bool IsUsable);
public sealed record PublicShellActivationResult(
bool Accepted,
string Code,
string Message,
PublicShellStatus Status);

View File

@@ -0,0 +1,83 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AppVersionProviderTests
{
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonExists_UsesVersionFile()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7", """
{"Version":"0.8.5.7","Codename":"Administrate"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
Assert.Equal("Administrate", info.Codename);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonIsMissing_FallsBackToDeploymentDirectory()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-0.8.5.7");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("0.8.5.7", info.Version);
}
[Fact]
public void ResolveFromPackageRoot_WhenVersionJsonContainsQuotedValues_NormalizesValues()
{
using var temp = TemporaryPackage.Create();
temp.CreateDeployment("app-1.2.3", """
{"Version":"'1.2.3'","Codename":"'Administrate'"}
""");
var info = AppVersionProvider.ResolveFromPackageRoot(temp.Root, "LanMountainDesktop.exe");
Assert.Equal("1.2.3", info.Version);
Assert.Equal("Administrate", info.Codename);
}
private sealed class TemporaryPackage : IDisposable
{
private TemporaryPackage(string root)
{
Root = root;
}
public string Root { get; }
public static TemporaryPackage Create()
{
var root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.VersionTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return new TemporaryPackage(root);
}
public void CreateDeployment(string name, string? versionJson = null)
{
var deployment = Path.Combine(Root, name);
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, "LanMountainDesktop.exe"), string.Empty);
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
if (versionJson is not null)
{
File.WriteAllText(Path.Combine(deployment, "version.json"), versionJson);
}
}
public void Dispose()
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,43 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class DeploymentLocatorTests : IDisposable
{
private readonly string _appRoot;
private readonly string _configRoot;
public DeploymentLocatorTests()
{
var testRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DeploymentLocatorTests", Guid.NewGuid().ToString("N"));
_appRoot = Path.Combine(testRoot, "app-root");
_configRoot = Path.Combine(testRoot, "config");
Directory.CreateDirectory(_appRoot);
Directory.CreateDirectory(_configRoot);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _configRoot;
}
[Fact]
public void ResolveHostExecutable_WhenSavedDebugPathIsMalformed_DoesNotThrow()
{
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, "bad\0path"));
var locator = new DeploymentLocator(_appRoot);
var result = locator.ResolveHostExecutable(CommandContext.FromArgs(["launch", "--debug"]));
Assert.NotEqual("debug_saved_custom_path", result.ResolutionSource);
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
var testRoot = Directory.GetParent(_appRoot)?.FullName;
if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot))
{
Directory.Delete(testRoot, recursive: true);
}
}
}

View File

@@ -0,0 +1,100 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class HostLaunchPlanBuilderTests : IDisposable
{
private readonly string _testRoot;
public HostLaunchPlanBuilderTests()
{
_testRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.HostLaunchPlanTests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_testRoot);
}
[Fact]
public void Build_UsesPackageRootAsWorkingDirectory_ForPublishedDeployment()
{
var packageRoot = Path.Combine(_testRoot, "package-root");
var deployment = CreateDeployment(packageRoot, "app-0.8.5.7");
var resultPath = Path.Combine(_testRoot, "launcher-result.json");
var context = CommandContext.FromArgs(
[
"launch",
"--app-root", packageRoot,
"--result", resultPath,
"--launch-source", "postinstall",
"--custom-host-arg", "custom-value"
]);
var locator = new DeploymentLocator(packageRoot);
var resolution = locator.ResolveHostExecutable(context);
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
Assert.Equal(Path.GetFullPath(packageRoot), plan.PackageRoot);
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
Assert.Equal(Path.Combine(deployment, GetExecutableName()), plan.HostPath);
Assert.Contains("--launch-source", plan.Arguments);
Assert.Contains("postinstall", plan.Arguments);
Assert.Contains("--custom-host-arg", plan.Arguments);
Assert.Contains("custom-value", plan.Arguments);
Assert.DoesNotContain("--app-root", plan.Arguments);
Assert.DoesNotContain(packageRoot, plan.Arguments);
Assert.DoesNotContain("--result", plan.Arguments);
Assert.DoesNotContain(resultPath, plan.Arguments);
Assert.Contains($"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}", plan.Arguments);
}
[Fact]
public void Build_KeepsPathsWithSpacesAsSingleArgumentListTokens()
{
var packageRoot = Path.Combine(_testRoot, "package root with spaces");
CreateDeployment(packageRoot, "app-0.8.5.7");
var context = CommandContext.FromArgs(["launch", "--app-root", packageRoot]);
var locator = new DeploymentLocator(packageRoot);
var resolution = locator.ResolveHostExecutable(context);
var plan = HostLaunchPlanBuilder.Build(context, locator, resolution);
var packageRootArgument = $"--{LauncherIpcConstants.PackageRootEnvVar}={Path.GetFullPath(packageRoot)}";
Assert.Contains(packageRootArgument, plan.Arguments);
Assert.Equal(Path.GetFullPath(packageRoot), plan.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar]);
Assert.DoesNotContain(plan.Arguments, argument => argument.StartsWith("\"", StringComparison.Ordinal));
Assert.Equal(Path.GetFullPath(packageRoot), plan.WorkingDirectory);
}
private static string CreateDeployment(string packageRoot, string deploymentName)
{
var deployment = Path.Combine(packageRoot, deploymentName);
Directory.CreateDirectory(deployment);
File.WriteAllText(Path.Combine(deployment, GetExecutableName()), string.Empty);
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
File.WriteAllText(
Path.Combine(deployment, "version.json"),
"""
{"Version":"0.8.5.7","Codename":"Administrate"}
""");
return deployment;
}
private static string GetExecutableName()
{
return OperatingSystem.IsWindows()
? "LanMountainDesktop.exe"
: "LanMountainDesktop";
}
public void Dispose()
{
if (Directory.Exists(_testRoot))
{
Directory.Delete(_testRoot, recursive: true);
}
}
}

View File

@@ -0,0 +1,48 @@
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class HostShutdownGateTests
{
[Fact]
public void Submit_WhenFirstExitRequest_AcceptsAndRecordsExit()
{
var gate = new HostShutdownGate();
var submission = gate.Submit(HostShutdownMode.Exit);
Assert.True(submission.Accepted);
Assert.True(submission.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Exit, submission.EffectiveMode);
Assert.True(gate.IsShutdownRequested);
Assert.Equal(HostShutdownMode.Exit, gate.EffectiveMode);
}
[Fact]
public void Submit_WhenDuplicateSameMode_AcceptsButDoesNotExecuteAgain()
{
var gate = new HostShutdownGate();
gate.Submit(HostShutdownMode.Exit);
var duplicate = gate.Submit(HostShutdownMode.Exit);
Assert.True(duplicate.Accepted);
Assert.False(duplicate.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Exit, duplicate.EffectiveMode);
}
[Fact]
public void Submit_WhenExitArrivesAfterRestart_DoesNotOverwriteRestart()
{
var gate = new HostShutdownGate();
gate.Submit(HostShutdownMode.Restart);
var conflictingExit = gate.Submit(HostShutdownMode.Exit);
Assert.False(conflictingExit.Accepted);
Assert.False(conflictingExit.IsFirstSubmission);
Assert.Equal(HostShutdownMode.Restart, conflictingExit.EffectiveMode);
Assert.Equal(HostShutdownMode.Restart, gate.EffectiveMode);
}
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Nodes;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherCoordinatorRegistryTests
{
[Fact]
public void TryReserveCoordinator_WhenActiveCoordinatorExists_ReturnsActiveAttempt()
{
using var temp = TemporaryAttemptState.Create();
var firstRegistry = new StartupAttemptRegistry(temp.StatePath);
var secondRegistry = new StartupAttemptRegistry(temp.StatePath);
Assert.True(firstRegistry.TryReserveCoordinator(
"normal",
"Foreground",
"pipe-a",
out var firstAttempt,
out var firstActive));
Assert.Null(firstActive);
Assert.False(secondRegistry.TryReserveCoordinator(
"normal",
"Foreground",
"pipe-b",
out _,
out var secondActive));
Assert.NotNull(secondActive);
Assert.Equal(firstAttempt.AttemptId, secondActive.AttemptId);
Assert.Equal("pipe-a", secondActive.CoordinatorPipeName);
Assert.Equal(Environment.ProcessId, secondActive.CoordinatorPid);
}
[Fact]
public void TryReserveCoordinator_WhenHeartbeatIsStale_TakesOverAttempt()
{
using var temp = TemporaryAttemptState.Create();
var firstRegistry = new StartupAttemptRegistry(temp.StatePath);
var secondRegistry = new StartupAttemptRegistry(temp.StatePath);
Assert.True(firstRegistry.TryReserveCoordinator(
"normal",
"Foreground",
"pipe-a",
out var firstAttempt,
out _));
temp.SetHeartbeat(DateTimeOffset.UtcNow.AddSeconds(-30));
Assert.True(secondRegistry.TryReserveCoordinator(
"normal",
"Foreground",
"pipe-b",
out var reservedAttempt,
out var activeAttempt));
Assert.Null(activeAttempt);
Assert.Equal(firstAttempt.AttemptId, reservedAttempt.AttemptId);
Assert.Equal("pipe-b", reservedAttempt.CoordinatorPipeName);
}
[Fact]
public void AssignOwnedHostProcess_ClearsReservedBeforeHostStart()
{
using var temp = TemporaryAttemptState.Create();
var registry = new StartupAttemptRegistry(temp.StatePath);
Assert.True(registry.TryReserveCoordinator(
"normal",
"Foreground",
"pipe-a",
out var reservedAttempt,
out _));
Assert.True(reservedAttempt.ReservedBeforeHostStart);
var assignedAttempt = registry.AssignOwnedHostProcess(
Environment.ProcessId,
StartupStage.Initializing,
"host assigned");
Assert.Equal(Environment.ProcessId, assignedAttempt.HostPid);
Assert.False(assignedAttempt.ReservedBeforeHostStart);
}
private sealed class TemporaryAttemptState : IDisposable
{
private TemporaryAttemptState(string directory)
{
Directory = directory;
StatePath = Path.Combine(directory, "startup-attempt.json");
}
public string Directory { get; }
public string StatePath { get; }
public static TemporaryAttemptState Create()
{
var directory = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.LauncherCoordinatorTests",
Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(directory);
return new TemporaryAttemptState(directory);
}
public void SetHeartbeat(DateTimeOffset heartbeatAtUtc)
{
var node = JsonNode.Parse(File.ReadAllText(StatePath))!.AsObject();
node["heartbeatAtUtc"] = heartbeatAtUtc.ToString("O");
File.WriteAllText(StatePath, node.ToJsonString());
}
public void Dispose()
{
if (System.IO.Directory.Exists(Directory))
{
System.IO.Directory.Delete(Directory, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,50 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
[Collection("LauncherDebugSettingsStore")]
public sealed class LauncherDebugSettingsStoreTests : IDisposable
{
private readonly string _tempDirectory;
public LauncherDebugSettingsStoreTests()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DebugSettingsTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDirectory);
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = _tempDirectory;
}
[Fact]
public void Load_WhenOnlyLegacyFilesExist_ReadsLegacySettings()
{
var customPath = Path.Combine(_tempDirectory, "legacy-host.exe");
File.WriteAllText(Path.Combine(_tempDirectory, "devmode.config"), "1");
File.WriteAllText(Path.Combine(_tempDirectory, "custom-host-path.config"), customPath);
var settings = LauncherDebugSettingsStore.Load();
Assert.True(settings.DevModeEnabled);
Assert.Equal(customPath, settings.CustomHostPath);
}
[Fact]
public void Save_WritesNewSettingsFiles()
{
var customPath = Path.Combine(_tempDirectory, "host.exe");
LauncherDebugSettingsStore.Save(new LauncherDebugSettings(true, customPath));
Assert.Equal("True", File.ReadAllText(Path.Combine(_tempDirectory, "dev-mode.flag")).Trim());
Assert.Equal(customPath, File.ReadAllText(Path.Combine(_tempDirectory, "custom-host-path.txt")).Trim());
}
public void Dispose()
{
LauncherDebugSettingsStore.ConfigBaseDirectoryOverride = null;
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
}

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

View File

@@ -56,6 +56,7 @@ public partial class App : Application
private readonly LocalizationService _localizationService = new();
private readonly FontFamilyService _fontFamilyService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly HostShutdownGate _shutdownGate = new();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
@@ -71,9 +72,11 @@ public partial class App : Application
private ShutdownIntent _shutdownIntent;
private DesktopTrayService? _desktopTrayService;
private DispatcherTimer? _shellRecoveryTimer;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
private FusedDesktopComponentLibraryWindow? _fusedComponentLibraryWindow;
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
@@ -82,6 +85,7 @@ public partial class App : Application
private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased;
private int _forcedExitScheduled;
private volatile bool _desktopShellInitializationStarted;
private bool _mainWindowOpened;
private bool _trayInitialized;
private readonly object _launcherProgressLock = new();
@@ -106,6 +110,7 @@ public partial class App : Application
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal INotificationService? NotificationService => _notificationService;
internal bool IsShutdownInProgress => _shutdownGate.IsShutdownRequested || _shutdownIntent != ShutdownIntent.None;
internal RestartPresentationMode GetCurrentRestartPresentationMode()
{
return _desktopShellState switch
@@ -118,6 +123,14 @@ public partial class App : Application
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{
if (IsShutdownInProgress)
{
AppLogger.Info(
"SettingsFacade",
$"Settings open ignored because shutdown is in progress. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
return;
}
EnsureSettingsWindowService();
AppLogger.Info(
"SettingsFacade",
@@ -172,6 +185,7 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePublicIpc();
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
_ = InitializeLauncherIpcAsync();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
@@ -312,6 +326,7 @@ public partial class App : Application
private void InitializeDesktopShell()
{
_desktopShellInitializationStarted = true;
_desktopShellHost ??= new DesktopShellHost(
InitializePluginRuntime,
InitializeTrayIcon,
@@ -347,11 +362,23 @@ public partial class App : Application
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", "Tray Open Desktop ignored because shutdown is in progress.");
return;
}
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
}
private void OnTrayRestartClick(object? sender, EventArgs e)
{
if (IsShutdownInProgress)
{
AppLogger.Info("HostLifecycle", "Tray Restart ignored because shutdown is already in progress.");
return;
}
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
@@ -361,6 +388,13 @@ public partial class App : Application
{
_ = sender;
_ = e;
if (IsShutdownInProgress)
{
AppLogger.Info("SettingsFacade", "Tray Settings ignored because shutdown is in progress.");
return;
}
OpenIndependentSettingsModule("TrayMenu");
}
@@ -368,28 +402,52 @@ public partial class App : Application
{
_ = sender;
_ = e;
if (IsShutdownInProgress)
{
AppLogger.Info("FusedDesktop", "Tray Component Library ignored because shutdown is in progress.");
return;
}
if (!OperatingSystem.IsWindows())
{
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
return;
}
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
EnsureTransparentOverlayWindow();
Dispatcher.UIThread.Post(() =>
{
if (IsShutdownInProgress)
{
AppLogger.Info("FusedDesktop", "Deferred Component Library open ignored because shutdown is in progress.");
return;
}
try
{
if (_fusedComponentLibraryWindow is { } existingWindow)
{
if (!existingWindow.IsVisible)
{
existingWindow.Show();
}
existingWindow.Activate();
return;
}
var fusedDesktopManager = FusedDesktopManagerServiceFactory.GetOrCreate();
fusedDesktopManager.EnterEditMode();
// 纭繚閫忔槑瑕嗙洊灞傜獥鍙e瓨鍦ㄥ苟鏄剧ず
EnsureTransparentOverlayWindow();
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Show();
}
var window = new FusedDesktopComponentLibraryWindow();
_fusedComponentLibraryWindow = window;
if (_transparentOverlayWindow is not null)
{
@@ -405,7 +463,11 @@ public partial class App : Application
}
// 璁╃鐞嗗櫒鏍规嵁宸插瓨鍌ㄧ殑鏈€鏂板揩鐓ч噸寤虹敓鎴愭墍鏈夊疄浣撳皬缁勪欢
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
fusedDesktopManager.ExitEditMode();
if (ReferenceEquals(_fusedComponentLibraryWindow, s))
{
_fusedComponentLibraryWindow = null;
}
};
window.Show();
@@ -414,6 +476,25 @@ public partial class App : Application
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to open fused desktop component library.", ex);
try
{
_transparentOverlayWindow?.SaveLayoutAndHide();
}
catch (Exception overlayEx)
{
AppLogger.Warn("FusedDesktop", "Failed to hide fused desktop overlay after library open failure.", overlayEx);
}
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception exitEx)
{
AppLogger.Warn("FusedDesktop", "Failed to exit edit mode after library open failure.", exitEx);
}
_fusedComponentLibraryWindow = null;
}
}, DispatcherPriority.Send);
}
@@ -478,6 +559,7 @@ public partial class App : Application
private void InitializeTrayIcon()
{
EnsureDesktopTrayService();
_desktopTrayService?.StartWatchdog();
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
if (_trayInitialized)
{
@@ -525,14 +607,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 +679,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");
}
}
@@ -653,10 +804,16 @@ public partial class App : Application
Resources["AppFontFamily"] = fontFamily;
}
private void ActivateMainWindow()
internal void ActivateMainWindow()
{
AppLogger.Info("SingleInstance", $"Activation callback received. Pid={Environment.ProcessId}.");
if (!_desktopShellInitializationStarted && _mainWindow is null)
{
AppLogger.Info("SingleInstance", "Activation acknowledged while desktop shell is still initializing.");
return;
}
try
{
var restored = Dispatcher.UIThread.CheckAccess()
@@ -667,7 +824,8 @@ public partial class App : Application
if (!restored)
{
throw new InvalidOperationException("Main window restore failed in activation callback.");
AppLogger.Warn("SingleInstance", "Activation callback could not restore the main window yet.");
return;
}
AppLogger.Info("SingleInstance", "Activation callback completed successfully.");
@@ -675,12 +833,17 @@ public partial class App : Application
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation callback failed while restoring the desktop shell.", ex);
throw;
}
}
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Restore ignored because shutdown is in progress. Source='{source}'.");
return;
}
Dispatcher.UIThread.Post(() =>
{
_ = RestoreOrCreateMainWindowCore(showSingleInstanceNotice, source);
@@ -689,6 +852,12 @@ public partial class App : Application
private bool RestoreOrCreateMainWindowCore(bool showSingleInstanceNotice, string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Restore skipped because shutdown is in progress. Source='{source}'.");
return false;
}
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("DesktopShell", $"Restore skipped because desktop lifetime is unavailable. Source='{source}'.");
@@ -719,7 +888,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 +905,6 @@ public partial class App : Application
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
@@ -765,6 +936,62 @@ public partial class App : Application
}
}
internal bool TrySubmitShutdown(HostShutdownMode mode, HostApplicationLifecycleRequest? request)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn(
"HostLifecycle",
$"Shutdown request ignored because desktop lifetime is unavailable. Mode='{mode}'; Source='{request?.Source ?? "Unknown"}'.");
return false;
}
return Dispatcher.UIThread.CheckAccess()
? TrySubmitShutdownCore(mode, request, desktop)
: Dispatcher.UIThread.InvokeAsync(
() => TrySubmitShutdownCore(mode, request, desktop),
DispatcherPriority.Send).GetAwaiter().GetResult();
}
private bool TrySubmitShutdownCore(
HostShutdownMode mode,
HostApplicationLifecycleRequest? request,
IClassicDesktopStyleApplicationLifetime desktop)
{
var source = request?.Source ?? "Unknown";
var submission = _shutdownGate.Submit(mode);
if (!submission.IsFirstSubmission)
{
AppLogger.Warn(
"HostLifecycle",
$"Shutdown request ignored because shutdown is already in progress. Requested='{submission.RequestedMode}'; Effective='{submission.EffectiveMode}'; Source='{source}'.");
return submission.Accepted;
}
_shutdownIntent = mode == HostShutdownMode.Restart
? ShutdownIntent.RestartRequested
: ShutdownIntent.ExitRequested;
AppLogger.Info(
"DesktopShell",
$"Shutdown committed. Intent='{_shutdownIntent}'; Source='{source}'; Reason='{request?.Reason ?? string.Empty}'; CurrentShellState='{_desktopShellState}'.");
ScheduleForcedProcessTermination($"ShutdownRequest:{source}");
StopShellRecoveryWatchdog();
PerformExitCleanup();
ReleaseSingleInstanceAfterExit($"ShutdownRequest:{source}");
try
{
desktop.Shutdown();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", $"Desktop lifetime shutdown failed. Source='{source}'.", ex);
return true;
}
}
internal void PrepareForShutdown(bool isRestart, string source)
{
void Mark()
@@ -861,6 +1088,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 +1221,7 @@ public partial class App : Application
}
_exitCleanupCompleted = true;
StopShellRecoveryWatchdog();
_settingsFacade.Settings.Changed -= OnSettingsChanged;
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
@@ -1032,6 +1277,30 @@ public partial class App : Application
disposableRegistry.Dispose();
}
if (_fusedComponentLibraryWindow is not null)
{
try
{
_fusedComponentLibraryWindow.Close();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to close fused desktop component library during shutdown.", ex);
}
finally
{
_fusedComponentLibraryWindow = null;
try
{
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
}
catch (Exception ex)
{
AppLogger.Warn("FusedDesktop", "Failed to exit fused desktop edit mode during shutdown.", ex);
}
}
}
if (_transparentOverlayWindow is not null)
{
try
@@ -1155,7 +1424,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 +1565,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 +1591,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 +1627,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 +1643,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 +1654,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 +1663,54 @@ public partial class App : Application
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
}
private bool EnsureTaskbarEntry(string source)
{
if (IsShutdownInProgress)
{
AppLogger.Info("DesktopShell", $"Taskbar repair skipped because shutdown is in progress. Source='{source}'.");
return false;
}
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 +1762,100 @@ public partial class App : Application
internal bool TryActivateMainWindowFromExternalIpc(string source)
{
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
return TryActivateMainWindowWithStatusFromExternalIpc(source).Accepted;
}
internal PublicShellActivationResult TryActivateMainWindowWithStatusFromExternalIpc(string source)
{
if (!_desktopShellInitializationStarted && _mainWindow is null)
{
return new PublicShellActivationResult(
false,
"startup_pending",
"Desktop process is running, but the shell has not started yet.",
GetPublicShellStatus());
}
var restored = RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
var status = GetPublicShellStatus();
if (restored)
{
return new PublicShellActivationResult(true, "activated", "Desktop window activation was requested.", status);
}
if (IsShutdownInProgress)
{
return new PublicShellActivationResult(false, "shutdown_in_progress", "Desktop is shutting down.", status);
}
var code = status.PublicIpcReady && (!status.MainWindowCreated || !status.MainWindowOpened)
? "startup_pending"
: status.PublicIpcReady && !status.DesktopVisible
? "shell_not_ready"
: "activation_failed";
var message = code switch
{
"startup_pending" => "Desktop process is running, but the shell is still creating the main window.",
"shell_not_ready" => "Desktop process is running, but the shell is not ready for activation yet.",
_ => "Desktop window activation failed."
};
return new PublicShellActivationResult(false, code, message, status);
}
internal PublicTrayStatus EnsureTrayReadyFromExternalIpc(string source)
{
EnsureTrayReady($"ExternalIpc:{source}");
return BuildPublicTrayStatus();
}
internal PublicTaskbarStatus EnsureTaskbarEntryFromExternalIpc(string source)
{
EnsureTaskbarEntry($"ExternalIpc:{source}");
return BuildPublicTaskbarStatus();
}
internal PublicShellStatus GetPublicShellStatus()
{
return new PublicShellStatus(
Environment.ProcessId,
_startupAt,
_launchSource,
_desktopShellState.ToString(),
_mainWindow is not null && !_mainWindowClosed,
_mainWindow?.IsVisible == true,
_mainWindowOpened,
_mainWindow?.IsVisible == true && _mainWindow.WindowState != WindowState.Minimized,
_publicIpcHostService is not null,
BuildPublicTrayStatus(),
BuildPublicTaskbarStatus());
}
private PublicTrayStatus BuildPublicTrayStatus()
{
return new PublicTrayStatus(
_desktopTrayService?.State.ToString() ?? TrayAvailabilityState.Unavailable.ToString(),
_desktopTrayService?.IsReady == true,
_desktopTrayService?.HasIcon == true,
_desktopTrayService?.HasMenu == true,
_desktopTrayService?.IsVisible == true,
_desktopTrayService?.ConsecutiveRecoveryFailures ?? 0);
}
private PublicTaskbarStatus BuildPublicTaskbarStatus()
{
var requested = ShouldShowMainWindowInTaskbar();
var mainWindowExists = _mainWindow is not null && !_mainWindowClosed;
var showInTaskbar = _mainWindow?.ShowInTaskbar == true;
var visible = _mainWindow?.IsVisible == true;
var minimized = _mainWindow?.WindowState == WindowState.Minimized;
return new PublicTaskbarStatus(
requested,
mainWindowExists,
showInTaskbar,
visible,
minimized,
requested && mainWindowExists && showInTaskbar && visible);
}
private void InitializePublicIpc()

View File

@@ -90,8 +90,8 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
<!-- 发布时也生成版本信息文件 -->
@@ -101,7 +101,7 @@
<AppVersion>$(Version)</AppVersion>
<AppCodename>Administrate</AppCodename>
</PropertyGroup>
<Exec Command="powershell -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1 -OutputPath '$(VersionFilePath)' -Version '$(AppVersion)' -Codename '$(AppCodename)'" Condition="'$(OS)' != 'Windows_NT'" />
<Exec Command="powershell -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="pwsh -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\..\scripts\Generate-VersionFile.ps1&quot; -OutputPath &quot;$(VersionFilePath)&quot; -Version &quot;$(AppVersion)&quot; -Codename &quot;$(AppCodename)&quot;" Condition="'$(OS)' != 'Windows_NT'" />
</Target>
</Project>

View File

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

View File

@@ -77,6 +77,16 @@ public sealed class Program
StartupRenderMode = renderMode;
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
singleInstance.StartActivationListener(() =>
{
if (Avalonia.Application.Current is App app)
{
app.ActivateMainWindow();
return;
}
AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready.");
});
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}

View File

@@ -34,6 +34,7 @@ internal sealed class DesktopTrayService : IDisposable
private NativeMenuItem? _restartMenuItem;
private NativeMenuItem? _exitMenuItem;
private int _consecutiveRecoveryFailures;
private bool _disposed;
public DesktopTrayService(
Application application,
@@ -63,6 +64,14 @@ internal sealed class DesktopTrayService : IDisposable
public bool IsReady => State == TrayAvailabilityState.Ready;
public bool HasIcon => _trayIcon?.Icon is not null;
public bool HasMenu => _trayIcon?.Menu is not null;
public bool IsVisible => _trayIcon?.IsVisible == true;
public int ConsecutiveRecoveryFailures => _consecutiveRecoveryFailures;
public event Action<TrayAvailabilityState>? StateChanged;
public bool EnsureReady(string reason)
@@ -105,6 +114,7 @@ internal sealed class DesktopTrayService : IDisposable
public void Dispose()
{
_disposed = true;
StopWatchdog();
try
@@ -118,6 +128,27 @@ internal sealed class DesktopTrayService : IDisposable
{
}
try
{
TrayIcon.SetIcons(_application, []);
}
catch
{
}
try
{
if (_trayIcon is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
_trayIcon = null;
SetState(TrayAvailabilityState.Unavailable, "Dispose");
}
@@ -126,7 +157,7 @@ internal sealed class DesktopTrayService : IDisposable
_ = sender;
_ = e;
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
if (_disposed || State == TrayAvailabilityState.Unavailable)
{
return;
}
@@ -256,6 +287,11 @@ internal sealed class DesktopTrayService : IDisposable
{
if (State == state)
{
if (state == TrayAvailabilityState.Failed)
{
StateChanged?.Invoke(state);
}
return;
}

View File

@@ -7,6 +7,15 @@ namespace LanMountainDesktop.Services.ExternalIpc;
internal sealed class PublicShellControlService : IPublicShellControlService
{
public Task<PublicShellStatus> GetShellStatusAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.GetPublicShellStatus()
?? CreateUnavailableStatus();
}).GetTask();
}
public Task<bool> ActivateMainWindowAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
@@ -15,6 +24,37 @@ internal sealed class PublicShellControlService : IPublicShellControlService
}).GetTask();
}
public Task<PublicShellActivationResult> ActivateMainWindowWithStatusAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.TryActivateMainWindowWithStatusFromExternalIpc("PublicIpc")
?? new PublicShellActivationResult(
false,
"app_unavailable",
"Application instance is not available.",
CreateUnavailableStatus());
}).GetTask();
}
public Task<PublicTrayStatus> EnsureTrayReadyAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.EnsureTrayReadyFromExternalIpc("PublicIpc")
?? new PublicTrayStatus("Unavailable", false, false, false, false, 0);
}).GetTask();
}
public Task<PublicTaskbarStatus> EnsureTaskbarEntryAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.EnsureTaskbarEntryFromExternalIpc("PublicIpc")
?? new PublicTaskbarStatus(false, false, false, false, false, false);
}).GetTask();
}
public Task<bool> OpenSettingsAsync(string? pageTag = null)
{
return Dispatcher.UIThread.InvokeAsync(() =>
@@ -44,4 +84,20 @@ internal sealed class PublicShellControlService : IPublicShellControlService
Source: "PublicIpc",
Reason: "External IPC requested exit.")) == true);
}
private static PublicShellStatus CreateUnavailableStatus()
{
return new PublicShellStatus(
Environment.ProcessId,
DateTimeOffset.UtcNow,
"unknown",
"Unavailable",
false,
false,
false,
false,
false,
new PublicTrayStatus("Unavailable", false, false, false, false, 0),
new PublicTaskbarStatus(false, false, false, false, false, false));
}
}

View File

@@ -23,23 +23,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
app = Application.Current as App;
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
if (app is null || app.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
}
else
{
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
}
return true;
return app.TrySubmitShutdown(HostShutdownMode.Exit, request);
}
catch (Exception ex)
{
@@ -55,6 +45,13 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
try
{
app = Application.Current as App;
if (app?.IsShutdownInProgress == true)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request ignored because shutdown is already in progress. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
if (HasPendingPluginUpgrades())
{
@@ -123,10 +120,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
@@ -143,8 +137,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
Process.Start(startInfo);
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
var shutdownRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
@@ -153,7 +146,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
: request.Reason
};
return TryExit(exitRequest);
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
}
private static string ResolveUpgradeHelperPath()

View File

@@ -0,0 +1,65 @@
namespace LanMountainDesktop.Services;
internal enum HostShutdownMode
{
Exit = 0,
Restart = 1
}
internal readonly record struct HostShutdownSubmission(
bool Accepted,
bool IsFirstSubmission,
HostShutdownMode EffectiveMode,
HostShutdownMode RequestedMode);
internal sealed class HostShutdownGate
{
private readonly object _gate = new();
private bool _submitted;
private HostShutdownMode _mode;
public bool IsShutdownRequested
{
get
{
lock (_gate)
{
return _submitted;
}
}
}
public HostShutdownMode? EffectiveMode
{
get
{
lock (_gate)
{
return _submitted ? _mode : null;
}
}
}
public HostShutdownSubmission Submit(HostShutdownMode requestedMode)
{
lock (_gate)
{
if (!_submitted)
{
_submitted = true;
_mode = requestedMode;
return new HostShutdownSubmission(
Accepted: true,
IsFirstSubmission: true,
EffectiveMode: requestedMode,
RequestedMode: requestedMode);
}
return new HostShutdownSubmission(
Accepted: _mode == requestedMode,
IsFirstSubmission: false,
EffectiveMode: _mode,
RequestedMode: requestedMode);
}
}
}

View File

@@ -16,6 +16,7 @@ public sealed class SingleInstanceService : IDisposable
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
private readonly ManualResetEventSlim _listenerReady = new(false);
private bool _ownsMutex;
private bool _disposed;
private Task? _listenTask;
@@ -64,6 +65,7 @@ public sealed class SingleInstanceService : IDisposable
"SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
_listenerReady.Wait(TimeSpan.FromMilliseconds(500));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
@@ -142,6 +144,7 @@ public sealed class SingleInstanceService : IDisposable
}
_listenCts.Dispose();
_listenerReady.Dispose();
if (_ownsMutex)
{
try
@@ -170,6 +173,7 @@ public sealed class SingleInstanceService : IDisposable
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
_listenerReady.Set();
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ Current built-in `[IpcPublic]` contracts:
- `IPublicAppInfoService`
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
- `IPublicShellControlService`
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
- Allows external .NET clients to query shell status, activate the shell, repair tray readiness, repair taskbar entry visibility, open settings, request restart, and request exit.
- `IPublicPluginCatalogService`
- Returns the merged public IPC catalog snapshot exposed by Host.
@@ -77,6 +77,8 @@ Launcher no longer depends on the previous custom named-pipe length-prefixed pro
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
Launcher-to-launcher de-duplication is intentionally separate from Host Public IPC. The active Launcher coordinator uses a per-user local pipe and `startup-attempt.json` heartbeat so secondary Launchers attach to the coordinator before any host process can be started twice.
## Plugin Public IPC Contribution Model
Plugins can contribute new external IPC services in two ways:

View File

@@ -569,3 +569,7 @@ Launcher now consumes Host startup telemetry from the unified public IPC stack:
- Launcher connects through `LanMountainDesktopIpcClient`
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
## Coordinator Guard
Launcher also owns a small per-user local coordinator used only between Launcher processes. It reserves `startup-attempt.json` before host launch, publishes a heartbeat, and exposes a local coordinator pipe for secondary Launchers. A secondary Launcher must attach to that coordinator or activate the existing Host through Public IPC instead of starting another Host process. See [Launcher Coordinator](LAUNCHER_COORDINATOR.md).

View File

@@ -0,0 +1,31 @@
# Launcher Coordinator
LanMountainDesktop Launcher uses a per-user coordinator to prevent duplicate host startup.
## Rules
- A Launcher reserves `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before starting the host.
- The active record stores coordinator pid, coordinator pipe name, heartbeat, host pid, Public IPC state, and shell status.
- Only the active coordinator may start the host process.
- Secondary Launchers attach to the coordinator and request desktop activation.
- A coordinator is considered live while its pid exists and its heartbeat is newer than `10s`.
- Normal launch probes Host Public IPC first; if the host is already running, Launcher activates it and exits.
## Tray And Taskbar
- Tray icon and tray menu are mandatory and are not controlled by user settings.
- Tray watchdog starts with the shell and runs until process exit.
- `ShowInTaskbar=true` affects only the main-window taskbar entry.
- When `ShowInTaskbar=true`, background mode uses a minimized taskbar entry while keeping tray visible.
- Pure `TrayOnly` is allowed only when `ShowInTaskbar=false` and tray is ready.
## Public Shell IPC
Launcher and external callers can use:
- `GetShellStatusAsync()`
- `ActivateMainWindowWithStatusAsync()`
- `EnsureTrayReadyAsync()`
- `EnsureTaskbarEntryAsync()`
These APIs report process, shell, tray, taskbar, and activation state separately so callers do not infer health from window visibility alone.

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

View File

@@ -1,15 +1,47 @@
# 生成版本信息文件
param(
[Parameter(Mandatory=$true)]
[string]$OutputPath,
[Parameter(Mandatory=$true)]
[string]$Version,
[Parameter(Mandatory=$false)]
[string]$Codename = "Administrate"
)
$ErrorActionPreference = "Stop"
function Normalize-ArgumentValue {
param(
[Parameter(Mandatory=$true)]
[AllowEmptyString()]
[string]$Value
)
$trimmed = $Value.Trim()
if ($trimmed.Length -ge 2) {
$first = $trimmed[0]
$last = $trimmed[$trimmed.Length - 1]
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $trimmed.Substring(1, $trimmed.Length - 2).Trim()
}
}
return $trimmed
}
$OutputPath = Normalize-ArgumentValue -Value $OutputPath
$Version = Normalize-ArgumentValue -Value $Version
$Codename = Normalize-ArgumentValue -Value $Codename
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
throw "OutputPath is required."
}
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Version is required."
}
$versionInfo = @{
Version = $Version
Codename = $Codename
@@ -18,11 +50,15 @@ $versionInfo = @{
$json = $versionInfo | ConvertTo-Json -Compress
$dir = Split-Path -Parent $OutputPath
if (!(Test-Path $dir)) {
if ([string]::IsNullOrWhiteSpace($dir)) {
throw "OutputPath must include a directory: $OutputPath"
}
if (!(Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
Write-Host "Generated version file: $OutputPath" -ForegroundColor Green
Write-Host " Version: $Version" -ForegroundColor Gray
Write-Host " Codename: $Codename" -ForegroundColor Gray