mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
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.
344 lines
10 KiB
C#
344 lines
10 KiB
C#
using System.Diagnostics;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Markup.Xaml;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using LanMountainDesktop.Launcher.Services;
|
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
|
|
|
namespace LanMountainDesktop.Launcher.Views;
|
|
|
|
public partial class SplashWindow : Window, ISplashStageReporter
|
|
{
|
|
private const int DebugModeClickThreshold = 5;
|
|
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
|
|
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
|
|
|
|
private readonly StartupVisualMode _mode;
|
|
private int _versionTextClickCount;
|
|
private bool _isDebugModeOpened;
|
|
private bool _isOpened;
|
|
private bool _layoutConfigured;
|
|
private bool _dismissed;
|
|
private PixelPoint _targetPosition;
|
|
private PixelPoint _slideHiddenPosition;
|
|
|
|
public SplashWindow()
|
|
: this(StartupVisualMode.Fade)
|
|
{
|
|
}
|
|
|
|
public SplashWindow(StartupVisualMode mode)
|
|
{
|
|
_mode = mode;
|
|
AvaloniaXamlLoader.Load(this);
|
|
Loaded += OnWindowLoaded;
|
|
Opened += OnWindowOpened;
|
|
}
|
|
|
|
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
|
|
{
|
|
versionBorder.PointerPressed += OnVersionTextClick;
|
|
}
|
|
}
|
|
|
|
private async void OnWindowOpened(object? sender, EventArgs e)
|
|
{
|
|
if (_isOpened)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isOpened = true;
|
|
ConfigureForVisualMode();
|
|
|
|
if (_mode == StartupVisualMode.Fade)
|
|
{
|
|
Opacity = 0d;
|
|
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
Opacity = 1d;
|
|
if (_mode == StartupVisualMode.SlideSplash)
|
|
{
|
|
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
public async Task DismissAsync()
|
|
{
|
|
if (_dismissed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_dismissed = true;
|
|
ConfigureForVisualMode();
|
|
|
|
if (_mode == StartupVisualMode.SlideSplash)
|
|
{
|
|
var from = Position;
|
|
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
|
|
}
|
|
else if (_mode == StartupVisualMode.Fade)
|
|
{
|
|
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
|
|
}
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
|
{
|
|
if (IsVisible)
|
|
{
|
|
Close();
|
|
}
|
|
});
|
|
}
|
|
|
|
public void Report(string stage, string message)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
|
{
|
|
statusText.Text = message;
|
|
}
|
|
|
|
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
|
{
|
|
var progress = ResolveProgress(stage);
|
|
if (progress > 0)
|
|
{
|
|
progressIndicator.IsIndeterminate = false;
|
|
progressIndicator.Value = progress;
|
|
}
|
|
else
|
|
{
|
|
progressIndicator.IsIndeterminate = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public void ReportStage(string stage, int progress)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
|
{
|
|
statusText.Text = stage;
|
|
}
|
|
|
|
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
|
{
|
|
progressIndicator.IsIndeterminate = false;
|
|
progressIndicator.Value = Math.Clamp(progress, 0, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
public void UpdateProgress(int percent, string? message = null)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(message) &&
|
|
this.FindControl<TextBlock>("StatusText") is { } statusText)
|
|
{
|
|
statusText.Text = message;
|
|
}
|
|
|
|
if (this.FindControl<ProgressBar>("ProgressIndicator") is { } progressIndicator)
|
|
{
|
|
progressIndicator.IsIndeterminate = false;
|
|
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
public void UpdateStatus(string message)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (this.FindControl<TextBlock>("StatusText") is { } statusText)
|
|
{
|
|
statusText.Text = message;
|
|
}
|
|
});
|
|
}
|
|
|
|
public void SetVersionInfo(string version, string codename)
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (this.FindControl<TextBlock>("VersionText") is { } versionText)
|
|
{
|
|
versionText.Text = $"{version} ({codename})";
|
|
}
|
|
});
|
|
}
|
|
|
|
public void SetDebugMode(bool isDebugMode)
|
|
{
|
|
if (!isDebugMode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UpdateStatus("[Debug Mode] Splash Preview");
|
|
}
|
|
|
|
private void ConfigureForVisualMode()
|
|
{
|
|
if (_layoutConfigured)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_layoutConfigured = true;
|
|
var compactHero = this.FindControl<Grid>("CompactHero");
|
|
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
|
|
|
|
if (_mode == StartupVisualMode.Fade)
|
|
{
|
|
compactHero?.SetCurrentValue(IsVisibleProperty, true);
|
|
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
|
|
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
|
|
Width = 480;
|
|
Height = 320;
|
|
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
|
return;
|
|
}
|
|
|
|
compactHero?.SetCurrentValue(IsVisibleProperty, false);
|
|
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
|
|
Background = Brushes.Black;
|
|
WindowStartupLocation = WindowStartupLocation.Manual;
|
|
|
|
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
|
|
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
|
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
|
|
|
|
Width = workingArea.Width / scale;
|
|
Height = workingArea.Height / scale;
|
|
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
|
|
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
|
|
Position = _mode == StartupVisualMode.SlideSplash
|
|
? _slideHiddenPosition
|
|
: _targetPosition;
|
|
}
|
|
|
|
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (_isDebugModeOpened)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_versionTextClickCount++;
|
|
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));
|
|
}
|
|
|
|
private static int ResolveProgress(string stage)
|
|
{
|
|
return stage.ToLowerInvariant() switch
|
|
{
|
|
"initializing" => 10,
|
|
"settings" => 25,
|
|
"update" => 30,
|
|
"plugins" => 50,
|
|
"ui" => 65,
|
|
"shell" => 80,
|
|
"activation" => 90,
|
|
"ready" => 100,
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
private static double EaseOutCubic(double value)
|
|
{
|
|
var inverse = 1d - value;
|
|
return 1d - (inverse * inverse * inverse);
|
|
}
|
|
|
|
private static double EaseInCubic(double value) => value * value * value;
|
|
}
|