二次启动拦截,统一了生命进程API
This commit is contained in:
lincube
2026-03-11 09:40:36 +08:00
parent 2781d7e0d9
commit e7a03404ce
21 changed files with 652 additions and 62 deletions

View File

@@ -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.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.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.", "capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() 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.preview": "Preview surface | placed: {0}",
"widget.subtitle.placement": "Placement {0} | placed: {1}", "widget.subtitle.placement": "Placement {0} | placed: {1}",
"common.dev": "dev", "common.dev": "dev",

View File

@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost"); var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion"); var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion"); var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
var messageBus = context.GetService<IPluginMessageBus>() var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available."); ?? throw new InvalidOperationException("Plugin message bus is not available.");
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
allowStatusBarPlacement: false, allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional, resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34))); 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() public void Dispose()

View File

@@ -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<IHostApplicationLifecycle>();
_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);
}
}

View File

@@ -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);
}

View File

@@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -12,6 +12,7 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views; using LanMountainDesktop.Views;
using AvaloniaWebView; using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -19,12 +20,19 @@ public partial class App : Application
{ {
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private bool _exitCleanupCompleted;
private SettingsWindow? _traySettingsWindow; private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons; private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService; private PluginRuntimeService? _pluginRuntimeService;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
public override void Initialize() public override void Initialize()
{ {
@@ -51,14 +59,14 @@ public partial class App : Application
desktop.Exit += (_, _) => desktop.Exit += (_, _) =>
{ {
AppLogger.Info("App", "Desktop lifetime exit triggered."); AppLogger.Info("App", "Desktop lifetime exit triggered.");
AppSettingsService.SettingsSaved -= OnAppSettingsSaved; PerformExitCleanup();
DisposeTrayIcon();
}; };
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel(),
}; };
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}"); AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
@@ -66,12 +74,9 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e) private void OnTrayExitClick(object? sender, EventArgs e)
{ {
DisposeTrayIcon(); _ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) Reason: "User selected Exit App from the tray menu."));
{
desktop.Shutdown();
}
} }
private void OnTraySettingsClick(object? sender, EventArgs e) private void OnTraySettingsClick(object? sender, EventArgs e)
@@ -114,18 +119,9 @@ public partial class App : Application
private void OnTrayRestartClick(object? sender, EventArgs e) private void OnTrayRestartClick(object? sender, EventArgs e)
{ {
AppRestartService.TryRestartApplication(); _ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
} Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
} }
private void DisableAvaloniaDataAnnotationValidation() private void DisableAvaloniaDataAnnotationValidation()
@@ -246,6 +242,95 @@ public partial class App : Application
_trayIcons = null; _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) private string L(string key, string fallback)
{ {
var snapshot = _appSettingsService.Load(); var snapshot = _appSettingsService.Load();

View File

@@ -251,6 +251,7 @@
"settings.update.status_launching_installer": "Download complete. Launching installer...", "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_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.", "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.update.status_launch_failed_format": "Failed to start installer: {0}",
"settings.about.title": "About", "settings.about.title": "About",
"settings.about.version_format": "Version: {0}", "settings.about.version_format": "Version: {0}",

View File

@@ -251,6 +251,7 @@
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...", "settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。", "settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。", "settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}", "settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于", "settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}", "settings.about.version_format": "版本号: {0}",

View File

@@ -17,6 +17,15 @@ sealed class Program
AppLogger.Initialize(); AppLogger.Initialize();
RegisterGlobalExceptionLogging(); 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); var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
@@ -24,6 +33,7 @@ sealed class Program
{ {
var renderMode = LoadConfiguredRenderMode(); var renderMode = LoadConfiguredRenderMode();
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally."); AppLogger.Info("Startup", "Application exited normally.");
} }
@@ -32,6 +42,10 @@ sealed class Program
AppLogger.Critical("Startup", "Application terminated during startup.", ex); AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw; throw;
} }
finally
{
App.CurrentSingleInstanceService = null;
}
} }
// Avalonia configuration, don't remove; also used by visual designer. // 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() private static void RegisterGlobalExceptionLogging()
{ {
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>

View File

@@ -3,8 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using Avalonia; using LanMountainDesktop.PluginSdk;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -12,17 +11,9 @@ public static class AppRestartService
{ {
public static bool TryRestartApplication() public static bool TryRestartApplication()
{ {
if (!TryRestartCurrentProcess()) return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
{ Source: nameof(AppRestartService),
return false; Reason: "Legacy restart entry point invoked.")) == true;
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
return true;
} }
public static bool TryRestartCurrentProcess() public static bool TryRestartCurrentProcess()

View File

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

View File

@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
{ {
return CreateRecorder(); 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 internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService

View File

@@ -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);
}
}
}
}

View File

@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
{ {
return SharedService.Value; return SharedService.Value;
} }
public static void DisposeSharedService()
{
if (SharedService.IsValueCreated)
{
SharedService.Value.Dispose();
}
}
} }
public sealed class StudyAnalyticsService : IStudyAnalyticsService public sealed class StudyAnalyticsService : IStudyAnalyticsService
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_disposed = true; _disposed = true;
StopTimerLocked(); StopTimerLocked();
_samplingTimer.Dispose(); _samplingTimer.Dispose();
_audioRecorderService.Dispose();
} }
} }
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_lastSessionReport = null; _lastSessionReport = null;
} }
} }

View File

@@ -6,9 +6,20 @@ namespace LanMountainDesktop.Services;
internal static class WindowsNativeDialogService internal static class WindowsNativeDialogService
{ {
private const uint Ok = 0x00000000; private const uint Ok = 0x00000000;
private const uint IconInformation = 0x00000040;
private const uint IconWarning = 0x00000030; 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) 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()) if (!OperatingSystem.IsWindows())
{ {
@@ -17,11 +28,11 @@ internal static class WindowsNativeDialogService
try try
{ {
_ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning); _ = MessageBoxW(IntPtr.Zero, message, caption, type);
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex); AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
} }
} }

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class MainWindow
var result = await dialog.ShowAsync(this); var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary) 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(); UpdatePendingRestartDock();
} }

View File

@@ -1,11 +1,12 @@
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -357,7 +358,8 @@ public partial class MainWindow
{ {
FileName = installerPath, FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
UseShellExecute = true UseShellExecute = true,
Verb = "runas"
}); });
_updateStatusText = L( _updateStatusText = L(
@@ -365,7 +367,16 @@ public partial class MainWindow
"Installer started. The app will close for update."); "Installer started. The app will close for update.");
UpdateUpdatePanelState(); 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) catch (Exception ex)
{ {

View File

@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -77,7 +78,9 @@ public partial class SettingsWindow
var result = await dialog.ShowAsync(this); var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary) 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(); UpdatePendingRestartDock();
} }

View File

@@ -1,13 +1,12 @@
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -250,23 +249,23 @@ public partial class SettingsWindow
{ {
FileName = installerPath, FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, 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."); _updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
UpdateUpdatePanelState(); UpdateUpdatePanelState();
Dispatcher.UIThread.Post(() => _ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
{ Source: nameof(SettingsWindow),
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) Reason: "Update installer started successfully from settings."));
{ }
desktop.Shutdown(); catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
} {
else _updateStatusText = L(
{ "settings.update.status_elevation_cancelled",
Close(); "Administrator permission was not granted. Update was cancelled.");
} UpdateUpdatePanelState();
}, DispatcherPriority.Background);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -38,6 +38,8 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
Compression=lzma2/ultra64 Compression=lzma2/ultra64
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade
; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches.
PrivilegesRequired=admin PrivilegesRequired=admin
CloseApplications=yes CloseApplications=yes
CloseApplicationsFilter={#MyAppExeName} CloseApplicationsFilter={#MyAppExeName}

View File

@@ -21,6 +21,7 @@ public sealed class PluginRuntimeService : IDisposable
private readonly PluginLoader _loader; private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
private readonly IServiceProvider _hostServices; private readonly IServiceProvider _hostServices;
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly List<LoadedPlugin> _loadedPlugins = []; private readonly List<LoadedPlugin> _loadedPlugins = [];
@@ -34,7 +35,7 @@ public sealed class PluginRuntimeService : IDisposable
{ {
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_packageManager = new PluginRuntimePackageManager(this); _packageManager = new PluginRuntimePackageManager(this);
_hostServices = new PluginHostServiceProvider(_packageManager); _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle);
_loader = new PluginLoader(CreateOptions()); _loader = new PluginLoader(CreateOptions());
} }
@@ -679,17 +680,29 @@ public sealed class PluginRuntimeService : IDisposable
private sealed class PluginHostServiceProvider : IServiceProvider private sealed class PluginHostServiceProvider : IServiceProvider
{ {
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly IHostApplicationLifecycle _applicationLifecycle;
public PluginHostServiceProvider(IPluginPackageManager packageManager) public PluginHostServiceProvider(
IPluginPackageManager packageManager,
IHostApplicationLifecycle applicationLifecycle)
{ {
_packageManager = packageManager; _packageManager = packageManager;
_applicationLifecycle = applicationLifecycle;
} }
public object? GetService(Type serviceType) public object? GetService(Type serviceType)
{ {
return serviceType == typeof(IPluginPackageManager) if (serviceType == typeof(IPluginPackageManager))
? _packageManager {
: null; return _packageManager;
}
if (serviceType == typeof(IHostApplicationLifecycle))
{
return _applicationLifecycle;
}
return null;
} }
} }