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.
This commit is contained in:
lincube
2026-04-23 09:03:35 +08:00
parent 001d77968f
commit 33591a0a63
20 changed files with 2008 additions and 1076 deletions

View File

@@ -0,0 +1,29 @@
# 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.

View File

@@ -6,6 +6,9 @@ using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher; namespace LanMountainDesktop.Launcher;
@@ -52,7 +55,7 @@ public partial class App : Application
} }
else else
{ {
var splashWindow = new SplashWindow(); var splashWindow = CreateSplashWindow();
splashWindow.Show(); splashWindow.Show();
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow); _ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
} }
@@ -68,7 +71,7 @@ public partial class App : Application
case "preview-splash": case "preview-splash":
{ {
Logger.Info("Preview command: splash."); Logger.Info("Preview command: splash.");
var splashWindow = new SplashWindow(); var splashWindow = CreateSplashWindow();
splashWindow.SetDebugMode(true); splashWindow.SetDebugMode(true);
splashWindow.Show(); splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow); _ = SimulateSplashPreviewAsync(desktop, splashWindow);
@@ -112,6 +115,12 @@ public partial class App : Application
} }
} }
private static SplashWindow CreateSplashWindow()
{
var preferences = StartupVisualPreferencesResolver.Resolve();
return new SplashWindow(preferences.Mode);
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window) private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{ {
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" }; var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
@@ -172,10 +181,13 @@ public partial class App : Application
SplashWindow splashWindow) SplashWindow splashWindow)
{ {
LauncherResult result; LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context);
while (true)
{
try try
{ {
var appRoot = Commands.ResolveAppRoot(context);
Logger.Info( Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
@@ -189,7 +201,7 @@ public partial class App : Application
new UpdateEngineService(deploymentLocator), new UpdateEngineService(deploymentLocator),
new PluginInstallerService()); new PluginInstallerService());
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false); result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -204,17 +216,41 @@ public partial class App : Application
}; };
} }
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}'."); Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false); 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; Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
} }
@@ -238,15 +274,31 @@ public partial class App : Application
} }
} }
private static async Task ShowFailureWindowAsync(LauncherResult result) private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
{ {
ErrorWindow? errorWindow = null; 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(() => await Dispatcher.UIThread.InvokeAsync(() =>
{ {
try try
{ {
errorWindow = new ErrorWindow(); errorWindow = new ErrorWindow();
if (hostProcessAlive)
{
errorWindow.ConfigureForRunningHostFailure(hostPid);
}
else
{
errorWindow.ConfigureForGenericFailure(allowRetry: true);
}
errorWindow.SetErrorMessage( errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show(); errorWindow.Show();
@@ -259,16 +311,38 @@ public partial class App : Application
if (errorWindow is null) if (errorWindow is null)
{ {
return; return ErrorWindowResult.Exit;
} }
try try
{ {
await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error("Failure window closed unexpectedly.", ex); Logger.Error("Failure window closed unexpectedly.", ex);
return ErrorWindowResult.Exit;
}
}
private static async Task<bool> TryActivateExistingInstanceAsync()
{
try
{
using var ipcClient = new LanMountainDesktopIpcClient();
await ipcClient.ConnectAsync().ConfigureAwait(false);
if (!ipcClient.IsConnected)
{
return false;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
return await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
return false;
} }
} }

View File

@@ -35,6 +35,7 @@
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" /> <None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 --> <!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" /> <AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup> </ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build"> <Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Models;
internal enum StartupAttemptState
{
Pending,
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed
}
internal sealed class StartupAttemptRecord
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("hostPid")]
public int HostPid { get; set; }
[JsonPropertyName("startedAtUtc")]
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { 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("state")]
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
}

View File

@@ -10,6 +10,11 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator internal sealed class LauncherFlowCoordinator
{ {
private static readonly TimeSpan StartupSoftTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan StartupHardTimeout = TimeSpan.FromSeconds(120);
private const string SoftTimeoutStatusMessage = "设备较慢,仍在启动,请稍候。";
private const string SoftTimeoutDetailsMessage = "桌面主进程仍在运行Launcher 会继续等待,不会重复启动。";
private static readonly string[] LauncherOnlyOptions = private static readonly string[] LauncherOnlyOptions =
[ [
"debug", "show-loading-details", "plugins-dir", "source", "result", "debug", "show-loading-details", "plugins-dir", "source", "result",
@@ -25,6 +30,7 @@ internal sealed class LauncherFlowCoordinator
private readonly OobeStateService _oobeStateService; private readonly OobeStateService _oobeStateService;
private readonly UpdateEngineService _updateEngine; private readonly UpdateEngineService _updateEngine;
private readonly PluginInstallerService _pluginInstallerService; private readonly PluginInstallerService _pluginInstallerService;
private readonly StartupAttemptRegistry _startupAttemptRegistry;
private readonly IReadOnlyList<IOobeStep> _oobeSteps; private readonly IReadOnlyList<IOobeStep> _oobeSteps;
public LauncherFlowCoordinator( public LauncherFlowCoordinator(
@@ -39,6 +45,7 @@ internal sealed class LauncherFlowCoordinator
_oobeStateService = oobeStateService; _oobeStateService = oobeStateService;
_updateEngine = updateEngine; _updateEngine = updateEngine;
_pluginInstallerService = pluginInstallerService; _pluginInstallerService = pluginInstallerService;
_startupAttemptRegistry = new StartupAttemptRegistry();
_oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)];
} }
@@ -66,6 +73,7 @@ internal sealed class LauncherFlowCoordinator
window.Show(); window.Show();
return window; return window;
}); });
var windowsClosingByCoordinator = false;
var versionInfo = _deploymentLocator.GetVersionInfo(); var versionInfo = _deploymentLocator.GetVersionInfo();
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename); splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
var reporter = (ISplashStageReporter)splashWindow; var reporter = (ISplashStageReporter)splashWindow;
@@ -85,8 +93,25 @@ internal sealed class LauncherFlowCoordinator
var lastStage = StartupStage.Initializing; var lastStage = StartupStage.Initializing;
var lastStageMessage = "launcher-started"; var lastStageMessage = "launcher-started";
var startupSuccessTracker = new StartupSuccessTracker(_context); var startupSuccessTracker = new StartupSuccessTracker(_context);
var activationFailureReason = string.Empty;
var ipcConnected = false;
var softTimeoutShown = false;
var attachedToExistingAttempt = false;
StartupAttemptRecord? trackedAttempt = null;
var loadingState = new LoadingStateMessage(); var loadingState = new LoadingStateMessage();
EventHandler? splashClosedHandler = null;
splashClosedHandler = (_, _) =>
{
if (windowsClosingByCoordinator)
{
return;
}
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
};
splashWindow.Closed += splashClosedHandler;
using var ipcClient = new LanMountainDesktopIpcClient(); using var ipcClient = new LanMountainDesktopIpcClient();
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message => ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
{ {
@@ -94,8 +119,9 @@ internal sealed class LauncherFlowCoordinator
{ {
try try
{ {
ipcConnected = true;
lastStage = message.Stage; lastStage = message.Stage;
lastStageMessage = message.Message ?? string.Empty; lastStageMessage = message.Message ?? message.Stage.ToString();
Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'."); Logger.Info($"IPC stage received. Stage='{message.Stage}'; Message='{message.Message ?? string.Empty}'.");
loadingState = loadingState with loadingState = loadingState with
@@ -108,6 +134,7 @@ internal sealed class LauncherFlowCoordinator
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString()); reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
loadingDetailsWindow?.UpdateLoadingState(loadingState); loadingDetailsWindow?.UpdateLoadingState(loadingState);
_startupAttemptRegistry.UpdateOwnedStage(message.Stage, message.Message, ipcConnected: true);
if (startupSuccessTracker.TryResolve(message.Stage, out var successState)) if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
{ {
@@ -116,6 +143,7 @@ internal sealed class LauncherFlowCoordinator
if (message.Stage == StartupStage.ActivationFailed) if (message.Stage == StartupStage.ActivationFailed)
{ {
activationFailureReason = message.Message ?? "activation_failed";
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed"); activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
} }
} }
@@ -170,7 +198,90 @@ internal sealed class LauncherFlowCoordinator
} }
reporter.Report("launch", "Launching desktop..."); reporter.Report("launch", "Launching desktop...");
var launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false); var launchOutcome = default(HostLaunchOutcome);
var attachableAttempt = _startupAttemptRegistry.TryGetAttachableAttempt(_context.LaunchSource, startupSuccessTracker.PolicyKey);
if (attachableAttempt is not null &&
_startupAttemptRegistry.AdoptAttempt(attachableAttempt.AttemptId) &&
TryGetLiveProcess(attachableAttempt.HostPid, out var attachedProcess))
{
trackedAttempt = attachableAttempt;
attachedToExistingAttempt = true;
ipcConnected = attachableAttempt.IpcConnected;
lastStage = attachableAttempt.LastObservedStage;
lastStageMessage = string.IsNullOrWhiteSpace(attachableAttempt.LastObservedMessage)
? "Attached to the existing startup attempt."
: attachableAttempt.LastObservedMessage;
reporter.Report(MapStartupStageToSplashStage(lastStage), lastStageMessage);
if (startupSuccessTracker.TryResolve(lastStage, out var attachedSuccessState))
{
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedSucceeded(attachedSuccessState.Stage, attachedSuccessState.Message);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: attachedSuccessState.Code,
message: attachedSuccessState.Message,
details: MergeDetails(
launcherContextDetails,
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive: true,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown: false,
recoveryActivationAttempted: false)));
}
if (attachableAttempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting)
{
softTimeoutShown = true;
reporter.Report("delayed", SoftTimeoutStatusMessage);
loadingState = BuildDelayedLoadingState(
loadingState,
SoftTimeoutStatusMessage,
SoftTimeoutDetailsMessage,
trackedAttempt.StartedAtUtc);
loadingDetailsWindow?.UpdateLoadingState(loadingState);
}
launchOutcome = HostLaunchOutcome.FromProcess(
attachedProcess!,
BuildResult(
true,
"launchHost",
"attached_attempt",
"Attached to an existing startup attempt.",
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive: true,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted: false)),
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive: true,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted: false));
}
else
{
launchOutcome = await LaunchHostWithIpcAsync().ConfigureAwait(false);
}
if (!launchOutcome.Result.Success) if (!launchOutcome.Result.Success)
{ {
return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails); return WithAdditionalDetails(launchOutcome.Result, launcherContextDetails);
@@ -189,7 +300,30 @@ internal sealed class LauncherFlowCoordinator
stage: "launch", stage: "launch",
code: "host_start_failed", code: "host_start_failed",
message: "Host launch did not create a process.", message: "Host launch did not create a process.",
details: MergeDetails(launcherContextDetails, launchOutcome.Details)); details: MergeDetails(
launcherContextDetails,
MergeDetails(
launchOutcome.Details,
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive: false,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted: false))));
}
if (!attachedToExistingAttempt)
{
trackedAttempt = _startupAttemptRegistry.StartOwnedAttempt(
launchOutcome.Process.Id,
_context.LaunchSource,
startupSuccessTracker.PolicyKey,
lastStage,
lastStageMessage);
} }
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false); var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
@@ -197,68 +331,172 @@ internal sealed class LauncherFlowCoordinator
{ {
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications."); Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
} }
else
{
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
}
Dictionary<string, string> ComposeLaunchDetails(bool hostProcessAlive, bool recoveryActivationAttempted = false)
{
return MergeDetails(
launcherContextDetails,
MergeDetails(
launchOutcome.Details,
BuildAttemptDetails(
trackedAttempt,
attachedToExistingAttempt,
ipcConnected,
hostProcessAlive,
lastStage,
lastStageMessage,
activationFailureReason,
softTimeoutShown,
recoveryActivationAttempted)));
}
var processExitTask = launchOutcome.Process.WaitForExitAsync(); var processExitTask = launchOutcome.Process.WaitForExitAsync();
var completedTask = await Task.WhenAny( var startedAt = trackedAttempt?.StartedAtUtc ?? DateTimeOffset.UtcNow;
successTcs.Task, var softTimeoutAt = startedAt + StartupSoftTimeout;
activationFailedTcs.Task, var hardTimeoutAt = startedAt + StartupHardTimeout;
processExitTask, var nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
if (completedTask == successTcs.Task) while (true)
{
if (successTcs.Task.IsCompleted)
{ {
var successState = await successTcs.Task.ConfigureAwait(false); var successState = await successTcs.Task.ConfigureAwait(false);
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedSucceeded(successState.Stage, successState.Message);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult( return BuildResult(
success: true, success: true,
stage: "launch", stage: "launch",
code: successState.Code, code: successState.Code,
message: successState.Message, message: successState.Message,
details: MergeDetails(launcherContextDetails, launchOutcome.Details)); details: ComposeLaunchDetails(!launchOutcome.Process.HasExited));
} }
if (completedTask == activationFailedTcs.Task) if (activationFailedTcs.Task.IsCompleted && string.IsNullOrWhiteSpace(activationFailureReason))
{ {
Logger.Warn($"Activation failure received before desktop visibility. Reason='{await activationFailedTcs.Task.ConfigureAwait(false)}'."); activationFailureReason = await activationFailedTcs.Task.ConfigureAwait(false);
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false); Logger.Warn($"Activation failure received before startup success. Reason='{activationFailureReason}'.");
if (retryOutcome is not null)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(retryOutcome, launcherContextDetails);
}
} }
if (completedTask == processExitTask) if (processExitTask.IsCompleted)
{ {
var exitCode = launchOutcome.Process.ExitCode; var exitCode = launchOutcome.Process.ExitCode;
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}."); Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired) windowsClosingByCoordinator = true;
{ if (exitCode == HostExitCodes.SecondaryActivationSucceeded)
var retryOutcome = await RetryActivationAfterEarlyFailureAsync().ConfigureAwait(false);
if (retryOutcome is not null)
{ {
_startupAttemptRegistry.MarkOwnedSucceeded(StartupStage.ActivationRedirected, "Host redirected activation to the existing desktop instance.");
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return WithAdditionalDetails(retryOutcome, launcherContextDetails); return BuildResult(
} success: true,
stage: "launch",
code: "activation_redirected",
message: "Host redirected activation to the existing desktop instance.",
details: MergeDetails(
ComposeLaunchDetails(hostProcessAlive: false),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["exitCode"] = exitCode.ToString()
}));
} }
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult( return BuildResult(
success: false, success: false,
stage: "launch", stage: "launch",
code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early", code: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
message: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_failed"
? "Host redirected activation to the existing desktop instance." : "host_exited_early",
message: exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired
? $"Host activation handshake failed before the required startup state was reported. ExitCode={exitCode}."
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.", : $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string> details: MergeDetails(
ComposeLaunchDetails(hostProcessAlive: false),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["exitCode"] = exitCode.ToString() ["exitCode"] = exitCode.ToString()
}))); }));
}
var now = DateTimeOffset.UtcNow;
if (!ipcConnected &&
!launchOutcome.Process.HasExited &&
now >= nextReconnectAttemptAt)
{
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
if (connected)
{
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
}
nextReconnectAttemptAt = DateTimeOffset.UtcNow.AddSeconds(5);
}
if (!softTimeoutShown &&
now >= softTimeoutAt &&
(!launchOutcome.Process.HasExited || ipcConnected))
{
softTimeoutShown = true;
_startupAttemptRegistry.MarkOwnedSoftTimeout(SoftTimeoutStatusMessage);
reporter.Report("delayed", SoftTimeoutStatusMessage);
loadingState = BuildDelayedLoadingState(
loadingState,
SoftTimeoutStatusMessage,
SoftTimeoutDetailsMessage,
trackedAttempt?.StartedAtUtc ?? startedAt);
loadingDetailsWindow?.UpdateLoadingState(loadingState);
}
if (now >= hardTimeoutAt)
{
break;
}
var nextCheckpointAt = hardTimeoutAt;
if (!softTimeoutShown && softTimeoutAt < nextCheckpointAt)
{
nextCheckpointAt = softTimeoutAt;
}
var delay = nextCheckpointAt - now;
if (delay > TimeSpan.FromSeconds(1))
{
delay = TimeSpan.FromSeconds(1);
}
else if (delay < TimeSpan.FromMilliseconds(100))
{
delay = TimeSpan.FromMilliseconds(100);
}
await Task.WhenAny(
successTcs.Task,
activationFailedTcs.Task,
processExitTask,
Task.Delay(delay)).ConfigureAwait(false);
}
var recoveryActivationAttempted = false;
if (!connected && !launchOutcome.Process.HasExited)
{
connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(1)).ConfigureAwait(false);
if (connected)
{
ipcConnected = true;
_startupAttemptRegistry.MarkOwnedIpcConnected();
}
} }
if (connected && !launchOutcome.Process.HasExited) if (connected && !launchOutcome.Process.HasExited)
{ {
recoveryActivationAttempted = true;
var recoveryOutcome = await TryRecoverWithPublicActivationAsync( var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
ipcClient, ipcClient,
launchOutcome.Process, launchOutcome.Process,
@@ -266,32 +504,40 @@ internal sealed class LauncherFlowCoordinator
startupSuccessTracker).ConfigureAwait(false); startupSuccessTracker).ConfigureAwait(false);
if (recoveryOutcome is not null) if (recoveryOutcome is not null)
{ {
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedSucceeded(recoveryOutcome.Stage, recoveryOutcome.Message);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult( return BuildResult(
success: true, success: true,
stage: "launch", stage: "launch",
code: recoveryOutcome.Code, code: recoveryOutcome.Code,
message: recoveryOutcome.Message, message: recoveryOutcome.Message,
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string> details: ComposeLaunchDetails(
{ !launchOutcome.Process.HasExited,
["recoveryActivationAttempted"] = bool.TrueString recoveryActivationAttempted: true));
})));
} }
} }
windowsClosingByCoordinator = true;
_startupAttemptRegistry.MarkOwnedFailed(lastStage, activationFailureReason);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false); await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult( return BuildResult(
success: false, success: false,
stage: "launch", stage: "launch",
code: "desktop_not_visible", code: "desktop_not_visible",
message: "Host process started, but it never reached the required startup state within 30 seconds.", message: "Host process started, but it never reached the required startup state within 120 seconds.",
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string> details: ComposeLaunchDetails(
{ !launchOutcome.Process.HasExited,
["ipcStage"] = lastStage.ToString(), recoveryActivationAttempted));
["ipcMessage"] = lastStageMessage
})));
} }
finally finally
{
if (splashClosedHandler is not null)
{
splashWindow.Closed -= splashClosedHandler;
}
if (!windowsClosingByCoordinator)
{ {
await Dispatcher.UIThread.InvokeAsync(() => await Dispatcher.UIThread.InvokeAsync(() =>
{ {
@@ -310,6 +556,7 @@ internal sealed class LauncherFlowCoordinator
}); });
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error("Launcher coordinator failed.", ex); Logger.Error("Launcher coordinator failed.", ex);
@@ -372,21 +619,18 @@ internal sealed class LauncherFlowCoordinator
} }
private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) private static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{ {
try try
{ {
if (splashWindow.IsVisible && splashWindow.IsLoaded) await splashWindow.DismissAsync().ConfigureAwait(false);
{
splashWindow.Close();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error("Failed to close splash window.", ex); Logger.Error("Failed to dismiss splash window.", ex);
} }
await Dispatcher.UIThread.InvokeAsync(() =>
{
try try
{ {
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible) if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
@@ -672,6 +916,7 @@ internal sealed class LauncherFlowCoordinator
try try
{ {
errorWindow = new ErrorWindow(); errorWindow = new ErrorWindow();
errorWindow.ConfigureForHostNotFound();
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found."); errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
errorWindow.Show(); errorWindow.Show();
Logger.Warn("Host not found. Showing error window."); Logger.Warn("Host not found. Showing error window.");
@@ -1000,6 +1245,94 @@ internal sealed class LauncherFlowCoordinator
return null; return null;
} }
private static LoadingStateMessage BuildDelayedLoadingState(
LoadingStateMessage loadingState,
string summaryMessage,
string detailMessage,
DateTimeOffset startedAtUtc)
{
var delayedItems = loadingState.ActiveItems
.Where(item => !string.Equals(item.Id, "launcher-soft-timeout", StringComparison.OrdinalIgnoreCase))
.ToList();
delayedItems.Insert(0, new LoadingItem
{
Id = "launcher-soft-timeout",
Type = LoadingItemType.System,
Name = "Startup still in progress",
Description = detailMessage,
State = LoadingState.Delayed,
ProgressPercent = Math.Max(loadingState.OverallProgressPercent, 1),
Message = detailMessage,
StartTime = startedAtUtc
});
return loadingState with
{
ActiveItems = delayedItems,
Message = summaryMessage,
Timestamp = DateTimeOffset.UtcNow,
TotalCount = Math.Max(loadingState.TotalCount, delayedItems.Count)
};
}
private static Dictionary<string, string> BuildAttemptDetails(
StartupAttemptRecord? trackedAttempt,
bool attachedToExistingAttempt,
bool ipcConnected,
bool hostProcessAlive,
StartupStage lastStage,
string lastStageMessage,
string? activationFailureReason,
bool softTimeoutShown,
bool recoveryActivationAttempted)
{
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["hostProcessAlive"] = hostProcessAlive.ToString(),
["attachedToExistingAttempt"] = attachedToExistingAttempt.ToString(),
["ipcConnected"] = ipcConnected.ToString(),
["ipcStage"] = lastStage.ToString(),
["ipcMessage"] = lastStageMessage,
["activationFailureReason"] = activationFailureReason ?? string.Empty,
["softTimeoutShown"] = softTimeoutShown.ToString(),
["recoveryActivationAttempted"] = recoveryActivationAttempted.ToString()
};
if (trackedAttempt is not null)
{
details["startupAttemptId"] = trackedAttempt.AttemptId;
details["startupAttemptState"] = trackedAttempt.State.ToString();
details["startupAttemptStartedAtUtc"] = trackedAttempt.StartedAtUtc.ToString("O");
details["startupAttemptUpdatedAtUtc"] = trackedAttempt.UpdatedAtUtc.ToString("O");
details["successPolicy"] = trackedAttempt.SuccessPolicy;
details["hostPid"] = trackedAttempt.HostPid.ToString();
}
return details;
}
private static bool TryGetLiveProcess(int processId, out Process? process)
{
process = null;
if (processId <= 0)
{
return false;
}
try
{
process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
process?.Dispose();
process = null;
return false;
}
}
private enum HostStartMode private enum HostStartMode
{ {
ShellExecute, ShellExecute,
@@ -1048,6 +1381,8 @@ internal sealed class LauncherFlowCoordinator
private bool _trayReady; private bool _trayReady;
private bool _backgroundReady; private bool _backgroundReady;
public string PolicyKey => _policy.ToString();
public StartupSuccessTracker(CommandContext context) public StartupSuccessTracker(CommandContext context)
{ {
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs); var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);

View File

@@ -0,0 +1,313 @@
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 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,
LaunchSource = launchSource,
SuccessPolicy = successPolicy,
LastObservedStage = stage,
LastObservedMessage = message ?? string.Empty,
StartedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
State = StartupAttemptState.Pending
};
ExecuteWithLock(() =>
{
SaveUnsafe(record);
_ownedAttemptId = record.AttemptId;
});
return Clone(record);
}
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);
}
public void UpdateOwnedStage(StartupStage stage, string? message, bool ipcConnected)
{
UpdateOwned(record =>
{
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? string.Empty;
if (ipcConnected)
{
record.IpcConnected = true;
}
});
}
public void MarkOwnedSoftTimeout(string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.SoftTimeout;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedDetachedWaiting()
{
UpdateOwned(record =>
{
if (record.State is StartupAttemptState.Pending or StartupAttemptState.SoftTimeout)
{
record.State = StartupAttemptState.DetachedWaiting;
}
});
}
public void MarkOwnedSucceeded(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Succeeded;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
public void MarkOwnedFailed(StartupStage stage, string? message)
{
UpdateOwned(record =>
{
record.State = StartupAttemptState.Failed;
record.LastObservedStage = stage;
record.LastObservedMessage = message ?? record.LastObservedMessage;
});
}
private void UpdateOwned(Action<StartupAttemptRecord> update)
{
if (string.IsNullOrWhiteSpace(_ownedAttemptId))
{
return;
}
ExecuteWithLock(() =>
{
var record = LoadUnsafe();
if (record is null || !string.Equals(record.AttemptId, _ownedAttemptId, StringComparison.Ordinal))
{
return;
}
update(record);
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
SaveUnsafe(record);
});
}
private void ExecuteWithLock(Action action)
{
using var mutex = new Mutex(false, _mutexName);
var hasHandle = false;
try
{
try
{
hasHandle = mutex.WaitOne(TimeSpan.FromSeconds(2));
}
catch (AbandonedMutexException)
{
hasHandle = true;
}
if (!hasHandle)
{
return;
}
action();
}
finally
{
if (hasHandle)
{
mutex.ReleaseMutex();
}
}
}
private StartupAttemptRecord? LoadUnsafe()
{
if (!File.Exists(_statePath))
{
return null;
}
try
{
var json = File.ReadAllText(_statePath);
return JsonSerializer.Deserialize<StartupAttemptRecord>(json, SerializerOptions);
}
catch
{
return null;
}
}
private void SaveUnsafe(StartupAttemptRecord record)
{
var directory = Path.GetDirectoryName(_statePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions));
}
private static bool IsAttachable(StartupAttemptRecord record)
{
if (record.State is not (StartupAttemptState.Pending or StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting))
{
return false;
}
return TryGetLiveProcess(record.HostPid, out _);
}
private static bool 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,
StartedAtUtc = record.StartedAtUtc,
UpdatedAtUtc = record.UpdatedAtUtc,
LaunchSource = record.LaunchSource,
SuccessPolicy = record.SuccessPolicy,
LastObservedStage = record.LastObservedStage,
LastObservedMessage = record.LastObservedMessage,
IpcConnected = record.IpcConnected,
State = record.State
};
}
}

View File

@@ -3,102 +3,96 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow" x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
x:DataType="views:ErrorWindow" x:DataType="views:ErrorWindow"
Title="阑山桌面" Title="LanMountain Desktop"
Width="520" Width="560"
Height="280" Height="320"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" Background="#111318"
TransparencyLevelHint="None" TransparencyLevelHint="None"
Icon="/Assets/logo.ico"> Icon="/Assets/logo.ico">
<Design.DataContext> <Design.DataContext>
<views:ErrorWindow /> <views:ErrorWindow />
</Design.DataContext> </Design.DataContext>
<!-- Fluent Design 风格对话框布局 -->
<Grid RowDefinitions="*,Auto"> <Grid RowDefinitions="*,Auto">
<!-- 主内容区域:左侧图标 + 右侧文字 --> <Grid Grid.Row="0"
<Grid Grid.Row="0" Margin="24,24,24,16" ColumnDefinitions="Auto,*"> Margin="24"
ColumnDefinitions="Auto,*">
<!-- 左侧:错误图标(可点击进入调试模式) -->
<Border x:Name="ErrorIconBorder" <Border x:Name="ErrorIconBorder"
Grid.Column="0" Grid.Column="0"
Width="48" Width="52"
Height="48" Height="52"
Margin="0,4,16,0" Margin="0,4,18,0"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}" Background="#2B161A"
CornerRadius="24" CornerRadius="26"
VerticalAlignment="Top"> VerticalAlignment="Top">
<TextBlock Text="&#xEA39;" <TextBlock Text="!"
FontSize="24" FontSize="24"
FontFamily="{DynamicResource SymbolThemeFontFamily}" FontWeight="Bold"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" Foreground="#FFB4AB"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Border> </Border>
<!-- 右侧:标题 + 内容 --> <StackPanel Grid.Column="1"
<StackPanel Grid.Column="1" Spacing="8"> Spacing="10">
<!-- 标题 -->
<TextBlock x:Name="TitleText" <TextBlock x:Name="TitleText"
Text="启动失败" Text="Launcher could not confirm startup"
FontSize="18" FontSize="20"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" Foreground="#F6F7FB"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<!-- 错误信息 -->
<TextBlock x:Name="ErrorMessageText" <TextBlock x:Name="ErrorMessageText"
Text="找不到阑山桌面应用程序。" Text="LanMountain Desktop did not reach the expected startup state."
FontSize="14" FontSize="14"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="#D2D7E1"
TextWrapping="Wrap"
LineHeight="22" />
<TextBlock x:Name="SuggestionText"
Text="You can inspect logs, retry when the old process is gone, or reactivate the current instance."
FontSize="13"
Foreground="#9BA5B7"
TextWrapping="Wrap" TextWrapping="Wrap"
LineHeight="20" /> LineHeight="20" />
<!-- 建议信息 -->
<TextBlock x:Name="SuggestionText"
Text="请确保应用程序已正确安装,或尝试重新安装。"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
TextWrapping="Wrap"
LineHeight="18"
Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- 底部:按钮区域 -->
<Border Grid.Row="1" <Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Padding="24,16"
Padding="24,16"> Background="#171A21">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto,Auto,Auto"
ColumnSpacing="8">
<Button x:Name="OpenLogButton" <Button x:Name="OpenLogButton"
Grid.Column="0" Grid.Column="0"
Content="打开日志" Content="Open Logs"
Width="100" MinWidth="108"
Height="32" Height="34"
FontSize="13"
HorizontalAlignment="Left" /> HorizontalAlignment="Left" />
<StackPanel Grid.Column="1"
Orientation="Horizontal" <Button x:Name="SecondaryActionButton"
Spacing="8"> Grid.Column="1"
Content="Wait"
MinWidth="108"
Height="34"
IsVisible="False" />
<Button x:Name="ExitButton" <Button x:Name="ExitButton"
Content="退出" Grid.Column="2"
Width="80" Content="Exit"
Height="32" MinWidth="90"
FontSize="13"/> Height="34" />
<Button x:Name="RetryButton"
Content="重试" <Button x:Name="PrimaryActionButton"
Width="80" Grid.Column="3"
Height="32" Content="Retry"
FontSize="13" MinWidth="108"
Theme="{DynamicResource AccentButtonTheme}"/> Height="34" />
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View File

@@ -1,542 +1,365 @@
using System.Diagnostics;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Launcher.Services;
using System.Diagnostics;
namespace LanMountainDesktop.Launcher.Views; namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
/// </summary>
public partial class ErrorWindow : Window public partial class ErrorWindow : Window
{ {
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
private int _iconClickCount = 0;
private const int DebugModeClickThreshold = 5; 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 bool _devModeEnabled;
private string? _customHostPath;
private ErrorWindowResult _primaryAction = ErrorWindowResult.Retry;
private ErrorWindowResult? _secondaryAction;
public ErrorWindow() public ErrorWindow()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
// 先加载保存的状态
_devModeEnabled = LoadDevModeStateInternal(); _devModeEnabled = LoadDevModeStateInternal();
_customHostPath = LoadCustomHostPathInternal(); _customHostPath = LoadCustomHostPathInternal();
// 延迟到窗口加载完成后再初始化组件,确保视觉树已准备好 Loaded += OnWindowLoaded;
this.Loaded += OnWindowLoaded; Closed += (_, _) => _completionSource.TrySetResult(ErrorWindowResult.Exit);
this.Opened += OnWindowOpened; 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) public void SetErrorMessage(string message)
{ {
var errorText = this.FindControl<TextBlock>("ErrorMessageText"); if (this.FindControl<TextBlock>("ErrorMessageText") is { } errorText)
if (errorText is not null)
{ {
errorText.Text = message; errorText.Text = message;
} }
} }
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode) public void SetDebugMode(bool isDebugMode)
{ {
_isDebugMode = isDebugMode; _isDebugMode = isDebugMode;
var titleText = this.FindControl<TextBlock>("TitleText"); if (isDebugMode && this.FindControl<TextBlock>("TitleText") is { } titleText)
if (titleText is not null && isDebugMode)
{ {
titleText.Text = "[调试模式] 错误页面"; titleText.Text = "[Debug] Launcher error";
} }
} }
/// <summary> public void ConfigureForHostNotFound()
/// 获取用户选择的主程序路径 {
/// </summary> 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);
}
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 string? GetCustomHostPath() => _customHostPath;
/// <summary>
/// 是否启用了开发模式
/// </summary>
public bool IsDevModeEnabled() => _devModeEnabled; public bool IsDevModeEnabled() => _devModeEnabled;
/// <summary> public Task<ErrorWindowResult> WaitForChoiceAsync() => _completionSource.Task;
/// 等待用户选择
/// </summary> public static bool CheckDevModeEnabled() => LoadDevModeStateInternal();
public Task<ErrorWindowResult> WaitForChoiceAsync()
public static string? GetSavedCustomHostPath() => LoadCustomHostPathInternal();
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{ {
return _completionSource.Task; if (this.FindControl<Border>("ErrorIconBorder") is { } errorIconBorder)
{
errorIconBorder.PointerPressed += OnErrorIconClick;
} }
/// <summary> if (this.FindControl<Button>("PrimaryActionButton") is { } primaryActionButton)
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能) {
/// </summary> primaryActionButton.Click += OnPrimaryActionClick;
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e) }
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++; _iconClickCount++;
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode) if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
{ {
EnterDebugMode(); EnterDebugMode();
} }
} }
/// <summary>
/// 进入调试模式 - 显示调试窗口
/// </summary>
private async void EnterDebugMode() private async void EnterDebugMode()
{ {
_isDebugMode = true; _isDebugMode = true;
// 创建并显示调试窗口
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath) var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
{ {
WindowStartupLocation = WindowStartupLocation.CenterOwner WindowStartupLocation = WindowStartupLocation.CenterOwner
}; };
// 订阅调试窗口关闭事件 debugWindow.Closed += (_, _) =>
debugWindow.Closed += (s, e) =>
{ {
// 更新状态
_devModeEnabled = debugWindow.IsDevModeEnabled; _devModeEnabled = debugWindow.IsDevModeEnabled;
_customHostPath = debugWindow.SelectedHostPath; _customHostPath = debugWindow.SelectedHostPath;
// 保存开发模式状态和自定义路径
SaveDevModeStateInternal(_devModeEnabled); SaveDevModeStateInternal(_devModeEnabled);
SaveCustomHostPathInternal(_customHostPath); SaveCustomHostPathInternal(_customHostPath);
// 如果启用了开发模式且没有选择路径,自动扫描 if (_devModeEnabled && string.IsNullOrWhiteSpace(_customHostPath))
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
{ {
ScanDevPaths(); ScanDevPaths();
// 扫描到路径后也保存
if (!string.IsNullOrEmpty(_customHostPath))
{
SaveCustomHostPathInternal(_customHostPath); SaveCustomHostPathInternal(_customHostPath);
} }
}
_isDebugMode = false;
_iconClickCount = 0;
}; };
await debugWindow.ShowDialog(this); 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) private async void OnOpenLogClick(object? sender, RoutedEventArgs e)
{ {
try try
{ {
var logFilePath = Logger.GetLogFilePath(); var logFilePath = Logger.GetLogFilePath();
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
if (string.IsNullOrEmpty(logFilePath) || !File.Exists(logFilePath))
{ {
// 如果没有日志文件,打开日志目录 OpenPath(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");
}
}
return; return;
} }
Console.WriteLine($"[ErrorWindow] Opening log file: {logFilePath}"); var logDirectory = !string.IsNullOrWhiteSpace(logFilePath)
OpenFile(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) 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> private static void OpenPath(string path)
/// 打开文件
/// </summary>
private static void OpenFile(string filePath)
{
try
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = "explorer.exe", FileName = "explorer.exe",
Arguments = $"\"{filePath}\"", Arguments = $"\"{path}\"",
UseShellExecute = true UseShellExecute = true
}); });
return;
} }
else if (OperatingSystem.IsMacOS())
if (OperatingSystem.IsMacOS())
{ {
Process.Start("open", filePath); Process.Start("open", path);
return;
} }
else if (OperatingSystem.IsLinux())
if (OperatingSystem.IsLinux())
{ {
Process.Start("xdg-open", filePath); Process.Start("xdg-open", path);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ErrorWindow] Failed to open file: {ex.Message}");
} }
} }
/// <summary> private static string GetConfigBaseDirectory()
/// 打开文件夹 {
/// </summary> var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
private static void OpenFolder(string folderPath) if (!string.IsNullOrWhiteSpace(appData))
{
return Path.Combine(appData, "LanMountainDesktop", ".launcher");
}
return Path.Combine(AppContext.BaseDirectory, ".launcher");
}
private static string GetDevModePath() => Path.Combine(GetConfigBaseDirectory(), "dev-mode.flag");
private static string GetCustomHostPathFile() => Path.Combine(GetConfigBaseDirectory(), "custom-host-path.txt");
private static bool LoadDevModeStateInternal()
{ {
try try
{ {
if (OperatingSystem.IsWindows()) return File.Exists(GetDevModePath()) &&
{ bool.TryParse(File.ReadAllText(GetDevModePath()).Trim(), out var enabled) &&
Process.Start(new ProcessStartInfo enabled;
{
FileName = "explorer.exe",
Arguments = $"\"{folderPath}\"",
UseShellExecute = true
});
} }
else if (OperatingSystem.IsMacOS()) catch
{ {
Process.Start("open", folderPath); return false;
}
else if (OperatingSystem.IsLinux())
{
Process.Start("xdg-open", folderPath);
} }
} }
catch (Exception ex)
private static void SaveDevModeStateInternal(bool enabled)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetDevModePath(), enabled.ToString());
}
catch
{
}
}
private static string? LoadCustomHostPathInternal()
{
try
{
var pathFile = GetCustomHostPathFile();
if (!File.Exists(pathFile))
{
return null;
}
var savedPath = File.ReadAllText(pathFile).Trim();
return string.IsNullOrWhiteSpace(savedPath) ? null : savedPath;
}
catch
{
return null;
}
}
private static void SaveCustomHostPathInternal(string? customHostPath)
{
try
{
Directory.CreateDirectory(GetConfigBaseDirectory());
File.WriteAllText(GetCustomHostPathFile(), customHostPath ?? string.Empty);
}
catch
{ {
Console.Error.WriteLine($"[ErrorWindow] Failed to open folder: {ex.Message}");
} }
} }
} }
/// <summary>
/// 错误窗口用户选择结果
/// </summary>
public enum ErrorWindowResult public enum ErrorWindowResult
{ {
/// <summary>
/// 重试
/// </summary>
Retry, Retry,
Exit,
/// <summary> ActivateExisting,
/// 退出 ContinueWaiting
/// </summary>
Exit
} }

View File

@@ -3,76 +3,82 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views" xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="480"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow" x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
x:DataType="views:SplashWindow" x:DataType="views:SplashWindow"
Title="LanMountain Desktop" Title="LanMountain Desktop"
Width="480" Width="480"
Height="320" Height="320"
CanResize="False" CanResize="False"
ShowInTaskbar="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
SystemDecorations="None" SystemDecorations="None"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" Background="#0B0B0B"
TransparencyLevelHint="None" TransparencyLevelHint="None"
Icon="/Assets/logo.ico"> Icon="/Assets/logo.ico">
<Design.DataContext> <Design.DataContext>
<views:SplashWindow /> <views:SplashWindow />
</Design.DataContext> </Design.DataContext>
<Grid> <Grid RowDefinitions="*,Auto"
<!-- 左上角:应用名称 --> Background="#0B0B0B">
<Grid Grid.Row="0">
<Grid x:Name="CompactHero"
Margin="24">
<TextBlock x:Name="AppNameText" <TextBlock x:Name="AppNameText"
Text="LanMountain Desktop" Text="LanMountain Desktop"
FontSize="24" FontSize="24"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Top" VerticalAlignment="Top"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Margin="24,24,0,0" Foreground="#F6F7FB" />
Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> </Grid>
<!-- 底部区域:进度条和状态 --> <Grid x:Name="FullscreenHero"
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24"> IsVisible="False">
<Grid.RowDefinitions> <StackPanel HorizontalAlignment="Center"
<RowDefinition Height="Auto"/> VerticalAlignment="Center"
<RowDefinition Height="Auto"/> Spacing="24">
</Grid.RowDefinitions> <Border Width="240"
Height="240"
<!-- 第一行:左下角版本信息,右下角阶段文字 --> Background="Transparent">
<Grid Grid.Row="0" Margin="0,0,0,8"> <Image Source="/Assets/logo_nightly.png"
<Grid.ColumnDefinitions> Stretch="Uniform" />
<ColumnDefinition Width="*"/> </Border>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> <TextBlock Text="LanMountain Desktop"
HorizontalAlignment="Center"
<!-- 左下角:版本和开发代号 - 可点击打开开发者界面(隐藏功能) --> FontSize="26"
<Border x:Name="VersionTextBorder" FontWeight="SemiBold"
Grid.Column="0" Foreground="#F6F7FB" />
Background="Transparent" </StackPanel>
Cursor="Hand" </Grid>
HorizontalAlignment="Left" </Grid>
VerticalAlignment="Bottom">
<TextBlock x:Name="VersionText" <Border Grid.Row="1"
FontSize="11" Padding="24,18,24,24"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Background="Transparent">
Opacity="0.8" <Grid RowDefinitions="Auto,Auto"
Text="1.0.0 (Administrate)" /> 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> </Border>
<!-- 右下角:阶段文字 -->
<TextBlock x:Name="StatusText" <TextBlock x:Name="StatusText"
Grid.Column="1" Grid.Column="1"
FontSize="11" FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="#B9C0CC"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Opacity="0.8"
Text="Initializing..." /> Text="Initializing..." />
</Grid> </Grid>
<!-- 底部:进度条 -->
<ProgressBar x:Name="ProgressIndicator" <ProgressBar x:Name="ProgressIndicator"
Grid.Row="1" Grid.Row="1"
Minimum="0" Minimum="0"
@@ -80,8 +86,9 @@
Value="0" Value="0"
Height="4" Height="4"
IsIndeterminate="False" IsIndeterminate="False"
Foreground="{DynamicResource AccentFillColorDefaultBrush}" Foreground="#F6F7FB"
Background="{DynamicResource ControlStrokeColorDefaultBrush}" /> Background="#2C313D" />
</Grid> </Grid>
</Border>
</Grid> </Grid>
</Window> </Window>

View File

@@ -1,122 +1,116 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services; using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Views; namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// 启动画面窗口 - 简洁设计
/// </summary>
public partial class SplashWindow : Window, ISplashStageReporter public partial class SplashWindow : Window, ISplashStageReporter
{ {
private int _versionTextClickCount = 0;
private const int DebugModeClickThreshold = 5; 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() public SplashWindow()
: this(StartupVisualMode.Fade)
{ {
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再绑定事件
this.Loaded += OnWindowLoaded;
} }
/// <summary> public SplashWindow(StartupVisualMode mode)
/// 窗口加载完成事件 {
/// </summary> _mode = mode;
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
}
private void OnWindowLoaded(object? sender, RoutedEventArgs e) private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{ {
Console.WriteLine("[SplashWindow] Window loaded, binding events..."); if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
// 绑定版本文本点击事件隐藏功能点击5次打开开发者界面
var versionTextBorder = this.FindControl<Border>("VersionTextBorder");
if (versionTextBorder is not null)
{ {
versionTextBorder.PointerPressed += OnVersionTextClick; versionBorder.PointerPressed += OnVersionTextClick;
Console.WriteLine("[SplashWindow] VersionTextBorder click event bound");
}
else
{
Console.Error.WriteLine("[SplashWindow] Failed to find VersionTextBorder!");
} }
} }
/// <summary> private async void OnWindowOpened(object? sender, EventArgs e)
/// 版本文本点击事件 - 连续点击5次打开开发者界面隐藏功能
/// </summary>
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
{ {
if (_isDebugModeOpened) return; if (_isOpened)
_versionTextClickCount++;
Console.WriteLine($"[SplashWindow] Version text clicked {_versionTextClickCount}/{DebugModeClickThreshold}");
if (_versionTextClickCount >= DebugModeClickThreshold)
{ {
OpenDebugWindow(); 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);
} }
} }
/// <summary> public async Task DismissAsync()
/// 打开开发者调试窗口
/// </summary>
private async void OpenDebugWindow()
{ {
_isDebugModeOpened = true; if (_dismissed)
Console.WriteLine("[SplashWindow] Opening debug window...");
try
{ {
// 加载保存的状态 return;
var devModeEnabled = ErrorWindow.CheckDevModeEnabled(); }
var customHostPath = ErrorWindow.GetSavedCustomHostPath();
_dismissed = true;
var debugWindow = new ErrorDebugWindow(devModeEnabled, customHostPath) ConfigureForVisualMode();
{
WindowStartupLocation = WindowStartupLocation.CenterScreen if (_mode == StartupVisualMode.SlideSplash)
}; {
var from = Position;
// 订阅窗口关闭事件以保存状态 await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
debugWindow.Closed += (s, e) => }
{ else if (_mode == StartupVisualMode.Fade)
Console.WriteLine("[SplashWindow] Debug window closed"); {
_isDebugModeOpened = false; await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
_versionTextClickCount = 0; }
};
await Dispatcher.UIThread.InvokeAsync(() =>
await debugWindow.ShowDialog(this); {
} if (IsVisible)
catch (Exception ex) {
{ Close();
Console.Error.WriteLine($"[SplashWindow] Error opening debug window: {ex.Message}"); }
_isDebugModeOpened = false; });
_versionTextClickCount = 0;
}
} }
/// <summary>
/// 更新进度和状态
/// </summary>
public void Report(string stage, string message) public void Report(string stage, string message)
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
var statusText = this.FindControl<TextBlock>("StatusText"); if (this.FindControl<TextBlock>("StatusText") is { } 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}"); statusText.Text = message;
return;
} }
// 更新状态文本 if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
statusText.Text = message; {
// 根据阶段更新进度
var progress = ResolveProgress(stage); var progress = ResolveProgress(stage);
if (progress > 0) if (progress > 0)
{ {
@@ -127,124 +121,223 @@ public partial class SplashWindow : Window, ISplashStageReporter
{ {
progressIndicator.IsIndeterminate = true; progressIndicator.IsIndeterminate = true;
} }
}
}); });
} }
/// <summary>
/// 更新进度0-100
/// </summary>
public void UpdateProgress(int percent, string? message = null)
{
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 UpdateProgress");
return;
}
if (!string.IsNullOrEmpty(message))
{
statusText.Text = message;
}
progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(percent, 0, 100);
});
}
/// <summary>
/// 更新状态文本
/// </summary>
public void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() =>
{
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in UpdateStatus");
return;
}
statusText.Text = message;
});
}
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
public void ReportStage(string stage, int progress) public void ReportStage(string stage, int progress)
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
var statusText = this.FindControl<TextBlock>("StatusText"); if (this.FindControl<TextBlock>("StatusText") is { } statusText)
var progressIndicator = this.FindControl<ProgressBar>("ProgressIndicator");
if (statusText is null || progressIndicator is null)
{ {
Console.Error.WriteLine($"[SplashWindow] Controls not found in ReportStage"); statusText.Text = stage;
return;
} }
statusText.Text = stage; if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
{
progressIndicator.IsIndeterminate = false; progressIndicator.IsIndeterminate = false;
progressIndicator.Value = Math.Clamp(progress, 0, 100); 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;
}
}); });
} }
/// <summary>
/// 设置版本和开发代号
/// </summary>
public void SetVersionInfo(string version, string codename) public void SetVersionInfo(string version, string codename)
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
var versionText = this.FindControl<TextBlock>("VersionText"); if (this.FindControl<TextBlock>("VersionText") is { } versionText)
if (versionText is null)
{ {
Console.Error.WriteLine($"[SplashWindow] VersionText not found in SetVersionInfo");
return;
}
versionText.Text = $"{version} ({codename})"; versionText.Text = $"{version} ({codename})";
}
}); });
} }
/// <summary>
/// 设置调试模式
/// </summary>
public void SetDebugMode(bool isDebugMode) public void SetDebugMode(bool isDebugMode)
{ {
Dispatcher.UIThread.Post(() => if (!isDebugMode)
{ {
var statusText = this.FindControl<TextBlock>("StatusText");
if (statusText is null)
{
Console.Error.WriteLine($"[SplashWindow] StatusText not found in SetDebugMode");
return; return;
} }
if (isDebugMode)
{ UpdateStatus("[Debug Mode] Splash Preview");
statusText.Text = "[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++;
if (_versionTextClickCount >= DebugModeClickThreshold)
{
OpenDebugWindow();
}
}
private async void OpenDebugWindow()
{
_isDebugModeOpened = true;
try
{
var debugWindow = new ErrorDebugWindow(
ErrorWindow.CheckDevModeEnabled(),
ErrorWindow.GetSavedCustomHostPath())
{
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
debugWindow.Closed += (_, _) =>
{
_isDebugModeOpened = false;
_versionTextClickCount = 0;
};
await debugWindow.ShowDialog(this);
}
catch (Exception ex)
{
Debug.WriteLine($"[SplashWindow] Failed to open debug window: {ex}");
_isDebugModeOpened = false;
_versionTextClickCount = 0;
}
}
private async Task AnimateOpacityAsync(double from, double to, TimeSpan duration)
{
await AnimateAsync(progress =>
{
Opacity = from + ((to - from) * progress);
}, duration, EaseOutCubic).ConfigureAwait(false);
}
private async Task AnimateWindowPositionAsync(
PixelPoint from,
PixelPoint to,
TimeSpan duration,
Func<double, double> easing)
{
await AnimateAsync(progress =>
{
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);
}
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
{
if (duration <= TimeSpan.Zero)
{
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>
/// 根据阶段名称解析进度值
/// </summary>
private static int ResolveProgress(string stage) private static int ResolveProgress(string stage)
{ {
return stage.ToLowerInvariant() switch return stage.ToLowerInvariant() switch
{ {
"initializing" => 10, "initializing" => 10,
"settings" => 25,
"update" => 30, "update" => 30,
"plugins" => 50, "plugins" => 50,
"launch" => 70, "ui" => 65,
"shell" => 80,
"activation" => 90,
"ready" => 100, "ready" => 100,
_ => 0 _ => 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

@@ -1,231 +1,85 @@
namespace LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Shared.Contracts.Launcher;
/// <summary>
/// 加载项类型
/// </summary>
public enum LoadingItemType public enum LoadingItemType
{ {
/// <summary>
/// 系统初始化
/// </summary>
System, System,
/// <summary>
/// 设置加载
/// </summary>
Settings, Settings,
/// <summary>
/// 插件
/// </summary>
Plugin, Plugin,
/// <summary>
/// 组件
/// </summary>
Component, Component,
/// <summary>
/// 资源
/// </summary>
Resource, Resource,
/// <summary>
/// 数据
/// </summary>
Data, Data,
/// <summary>
/// 网络请求
/// </summary>
Network, Network,
/// <summary>
/// 其他
/// </summary>
Other Other
} }
/// <summary>
/// 加载状态
/// </summary>
public enum LoadingState public enum LoadingState
{ {
/// <summary>
/// 等待中
/// </summary>
Pending, Pending,
/// <summary>
/// 进行中
/// </summary>
InProgress, InProgress,
/// <summary>
/// 已完成
/// </summary>
Completed, Completed,
Delayed,
/// <summary>
/// 失败
/// </summary>
Failed, Failed,
/// <summary>
/// 已取消
/// </summary>
Cancelled, Cancelled,
/// <summary>
/// 超时
/// </summary>
Timeout Timeout
} }
/// <summary>
/// 加载项信息
/// </summary>
public record LoadingItem public record LoadingItem
{ {
/// <summary>
/// 加载项唯一标识
/// </summary>
public required string Id { get; init; } public required string Id { get; init; }
/// <summary>
/// 加载项类型
/// </summary>
public LoadingItemType Type { get; init; } public LoadingItemType Type { get; init; }
/// <summary>
/// 加载项名称
/// </summary>
public required string Name { get; init; } public required string Name { get; init; }
/// <summary>
/// 加载项描述
/// </summary>
public string? Description { get; init; } public string? Description { get; init; }
/// <summary>
/// 当前状态
/// </summary>
public LoadingState State { get; init; } public LoadingState State { get; init; }
/// <summary>
/// 进度百分比 (0-100)
/// </summary>
public int ProgressPercent { get; init; } public int ProgressPercent { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; } public string? Message { get; init; }
/// <summary>
/// 错误信息(当 State 为 Failed 时)
/// </summary>
public string? ErrorMessage { get; init; } public string? ErrorMessage { get; init; }
/// <summary>
/// 开始时间
/// </summary>
public DateTimeOffset? StartTime { get; init; } public DateTimeOffset? StartTime { get; init; }
/// <summary>
/// 结束时间
/// </summary>
public DateTimeOffset? EndTime { get; init; } public DateTimeOffset? EndTime { get; init; }
/// <summary>
/// 预计剩余时间(秒)
/// </summary>
public int? EstimatedRemainingSeconds { get; init; } public int? EstimatedRemainingSeconds { get; init; }
/// <summary>
/// 子加载项
/// </summary>
public List<LoadingItem>? Children { get; init; } public List<LoadingItem>? Children { get; init; }
/// <summary>
/// 额外数据
/// </summary>
public Dictionary<string, string>? Metadata { get; init; } public Dictionary<string, string>? Metadata { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
} }
/// <summary>
/// 加载状态更新消息
/// </summary>
public record LoadingStateMessage public record LoadingStateMessage
{ {
/// <summary>
/// 当前启动阶段
/// </summary>
public StartupStage Stage { get; init; } public StartupStage Stage { get; init; }
/// <summary>
/// 整体进度百分比 (0-100)
/// </summary>
public int OverallProgressPercent { get; init; } public int OverallProgressPercent { get; init; }
/// <summary>
/// 当前活动的加载项
/// </summary>
public List<LoadingItem> ActiveItems { get; init; } = new(); public List<LoadingItem> ActiveItems { get; init; } = new();
/// <summary>
/// 已完成的加载项数量
/// </summary>
public int CompletedCount { get; init; } public int CompletedCount { get; init; }
/// <summary>
/// 总加载项数量
/// </summary>
public int TotalCount { get; init; } public int TotalCount { get; init; }
/// <summary>
/// 状态消息
/// </summary>
public string? Message { get; init; } public string? Message { get; init; }
/// <summary>
/// 是否有错误
/// </summary>
public bool HasErrors { get; init; } public bool HasErrors { get; init; }
/// <summary>
/// 错误消息列表
/// </summary>
public List<string>? ErrorMessages { get; init; } public List<string>? ErrorMessages { get; init; }
/// <summary>
/// 时间戳
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
} }
/// <summary>
/// 详细的加载进度消息(用于实时更新)
/// </summary>
public record DetailedProgressMessage : StartupProgressMessage public record DetailedProgressMessage : StartupProgressMessage
{ {
/// <summary>
/// 当前加载项
/// </summary>
public LoadingItem? CurrentItem { get; init; } public LoadingItem? CurrentItem { get; init; }
/// <summary>
/// 所有加载项
/// </summary>
public List<LoadingItem>? AllItems { get; init; } public List<LoadingItem>? AllItems { get; init; }
/// <summary>
/// 是否为主要更新
/// </summary>
public bool IsMajorUpdate { get; init; } 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

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

@@ -719,7 +719,10 @@ public partial class App : Application
mainWindow.WindowState = WindowState.Normal; mainWindow.WindowState = WindowState.Normal;
} }
if (mainWindow.WindowState != WindowState.FullScreen) mainWindow.EnsureForegroundWindowLayout();
if (mainWindow.ShouldUseFullscreenWindow() &&
mainWindow.WindowState != WindowState.FullScreen)
{ {
mainWindow.WindowState = WindowState.FullScreen; mainWindow.WindowState = WindowState.FullScreen;
} }
@@ -1359,7 +1362,10 @@ public partial class App : Application
mainWindow.WindowState = WindowState.Normal; mainWindow.WindowState = WindowState.Normal;
} }
if (mainWindow.WindowState != WindowState.FullScreen) mainWindow.EnsureForegroundWindowLayout();
if (mainWindow.ShouldUseFullscreenWindow() &&
mainWindow.WindowState != WindowState.FullScreen)
{ {
mainWindow.WindowState = WindowState.FullScreen; mainWindow.WindowState = WindowState.FullScreen;
} }

View File

@@ -152,6 +152,8 @@ public sealed class AppSettingsSnapshot
public bool EnableThreeFingerSwipe { get; set; } = false; public bool EnableThreeFingerSwipe { get; set; } = false;
public bool EnableFadeTransition { get; set; } = true;
public bool EnableSlideTransition { get; set; } = false; public bool EnableSlideTransition { get; set; } = false;
public bool ShowInTaskbar { get; set; } = false; public bool ShowInTaskbar { get; set; } = false;

View File

@@ -13,6 +13,7 @@ using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.ViewModels; namespace LanMountainDesktop.ViewModels;
@@ -201,7 +202,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
SelectedRenderMode = RenderModes.FirstOrDefault(option => SelectedRenderMode = RenderModes.FirstOrDefault(option =>
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase)) string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
?? RenderModes[0]; ?? RenderModes[0];
EnableSlideTransition = appSnapshot.EnableSlideTransition; ApplyTransitionPreferences(appSnapshot.EnableFadeTransition, appSnapshot.EnableSlideTransition);
ShowInTaskbar = appSnapshot.ShowInTaskbar; ShowInTaskbar = appSnapshot.ShowInTaskbar;
_isInitializing = false; _isInitializing = false;
@@ -235,9 +236,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
return; 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))) if (changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar)))
@@ -263,6 +266,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
[ObservableProperty] [ObservableProperty]
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default"); private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
[ObservableProperty]
private bool _enableFadeTransition = true;
[ObservableProperty] [ObservableProperty]
private bool _enableSlideTransition; 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 IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition;
public string FadeTransitionDescription => EnableSlideTransition
? "滑动模式已启用,淡入淡出不可同时使用。"
: "启用后,启动与恢复过程使用淡入淡出效果。";
[ObservableProperty] [ObservableProperty]
private string _pageTitle = string.Empty; private string _pageTitle = string.Empty;
@@ -372,8 +384,22 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
partial void OnEnableSlideTransitionChanged(bool value) partial void OnEnableSlideTransitionChanged(bool value)
{ {
if (_isInitializing) return; if (_isInitializing)
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value); {
return;
}
SaveTransitionPreferences(EnableFadeTransition, value);
}
partial void OnEnableFadeTransitionChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveTransitionPreferences(value, EnableSlideTransition);
} }
partial void OnShowInTaskbarChanged(bool value) partial void OnShowInTaskbarChanged(bool value)
@@ -394,6 +420,35 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]); _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() private IReadOnlyList<SelectionOption> CreateLanguageOptions()
{ {
return return

View File

@@ -79,6 +79,7 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), 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.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase))) string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
{ {
@@ -690,6 +691,7 @@ public partial class MainWindow
StatusBarShadowColor = _statusBarShadowColor, StatusBarShadowColor = _statusBarShadowColor,
StatusBarShadowOpacity = _statusBarShadowOpacity, StatusBarShadowOpacity = _statusBarShadowOpacity,
EnableThreeFingerSwipe = existingSnapshot.EnableThreeFingerSwipe, EnableThreeFingerSwipe = existingSnapshot.EnableThreeFingerSwipe,
EnableFadeTransition = existingSnapshot.EnableFadeTransition,
EnableSlideTransition = existingSnapshot.EnableSlideTransition, EnableSlideTransition = existingSnapshot.EnableSlideTransition,
ShowInTaskbar = existingSnapshot.ShowInTaskbar, ShowInTaskbar = existingSnapshot.ShowInTaskbar,
EnableFusedDesktop = existingSnapshot.EnableFusedDesktop, EnableFusedDesktop = existingSnapshot.EnableFusedDesktop,

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -23,6 +24,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Theme; using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components; using LanMountainDesktop.Views.Components;
@@ -134,6 +136,8 @@ public partial class MainWindow : Window
private string _gridSpacingPreset = "Relaxed"; private string _gridSpacingPreset = "Relaxed";
private bool _isSlideAnimationActive; private bool _isSlideAnimationActive;
private TranslateTransform? _desktopPageSlideTransform; private TranslateTransform? _desktopPageSlideTransform;
private PixelPoint? _preparedWindowTargetPosition;
private PixelPoint? _preparedWindowHiddenPosition;
private string _statusBarSpacingMode = "Relaxed"; private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12; private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground; private bool _statusBarClockTransparentBackground;
@@ -862,24 +866,45 @@ public partial class MainWindow : Window
return _desktopPageSlideTransform; 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() private async void SlideOutAndMinimizeAsync()
{ {
_isSlideAnimationActive = true; _isSlideAnimationActive = true;
DesktopPage.IsHitTestVisible = false; DesktopPage.IsHitTestVisible = false;
var useSlide = IsSlideTransitionEnabled(); var preferences = GetStartupVisualPreferences();
var slideTransform = GetDesktopPageSlideTransform(); 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; DesktopPage.Opacity = 0;
await Task.Delay(FluttermotionToken.Page);
await Task.Delay(useSlide }
? FluttermotionToken.Intro
: FluttermotionToken.Page);
if (!_isSlideAnimationActive) if (!_isSlideAnimationActive)
{ {
@@ -900,44 +925,63 @@ public partial class MainWindow : Window
WindowState = WindowState.Minimized; WindowState = WindowState.Minimized;
} }
slideTransform.X = 0;
DesktopPage.Opacity = 1; DesktopPage.Opacity = 1;
DesktopPage.IsHitTestVisible = true; DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false; _isSlideAnimationActive = false;
if (slideLayout is { } layout)
{
Position = layout.VisiblePosition;
}
} }
public void PrepareEnterAnimation() public void PrepareEnterAnimation()
{ {
_isSlideAnimationActive = false; _isSlideAnimationActive = false;
var useSlide = IsSlideTransitionEnabled(); var preferences = GetStartupVisualPreferences();
var slideTransform = GetDesktopPageSlideTransform(); _preparedWindowTargetPosition = null;
_preparedWindowHiddenPosition = null;
var savedTransitions = DesktopPage.Transitions; if (preferences.Mode == StartupVisualMode.SlideSplash)
DesktopPage.Transitions = null;
DesktopPage.Opacity = 0;
if (useSlide)
{ {
var screen = Screens.ScreenFromVisual(this); var layout = ResolveWindowAnimationLayout();
var scale = screen?.Scaling ?? 1d; _preparedWindowTargetPosition = layout.VisiblePosition;
var screenWidthDip = screen is null _preparedWindowHiddenPosition = layout.HiddenPosition;
? 1920d ApplyWindowAnimationLayout(layout);
: screen.WorkingArea.Width / Math.Max(scale, 0.01d); Position = layout.HiddenPosition;
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidthDip; DesktopPage.Opacity = 1;
DesktopPage.IsHitTestVisible = false;
_isSlideAnimationActive = true;
return;
} }
if (preferences.Mode == StartupVisualMode.Fade)
{
var savedTransitions = DesktopPage.Transitions;
DesktopPage.Transitions = null;
DesktopPage.Opacity = 0;
DesktopPage.Transitions = savedTransitions; DesktopPage.Transitions = savedTransitions;
DesktopPage.IsHitTestVisible = false; DesktopPage.IsHitTestVisible = false;
_isSlideAnimationActive = true; _isSlideAnimationActive = true;
return;
}
DesktopPage.Opacity = 1;
DesktopPage.IsHitTestVisible = true;
} }
public void PlayEnterAnimation() 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; DesktopPage.Opacity = 1;
slideTransform.X = 0;
DesktopPage.IsHitTestVisible = true; DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false; _isSlideAnimationActive = false;
} }
@@ -949,10 +993,67 @@ public partial class MainWindow : Window
return false; return false;
} }
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); return GetStartupVisualPreferences().Mode == StartupVisualMode.SlideSplash;
return snapshot.EnableSlideTransition;
} }
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) private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{ {
if (e.Property != WindowStateProperty) if (e.Property != WindowStateProperty)
@@ -967,10 +1068,17 @@ public partial class MainWindow : Window
{ {
PrepareEnterAnimation(); PrepareEnterAnimation();
if (ShouldUseFullscreenWindow())
{
if (newState != WindowState.FullScreen) if (newState != WindowState.FullScreen)
{ {
WindowState = WindowState.FullScreen; WindowState = WindowState.FullScreen;
} }
}
else if (newState == WindowState.Minimized)
{
WindowState = WindowState.Normal;
}
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
@@ -980,7 +1088,8 @@ public partial class MainWindow : Window
return; return;
} }
if (newState is WindowState.Minimized or WindowState.FullScreen) if (newState == WindowState.Minimized ||
(ShouldUseFullscreenWindow() && newState == WindowState.FullScreen))
{ {
return; return;
} }
@@ -998,9 +1107,12 @@ public partial class MainWindow : Window
} }
if (WindowState is not (WindowState.Minimized or WindowState.FullScreen)) if (WindowState is not (WindowState.Minimized or WindowState.FullScreen))
{
if (ShouldUseFullscreenWindow())
{ {
WindowState = WindowState.FullScreen; WindowState = WindowState.FullScreen;
} }
}
}); });
} }

View File

@@ -9,7 +9,6 @@
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated"> <StackPanel Classes="settings-page-container settings-page-animated">
<!-- 区域设置分组 -->
<controls:IconText Icon="Globe" <controls:IconText Icon="Globe"
Text="{Binding BasicHeader}" Text="{Binding BasicHeader}"
Margin="0,0,0,4" /> Margin="0,0,0,4" />
@@ -76,7 +75,6 @@
<Separator Classes="settings-separator" /> <Separator Classes="settings-separator" />
<!-- 运行时设置分组 -->
<controls:IconText Icon="DeveloperBoard" <controls:IconText Icon="DeveloperBoard"
Text="{Binding RuntimeHeader}" Text="{Binding RuntimeHeader}"
Margin="0,0,0,4" /> Margin="0,0,0,4" />
@@ -106,8 +104,20 @@
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander Header="滑入滑出过渡效果" <ui:SettingsExpander Header="淡入淡出效果"
Description="启用后,进入和退出桌面时使用滑入滑出动画(仅 Windows" 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}"> IsVisible="{Binding IsSlideTransitionAvailable}">
<ui:SettingsExpander.IconSource> <ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ArrowRight" /> <fi:SymbolIconSource Symbol="ArrowRight" />
@@ -118,7 +128,7 @@
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander Header="桌面主窗口在任务栏显示图标" <ui:SettingsExpander Header="桌面主窗口在任务栏显示图标"
Description="仅控制桌面主窗口在系统任务栏中的图标显示;不会影响设置窗口,设置窗口打开时始终保留独立任务栏图标"> Description="仅控制桌面主窗口在系统任务栏中的图标显示,不影响设置窗口">
<ui:SettingsExpander.IconSource> <ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Window" /> <fi:SymbolIconSource Symbol="Window" />
</ui:SettingsExpander.IconSource> </ui:SettingsExpander.IconSource>

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.