Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
lincube
e7a03404ce 0.5.14
二次启动拦截,统一了生命进程API
2026-03-11 09:40:36 +08:00
35 changed files with 1020 additions and 177 deletions

17
.github/FIX_REPORT.md vendored
View File

@@ -8,14 +8,14 @@ MSBUILD : error MSB1003: Specify a project or solution file.
The current working directory does not contain a project or solution file. The current working directory does not contain a project or solution file.
``` ```
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。 **原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
--- ---
## 🔧 已采取的修复 ## 🔧 已采取的修复
### 1. 创建解决方案文件 ### 1. 创建 `.slnx` 解决方案文件
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含: ✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
- `LanMountainDesktop/LanMountainDesktop.csproj` - `LanMountainDesktop/LanMountainDesktop.csproj`
### 2. 验证本地构建工作 ### 2. 验证本地构建工作
@@ -35,10 +35,10 @@ The current working directory does not contain a project or solution file.
## 📋 解决方案文件内容 ## 📋 解决方案文件内容
包含主桌面项目的标准 Visual Studio 解决方案格式: 包含主桌面项目的标准 XML 解决方案格式:
``` ```
LanMountainDesktop.sln LanMountainDesktop.slnx
└── LanMountainDesktop (Desktop UI - Avalonia) └── LanMountainDesktop (Desktop UI - Avalonia)
``` ```
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
```bash ```bash
# 1. 添加新创建的解决方案文件 # 1. 添加新创建的解决方案文件
git add LanMountainDesktop.sln git add LanMountainDesktop.slnx
git add global.json
# 2. 提交 # 2. 提交
git commit -m "Add solution file for desktop project" git commit -m "Migrate desktop solution to .slnx"
# 3. 推送 # 3. 推送
git push origin main git push origin main
@@ -92,7 +93,7 @@ git push origin v1.0.1
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 | | `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 | | `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 | | `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 | | `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
--- ---

3
.github/README.md vendored
View File

@@ -36,9 +36,10 @@
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md` - 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态 ## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。 - 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json` - 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。 - 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明 ## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md) 运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

View File

@@ -9,7 +9,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
build-windows: build-windows:

View File

@@ -8,7 +8,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
analyze: analyze:

View File

@@ -18,7 +18,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
prepare: prepare:

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

@@ -6,6 +6,13 @@ using LanMountainDesktop.PluginSdk;
internal static class Program internal static class Program
{ {
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args) private static async Task<int> Main(string[] args)
{ {
var result = new HelperResult(); var result = new HelperResult();
@@ -35,10 +42,12 @@ internal static class Program
var manifest = ReadManifestFromPackage(fullSourcePath); var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory); Directory.CreateDirectory(fullPluginsDirectory);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
File.Copy(fullSourcePath, destinationPath, overwrite: true); var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
result = new HelperResult result = new HelperResult
{ {
@@ -123,7 +132,7 @@ internal static class Program
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
} }
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId) private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{ {
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName)); var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
foreach (var existingPackagePath in Directory foreach (var existingPackagePath in Directory
@@ -133,13 +142,19 @@ internal static class Program
{ {
try try
{ {
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath); var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
} }
File.Delete(existingPackagePath); DeleteFileWithRetry(existingPackagePath);
} }
catch catch
{ {
@@ -148,6 +163,56 @@ internal static class Program
} }
} }
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId) private static string BuildInstalledPackageFileName(string pluginId)
{ {
var invalidChars = Path.GetInvalidFileNameChars(); var invalidChars = Path.GetInvalidFileNameChars();

View File

@@ -1,43 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

7
LanMountainDesktop.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj" />
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
</Solution>

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,99 @@ 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;
if (mainWindow is MainWindow lanMountainMainWindow)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
}
}
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}",
@@ -742,7 +743,10 @@
"placement.fit": "Fit", "placement.fit": "Fit",
"placement.stretch": "Stretch", "placement.stretch": "Stretch",
"placement.center": "Center", "placement.center": "Center",
"placement.tile": "Tile" "placement.tile": "Tile",
} "single_instance.notice.title": "App already open",
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
"single_instance.notice.button": "Got it"
}

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}",
@@ -742,7 +743,10 @@
"placement.fit": "适应", "placement.fit": "适应",
"placement.stretch": "拉伸", "placement.stretch": "拉伸",
"placement.center": "居中", "placement.center": "居中",
"placement.tile": "平铺" "placement.tile": "平铺",
} "single_instance.notice.title": "应用已打开",
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
"single_instance.notice.button": "知道了"
}

View File

@@ -1,8 +1,10 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.WebView.Desktop; using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using System;
using System.Threading.Tasks;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -17,6 +19,14 @@ sealed class Program
AppLogger.Initialize(); AppLogger.Initialize();
RegisterGlobalExceptionLogging(); RegisterGlobalExceptionLogging();
using var singleInstance = AcquireSingleInstance(args);
if (!singleInstance.IsPrimaryInstance)
{
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
return;
}
var diagnostics = StartupDiagnosticsService.Run(args); var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
@@ -24,6 +34,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 +43,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.
@@ -58,6 +73,42 @@ sealed class Program
return builder; return builder;
} }
private static SingleInstanceService AcquireSingleInstance(string[] args)
{
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var singleInstance = SingleInstanceService.CreateDefault();
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
{
return singleInstance;
}
AppLogger.Info(
"Startup",
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
singleInstance.Dispose();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
while (DateTime.UtcNow < deadline)
{
var retryInstance = SingleInstanceService.CreateDefault();
if (retryInstance.IsPrimaryInstance)
{
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
return retryInstance;
}
retryInstance.Dispose();
Thread.Sleep(150);
}
AppLogger.Warn(
"Startup",
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
return SingleInstanceService.CreateDefault();
}
private static string LoadConfiguredRenderMode() private static string LoadConfiguredRenderMode()
{ {
try try
@@ -71,6 +122,27 @@ sealed class Program
} }
} }
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
{
try
{
using var process = Process.GetProcessById(processId);
var remaining = deadlineUtc - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
{
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
}
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
}
}
private static void RegisterGlobalExceptionLogging() private static void RegisterGlobalExceptionLogging()
{ {
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>

View File

@@ -3,26 +3,19 @@ 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;
public static class AppRestartService public static class AppRestartService
{ {
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
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()
@@ -84,6 +77,21 @@ public static class AppRestartService
return null; return null;
} }
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo( private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath, string executablePath,
string? entryAssemblyPath, string? entryAssemblyPath,
@@ -97,6 +105,7 @@ public static class AppRestartService
}; };
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -119,6 +128,7 @@ public static class AppRestartService
startInfo.ArgumentList.Add(entryAssemblyPath); startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -126,10 +136,34 @@ public static class AppRestartService
{ {
for (var i = 1; i < commandLineArgs.Count; i++) for (var i = 1; i < commandLineArgs.Count; i++)
{ {
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
startInfo.ArgumentList.Add(commandLineArgs[i]); startInfo.ArgumentList.Add(commandLineArgs[i]);
} }
} }
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
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? NormalizeExistingPath(string? path)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))

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

@@ -0,0 +1,59 @@
using System;
using Avalonia.Interactivity;
using Avalonia.Threading;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private readonly DispatcherTimer _singleInstanceNoticeTimer = new()
{
Interval = TimeSpan.FromSeconds(6)
};
internal void ShowSingleInstanceNotice()
{
if (Dispatcher.UIThread.CheckAccess())
{
ShowSingleInstanceNoticeCore();
return;
}
Dispatcher.UIThread.Post(ShowSingleInstanceNoticeCore, DispatcherPriority.Send);
}
private void ShowSingleInstanceNoticeCore()
{
SingleInstanceNoticeTitleTextBlock.Text = L(
"single_instance.notice.title",
"App already open");
SingleInstanceNoticeDescriptionTextBlock.Text = L(
"single_instance.notice.description",
"LanMountainDesktop is already running. Switched back to the active desktop.");
SingleInstanceNoticeButtonTextBlock.Text = L(
"single_instance.notice.button",
"Got it");
SingleInstanceNoticeDock.IsVisible = true;
_singleInstanceNoticeTimer.Stop();
_singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick;
_singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick;
_singleInstanceNoticeTimer.Start();
}
private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e)
{
HideSingleInstanceNotice();
}
private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e)
{
HideSingleInstanceNotice();
}
private void HideSingleInstanceNotice()
{
_singleInstanceNoticeTimer.Stop();
SingleInstanceNoticeDock.IsVisible = false;
}
}

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

@@ -469,51 +469,98 @@
</ScrollViewer> </ScrollViewer>
</ui:NavigationView> </ui:NavigationView>
<Border x:Name="PendingRestartDock" <StackPanel Grid.Row="1"
Grid.Row="1" Spacing="12">
IsVisible="False" <Border x:Name="SingleInstanceNoticeDock"
Classes="glass-panel" IsVisible="False"
CornerRadius="18" Classes="glass-panel"
Padding="14,12"> CornerRadius="18"
<Grid ColumnDefinitions="Auto,*,Auto" Padding="14,12">
ColumnSpacing="12"> <Grid ColumnDefinitions="Auto,*,Auto"
<Border Width="34" ColumnSpacing="12">
Height="34" <Border Width="34"
CornerRadius="17" Height="34"
Background="{DynamicResource AdaptiveAccentBrush}"> CornerRadius="17"
<fi:FluentIcon Icon="ArrowSync" Background="{DynamicResource AdaptiveAccentBrush}">
IconVariant="Regular" <fi:FluentIcon Icon="Alert"
FontSize="16" IconVariant="Regular"
Foreground="White" FontSize="16"
HorizontalAlignment="Center" Foreground="White"
VerticalAlignment="Center" /> HorizontalAlignment="Center"
</Border> VerticalAlignment="Center" />
<StackPanel Grid.Column="1" </Border>
Spacing="2" <StackPanel Grid.Column="1"
VerticalAlignment="Center"> Spacing="2"
<TextBlock x:Name="PendingRestartDockTitleTextBlock" VerticalAlignment="Center">
FontSize="13" <TextBlock x:Name="SingleInstanceNoticeTitleTextBlock"
FontWeight="SemiBold" FontSize="13"
Text="Restart required" /> FontWeight="SemiBold"
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock" Text="App already open" />
TextWrapping="Wrap" <TextBlock x:Name="SingleInstanceNoticeDescriptionTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" TextWrapping="Wrap"
Text="Your changes will apply after restarting the app." /> Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
</StackPanel> Text="LanMountainDesktop is already running. Switched back to the active desktop." />
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel> </StackPanel>
</Button> <Button x:Name="SingleInstanceNoticeButton"
</Grid> Grid.Column="2"
</Border> Padding="14,8"
Click="OnSingleInstanceNoticeButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Checkmark"
IconVariant="Regular" />
<TextBlock x:Name="SingleInstanceNoticeButtonTextBlock"
VerticalAlignment="Center"
Text="Got it" />
</StackPanel>
</Button>
</Grid>
</Border>
<Border x:Name="PendingRestartDock"
IsVisible="False"
Classes="glass-panel"
CornerRadius="18"
Padding="14,12">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Border Width="34"
Height="34"
CornerRadius="17"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="16"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
FontSize="13"
FontWeight="SemiBold"
Text="Restart required" />
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Your changes will apply after restarting the app." />
</StackPanel>
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel>
</Button>
</Grid>
</Border>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</Border> </Border>

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

@@ -78,9 +78,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
} }
} }
await using var hashStream = File.OpenRead(downloadPath); string actualHash;
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); await using (var hashStream = File.OpenRead(downloadPath))
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); {
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{ {
File.Delete(downloadPath); File.Delete(downloadPath);

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

View File

@@ -31,6 +31,7 @@
- Windows 是当前主要目标平台。 - Windows 是当前主要目标平台。
- 已提供组件系统、插件系统、主题系统和设置系统。 - 已提供组件系统、插件系统、主题系统和设置系统。
- 中文为主语言,英文为附加扩展语言。 - 中文为主语言,英文为附加扩展语言。
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`SDK 版本由根目录 `global.json` 锁定。
### 运行说明 ### 运行说明

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestFeature"
}
}

8
run.md
View File

@@ -8,12 +8,14 @@
- 安装 .NET SDK 10。 - 安装 .NET SDK 10。
- 桌面端建议在 Windows 上运行。 - 桌面端建议在 Windows 上运行。
- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`
- SDK 版本由仓库根目录 `global.json` 锁定。
### 构建 ### 构建
```bash ```bash
dotnet restore dotnet restore
dotnet build LanMountainDesktop.sln -c Debug dotnet build LanMountainDesktop.slnx -c Debug
``` ```
### 运行桌面端 ### 运行桌面端
@@ -41,11 +43,13 @@ dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
This guide explains how to run LanMountainDesktop locally. This guide explains how to run LanMountainDesktop locally.
The repository entry solution is `LanMountainDesktop.slnx`, and the SDK version is pinned by the root `global.json`.
### Build ### Build
```bash ```bash
dotnet restore dotnet restore
dotnet build LanMountainDesktop.sln -c Debug dotnet build LanMountainDesktop.slnx -c Debug
``` ```
### Run ### Run