diff --git a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/Localization/en-US.json b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/Localization/en-US.json index 438ea77..09f91b9 100644 --- a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/Localization/en-US.json +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/Localization/en-US.json @@ -67,6 +67,11 @@ "capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.", "capability.widget_context.title": "PluginDesktopComponentContext", "capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService() against the same plugin service container.", + "widget.close_desktop.display_name": "Close Desktop", + "widget.close_desktop.text": "Close Desktop", + "widget.close_desktop.hint": "Exit LanMountainDesktop on click", + "widget.close_desktop.unavailable": "Host lifecycle API is unavailable", + "widget.close_desktop.failed": "Host rejected the exit request", "widget.subtitle.preview": "Preview surface | placed: {0}", "widget.subtitle.placement": "Placement {0} | placed: {1}", "common.dev": "dev", diff --git a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs index 9f57b11..f056e07 100644 --- a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs @@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost"); var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion"); var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion"); + var hostApplicationLifecycle = context.GetService(); var messageBus = context.GetService() ?? throw new InvalidOperationException("Plugin message bus is not available."); @@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable allowStatusBarPlacement: false, resizeMode: PluginDesktopComponentResizeMode.Proportional, cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34))); + + context.RegisterDesktopComponent(new PluginDesktopComponentRegistration( + "LanMountainDesktop.SamplePlugin.CloseDesktop", + localizer.GetString("widget.close_desktop.display_name", "关闭桌面"), + widgetContext => new SamplePluginCloseDesktopWidget(widgetContext), + iconKey: "DismissCircle", + category: localizer.GetString("widget.category", "鎻掍欢"), + minWidthCells: 2, + minHeightCells: 1, + allowDesktopPlacement: true, + allowStatusBarPlacement: false, + resizeMode: PluginDesktopComponentResizeMode.Free, + cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22))); } public void Dispose() diff --git a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginCloseDesktopWidget.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginCloseDesktopWidget.cs new file mode 100644 index 0000000..a297025 --- /dev/null +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginCloseDesktopWidget.cs @@ -0,0 +1,166 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.SamplePlugin; + +internal sealed class SamplePluginCloseDesktopWidget : Border +{ + private readonly PluginLocalizer _localizer; + private readonly IHostApplicationLifecycle? _hostApplicationLifecycle; + private readonly TextBlock _titleTextBlock; + private readonly TextBlock _statusTextBlock; + + public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context) + { + _localizer = PluginLocalizer.Create(context); + _hostApplicationLifecycle = context.GetService(); + + _titleTextBlock = new TextBlock + { + Text = T("widget.close_desktop.text", "关闭桌面"), + Foreground = Brushes.White, + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center + }; + + _statusTextBlock = new TextBlock + { + Text = _hostApplicationLifecycle is null + ? T("widget.close_desktop.unavailable", "宿主未提供退出接口") + : T("widget.close_desktop.hint", "点击后退出阑山桌面"), + Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")), + VerticalAlignment = VerticalAlignment.Center + }; + + var contentGrid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + ColumnSpacing = 14, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + CreateIconShell(), + new StackPanel + { + Spacing = 2, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + _titleTextBlock, + _statusTextBlock + } + } + } + }; + + Grid.SetColumn(contentGrid.Children[1], 1); + + var actionButton = new Button + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + IsEnabled = _hostApplicationLifecycle is not null, + Content = contentGrid + }; + actionButton.Click += OnButtonClick; + + Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + [ + new GradientStop(Color.Parse("#FF0B1220"), 0), + new GradientStop(Color.Parse("#FF172554"), 0.55), + new GradientStop(Color.Parse("#FF7F1D1D"), 1) + ] + }; + BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185")); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(18); + Padding = new Thickness(14, 10); + Child = actionButton; + + SizeChanged += OnSizeChanged; + ApplyScale(); + } + + private Border CreateIconShell() + { + return new Border + { + Width = 36, + Height = 36, + CornerRadius = new CornerRadius(999), + Background = new SolidColorBrush(Color.Parse("#33F87171")), + BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")), + BorderThickness = new Thickness(1), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "⏻", + FontSize = 18, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center + } + }; + } + + private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest( + Source: "SamplePlugin.CloseDesktopWidget", + Reason: "User invoked the sample plugin close-desktop widget.")) == true) + { + return; + } + + _statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求"); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyScale(); + } + + private void ApplyScale() + { + var basis = Bounds.Height > 1 ? Bounds.Height : 72; + Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14)); + CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24)); + + if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid) + { + return; + } + + if (contentGrid.Children[0] is Border iconShell) + { + var iconSize = Math.Clamp(basis * 0.58, 28, 40); + iconShell.Width = iconSize; + iconShell.Height = iconSize; + if (iconShell.Child is TextBlock iconText) + { + iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20); + } + } + + _titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20); + _statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13); + } + + private string T(string key, string fallback) + { + return _localizer.GetString(key, fallback); + } +} diff --git a/LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs b/LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs new file mode 100644 index 0000000..cbd3817 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs @@ -0,0 +1,12 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed record HostApplicationLifecycleRequest( + string? Source = null, + string? Reason = null); + +public interface IHostApplicationLifecycle +{ + bool TryExit(HostApplicationLifecycleRequest? request = null); + + bool TryRestart(HostApplicationLifecycleRequest? request = null); +} diff --git a/LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj b/LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj index 924f51f..ce3588b 100644 --- a/LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj +++ b/LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj @@ -1,9 +1,11 @@ - + Exe net10.0 enable enable + 1.0.0 + $(Version) diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index c7e5eb3..88d65a1 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -12,6 +12,7 @@ using LanMountainDesktop.Services; using LanMountainDesktop.ViewModels; using LanMountainDesktop.Views; using AvaloniaWebView; +using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop; @@ -19,12 +20,19 @@ public partial class App : Application { private readonly AppSettingsService _appSettingsService = new(); private readonly LocalizationService _localizationService = new(); + private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); + private bool _exitCleanupCompleted; private SettingsWindow? _traySettingsWindow; private TrayIcons? _trayIcons; private PluginRuntimeService? _pluginRuntimeService; + internal static SingleInstanceService? CurrentSingleInstanceService { get; set; } + internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle => + (Current as App)?._hostApplicationLifecycle; + public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; + public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle; public override void Initialize() { @@ -51,14 +59,14 @@ public partial class App : Application desktop.Exit += (_, _) => { AppLogger.Info("App", "Desktop lifetime exit triggered."); - AppSettingsService.SettingsSaved -= OnAppSettingsSaved; - DisposeTrayIcon(); + PerformExitCleanup(); }; desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(), }; AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}"); + CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow); } base.OnFrameworkInitializationCompleted(); @@ -66,12 +74,9 @@ public partial class App : Application private void OnTrayExitClick(object? sender, EventArgs e) { - DisposeTrayIcon(); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Shutdown(); - } + _ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest( + Source: "TrayMenu", + Reason: "User selected Exit App from the tray menu.")); } private void OnTraySettingsClick(object? sender, EventArgs e) @@ -114,18 +119,9 @@ public partial class App : Application private void OnTrayRestartClick(object? sender, EventArgs e) { - AppRestartService.TryRestartApplication(); - } - - private void OnAppSettingsSaved(string _) - { - Dispatcher.UIThread.Post(() => - { - if (_trayIcons is not null) - { - InitializeTrayIcon(); - } - }, DispatcherPriority.Background); + _ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest( + Source: "TrayMenu", + Reason: "User selected Restart App from the tray menu.")); } private void DisableAvaloniaDataAnnotationValidation() @@ -246,6 +242,95 @@ public partial class App : Application _trayIcons = null; } + private void ActivateMainWindow() + { + Dispatcher.UIThread.Post(() => + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + return; + } + + if (desktop.MainWindow is not Window mainWindow) + { + return; + } + + try + { + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + + if (mainWindow.WindowState == WindowState.Minimized) + { + mainWindow.WindowState = WindowState.Normal; + } + + mainWindow.Activate(); + mainWindow.Topmost = true; + mainWindow.Topmost = false; + } + catch (Exception ex) + { + AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex); + } + }, DispatcherPriority.Send); + } + + private void OnAppSettingsSaved(string _) + { + Dispatcher.UIThread.Post(() => + { + if (_trayIcons is not null) + { + InitializeTrayIcon(); + } + }, DispatcherPriority.Background); + } + + private void PerformExitCleanup() + { + if (_exitCleanupCompleted) + { + return; + } + + _exitCleanupCompleted = true; + AppSettingsService.SettingsSaved -= OnAppSettingsSaved; + + try + { + _traySettingsWindow?.Close(); + } + catch (Exception ex) + { + AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex); + } + finally + { + _traySettingsWindow = null; + } + + try + { + _pluginRuntimeService?.Dispose(); + } + catch (Exception ex) + { + AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex); + } + finally + { + _pluginRuntimeService = null; + } + + AudioRecorderServiceFactory.DisposeSharedServices(); + StudyAnalyticsServiceFactory.DisposeSharedService(); + DisposeTrayIcon(); + } + private string L(string key, string fallback) { var snapshot = _appSettingsService.Load(); diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index b7d6b9e..6b0b1af 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -251,6 +251,7 @@ "settings.update.status_launching_installer": "Download complete. Launching installer...", "settings.update.status_installer_missing": "Installer file was not found after download.", "settings.update.status_installer_started": "Installer started. The app will close for update.", + "settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.", "settings.update.status_launch_failed_format": "Failed to start installer: {0}", "settings.about.title": "About", "settings.about.version_format": "Version: {0}", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 5e04470..cf8075a 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -251,6 +251,7 @@ "settings.update.status_launching_installer": "下载完成,正在启动安装程序...", "settings.update.status_installer_missing": "下载后未找到安装包文件。", "settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。", + "settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。", "settings.update.status_launch_failed_format": "启动安装程序失败:{0}", "settings.about.title": "关于", "settings.about.version_format": "版本号: {0}", diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index e5d8fc5..1a4b4f8 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -17,6 +17,15 @@ sealed class Program AppLogger.Initialize(); RegisterGlobalExceptionLogging(); + using var singleInstance = SingleInstanceService.CreateDefault(); + if (!singleInstance.IsPrimaryInstance) + { + AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running."); + var notified = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); + ShowAlreadyRunningNotice(notified); + return; + } + var diagnostics = StartupDiagnosticsService.Run(args); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); @@ -24,6 +33,7 @@ sealed class Program { var renderMode = LoadConfiguredRenderMode(); AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); + App.CurrentSingleInstanceService = singleInstance; BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); AppLogger.Info("Startup", "Application exited normally."); } @@ -32,6 +42,10 @@ sealed class Program AppLogger.Critical("Startup", "Application terminated during startup.", ex); throw; } + finally + { + App.CurrentSingleInstanceService = null; + } } // Avalonia configuration, don't remove; also used by visual designer. @@ -71,6 +85,16 @@ sealed class Program } } + private static void ShowAlreadyRunningNotice(bool notifiedPrimaryInstance) + { + const string caption = "LanMountainDesktop"; + var message = notifiedPrimaryInstance + ? "应用已打开,不需要多开了。\r\n\r\n已为你切换到正在运行的阑山桌面。" + : "应用已打开,不需要多开了。\r\n\r\n请切换到正在运行的阑山桌面。"; + + WindowsNativeDialogService.ShowInformation(caption, message); + } + private static void RegisterGlobalExceptionLogging() { AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => diff --git a/LanMountainDesktop/Services/AppRestartService.cs b/LanMountainDesktop/Services/AppRestartService.cs index 4cdb416..f9318df 100644 --- a/LanMountainDesktop/Services/AppRestartService.cs +++ b/LanMountainDesktop/Services/AppRestartService.cs @@ -3,8 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; +using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.Services; @@ -12,17 +11,9 @@ public static class AppRestartService { public static bool TryRestartApplication() { - if (!TryRestartCurrentProcess()) - { - return false; - } - - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Shutdown(); - } - - return true; + return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( + Source: nameof(AppRestartService), + Reason: "Legacy restart entry point invoked.")) == true; } public static bool TryRestartCurrentProcess() diff --git a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs new file mode 100644 index 0000000..ec6717e --- /dev/null +++ b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Services; + +public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle +{ + public bool TryExit(HostApplicationLifecycleRequest? request = null) + { + try + { + AppLogger.Info( + "HostLifecycle", + $"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'."); + + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + { + AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable."); + return false; + } + + if (Dispatcher.UIThread.CheckAccess()) + { + desktop.Shutdown(); + } + else + { + Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send); + } + + return true; + } + catch (Exception ex) + { + AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex); + return false; + } + } + + public bool TryRestart(HostApplicationLifecycleRequest? request = null) + { + try + { + var startInfo = AppRestartService.CreateRestartStartInfo(); + if (startInfo is null) + { + AppLogger.Warn( + "HostLifecycle", + $"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'."); + return false; + } + + Process.Start(startInfo); + var exitRequest = request is null + ? new HostApplicationLifecycleRequest(Reason: "Restart accepted.") + : request with + { + Reason = string.IsNullOrWhiteSpace(request.Reason) + ? "Restart accepted." + : request.Reason + }; + + return TryExit(exitRequest); + } + catch (Exception ex) + { + AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex); + return false; + } + } +} diff --git a/LanMountainDesktop/Services/IAudioRecorderService.cs b/LanMountainDesktop/Services/IAudioRecorderService.cs index 86de96c..e3329b4 100644 --- a/LanMountainDesktop/Services/IAudioRecorderService.cs +++ b/LanMountainDesktop/Services/IAudioRecorderService.cs @@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory { return CreateRecorder(); } + + public static void DisposeSharedServices() + { + if (SharedRecorderService.IsValueCreated) + { + SharedRecorderService.Value.Dispose(); + } + + if (SharedStudyMonitoringService.IsValueCreated) + { + SharedStudyMonitoringService.Value.Dispose(); + } + } } internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService diff --git a/LanMountainDesktop/Services/SingleInstanceService.cs b/LanMountainDesktop/Services/SingleInstanceService.cs new file mode 100644 index 0000000..f6df3f5 --- /dev/null +++ b/LanMountainDesktop/Services/SingleInstanceService.cs @@ -0,0 +1,151 @@ +using System; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed class SingleInstanceService : IDisposable +{ + private readonly Mutex _mutex; + private readonly string _pipeName; + private readonly CancellationTokenSource _listenCts = new(); + private bool _ownsMutex; + private bool _disposed; + private Task? _listenTask; + + private SingleInstanceService(string mutexName, string pipeName) + { + _mutex = new Mutex(initiallyOwned: false, mutexName); + _pipeName = pipeName; + try + { + _ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false); + } + catch (AbandonedMutexException) + { + _ownsMutex = true; + } + } + + public bool IsPrimaryInstance => _ownsMutex; + + public static SingleInstanceService CreateDefault() + { + const string appId = "LanMountainDesktop"; + var userName = Environment.UserName; + var scopeSeed = $"{appId}:{userName}"; + var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed))); + var suffix = scopeHash[..16]; + var mutexName = OperatingSystem.IsWindows() + ? $"Local\\{appId}.SingleInstance.{suffix}" + : $"{appId}.SingleInstance.{suffix}"; + return new SingleInstanceService( + mutexName, + $"{appId}.Activate.{suffix}"); + } + + public void StartActivationListener(Action onActivationRequested) + { + ArgumentNullException.ThrowIfNull(onActivationRequested); + + if (!_ownsMutex || _disposed || _listenTask is not null) + { + return; + } + + _listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token)); + } + + public bool TryNotifyPrimaryInstance(TimeSpan timeout) + { + if (_ownsMutex || _disposed) + { + return false; + } + + try + { + using var client = new NamedPipeClientStream( + serverName: ".", + pipeName: _pipeName, + direction: PipeDirection.Out, + options: PipeOptions.Asynchronous); + + client.Connect((int)Math.Max(1, timeout.TotalMilliseconds)); + client.WriteByte(1); + client.Flush(); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex); + return false; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _listenCts.Cancel(); + try + { + _listenTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + // Ignore listener shutdown races during process exit. + } + + _listenCts.Dispose(); + if (_ownsMutex) + { + try + { + _mutex.ReleaseMutex(); + } + catch (ApplicationException) + { + // Ownership may already be lost during shutdown. + } + } + + _mutex.Dispose(); + } + + private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var server = new NamedPipeServerStream( + _pipeName, + PipeDirection.In, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false); + onActivationRequested(); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + AppLogger.Warn("SingleInstance", "Activation listener failed.", ex); + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs index 1f91640..41cddf5 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs @@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory { return SharedService.Value; } + + public static void DisposeSharedService() + { + if (SharedService.IsValueCreated) + { + SharedService.Value.Dispose(); + } + } } public sealed class StudyAnalyticsService : IStudyAnalyticsService @@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService _disposed = true; StopTimerLocked(); _samplingTimer.Dispose(); + _audioRecorderService.Dispose(); } } @@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService _lastSessionReport = null; } } - diff --git a/LanMountainDesktop/Services/WindowsNativeDialogService.cs b/LanMountainDesktop/Services/WindowsNativeDialogService.cs index 5f16d88..02edc02 100644 --- a/LanMountainDesktop/Services/WindowsNativeDialogService.cs +++ b/LanMountainDesktop/Services/WindowsNativeDialogService.cs @@ -6,9 +6,20 @@ namespace LanMountainDesktop.Services; internal static class WindowsNativeDialogService { private const uint Ok = 0x00000000; + private const uint IconInformation = 0x00000040; private const uint IconWarning = 0x00000030; + public static void ShowInformation(string caption, string message) + { + Show(caption, message, Ok | IconInformation, "NativeDialog"); + } + public static void ShowWarning(string caption, string message) + { + Show(caption, message, Ok | IconWarning, "StartupDiagnostics"); + } + + private static void Show(string caption, string message, uint type, string logCategory) { if (!OperatingSystem.IsWindows()) { @@ -17,11 +28,11 @@ internal static class WindowsNativeDialogService try { - _ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning); + _ = MessageBoxW(IntPtr.Zero, message, caption, type); } catch (Exception ex) { - AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex); + AppLogger.Warn(logCategory, "Failed to show native dialog.", ex); } } diff --git a/LanMountainDesktop/Views/MainWindow.RestartPrompt.cs b/LanMountainDesktop/Views/MainWindow.RestartPrompt.cs index 4f2b4cf..ed4982d 100644 --- a/LanMountainDesktop/Views/MainWindow.RestartPrompt.cs +++ b/LanMountainDesktop/Views/MainWindow.RestartPrompt.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Avalonia.Interactivity; using Avalonia.Threading; using FluentAvalonia.UI.Controls; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -77,7 +78,9 @@ public partial class MainWindow var result = await dialog.ShowAsync(this); if (result == ContentDialogResult.Primary) { - if (!AppRestartService.TryRestartApplication()) + if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( + Source: nameof(MainWindow), + Reason: "User confirmed a pending restart prompt.")) != true) { UpdatePendingRestartDock(); } diff --git a/LanMountainDesktop/Views/MainWindow.Update.cs b/LanMountainDesktop/Views/MainWindow.Update.cs index 817f380..60d9994 100644 --- a/LanMountainDesktop/Views/MainWindow.Update.cs +++ b/LanMountainDesktop/Views/MainWindow.Update.cs @@ -1,11 +1,12 @@ using System; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.Threading; using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -357,7 +358,8 @@ public partial class MainWindow { FileName = installerPath, WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, - UseShellExecute = true + UseShellExecute = true, + Verb = "runas" }); _updateStatusText = L( @@ -365,7 +367,16 @@ public partial class MainWindow "Installer started. The app will close for update."); UpdateUpdatePanelState(); - Dispatcher.UIThread.Post(Close, DispatcherPriority.Background); + _ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest( + Source: nameof(MainWindow), + Reason: "Update installer started successfully.")); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + _updateStatusText = L( + "settings.update.status_elevation_cancelled", + "Administrator permission was not granted. Update was cancelled."); + UpdateUpdatePanelState(); } catch (Exception ex) { diff --git a/LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs b/LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs index 38f5205..23517c7 100644 --- a/LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs +++ b/LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Avalonia.Interactivity; using Avalonia.Threading; using FluentAvalonia.UI.Controls; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -77,7 +78,9 @@ public partial class SettingsWindow var result = await dialog.ShowAsync(this); if (result == ContentDialogResult.Primary) { - if (!AppRestartService.TryRestartApplication()) + if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( + Source: nameof(SettingsWindow), + Reason: "User confirmed a pending restart prompt from settings.")) != true) { UpdatePendingRestartDock(); } diff --git a/LanMountainDesktop/Views/SettingsWindow.Update.cs b/LanMountainDesktop/Views/SettingsWindow.Update.cs index 32db285..7f7f48c 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Update.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Update.cs @@ -1,13 +1,12 @@ using System; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Threading.Tasks; -using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Interactivity; -using Avalonia.Threading; using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -250,23 +249,23 @@ public partial class SettingsWindow { FileName = installerPath, WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, - UseShellExecute = true + UseShellExecute = true, + Verb = "runas" }); _updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update."); UpdateUpdatePanelState(); - Dispatcher.UIThread.Post(() => - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Shutdown(); - } - else - { - Close(); - } - }, DispatcherPriority.Background); + _ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest( + Source: nameof(SettingsWindow), + Reason: "Update installer started successfully from settings.")); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + _updateStatusText = L( + "settings.update.status_elevation_cancelled", + "Administrator permission was not granted. Update was cancelled."); + UpdateUpdatePanelState(); } catch (Exception ex) { diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index 2b1feb2..3c5b26f 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -38,6 +38,8 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch} Compression=lzma2/ultra64 SolidCompression=yes WizardStyle=modern +; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade +; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches. PrivilegesRequired=admin CloseApplications=yes CloseApplicationsFilter={#MyAppExeName} diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index 159429a..cf4a844 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -21,6 +21,7 @@ public sealed class PluginRuntimeService : IDisposable private readonly PluginLoader _loader; private readonly AppSettingsService _appSettingsService = new(); + private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService(); private readonly IServiceProvider _hostServices; private readonly IPluginPackageManager _packageManager; private readonly List _loadedPlugins = []; @@ -34,7 +35,7 @@ public sealed class PluginRuntimeService : IDisposable { PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); _packageManager = new PluginRuntimePackageManager(this); - _hostServices = new PluginHostServiceProvider(_packageManager); + _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle); _loader = new PluginLoader(CreateOptions()); } @@ -679,17 +680,29 @@ public sealed class PluginRuntimeService : IDisposable private sealed class PluginHostServiceProvider : IServiceProvider { private readonly IPluginPackageManager _packageManager; + private readonly IHostApplicationLifecycle _applicationLifecycle; - public PluginHostServiceProvider(IPluginPackageManager packageManager) + public PluginHostServiceProvider( + IPluginPackageManager packageManager, + IHostApplicationLifecycle applicationLifecycle) { _packageManager = packageManager; + _applicationLifecycle = applicationLifecycle; } public object? GetService(Type serviceType) { - return serviceType == typeof(IPluginPackageManager) - ? _packageManager - : null; + if (serviceType == typeof(IPluginPackageManager)) + { + return _packageManager; + } + + if (serviceType == typeof(IHostApplicationLifecycle)) + { + return _applicationLifecycle; + } + + return null; } }