mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
Stamp release versions and harden launcher
Add automatic release version stamping and multiple launcher reliability improvements. The Release workflow now runs scripts/Set-ReleaseVersion.ps1 in build jobs to inject tag-derived Version/AssemblyVersion into project metadata; several .csproj/Directory.Build.props and app.manifest files were changed to use a dev placeholder. Introduced AppVersionProvider (and related runtime metadata) to centralize version resolution and updated DeploymentLocator to use it and to prefer package-root/version.json. Launcher startup flow was hardened: added startup success tracking, public-activation recovery path, improved success/fallback semantics, and related IPC handling. UI/UX fixes include OOBE entrance/exit animation improvements (scaling-aware, concurrent fade+translate) and minor window lifecycle reorder in DesktopShellHost. CommandContext now recognizes restart and key=value args. New DesktopTrayService and .trae spec files (spec, checklist, tasks) document shell/tray hardening work. Miscellaneous logging, comments and housekeeping edits across launcher and shared contracts to support the above.
This commit is contained in:
@@ -59,6 +59,9 @@ public partial class App : Application
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
||||
private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal";
|
||||
private readonly RestartPresentationMode? _requestedRestartPresentationMode =
|
||||
LauncherRuntimeMetadata.GetRestartPresentationMode(Environment.GetCommandLineArgs());
|
||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
@@ -67,12 +70,7 @@ public partial class App : Application
|
||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
|
||||
private TrayIcon? _trayIcon;
|
||||
private NativeMenuItem? _trayShowDesktopMenuItem;
|
||||
private NativeMenuItem? _traySettingsMenuItem;
|
||||
private NativeMenuItem? _trayComponentLibraryMenuItem;
|
||||
private NativeMenuItem? _trayRestartMenuItem;
|
||||
private NativeMenuItem? _trayExitMenuItem;
|
||||
private DesktopTrayService? _desktopTrayService;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private TransparentOverlayWindow? _transparentOverlayWindow;
|
||||
@@ -108,6 +106,15 @@ public partial class App : Application
|
||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
||||
internal INotificationService? NotificationService => _notificationService;
|
||||
internal RestartPresentationMode GetCurrentRestartPresentationMode()
|
||||
{
|
||||
return _desktopShellState switch
|
||||
{
|
||||
DesktopShellState.TrayOnly => RestartPresentationMode.Tray,
|
||||
DesktopShellState.MinimizedToTaskbar => RestartPresentationMode.Minimized,
|
||||
_ => RestartPresentationMode.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
||||
{
|
||||
@@ -470,128 +477,90 @@ public partial class App : Application
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
try
|
||||
EnsureDesktopTrayService();
|
||||
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
|
||||
if (_trayInitialized)
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
_trayShowDesktopMenuItem = new NativeMenuItem();
|
||||
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
|
||||
|
||||
_traySettingsMenuItem = new NativeMenuItem();
|
||||
_traySettingsMenuItem.Click += OnTraySettingsClick;
|
||||
|
||||
_trayComponentLibraryMenuItem = new NativeMenuItem();
|
||||
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
|
||||
|
||||
_trayRestartMenuItem = new NativeMenuItem();
|
||||
_trayRestartMenuItem.Click += OnTrayRestartClick;
|
||||
|
||||
_trayExitMenuItem = new NativeMenuItem();
|
||||
_trayExitMenuItem.Click += OnTrayExitClick;
|
||||
|
||||
var trayMenu = new NativeMenu();
|
||||
trayMenu.Items.Add(_trayShowDesktopMenuItem);
|
||||
trayMenu.Items.Add(_traySettingsMenuItem);
|
||||
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayRestartMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_trayExitMenuItem);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = _appLogoService.CreateTrayIcon(),
|
||||
Menu = trayMenu,
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
TrayIcon.SetIcons(this, [_trayIcon]);
|
||||
}
|
||||
|
||||
RefreshTrayIconContent();
|
||||
_trayInitialized = true;
|
||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trayInitialized = false;
|
||||
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||
}
|
||||
|
||||
AppLogger.Warn("TrayIcon", "Tray initialization did not reach the ready state.");
|
||||
}
|
||||
|
||||
private void RefreshTrayIconContent()
|
||||
{
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = true;
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
if (_trayShowDesktopMenuItem is not null)
|
||||
{
|
||||
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
|
||||
}
|
||||
|
||||
if (_traySettingsMenuItem is not null)
|
||||
{
|
||||
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
|
||||
}
|
||||
|
||||
RefreshFusedDesktopMenuItemVisibility();
|
||||
|
||||
if (_trayRestartMenuItem is not null)
|
||||
{
|
||||
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
|
||||
}
|
||||
|
||||
if (_trayExitMenuItem is not null)
|
||||
{
|
||||
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
|
||||
}
|
||||
EnsureDesktopTrayService();
|
||||
_desktopTrayService?.Refresh("RefreshTrayContent");
|
||||
_trayInitialized = _desktopTrayService?.IsReady == true;
|
||||
}
|
||||
|
||||
private void RefreshFusedDesktopMenuItemVisibility()
|
||||
{
|
||||
if (_trayComponentLibraryMenuItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_trayComponentLibraryMenuItem.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
|
||||
|
||||
if (_trayComponentLibraryMenuItem.IsVisible)
|
||||
{
|
||||
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
|
||||
}
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
_desktopTrayService?.Dispose();
|
||||
_trayInitialized = false;
|
||||
}
|
||||
|
||||
private void EnsureDesktopTrayService()
|
||||
{
|
||||
if (_desktopTrayService is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
_desktopTrayService = new DesktopTrayService(
|
||||
this,
|
||||
_appLogoService,
|
||||
L,
|
||||
ShouldShowTrayComponentLibraryMenuItem,
|
||||
OnTrayShowDesktopClick,
|
||||
OnTraySettingsClick,
|
||||
OnTrayComponentLibraryClick,
|
||||
OnTrayRestartClick,
|
||||
OnTrayExitClick);
|
||||
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
|
||||
}
|
||||
|
||||
private bool EnsureTrayReady(string reason)
|
||||
{
|
||||
EnsureDesktopTrayService();
|
||||
var ready = _desktopTrayService?.EnsureReady(reason) == true;
|
||||
_trayInitialized = ready;
|
||||
if (ready)
|
||||
{
|
||||
_trayIcon.IsVisible = false;
|
||||
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
private void OnTrayAvailabilityStateChanged(TrayAvailabilityState state)
|
||||
{
|
||||
_trayInitialized = state == TrayAvailabilityState.Ready;
|
||||
|
||||
if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly)
|
||||
{
|
||||
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldShowTrayComponentLibraryMenuItem()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
return appSnapshot.EnableFusedDesktop;
|
||||
}
|
||||
|
||||
private void EnsureSettingsWindowService()
|
||||
{
|
||||
_settingsPageRegistry ??= new SettingsPageRegistry(
|
||||
@@ -764,6 +733,7 @@ public partial class App : Application
|
||||
mainWindow.PlayEnterAnimation();
|
||||
}, DispatcherPriority.Background);
|
||||
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
@@ -872,6 +842,7 @@ public partial class App : Application
|
||||
if (themeChanged)
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}
|
||||
|
||||
if (languageChanged)
|
||||
@@ -898,7 +869,11 @@ public partial class App : Application
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
RefreshTrayIconContent();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ApplyAdaptiveThemeResources()
|
||||
@@ -1144,18 +1119,56 @@ public partial class App : Application
|
||||
{
|
||||
mainWindow.Opened -= OnMainWindowOpened;
|
||||
_mainWindowOpened = true;
|
||||
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
|
||||
|
||||
if (TryApplyStartupPresentation(mainWindow))
|
||||
{
|
||||
AppLogger.Info(
|
||||
"App",
|
||||
$"Main window opened and startup presentation was applied. LaunchSource='{_launchSource}'; RestartPresentation='{_requestedRestartPresentationMode?.ToString() ?? "<none>"}'; ShellState='{_desktopShellState}'.");
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
|
||||
_loadingStateReporter?.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"App",
|
||||
$"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'.");
|
||||
|
||||
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
|
||||
ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible.");
|
||||
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
|
||||
_loadingStateReporter?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryApplyStartupPresentation(MainWindow mainWindow)
|
||||
{
|
||||
if (!string.Equals(_launchSource, "restart", StringComparison.OrdinalIgnoreCase) ||
|
||||
_requestedRestartPresentationMode is null ||
|
||||
_requestedRestartPresentationMode == RestartPresentationMode.Foreground)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (_requestedRestartPresentationMode)
|
||||
{
|
||||
case RestartPresentationMode.Minimized:
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
|
||||
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
return true;
|
||||
|
||||
case RestartPresentationMode.Tray:
|
||||
HideMainWindowToTray(mainWindow, "StartupRestartPresentation");
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private MainWindow GetOrCreateMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
@@ -1242,7 +1255,15 @@ public partial class App : Application
|
||||
|
||||
if (_shutdownIntent == ShutdownIntent.None)
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||
if (EnsureTrayReady("MainWindowClosedUnexpected"))
|
||||
{
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowClosedUnexpectedWithoutTray");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,9 +1297,17 @@ public partial class App : Application
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!EnsureTrayReady($"HideToTray:{source}"))
|
||||
{
|
||||
RecoverFromTrayUnavailable(mainWindow, source);
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.ShowInTaskbar = false;
|
||||
mainWindow.Hide();
|
||||
_desktopTrayService?.StartWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
@@ -1293,9 +1322,56 @@ public partial class App : Application
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
|
||||
RecoverFromTrayUnavailable(mainWindow, source);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecoverFromTrayUnavailable(MainWindow mainWindow, string source)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"DesktopShell",
|
||||
$"Tray was unavailable. Recovering to a visible or taskbar-backed state instead of TrayOnly. Source='{source}'.");
|
||||
|
||||
var showInTaskbar = ShouldShowMainWindowInTaskbar();
|
||||
if (showInTaskbar)
|
||||
{
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
mainWindow.WindowState = WindowState.Minimized;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
|
||||
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
_desktopTrayService?.StopWatchdog();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
|
||||
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
|
||||
}
|
||||
|
||||
private bool ShouldShowMainWindowInTaskbar()
|
||||
{
|
||||
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
|
||||
@@ -1364,17 +1440,19 @@ public partial class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
|
||||
_publicIpcHostService = new PublicIpcHostService();
|
||||
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
|
||||
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
|
||||
new PublicAppInfoService(version, "Administrate", _startupAt));
|
||||
new PublicAppInfoService(_startupAt));
|
||||
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
|
||||
new PublicShellControlService());
|
||||
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
|
||||
new PublicPluginCatalogService(_publicIpcHostService));
|
||||
_publicIpcHostService.Start();
|
||||
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
|
||||
AppLogger.Info(
|
||||
"PublicIpc",
|
||||
$"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class Program
|
||||
AppLogger.Initialize();
|
||||
DevPluginOptions.Parse(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
@@ -42,19 +39,34 @@ public static class AppRestartService
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
string? entryAssemblyLocation = null,
|
||||
RestartPresentationMode? restartPresentationMode = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
var resolvedProcessPath = NormalizeExistingFile(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingFile(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
var normalizedRestartPresentation = restartPresentationMode
|
||||
?? LauncherRuntimeMetadata.GetRestartPresentationMode(args)
|
||||
?? RestartPresentationMode.Foreground;
|
||||
|
||||
var launcherStartInfo = TryCreateLauncherStartInfo(
|
||||
args,
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
normalizedRestartPresentation);
|
||||
if (launcherStartInfo is not null)
|
||||
{
|
||||
return launcherStartInfo;
|
||||
}
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
@@ -62,7 +74,8 @@ public static class AppRestartService
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
@@ -71,7 +84,8 @@ public static class AppRestartService
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
args,
|
||||
normalizedRestartPresentation);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -80,22 +94,20 @@ public static class AppRestartService
|
||||
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartParentProcessId(commandLineArgs);
|
||||
}
|
||||
|
||||
foreach (var argument in commandLineArgs)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(argument, out var processId))
|
||||
{
|
||||
return processId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
public static RestartPresentationMode? TryGetRestartPresentationMode(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -104,18 +116,17 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
|
||||
var args = new System.Text.StringBuilder();
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
@@ -129,51 +140,182 @@ public static class AppRestartService
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
// UseShellExecute=true 时使用 Arguments 字符串
|
||||
var args = new System.Text.StringBuilder();
|
||||
args.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendArgumentsToString(args, commandLineArgs);
|
||||
AppendRestartParentProcessArgumentToString(args);
|
||||
startInfo.Arguments = args.ToString();
|
||||
var arguments = new StringBuilder();
|
||||
arguments.Append(QuoteArgument(entryAssemblyPath));
|
||||
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
|
||||
startInfo.Arguments = arguments.ToString();
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
private static ProcessStartInfo? TryCreateLauncherStartInfo(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
var launcherPath = ResolveLauncherPath(commandLineArgs, processPath, entryAssemblyPath);
|
||||
if (string.IsNullOrWhiteSpace(launcherPath))
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
return null;
|
||||
}
|
||||
|
||||
var arguments = new StringBuilder();
|
||||
AppendFilteredArguments(arguments, commandLineArgs);
|
||||
AppendRestartArguments(arguments, restartPresentationMode);
|
||||
|
||||
return new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = arguments.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveLauncherPath(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
var launcherFileName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher";
|
||||
|
||||
foreach (var packageRootCandidate in GetPackageRootCandidates(commandLineArgs, processPath, entryAssemblyPath))
|
||||
{
|
||||
var normalizedRoot = NormalizeExistingDirectory(packageRootCandidate);
|
||||
if (string.IsNullOrWhiteSpace(normalizedRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
var directCandidate = Path.Combine(normalizedRoot, launcherFileName);
|
||||
if (File.Exists(directCandidate))
|
||||
{
|
||||
return directCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
private static IEnumerable<string?> GetPackageRootCandidates(
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
string? processPath,
|
||||
string? entryAssemblyPath)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
yield return LauncherRuntimeMetadata.GetPackageRoot(commandLineArgs);
|
||||
|
||||
foreach (var path in new[] { entryAssemblyPath, processPath, AppContext.BaseDirectory })
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
var directory = GetDirectoryFromPath(path);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append(QuoteArgument(commandLineArgs[i]));
|
||||
yield return directory;
|
||||
yield return Path.GetDirectoryName(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
private static string? GetDirectoryFromPath(string? path)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath)
|
||||
? Path.GetDirectoryName(fullPath)
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
|
||||
private static void AppendForwardedArguments(
|
||||
StringBuilder builder,
|
||||
IReadOnlyList<string> commandLineArgs,
|
||||
RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0) builder.Append(' ');
|
||||
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
AppendFilteredArguments(builder, commandLineArgs);
|
||||
AppendRestartArguments(builder, restartPresentationMode);
|
||||
}
|
||||
|
||||
private static void AppendFilteredArguments(StringBuilder builder, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var index = 1; index < commandLineArgs.Count; index++)
|
||||
{
|
||||
if (ShouldSkipArgument(commandLineArgs, ref index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(QuoteArgument(commandLineArgs[index]));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkipArgument(IReadOnlyList<string> commandLineArgs, ref int index)
|
||||
{
|
||||
var argument = commandLineArgs[index];
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = argument[2..];
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
key = key[..equalsIndex];
|
||||
}
|
||||
|
||||
var shouldSkip = string.Equals(key, LauncherIpcConstants.LaunchSourceOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartParentPidOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.RestartPresentationOptionName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.LauncherPidEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.PackageRootEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.VersionEnvVar, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, LauncherIpcConstants.CodenameEnvVar, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (shouldSkip &&
|
||||
equalsIndex < 0 &&
|
||||
index + 1 < commandLineArgs.Count &&
|
||||
!commandLineArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
return shouldSkip;
|
||||
}
|
||||
|
||||
private static void AppendRestartArguments(StringBuilder builder, RestartPresentationMode restartPresentationMode)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append($"--{LauncherIpcConstants.LaunchSourceOptionName}=restart");
|
||||
builder.Append($" --{LauncherIpcConstants.RestartParentPidOptionName}={Environment.ProcessId}");
|
||||
builder.Append(
|
||||
$" --{LauncherIpcConstants.RestartPresentationOptionName}={LauncherRuntimeMetadata.FormatRestartPresentation(restartPresentationMode)}");
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
@@ -188,7 +330,7 @@ public static class AppRestartService
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
@@ -206,21 +348,7 @@ public static class AppRestartService
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
if (string.IsNullOrWhiteSpace(argument) ||
|
||||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
argument[RestartParentPidArgumentPrefix.Length..],
|
||||
out processId) && processId > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
private static string? NormalizeExistingFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
@@ -238,6 +366,24 @@ public static class AppRestartService
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingDirectory(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return Directory.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
|
||||
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal enum TrayAvailabilityState
|
||||
{
|
||||
Unavailable = 0,
|
||||
Initializing = 1,
|
||||
Ready = 2,
|
||||
Recovering = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
internal sealed class DesktopTrayService : IDisposable
|
||||
{
|
||||
private readonly Application _application;
|
||||
private readonly IAppLogoService _appLogoService;
|
||||
private readonly Func<string, string, string> _localize;
|
||||
private readonly Func<bool> _shouldShowComponentLibraryMenuItem;
|
||||
private readonly EventHandler _onShowDesktop;
|
||||
private readonly EventHandler _onSettings;
|
||||
private readonly EventHandler _onComponentLibrary;
|
||||
private readonly EventHandler _onRestart;
|
||||
private readonly EventHandler _onExit;
|
||||
private readonly DispatcherTimer _watchdogTimer;
|
||||
|
||||
private TrayIcon? _trayIcon;
|
||||
private NativeMenuItem? _showDesktopMenuItem;
|
||||
private NativeMenuItem? _settingsMenuItem;
|
||||
private NativeMenuItem? _componentLibraryMenuItem;
|
||||
private NativeMenuItem? _restartMenuItem;
|
||||
private NativeMenuItem? _exitMenuItem;
|
||||
private int _consecutiveRecoveryFailures;
|
||||
|
||||
public DesktopTrayService(
|
||||
Application application,
|
||||
IAppLogoService appLogoService,
|
||||
Func<string, string, string> localize,
|
||||
Func<bool> shouldShowComponentLibraryMenuItem,
|
||||
EventHandler onShowDesktop,
|
||||
EventHandler onSettings,
|
||||
EventHandler onComponentLibrary,
|
||||
EventHandler onRestart,
|
||||
EventHandler onExit)
|
||||
{
|
||||
_application = application ?? throw new ArgumentNullException(nameof(application));
|
||||
_appLogoService = appLogoService ?? throw new ArgumentNullException(nameof(appLogoService));
|
||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||
_shouldShowComponentLibraryMenuItem = shouldShowComponentLibraryMenuItem ?? throw new ArgumentNullException(nameof(shouldShowComponentLibraryMenuItem));
|
||||
_onShowDesktop = onShowDesktop ?? throw new ArgumentNullException(nameof(onShowDesktop));
|
||||
_onSettings = onSettings ?? throw new ArgumentNullException(nameof(onSettings));
|
||||
_onComponentLibrary = onComponentLibrary ?? throw new ArgumentNullException(nameof(onComponentLibrary));
|
||||
_onRestart = onRestart ?? throw new ArgumentNullException(nameof(onRestart));
|
||||
_onExit = onExit ?? throw new ArgumentNullException(nameof(onExit));
|
||||
|
||||
_watchdogTimer = new DispatcherTimer(TimeSpan.FromSeconds(5), DispatcherPriority.Background, OnWatchdogTick);
|
||||
}
|
||||
|
||||
public TrayAvailabilityState State { get; private set; } = TrayAvailabilityState.Unavailable;
|
||||
|
||||
public bool IsReady => State == TrayAvailabilityState.Ready;
|
||||
|
||||
public event Action<TrayAvailabilityState>? StateChanged;
|
||||
|
||||
public bool EnsureReady(string reason)
|
||||
{
|
||||
if (HasHealthyTray())
|
||||
{
|
||||
_consecutiveRecoveryFailures = 0;
|
||||
SetState(TrayAvailabilityState.Ready, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryCreateOrRefreshTray(reason, isRecoveryAttempt: State != TrayAvailabilityState.Unavailable);
|
||||
}
|
||||
|
||||
public void Refresh(string reason)
|
||||
{
|
||||
if (!EnsureReady(reason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTrayContent();
|
||||
}
|
||||
|
||||
public void StartWatchdog()
|
||||
{
|
||||
if (!_watchdogTimer.IsEnabled)
|
||||
{
|
||||
_watchdogTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void StopWatchdog()
|
||||
{
|
||||
if (_watchdogTimer.IsEnabled)
|
||||
{
|
||||
_watchdogTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopWatchdog();
|
||||
|
||||
try
|
||||
{
|
||||
if (_trayIcon is not null)
|
||||
{
|
||||
_trayIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
SetState(TrayAvailabilityState.Unavailable, "Dispose");
|
||||
}
|
||||
|
||||
private void OnWatchdogTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasHealthyTray())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryCreateOrRefreshTray("Watchdog", isRecoveryAttempt: true);
|
||||
}
|
||||
|
||||
private bool TryCreateOrRefreshTray(string reason, bool isRecoveryAttempt)
|
||||
{
|
||||
try
|
||||
{
|
||||
SetState(
|
||||
isRecoveryAttempt ? TrayAvailabilityState.Recovering : TrayAvailabilityState.Initializing,
|
||||
reason);
|
||||
|
||||
EnsureTrayObjects();
|
||||
ApplyTrayContent();
|
||||
TrayIcon.SetIcons(_application, [_trayIcon!]);
|
||||
|
||||
if (!HasHealthyTray())
|
||||
{
|
||||
throw new InvalidOperationException("Tray icon did not reach a healthy state after initialization.");
|
||||
}
|
||||
|
||||
_consecutiveRecoveryFailures = 0;
|
||||
SetState(TrayAvailabilityState.Ready, reason);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_consecutiveRecoveryFailures++;
|
||||
SetState(TrayAvailabilityState.Failed, $"{reason}:{ex.GetType().Name}");
|
||||
AppLogger.Warn("TrayIcon", $"Tray initialization/recovery failed. Reason='{reason}'. Attempt={_consecutiveRecoveryFailures}.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTrayObjects()
|
||||
{
|
||||
_showDesktopMenuItem ??= CreateMenuItem(_onShowDesktop);
|
||||
_settingsMenuItem ??= CreateMenuItem(_onSettings);
|
||||
_componentLibraryMenuItem ??= CreateMenuItem(_onComponentLibrary);
|
||||
_restartMenuItem ??= CreateMenuItem(_onRestart);
|
||||
_exitMenuItem ??= CreateMenuItem(_onExit);
|
||||
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
var trayMenu = new NativeMenu();
|
||||
trayMenu.Items.Add(_showDesktopMenuItem);
|
||||
trayMenu.Items.Add(_settingsMenuItem);
|
||||
trayMenu.Items.Add(_componentLibraryMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_restartMenuItem);
|
||||
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||
trayMenu.Items.Add(_exitMenuItem);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Menu = trayMenu
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTrayContent()
|
||||
{
|
||||
if (_trayIcon is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_trayIcon.Icon = _appLogoService.CreateTrayIcon();
|
||||
_trayIcon.IsVisible = true;
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_trayIcon.ToolTipText = _localize("tray.tooltip", "LanMountainDesktop");
|
||||
}
|
||||
|
||||
if (_showDesktopMenuItem is not null)
|
||||
{
|
||||
_showDesktopMenuItem.Header = _localize("tray.menu.show_desktop", "Open Desktop");
|
||||
}
|
||||
|
||||
if (_settingsMenuItem is not null)
|
||||
{
|
||||
_settingsMenuItem.Header = _localize("tray.menu.settings", "Settings");
|
||||
}
|
||||
|
||||
if (_componentLibraryMenuItem is not null)
|
||||
{
|
||||
_componentLibraryMenuItem.IsVisible = _shouldShowComponentLibraryMenuItem();
|
||||
if (_componentLibraryMenuItem.IsVisible)
|
||||
{
|
||||
_componentLibraryMenuItem.Header = _localize("tray.menu.component_library", "Component Library");
|
||||
}
|
||||
}
|
||||
|
||||
if (_restartMenuItem is not null)
|
||||
{
|
||||
_restartMenuItem.Header = _localize("tray.menu.restart", "Restart App");
|
||||
}
|
||||
|
||||
if (_exitMenuItem is not null)
|
||||
{
|
||||
_exitMenuItem.Header = _localize("tray.menu.exit", "Exit App");
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasHealthyTray()
|
||||
{
|
||||
return _trayIcon is not null &&
|
||||
_trayIcon.Menu is not null &&
|
||||
_trayIcon.Icon is not null &&
|
||||
_trayIcon.IsVisible &&
|
||||
_showDesktopMenuItem is not null &&
|
||||
_settingsMenuItem is not null &&
|
||||
_componentLibraryMenuItem is not null &&
|
||||
_restartMenuItem is not null &&
|
||||
_exitMenuItem is not null;
|
||||
}
|
||||
|
||||
private void SetState(TrayAvailabilityState state, string reason)
|
||||
{
|
||||
if (State == state)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = State;
|
||||
State = state;
|
||||
AppLogger.Info("TrayIcon", $"Tray availability changed. Previous='{previous}'; Current='{state}'; Reason='{reason}'.");
|
||||
StateChanged?.Invoke(state);
|
||||
}
|
||||
|
||||
private static NativeMenuItem CreateMenuItem(EventHandler clickHandler)
|
||||
{
|
||||
var item = new NativeMenuItem();
|
||||
item.Click += clickHandler;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicAppInfoService : IPublicAppInfoService
|
||||
{
|
||||
private readonly string _version;
|
||||
private readonly string _codename;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
|
||||
public PublicAppInfoService(string version, string codename, DateTimeOffset startedAt)
|
||||
public PublicAppInfoService(DateTimeOffset startedAt)
|
||||
{
|
||||
_version = version;
|
||||
_codename = codename;
|
||||
_startedAt = startedAt;
|
||||
}
|
||||
|
||||
public PublicAppInfoSnapshot GetAppInfo()
|
||||
{
|
||||
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
|
||||
return new PublicAppInfoSnapshot(
|
||||
"LanMountainDesktop",
|
||||
_version,
|
||||
_codename,
|
||||
versionInfo.Version,
|
||||
versionInfo.Codename,
|
||||
IpcConstants.DefaultPipeName,
|
||||
Environment.ProcessId,
|
||||
_startedAt);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
@@ -105,7 +106,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
@@ -121,7 +124,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
|
||||
return TryExit(request);
|
||||
@@ -129,7 +131,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
if (startInfo is null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
@@ -139,7 +143,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
var app = Application.Current as App;
|
||||
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
|
||||
var exitRequest = request is null
|
||||
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||
|
||||
@@ -7,9 +7,7 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
namespace LanMountainDesktop.Services.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
|
||||
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
|
||||
/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
|
||||
/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
|
||||
/// </summary>
|
||||
public class LauncherIpcClient : IDisposable
|
||||
{
|
||||
@@ -18,23 +16,14 @@ public class LauncherIpcClient : IDisposable
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
private NamedPipeClientStream? _pipeClient;
|
||||
private bool _isConnected;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否已连接到 Launcher
|
||||
/// </summary>
|
||||
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
|
||||
|
||||
/// <summary>
|
||||
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
|
||||
/// </summary>
|
||||
private const int LengthPrefixSize = 4;
|
||||
|
||||
/// <summary>
|
||||
/// 连接到 Launcher 的 IPC 服务端
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -50,7 +39,6 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Launcher 可能没有启动 IPC 服务端,这是正常的
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -60,24 +48,20 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 报告启动进度(在同一连接上可多次调用)
|
||||
/// </summary>
|
||||
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||
{
|
||||
if (!_isConnected || _pipeClient?.IsConnected != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(message, StartupProgressJsonOptions);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// 长度前缀协议:[4字节长度][消息正文]
|
||||
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
|
||||
|
||||
// 加锁保证单条消息的长度前缀和正文原子写入
|
||||
lock (_writeLock)
|
||||
{
|
||||
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||
@@ -85,12 +69,10 @@ public class LauncherIpcClient : IDisposable
|
||||
_pipeClient.Flush();
|
||||
}
|
||||
|
||||
// 将同步写入包装为已完成的 Task
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 管道断开
|
||||
_isConnected = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -100,30 +82,9 @@ public class LauncherIpcClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查是否从 Launcher 启动
|
||||
/// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
|
||||
/// 命令行参数作为备选确保兼容性)
|
||||
/// </summary>
|
||||
public static bool IsLaunchedByLauncher()
|
||||
{
|
||||
// 优先检查环境变量
|
||||
if (!string.IsNullOrEmpty(
|
||||
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>)
|
||||
foreach (var arg in Environment.GetCommandLineArgs())
|
||||
{
|
||||
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return LauncherRuntimeMetadata.GetLauncherProcessId(Environment.GetCommandLineArgs()) is not null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -414,7 +414,7 @@ internal sealed class NotificationWindowManager
|
||||
|
||||
var screen = GetPrimaryScreen();
|
||||
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
|
||||
var scale = 1d;
|
||||
var scale = screen?.Scaling ?? 1d;
|
||||
|
||||
for (var i = 0; i < windows.Count; i++)
|
||||
{
|
||||
@@ -432,12 +432,19 @@ internal sealed class NotificationWindowManager
|
||||
int stackIndex)
|
||||
{
|
||||
window.Measure(Size.Infinity);
|
||||
var windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
|
||||
var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
|
||||
var windowWidthDip = window.Bounds.Width > 0
|
||||
? window.Bounds.Width
|
||||
: window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
|
||||
var windowHeightDip = window.Bounds.Height > 0
|
||||
? window.Bounds.Height
|
||||
: window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
|
||||
|
||||
var windowWidth = (int)Math.Round(windowWidthDip * scale);
|
||||
var windowHeight = (int)Math.Round(windowHeightDip * scale);
|
||||
|
||||
var margin = (int)Math.Round(Margin * scale);
|
||||
var spacing = (int)Math.Round(Spacing * scale);
|
||||
var stackedOffset = stackIndex * ((int)Math.Round(windowHeight) + spacing);
|
||||
var stackedOffset = stackIndex * (windowHeight + spacing);
|
||||
|
||||
return position switch
|
||||
{
|
||||
@@ -446,31 +453,31 @@ internal sealed class NotificationWindowManager
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.TopRight => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.TopCenter => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Y + margin + stackedOffset),
|
||||
|
||||
NotificationPosition.BottomLeft => new PixelPoint(
|
||||
workingArea.X + margin,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.BottomRight => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.BottomCenter => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Bottom - windowHeight - margin - stackedOffset),
|
||||
|
||||
NotificationPosition.Center => new PixelPoint(
|
||||
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
|
||||
workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2),
|
||||
workingArea.X + (workingArea.Width - windowWidth) / 2,
|
||||
workingArea.Y + (workingArea.Height - windowHeight) / 2),
|
||||
|
||||
_ => new PixelPoint(
|
||||
workingArea.Right - (int)Math.Round(windowWidth) - margin,
|
||||
workingArea.Right - windowWidth - margin,
|
||||
workingArea.Y + margin + stackedOffset)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1290,6 +1290,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppVersionText()
|
||||
{
|
||||
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
|
||||
.ResolveForCurrentProcess()
|
||||
.Version;
|
||||
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envVersion))
|
||||
@@ -1337,6 +1341,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string GetAppCodenameText()
|
||||
{
|
||||
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
|
||||
.ResolveForCurrentProcess()
|
||||
.Codename;
|
||||
|
||||
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
|
||||
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envCodename))
|
||||
|
||||
@@ -920,8 +920,12 @@ public partial class MainWindow : Window
|
||||
|
||||
if (useSlide)
|
||||
{
|
||||
var screenWidth = Screens.ScreenFromVisual(this)?.Bounds.Width ?? 3840;
|
||||
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidth;
|
||||
var screen = Screens.ScreenFromVisual(this);
|
||||
var scale = screen?.Scaling ?? 1d;
|
||||
var screenWidthDip = screen is null
|
||||
? 1920d
|
||||
: screen.WorkingArea.Width / Math.Max(scale, 0.01d);
|
||||
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidthDip;
|
||||
}
|
||||
|
||||
DesktopPage.Transitions = savedTransitions;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Desktop"/>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
|
||||
Reference in New Issue
Block a user