mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
changed.对启动器重构的尝试
This commit is contained in:
78
LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs
Normal file
78
LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal static class ApplyUpdateGuiFlow
|
||||
{
|
||||
public static async Task RunAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = UpdateEngineFactory.Create(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
var success = true;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
|
||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = updateResult.Message;
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success && queueResult.Code != "noop")
|
||||
{
|
||||
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = ex.Message;
|
||||
Logger.Error("Apply-update flow failed.", ex);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
||||
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "apply-update",
|
||||
Code = success ? "ok" : "failed",
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = context.Command,
|
||||
["launchSource"] = context.LaunchSource
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
|
||||
{
|
||||
private ISplashStageReporter? _inner;
|
||||
private readonly List<(string Stage, string Message)> _pending = [];
|
||||
|
||||
public void SetInner(ISplashStageReporter inner)
|
||||
{
|
||||
_inner = inner;
|
||||
foreach (var (stage, message) in _pending)
|
||||
{
|
||||
_inner.Report(stage, message);
|
||||
}
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
if (_inner is not null)
|
||||
{
|
||||
_inner.Report(stage, message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pending.Add((stage, message));
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportStage(string stage, int progress)
|
||||
{
|
||||
if (_inner is not null)
|
||||
{
|
||||
_inner.ReportStage(stage, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ internal static class ApplyUpdateEntryHandler
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window) =>
|
||||
LauncherCompositionRoot.RunApplyUpdateWithWindowAsync(desktop, context, window);
|
||||
ApplyUpdateGuiFlow.RunAsync(desktop, context, window);
|
||||
}
|
||||
|
||||
internal static class AirAppBrokerEntryHandler
|
||||
|
||||
184
LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs
Normal file
184
LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal static class LaunchUiPresenter
|
||||
{
|
||||
public static async Task HideSplashAsync(SplashWindow splashWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(splashWindow.Hide);
|
||||
}
|
||||
|
||||
public static async Task ShowSplashAsync(SplashWindow splashWindow)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(splashWindow.Show);
|
||||
}
|
||||
|
||||
public static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to dismiss splash window.", ex);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (loadingDetailsWindow is not null && loadingDetailsWindow.IsVisible)
|
||||
{
|
||||
loadingDetailsWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close loading details window.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
errorWindow.ConfigureForHostNotFound();
|
||||
errorWindow.SetErrorMessage("LanMountainDesktop host executable was not found.");
|
||||
errorWindow.Show();
|
||||
Logger.Warn("Host not found. Showing error window.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show host-not-found error window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return (ErrorWindowResult.Exit, null);
|
||||
}
|
||||
|
||||
ErrorWindowResult result;
|
||||
string? customPath;
|
||||
try
|
||||
{
|
||||
result = await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
customPath = errorWindow.GetCustomHostPath();
|
||||
Logger.Info($"Host-not-found window result='{result}'; HasCustomPath={!string.IsNullOrWhiteSpace(customPath)}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed while waiting for host-not-found window result.", ex);
|
||||
result = ErrorWindowResult.Exit;
|
||||
customPath = null;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (errorWindow.IsVisible && errorWindow.IsLoaded)
|
||||
{
|
||||
errorWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close host-not-found error window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
return (result, customPath);
|
||||
}
|
||||
|
||||
public static async Task<MigrationResult> ShowMigrationPromptAsync(LegacyVersionInfo legacyInfo)
|
||||
{
|
||||
MigrationPromptWindow? migrationWindow = null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
migrationWindow = new MigrationPromptWindow();
|
||||
migrationWindow.SetLegacyInfo(legacyInfo);
|
||||
migrationWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show migration prompt window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (migrationWindow is null)
|
||||
{
|
||||
return MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
MigrationResult result;
|
||||
try
|
||||
{
|
||||
result = await migrationWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed while waiting for migration prompt result.", ex);
|
||||
result = MigrationResult.Skipped;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (migrationWindow.IsVisible && migrationWindow.IsLoaded)
|
||||
{
|
||||
migrationWindow.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to close migration prompt window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string MapStartupStageToSplashStage(StartupStage stage) => stage switch
|
||||
{
|
||||
StartupStage.Initializing => "initializing",
|
||||
StartupStage.LoadingSettings => "settings",
|
||||
StartupStage.LoadingPlugins => "plugins",
|
||||
StartupStage.TrayReady => "shell",
|
||||
StartupStage.InitializingUI => "ui",
|
||||
StartupStage.ShellInitialized => "shell",
|
||||
StartupStage.BackgroundReady => "ready",
|
||||
StartupStage.DesktopVisible => "ready",
|
||||
StartupStage.ActivationRedirected => "activation",
|
||||
StartupStage.ActivationFailed => "error",
|
||||
StartupStage.Ready => "ready",
|
||||
_ => "launch"
|
||||
};
|
||||
|
||||
public static async Task<MultiInstancePromptResult> ShowMultiInstancePromptAsync(PublicShellStatus status)
|
||||
{
|
||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var prompt = new MultiInstancePromptWindow();
|
||||
prompt.SetDetails(status.ProcessId, status.ShellState);
|
||||
prompt.Show();
|
||||
return await prompt.WaitForChoiceAsync().ConfigureAwait(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs
Normal file
174
LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 启动器背景图片服务
|
||||
/// </summary>
|
||||
internal static class LauncherBackgroundService
|
||||
{
|
||||
private const string PictureFileName = "Launcher Picture";
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
|
||||
private const double AspectRatioTolerance = 0.15; // 15% 误差
|
||||
|
||||
private static Bitmap? _cachedBitmap;
|
||||
private static string? _cachedPath;
|
||||
|
||||
/// <summary>
|
||||
/// 背景图片信息
|
||||
/// </summary>
|
||||
public record BackgroundImageInfo
|
||||
{
|
||||
public required bool Exists { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public Bitmap? Bitmap { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public double AspectRatio { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载背景图片
|
||||
/// </summary>
|
||||
public static BackgroundImageInfo LoadBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
var launcherPath = resolver.ResolveLauncherDataPath();
|
||||
|
||||
// 查找图片文件
|
||||
var imagePath = FindImageFile(launcherPath);
|
||||
if (imagePath == null)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = "未找到背景图片文件"
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
if (fileInfo.Length > MaxFileSize)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
|
||||
};
|
||||
}
|
||||
|
||||
// 使用缓存
|
||||
if (_cachedBitmap != null && _cachedPath == imagePath)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = _cachedBitmap,
|
||||
Width = _cachedBitmap.PixelSize.Width,
|
||||
Height = _cachedBitmap.PixelSize.Height,
|
||||
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
|
||||
};
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
var bitmap = new Bitmap(imagePath);
|
||||
var width = bitmap.PixelSize.Width;
|
||||
var height = bitmap.PixelSize.Height;
|
||||
var aspectRatio = (double)width / height;
|
||||
|
||||
// 校验比例
|
||||
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
|
||||
if (ratioDiff > AspectRatioTolerance)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio,
|
||||
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
|
||||
};
|
||||
}
|
||||
|
||||
// 缓存图片
|
||||
_cachedBitmap = bitmap;
|
||||
_cachedPath = imagePath;
|
||||
|
||||
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
|
||||
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = bitmap,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = $"加载失败: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找图片文件
|
||||
/// </summary>
|
||||
private static string? FindImageFile(string directory)
|
||||
{
|
||||
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var path = Path.Combine(directory, PictureFileName + ext);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
|
||||
var files = Directory.GetFiles(directory, PictureFileName + ".*");
|
||||
foreach (var file in files)
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (extensions.Contains(ext))
|
||||
{
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
_cachedBitmap?.Dispose();
|
||||
_cachedBitmap = null;
|
||||
_cachedPath = null;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher GUI 入口装配:创建编排器并驱动启动流程。
|
||||
/// Launcher GUI composition root. It only wires services and dispatches to entry coordinators.
|
||||
/// </summary>
|
||||
internal static class LauncherCompositionRoot
|
||||
{
|
||||
@@ -19,593 +12,15 @@ internal static class LauncherCompositionRoot
|
||||
CommandContext context,
|
||||
string appRoot,
|
||||
StartupAttemptRegistry startupAttemptRegistry,
|
||||
LauncherCoordinatorIpcServer coordinatorServer) =>
|
||||
LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||
LauncherCoordinatorIpcServer coordinatorServer)
|
||||
{
|
||||
_ = appRoot;
|
||||
return LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer);
|
||||
}
|
||||
|
||||
public static async Task RunOrchestratorWithSplashAsync(
|
||||
public static Task RunOrchestratorWithSplashAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var orchestrator = CreateOrchestrator(
|
||||
context,
|
||||
appRoot,
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
public static async Task RunApplyUpdateWithWindowAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
UpdateWindow window)
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineFacade(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
var success = true;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10));
|
||||
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = updateResult.Message;
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60));
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
|
||||
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success && queueResult.Code != "noop")
|
||||
{
|
||||
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90));
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
success = false;
|
||||
errorMessage = ex.Message;
|
||||
Logger.Error("Apply-update flow failed.", ex);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
|
||||
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
|
||||
|
||||
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "apply-update",
|
||||
Code = success ? "ok" : "failed",
|
||||
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["command"] = context.Command,
|
||||
["launchSource"] = context.LaunchSource
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
|
||||
{
|
||||
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var hostPid))
|
||||
{
|
||||
return hostPid;
|
||||
}
|
||||
|
||||
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
|
||||
int.TryParse(existingHostPidText, out var existingHostPid))
|
||||
{
|
||||
return existingHostPid;
|
||||
}
|
||||
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show launcher failure window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SplashWindow splashWindow) =>
|
||||
LauncherGuiCoordinator.RunAsync(desktop, context, splashWindow);
|
||||
}
|
||||
|
||||
533
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
533
LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Resources;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
internal static class LauncherGuiCoordinator
|
||||
{
|
||||
public static async Task RunAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
CommandContext context,
|
||||
SplashWindow splashWindow)
|
||||
{
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
successPolicy,
|
||||
coordinatorPipeName,
|
||||
out var reservedAttempt,
|
||||
out var activeCoordinatorAttempt))
|
||||
{
|
||||
result = await AttachToExistingCoordinatorAsync(
|
||||
context,
|
||||
currentSplashWindow,
|
||||
activeCoordinatorAttempt).ConfigureAwait(false);
|
||||
|
||||
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
new LauncherAirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
coordinatorPipeName,
|
||||
BuildCoordinatorStatusFromAttempt(reservedAttempt),
|
||||
HandleCoordinatorRequestAsync,
|
||||
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
|
||||
coordinatorServer.Start();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
|
||||
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
|
||||
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
|
||||
|
||||
var orchestrator = LauncherCompositionRoot.CreateOrchestrator(
|
||||
context,
|
||||
appRoot,
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer);
|
||||
|
||||
result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Coordinator threw an unhandled exception.", ex);
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = $"Launcher failed: {ex.Message}",
|
||||
ErrorMessage = ex.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
if (result.Success ||
|
||||
result.Code == "host_not_found" ||
|
||||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
|
||||
if (failureAction == ErrorWindowResult.Exit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (failureAction == ErrorWindowResult.ActivateExisting &&
|
||||
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launch",
|
||||
Code = "activation_requested",
|
||||
Message = "Launcher activated the existing desktop instance.",
|
||||
Details = result.Details
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
currentSplashWindow = CreateSplashWindow();
|
||||
currentSplashWindow.Show();
|
||||
}
|
||||
|
||||
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
|
||||
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static SplashWindow CreateSplashWindow()
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
|
||||
return window;
|
||||
}
|
||||
|
||||
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
|
||||
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid)
|
||||
{
|
||||
if (result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var hostPid))
|
||||
{
|
||||
return hostPid;
|
||||
}
|
||||
|
||||
if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) &&
|
||||
int.TryParse(existingHostPidText, out var existingHostPid))
|
||||
{
|
||||
return existingHostPid;
|
||||
}
|
||||
|
||||
return fallbackHostPid;
|
||||
}
|
||||
|
||||
private static async Task WaitForManagedProcessesToExitAsync(
|
||||
int hostPid,
|
||||
LauncherAirAppLifecycleService airAppLifecycleService)
|
||||
{
|
||||
Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}.");
|
||||
while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains.");
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
|
||||
CommandContext context,
|
||||
SplashWindow? splashWindow,
|
||||
StartupAttemptRecord? activeCoordinatorAttempt)
|
||||
{
|
||||
var reporter = splashWindow as ISplashStageReporter;
|
||||
reporter?.Report("activation", Strings.Preview_ActivationConnecting);
|
||||
|
||||
if (activeCoordinatorAttempt is not null &&
|
||||
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
|
||||
{
|
||||
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
|
||||
? LauncherCoordinatorCommands.Attach
|
||||
: LauncherCoordinatorCommands.ActivateDesktop;
|
||||
var request = new LauncherCoordinatorRequest
|
||||
{
|
||||
Command = command,
|
||||
LaunchSource = context.LaunchSource,
|
||||
SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context)
|
||||
};
|
||||
|
||||
var response = await new LauncherCoordinatorIpcClient()
|
||||
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
reporter?.Report("activation", response.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = response.Accepted ||
|
||||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
|
||||
Message = success && !response.Accepted
|
||||
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
|
||||
: response.Message,
|
||||
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
reporter?.Report("activation", activation.Message);
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = success,
|
||||
Stage = "launch",
|
||||
Code = activation.Accepted
|
||||
? "existing_host_activated"
|
||||
: success
|
||||
? "existing_host_startup_pending"
|
||||
: "existing_host_activation_failed",
|
||||
Message = success && !activation.Accepted
|
||||
? "Existing desktop process is still starting; Launcher attached without starting another process."
|
||||
: activation.Message,
|
||||
Details = BuildCoordinatorResultDetails(null, activation)
|
||||
};
|
||||
}
|
||||
|
||||
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "launcher_coordinator_unavailable",
|
||||
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
|
||||
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
|
||||
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
|
||||
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
|
||||
LauncherCoordinatorRequest request,
|
||||
LauncherCoordinatorStatus status)
|
||||
{
|
||||
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
if (activation is not null)
|
||||
{
|
||||
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
|
||||
{
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = activation.Accepted,
|
||||
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
|
||||
Message = activation.Message,
|
||||
Status = status,
|
||||
ActivationResult = activation
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
return new LauncherCoordinatorResponse
|
||||
{
|
||||
Accepted = true,
|
||||
Code = "attached_to_launcher_coordinator",
|
||||
Message = "Attached to the active Launcher coordinator.",
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
|
||||
{
|
||||
return new LauncherCoordinatorStatus
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
CoordinatorPid = Environment.ProcessId,
|
||||
HostPid = attempt.HostPid,
|
||||
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
|
||||
LaunchSource = attempt.LaunchSource,
|
||||
SuccessPolicy = attempt.SuccessPolicy,
|
||||
LastObservedStage = attempt.LastObservedStage,
|
||||
LastObservedMessage = attempt.LastObservedMessage,
|
||||
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
|
||||
State = attempt.State.ToString(),
|
||||
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
|
||||
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
|
||||
Succeeded = attempt.State == StartupAttemptState.Succeeded,
|
||||
UpdatedAtUtc = attempt.UpdatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRecoverableActivationFailure(
|
||||
PublicShellActivationResult? activation,
|
||||
LauncherCoordinatorStatus? status)
|
||||
{
|
||||
if (activation is { Accepted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status is { Completed: false, HostProcessAlive: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var shellStatus = activation?.Status;
|
||||
if (shellStatus is null || !shellStatus.PublicIpcReady)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shellStatus.MainWindowOpened ||
|
||||
!shellStatus.DesktopVisible ||
|
||||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCoordinatorResultDetails(
|
||||
LauncherCoordinatorStatus? status,
|
||||
PublicShellActivationResult? activation)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
|
||||
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
|
||||
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
|
||||
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
|
||||
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
|
||||
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
|
||||
["startupState"] = status?.State ?? string.Empty,
|
||||
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
|
||||
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
|
||||
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
|
||||
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
|
||||
{
|
||||
if (splashWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await splashWindow.DismissAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
|
||||
{
|
||||
ErrorWindow? errorWindow = null;
|
||||
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
|
||||
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
|
||||
hostProcessAliveValue;
|
||||
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
|
||||
int.TryParse(hostPidText, out var parsedPid)
|
||||
? parsedPid
|
||||
: (int?)null;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
errorWindow = new ErrorWindow();
|
||||
if (hostProcessAlive)
|
||||
{
|
||||
errorWindow.ConfigureForRunningHostFailure(hostPid);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorWindow.ConfigureForGenericFailure(allowRetry: true);
|
||||
}
|
||||
|
||||
errorWindow.SetErrorMessage(
|
||||
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
|
||||
errorWindow.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to show launcher failure window.", ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (errorWindow is null)
|
||||
{
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failure window closed unexpectedly.", ex);
|
||||
return ErrorWindowResult.Exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryActivateExistingInstanceAsync()
|
||||
{
|
||||
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return activation?.Accepted == true;
|
||||
}
|
||||
|
||||
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
if (!ipcClient.IsConnected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
|
||||
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
|
||||
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != activationTask)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await activationTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLiveProcess(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ internal static class LauncherServiceRegistration
|
||||
services.AddSingleton(new DeploymentLocator(appRoot));
|
||||
services.AddSingleton(sp => new OobeStateService(appRoot));
|
||||
services.AddSingleton(sp => new DataLocationResolver(appRoot));
|
||||
services.AddSingleton<IUpdateEngine>(sp => new UpdateEngineFacade(sp.GetRequiredService<DeploymentLocator>()));
|
||||
services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService<DeploymentLocator>()));
|
||||
services.AddSingleton<HostLaunchService>();
|
||||
services.AddSingleton<StartupAttemptRegistry>();
|
||||
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||
|
||||
68
LanMountainDesktop.Launcher/Shell/ThemeService.cs
Normal file
68
LanMountainDesktop.Launcher/Shell/ThemeService.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 主题服务,管理启动器的主题设置
|
||||
/// </summary>
|
||||
public static class ThemeService
|
||||
{
|
||||
private static ThemeVariant _currentTheme = ThemeVariant.Light;
|
||||
private static string _accentColor = "#0078D4";
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题
|
||||
/// </summary>
|
||||
public static ThemeVariant CurrentTheme => _currentTheme;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前主题色
|
||||
/// </summary>
|
||||
public static string AccentColor => _accentColor;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主题设置
|
||||
/// </summary>
|
||||
public static void ApplyTheme(ThemeMode mode, string accentColor)
|
||||
{
|
||||
_currentTheme = mode switch
|
||||
{
|
||||
ThemeMode.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Light
|
||||
};
|
||||
_accentColor = accentColor;
|
||||
|
||||
// 应用到当前应用程序
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
app.RequestedThemeVariant = _currentTheme;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用浅色主题
|
||||
/// </summary>
|
||||
public static void ApplyLightTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Light, accentColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用深色主题
|
||||
/// </summary>
|
||||
public static void ApplyDarkTheme(string accentColor)
|
||||
{
|
||||
ApplyTheme(ThemeMode.Dark, accentColor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主题模式
|
||||
/// </summary>
|
||||
public enum ThemeMode
|
||||
{
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
Reference in New Issue
Block a user