mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6952cb2c3e | ||
|
|
d3356f3319 | ||
|
|
57c5e41a5c | ||
|
|
ce2b218dfa | ||
|
|
efdfa68dab | ||
|
|
87110f1d69 | ||
|
|
e7a03404ce |
17
.github/FIX_REPORT.md
vendored
17
.github/FIX_REPORT.md
vendored
@@ -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.
|
||||
```
|
||||
|
||||
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
||||
**原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已采取的修复
|
||||
|
||||
### 1. 创建解决方案文件
|
||||
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
|
||||
### 1. 创建 `.slnx` 解决方案文件
|
||||
✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
|
||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
|
||||
|
||||
```bash
|
||||
# 1. 添加新创建的解决方案文件
|
||||
git add LanMountainDesktop.sln
|
||||
git add LanMountainDesktop.slnx
|
||||
git add global.json
|
||||
|
||||
# 2. 提交
|
||||
git commit -m "Add solution file for desktop project"
|
||||
git commit -m "Migrate desktop solution to .slnx"
|
||||
|
||||
# 3. 推送
|
||||
git push origin main
|
||||
@@ -92,7 +93,7 @@ git push origin v1.0.1
|
||||
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
|
||||
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
|
||||
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
|
||||
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 |
|
||||
| `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
.github/README.md
vendored
3
.github/README.md
vendored
@@ -36,9 +36,10 @@
|
||||
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||
|
||||
## 当前状态
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
|
||||
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||
- 当前体验以 Windows 为主要目标平台。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
## 运行说明
|
||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore -c Release -v minimal
|
||||
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -101,10 +101,10 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore -c Release -v minimal
|
||||
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -492,3 +492,13 @@ nul
|
||||
/_build_verify_plugin_services
|
||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||
/_build_obj
|
||||
|
||||
# LanMountainDesktop local workspace files
|
||||
/.arts/
|
||||
/.knox/
|
||||
/.lingma/
|
||||
/.tmp/
|
||||
/publish-test/
|
||||
/validator-restore.log
|
||||
/temp_old_main.axaml
|
||||
/temp_old_main_utf8.axaml
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<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.placement": "Placement {0} | placed: {1}",
|
||||
"common.dev": "dev",
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
||||
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
||||
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
||||
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||
var messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||
|
||||
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
||||
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
|
||||
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
||||
iconKey: "DismissCircle",
|
||||
category: localizer.GetString("widget.category", "鎻掍欢"),
|
||||
minWidthCells: 2,
|
||||
minHeightCells: 1,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Free,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
void Initialize(HostBuilderContext context, IServiceCollection services);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||
public interface IPluginContext : IPluginRuntimeContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
void RegisterService<TService>(TService service)
|
||||
where TService : class;
|
||||
|
||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
}
|
||||
|
||||
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginExportRegistry
|
||||
{
|
||||
IReadOnlyList<PluginServiceExportDescriptor> GetExports();
|
||||
|
||||
IReadOnlyList<PluginServiceExportDescriptor> GetExports(Type contractType);
|
||||
|
||||
PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId);
|
||||
|
||||
TContract? GetExport<TContract>(string providerPluginId)
|
||||
where TContract : class;
|
||||
}
|
||||
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginRuntimeContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>2.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
@@ -40,13 +40,42 @@ public sealed class PluginDesktopComponentRegistration
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
int minHeightCells = 2,
|
||||
bool allowDesktopPlacement = true,
|
||||
bool allowStatusBarPlacement = false,
|
||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
string? displayNameLocalizationKey = null,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
: this(
|
||||
componentId,
|
||||
displayName,
|
||||
(_, context) => controlFactory(context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PluginLocalizer
|
||||
|
||||
public string LanguageCode { get; }
|
||||
|
||||
public static PluginLocalizer Create(IPluginContext context)
|
||||
public static PluginLocalizer Create(IPluginRuntimeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
|
||||
@@ -9,7 +9,8 @@ public sealed record PluginManifest(
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
string? ApiVersion = null,
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -57,6 +58,7 @@ public sealed record PluginManifest(
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
@@ -65,7 +67,8 @@ public sealed record PluginManifest(
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||
SharedContracts = normalizedSharedContracts
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
@@ -82,7 +85,41 @@ public sealed record PluginManifest(
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'. Upgrade the plugin to API {PluginSdkInfo.ApiVersion}.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
|
||||
string manifestPath,
|
||||
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
|
||||
{
|
||||
if (sharedContracts is null || sharedContracts.Count == 0)
|
||||
{
|
||||
return Array.Empty<PluginSharedContractReference>();
|
||||
}
|
||||
|
||||
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contract in sharedContracts)
|
||||
{
|
||||
if (contract is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
|
||||
}
|
||||
|
||||
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
|
||||
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
||||
if (!seenIds.Add(contractKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
|
||||
}
|
||||
|
||||
normalized.Add(normalizedContract);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ApiVersion = "2.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Avalonia.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPluginSettingsPage<TControl>(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string title,
|
||||
int sortOrder = 0)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginSettingsPageRegistration(
|
||||
id,
|
||||
title,
|
||||
provider => ActivatorUtilities.CreateInstance<TControl>(provider),
|
||||
sortOrder));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||
this IServiceCollection services,
|
||||
string componentId,
|
||||
string displayName,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
int minHeightCells = 2,
|
||||
bool allowDesktopPlacement = true,
|
||||
bool allowStatusBarPlacement = false,
|
||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
string? displayNameLocalizationKey = null,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||
componentId,
|
||||
displayName,
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||
|
||||
if (!services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
|
||||
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
|
||||
existing.ContractType == typeof(TContract) &&
|
||||
existing.ImplementationType == typeof(TImplementation)))
|
||||
{
|
||||
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
|
||||
if (contractDescriptor is null)
|
||||
{
|
||||
services.AddSingleton<TContract, TImplementation>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginServiceExportDescriptor(
|
||||
string ProviderPluginId,
|
||||
Type ContractType,
|
||||
object ServiceInstance);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginServiceExportRegistration
|
||||
{
|
||||
public PluginServiceExportRegistration(Type contractType, Type implementationType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contractType);
|
||||
ArgumentNullException.ThrowIfNull(implementationType);
|
||||
|
||||
ContractType = contractType;
|
||||
ImplementationType = implementationType;
|
||||
}
|
||||
|
||||
public Type ContractType { get; }
|
||||
|
||||
public Type ImplementationType { get; }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ public sealed class PluginSettingsPageRegistration
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
Func<IServiceProvider, Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
@@ -20,11 +20,20 @@ public sealed class PluginSettingsPageRegistration
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
: this(id, title, _ => contentFactory(), sortOrder)
|
||||
{
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
public Func<IServiceProvider, Control> ContentFactory { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginSharedContractReference(
|
||||
string Id,
|
||||
string Version,
|
||||
string AssemblyName)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public string NormalizedId => Id.Trim();
|
||||
|
||||
[JsonIgnore]
|
||||
public string NormalizedVersion => Version.Trim();
|
||||
|
||||
[JsonIgnore]
|
||||
public string NormalizedAssemblyName => AssemblyName.Trim();
|
||||
|
||||
internal PluginSharedContractReference NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Version = RequireValue(Version, nameof(Version), manifestPath),
|
||||
AssemblyName = RequireValue(AssemblyName, nameof(AssemblyName), manifestPath)
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.Version, out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares invalid shared contract version '{normalized.Version}' for '{normalized.Id}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required shared contract property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,13 @@ using LanMountainDesktop.PluginSdk;
|
||||
|
||||
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)
|
||||
{
|
||||
var result = new HelperResult();
|
||||
@@ -35,10 +42,12 @@ internal static class Program
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, 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
|
||||
{
|
||||
@@ -123,7 +132,7 @@ internal static class Program
|
||||
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));
|
||||
foreach (var existingPackagePath in Directory
|
||||
@@ -133,13 +142,19 @@ internal static class Program
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(existingPackagePath);
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
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)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
|
||||
@@ -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
|
||||
6
LanMountainDesktop.slnx
Normal file
6
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<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>
|
||||
@@ -12,6 +12,7 @@ using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -19,12 +20,19 @@ public partial class App : Application
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private bool _exitCleanupCompleted;
|
||||
|
||||
private SettingsWindow? _traySettingsWindow;
|
||||
private TrayIcons? _trayIcons;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
|
||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
@@ -51,14 +59,14 @@ public partial class App : Application
|
||||
desktop.Exit += (_, _) =>
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
||||
DisposeTrayIcon();
|
||||
PerformExitCleanup();
|
||||
};
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
@@ -66,12 +74,9 @@ public partial class App : Application
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Exit App from the tray menu."));
|
||||
}
|
||||
|
||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||
@@ -114,18 +119,9 @@ public partial class App : Application
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
AppRestartService.TryRestartApplication();
|
||||
}
|
||||
|
||||
private void OnAppSettingsSaved(string _)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
InitializeTrayIcon();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Restart App from the tray menu."));
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -246,6 +242,99 @@ public partial class App : Application
|
||||
_trayIcons = null;
|
||||
}
|
||||
|
||||
private void ActivateMainWindow()
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (desktop.MainWindow is not Window mainWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
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)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
||||
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||
"settings.about.title": "About",
|
||||
"settings.about.version_format": "Version: {0}",
|
||||
@@ -742,7 +743,10 @@
|
||||
"placement.fit": "Fit",
|
||||
"placement.stretch": "Stretch",
|
||||
"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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
||||
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
|
||||
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
||||
"settings.about.title": "关于",
|
||||
"settings.about.version_format": "版本号: {0}",
|
||||
@@ -742,7 +743,10 @@
|
||||
"placement.fit": "适应",
|
||||
"placement.stretch": "拉伸",
|
||||
"placement.center": "居中",
|
||||
"placement.tile": "平铺"
|
||||
}
|
||||
"placement.tile": "平铺",
|
||||
"single_instance.notice.title": "应用已打开",
|
||||
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
|
||||
"single_instance.notice.button": "知道了"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.Services;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -17,6 +19,14 @@ sealed class Program
|
||||
AppLogger.Initialize();
|
||||
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);
|
||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||
|
||||
@@ -24,6 +34,7 @@ sealed class Program
|
||||
{
|
||||
var renderMode = LoadConfiguredRenderMode();
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
@@ -32,6 +43,10 @@ sealed class Program
|
||||
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
App.CurrentSingleInstanceService = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
@@ -58,6 +73,42 @@ sealed class Program
|
||||
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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||
|
||||
@@ -3,26 +3,19 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
if (!TryRestartCurrentProcess())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
return true;
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(AppRestartService),
|
||||
Reason: "Legacy restart entry point invoked.")) == true;
|
||||
}
|
||||
|
||||
public static bool TryRestartCurrentProcess()
|
||||
@@ -84,6 +77,21 @@ public static class AppRestartService
|
||||
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(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
@@ -97,6 +105,7 @@ public static class AppRestartService
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ public static class AppRestartService
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -126,10 +136,34 @@ public static class AppRestartService
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
|
||||
@@ -118,13 +118,13 @@ public static class DesktopComponentRegistryFactory
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
|
||||
{
|
||||
return CreateRecorder();
|
||||
}
|
||||
|
||||
public static void DisposeSharedServices()
|
||||
{
|
||||
if (SharedRecorderService.IsValueCreated)
|
||||
{
|
||||
SharedRecorderService.Value.Dispose();
|
||||
}
|
||||
|
||||
if (SharedStudyMonitoringService.IsValueCreated)
|
||||
{
|
||||
SharedStudyMonitoringService.Value.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
|
||||
|
||||
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal file
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
|
||||
public static void DisposeSharedService()
|
||||
{
|
||||
if (SharedService.IsValueCreated)
|
||||
{
|
||||
SharedService.Value.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
_disposed = true;
|
||||
StopTimerLocked();
|
||||
_samplingTimer.Dispose();
|
||||
_audioRecorderService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
_lastSessionReport = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,20 @@ namespace LanMountainDesktop.Services;
|
||||
internal static class WindowsNativeDialogService
|
||||
{
|
||||
private const uint Ok = 0x00000000;
|
||||
private const uint IconInformation = 0x00000040;
|
||||
private const uint IconWarning = 0x00000030;
|
||||
|
||||
public static void ShowInformation(string caption, string message)
|
||||
{
|
||||
Show(caption, message, Ok | IconInformation, "NativeDialog");
|
||||
}
|
||||
|
||||
public static void ShowWarning(string caption, string message)
|
||||
{
|
||||
Show(caption, message, Ok | IconWarning, "StartupDiagnostics");
|
||||
}
|
||||
|
||||
private static void Show(string caption, string message, uint type, string logCategory)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
@@ -17,11 +28,11 @@ internal static class WindowsNativeDialogService
|
||||
|
||||
try
|
||||
{
|
||||
_ = MessageBoxW(IntPtr.Zero, message, caption, Ok | IconWarning);
|
||||
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("StartupDiagnostics", "Failed to show legacy executable warning dialog.", ex);
|
||||
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -77,7 +78,9 @@ public partial class MainWindow
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(MainWindow),
|
||||
Reason: "User confirmed a pending restart prompt.")) != true)
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
59
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal file
59
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -357,7 +358,8 @@ public partial class MainWindow
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = true
|
||||
UseShellExecute = true,
|
||||
Verb = "runas"
|
||||
});
|
||||
|
||||
_updateStatusText = L(
|
||||
@@ -365,7 +367,16 @@ public partial class MainWindow
|
||||
"Installer started. The app will close for update.");
|
||||
UpdateUpdatePanelState();
|
||||
|
||||
Dispatcher.UIThread.Post(Close, DispatcherPriority.Background);
|
||||
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(MainWindow),
|
||||
Reason: "Update installer started successfully."));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
_updateStatusText = L(
|
||||
"settings.update.status_elevation_cancelled",
|
||||
"Administrator permission was not granted. Update was cancelled.");
|
||||
UpdateUpdatePanelState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -469,51 +469,98 @@
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
Grid.Row="1"
|
||||
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 Grid.Row="1"
|
||||
Spacing="12">
|
||||
<Border x:Name="SingleInstanceNoticeDock"
|
||||
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="Alert"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="SingleInstanceNoticeTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="App already open" />
|
||||
<TextBlock x:Name="SingleInstanceNoticeDescriptionTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="LanMountainDesktop is already running. Switched back to the active desktop." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button x:Name="SingleInstanceNoticeButton"
|
||||
Grid.Column="2"
|
||||
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>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -77,7 +78,9 @@ public partial class SettingsWindow
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(SettingsWindow),
|
||||
Reason: "User confirmed a pending restart prompt from settings.")) != true)
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -250,23 +249,23 @@ public partial class SettingsWindow
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = true
|
||||
UseShellExecute = true,
|
||||
Verb = "runas"
|
||||
});
|
||||
|
||||
_updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
|
||||
UpdateUpdatePanelState();
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(SettingsWindow),
|
||||
Reason: "Update installer started successfully from settings."));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
_updateStatusText = L(
|
||||
"settings.update.status_elevation_cancelled",
|
||||
"Administrator permission was not granted. Update was cancelled.");
|
||||
UpdateUpdatePanelState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -38,6 +38,8 @@ OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
; Leave PrivilegesRequiredOverridesAllowed unset so users cannot downgrade
|
||||
; installation mode via dialog or /ALLUSERS /CURRENTUSER command-line switches.
|
||||
PrivilegesRequired=admin
|
||||
CloseApplications=yes
|
||||
CloseApplicationsFilter={#MyAppExeName}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
@@ -18,9 +19,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
string assemblyPath,
|
||||
Assembly assembly,
|
||||
IPlugin plugin,
|
||||
IPluginContext context,
|
||||
IPluginRuntimeContext runtimeContext,
|
||||
IServiceProvider services,
|
||||
IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
|
||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
||||
IReadOnlyList<IHostedService> hostedServices,
|
||||
PluginLoadContext loadContext)
|
||||
{
|
||||
Manifest = manifest;
|
||||
@@ -28,9 +32,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
Plugin = plugin;
|
||||
Context = context;
|
||||
RuntimeContext = runtimeContext;
|
||||
Services = services;
|
||||
SettingsPages = settingsPages;
|
||||
DesktopComponents = desktopComponents;
|
||||
ExportedServices = exportedServices;
|
||||
HostedServices = hostedServices;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
@@ -44,14 +51,22 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
|
||||
public IPlugin Plugin { get; }
|
||||
|
||||
public IPluginContext Context { get; }
|
||||
public IPluginRuntimeContext RuntimeContext { get; }
|
||||
|
||||
public IPluginRuntimeContext Context => RuntimeContext;
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> SettingsPages { get; }
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||
|
||||
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
||||
|
||||
public PluginLoadContext LoadContext { get; }
|
||||
|
||||
private IReadOnlyList<IHostedService> HostedServices { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
@@ -64,6 +79,18 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = HostedServices.Count - 1; i >= 0; i--)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HostedServices[i].StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore plugin hosted service shutdown failures to allow unload cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
if (Plugin is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
@@ -73,11 +100,20 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
if (Context is IAsyncDisposable asyncContext)
|
||||
if (Services is IAsyncDisposable asyncServices)
|
||||
{
|
||||
await asyncServices.DisposeAsync();
|
||||
}
|
||||
else if (Services is IDisposable disposableServices)
|
||||
{
|
||||
disposableServices.Dispose();
|
||||
}
|
||||
|
||||
if (RuntimeContext is IAsyncDisposable asyncContext)
|
||||
{
|
||||
await asyncContext.DisposeAsync();
|
||||
}
|
||||
else if (Context is IDisposable disposableContext)
|
||||
else if (RuntimeContext is IDisposable disposableContext)
|
||||
{
|
||||
disposableContext.Dispose();
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public partial class MainWindow
|
||||
Control content;
|
||||
try
|
||||
{
|
||||
content = contribution.Registration.ContentFactory();
|
||||
content = contribution.Registration.ContentFactory(contribution.Plugin.Services);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
83
LanMountainDesktop/plugins/PluginExportRegistry.cs
Normal file
83
LanMountainDesktop/plugins/PluginExportRegistry.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
internal sealed class PluginExportRegistry : IPluginExportRegistry
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<PluginServiceExportDescriptor> _exports = [];
|
||||
|
||||
public IReadOnlyList<PluginServiceExportDescriptor> GetExports()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _exports.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginServiceExportDescriptor> GetExports(Type contractType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contractType);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _exports
|
||||
.Where(descriptor => descriptor.ContractType == contractType)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contractType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(providerPluginId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _exports.FirstOrDefault(descriptor =>
|
||||
descriptor.ContractType == contractType &&
|
||||
string.Equals(descriptor.ProviderPluginId, providerPluginId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public TContract? GetExport<TContract>(string providerPluginId)
|
||||
where TContract : class
|
||||
{
|
||||
return GetExport(typeof(TContract), providerPluginId)?.ServiceInstance as TContract;
|
||||
}
|
||||
|
||||
public void ReplaceExports(string pluginId, IEnumerable<PluginServiceExportDescriptor> descriptors)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
|
||||
ArgumentNullException.ThrowIfNull(descriptors);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_exports.RemoveAll(descriptor =>
|
||||
string.Equals(descriptor.ProviderPluginId, pluginId, StringComparison.OrdinalIgnoreCase));
|
||||
_exports.AddRange(descriptors);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveExports(string pluginId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_exports.RemoveAll(descriptor =>
|
||||
string.Equals(descriptor.ProviderPluginId, pluginId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_exports.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ using System.Threading.Tasks;
|
||||
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
@@ -135,19 +137,45 @@ public sealed class PluginLoader
|
||||
{
|
||||
PluginLoadContext? loadContext = null;
|
||||
IPlugin? plugin = null;
|
||||
PluginContext? context = null;
|
||||
PluginRuntimeContext? runtimeContext = null;
|
||||
ServiceProvider? pluginServices = null;
|
||||
IReadOnlyList<IHostedService> hostedServices = Array.Empty<IHostedService>();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
|
||||
|
||||
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var pluginType = ResolvePluginType(assembly);
|
||||
plugin = CreatePluginInstance(pluginType);
|
||||
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
||||
runtimeContext = CreateRuntimeContext(manifest, pluginDirectory, dataDirectory, properties);
|
||||
var serviceCollection = CreateServiceCollection(runtimeContext, services);
|
||||
var hostBuilderContext = CreateHostBuilderContext(runtimeContext);
|
||||
|
||||
plugin.Initialize(context);
|
||||
var settingsPages = context.GetSettingsPagesSnapshot();
|
||||
var desktopComponents = context.GetDesktopComponentsSnapshot();
|
||||
plugin.Initialize(hostBuilderContext, serviceCollection);
|
||||
|
||||
pluginServices = serviceCollection.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateScopes = false,
|
||||
ValidateOnBuild = true
|
||||
});
|
||||
runtimeContext.SetServices(pluginServices);
|
||||
|
||||
var settingsPages = pluginServices
|
||||
.GetServices<PluginSettingsPageRegistration>()
|
||||
.OrderBy(page => page.SortOrder)
|
||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var desktopComponents = pluginServices
|
||||
.GetServices<PluginDesktopComponentRegistration>()
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var exportedServices = ResolveExports(manifest, pluginServices);
|
||||
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
|
||||
StartHostedServices(hostedServices);
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(
|
||||
manifest,
|
||||
@@ -155,17 +183,22 @@ public sealed class PluginLoader
|
||||
assemblyPath,
|
||||
assembly,
|
||||
plugin,
|
||||
context,
|
||||
runtimeContext,
|
||||
pluginServices,
|
||||
settingsPages,
|
||||
desktopComponents,
|
||||
exportedServices,
|
||||
hostedServices,
|
||||
loadContext);
|
||||
|
||||
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StopHostedServices(hostedServices);
|
||||
DisposeInstance(pluginServices);
|
||||
DisposeInstance(plugin);
|
||||
DisposeInstance(context);
|
||||
DisposeInstance(runtimeContext);
|
||||
loadContext?.Unload();
|
||||
return PluginLoadResult.Failure(sourcePath, manifest, ex);
|
||||
}
|
||||
@@ -243,23 +276,124 @@ public sealed class PluginLoader
|
||||
}
|
||||
}
|
||||
|
||||
private PluginContext CreateContext(
|
||||
private PluginRuntimeContext CreateRuntimeContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
return new PluginContext(
|
||||
return new PluginRuntimeContext(
|
||||
manifest,
|
||||
pluginDirectory,
|
||||
dataDirectory,
|
||||
services ?? NullServiceProvider.Instance,
|
||||
CreateReadOnlyProperties(properties));
|
||||
}
|
||||
|
||||
private ServiceCollection CreateServiceCollection(
|
||||
PluginRuntimeContext runtimeContext,
|
||||
IServiceProvider? hostServices)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(runtimeContext);
|
||||
services.AddSingleton<IPluginRuntimeContext>(runtimeContext);
|
||||
services.AddSingleton(runtimeContext.Manifest);
|
||||
services.AddSingleton<IReadOnlyDictionary<string, object?>>(runtimeContext.Properties);
|
||||
services.AddSingleton<IPluginMessageBus, PluginMessageBus>();
|
||||
|
||||
RegisterHostService<IPluginPackageManager>(services, hostServices);
|
||||
RegisterHostService<IHostApplicationLifecycle>(services, hostServices);
|
||||
RegisterHostService<IPluginExportRegistry>(services, hostServices);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterHostService<TService>(IServiceCollection services, IServiceProvider? hostServices)
|
||||
where TService : class
|
||||
{
|
||||
if (hostServices?.GetService(typeof(TService)) is TService service)
|
||||
{
|
||||
services.AddSingleton(service);
|
||||
}
|
||||
}
|
||||
|
||||
private static HostBuilderContext CreateHostBuilderContext(PluginRuntimeContext runtimeContext)
|
||||
{
|
||||
var hostBuilderContext = new HostBuilderContext(new Dictionary<object, object>());
|
||||
hostBuilderContext.Properties["LanMountainDesktop.PluginManifest"] = runtimeContext.Manifest;
|
||||
hostBuilderContext.Properties["LanMountainDesktop.PluginDirectory"] = runtimeContext.PluginDirectory;
|
||||
hostBuilderContext.Properties["LanMountainDesktop.PluginDataDirectory"] = runtimeContext.DataDirectory;
|
||||
hostBuilderContext.Properties["LanMountainDesktop.PluginRuntimeContext"] = runtimeContext;
|
||||
|
||||
foreach (var pair in runtimeContext.Properties)
|
||||
{
|
||||
if (pair.Value is not null)
|
||||
{
|
||||
hostBuilderContext.Properties[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return hostBuilderContext;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginServiceExportDescriptor> ResolveExports(
|
||||
PluginManifest manifest,
|
||||
IServiceProvider services)
|
||||
{
|
||||
return services
|
||||
.GetServices<PluginServiceExportRegistration>()
|
||||
.Select(registration =>
|
||||
{
|
||||
if (!IsSupportedExportContract(manifest, registration.ContractType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' exported contract '{registration.ContractType.FullName}', but export contracts must come from LanMountainDesktop.PluginSdk or a manifest-declared shared contract assembly.");
|
||||
}
|
||||
|
||||
return new PluginServiceExportDescriptor(
|
||||
manifest.Id,
|
||||
registration.ContractType,
|
||||
services.GetService(registration.ContractType)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' exported contract '{registration.ContractType.FullName}', but no singleton service instance was registered."));
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsSupportedExportContract(PluginManifest manifest, Type contractType)
|
||||
{
|
||||
if (contractType.Assembly == typeof(IPlugin).Assembly)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var assemblyFileName = contractType.Assembly.GetName().Name + ".dll";
|
||||
return manifest.SharedContracts?.Any(contract =>
|
||||
string.Equals(contract.AssemblyName, assemblyFileName, StringComparison.OrdinalIgnoreCase)) == true;
|
||||
}
|
||||
|
||||
private static void StartHostedServices(IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
foreach (var hostedService in hostedServices)
|
||||
{
|
||||
hostedService.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private static void StopHostedServices(IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
foreach (var hostedService in hostedServices.Reverse())
|
||||
{
|
||||
try
|
||||
{
|
||||
hostedService.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore best-effort shutdown during failed startup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PluginCandidate> DiscoverCandidates(
|
||||
string pluginsRootDirectory,
|
||||
List<PluginLoadResult> preparationFailures)
|
||||
@@ -436,6 +570,27 @@ public sealed class PluginLoader
|
||||
return new ReadOnlyDictionary<string, object?>(map);
|
||||
}
|
||||
|
||||
private static void ValidatePluginRuntimeAssets(
|
||||
PluginManifest manifest,
|
||||
string assemblyPath,
|
||||
string pluginDirectory)
|
||||
{
|
||||
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
|
||||
if (!File.Exists(depsFilePath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
|
||||
}
|
||||
|
||||
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");
|
||||
if (Directory.Exists(runtimesDirectory) &&
|
||||
!Directory.EnumerateFiles(runtimesDirectory, "*", SearchOption.AllDirectories).Any())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' contains an empty 'runtimes' directory. Native/runtime assets must be packaged together with the plugin.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Type ResolvePluginType(Assembly assembly)
|
||||
{
|
||||
var candidateTypes = GetLoadableTypes(assembly)
|
||||
@@ -543,34 +698,19 @@ public sealed class PluginLoader
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable
|
||||
private sealed class PluginRuntimeContext : IPluginRuntimeContext
|
||||
{
|
||||
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, object> _registeredServices = [];
|
||||
private readonly List<object> _serviceRegistrationOrder = [];
|
||||
private readonly object _serviceGate = new();
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private int _disposed;
|
||||
|
||||
public PluginContext(
|
||||
public PluginRuntimeContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
_hostServices = services;
|
||||
Services = new PluginCompositeServiceProvider(this);
|
||||
Properties = properties;
|
||||
|
||||
RegisterBuiltInService<IPluginContext>(this);
|
||||
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||
Services = NullServiceProvider.Instance;
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
@@ -579,7 +719,7 @@ public sealed class PluginLoader
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
public IServiceProvider Services { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
@@ -602,181 +742,9 @@ public sealed class PluginLoader
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RegisterService<TService>(TService service)
|
||||
where TService : class
|
||||
public void SetServices(IServiceProvider services)
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: false);
|
||||
}
|
||||
|
||||
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!_settingsPages.TryAdd(registration.Id, registration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _settingsPages.Values
|
||||
.OrderBy(page => page.SortOrder)
|
||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _desktopComponents.Values
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal object? ResolveService(Type serviceType)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IServiceProvider))
|
||||
{
|
||||
return Services;
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (_registeredServices.TryGetValue(serviceType, out var service))
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
foreach (var registeredService in _registeredServices.Values)
|
||||
{
|
||||
if (serviceType.IsInstanceOfType(registeredService))
|
||||
{
|
||||
return registeredService;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _hostServices.GetService(serviceType);
|
||||
}
|
||||
|
||||
private void RegisterBuiltInService<TService>(TService service)
|
||||
where TService : class
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object[] services;
|
||||
lock (_serviceGate)
|
||||
{
|
||||
services = _serviceRegistrationOrder.ToArray();
|
||||
_registeredServices.Clear();
|
||||
_serviceRegistrationOrder.Clear();
|
||||
}
|
||||
|
||||
_settingsPages.Clear();
|
||||
_desktopComponents.Clear();
|
||||
|
||||
var disposedServices = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
for (var i = services.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var service = services[i];
|
||||
if (ReferenceEquals(service, this) || !disposedServices.Add(service))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (service is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else if (service is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterServiceCore(Type serviceType, object service, bool allowOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
ArgumentNullException.ThrowIfNull(service);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!serviceType.IsInstanceOfType(service))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (!allowOverride && _registeredServices.ContainsKey(serviceType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
_registeredServices[serviceType] = service;
|
||||
_serviceRegistrationOrder.Add(service);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PluginContext));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginCompositeServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly PluginContext _context;
|
||||
|
||||
public PluginCompositeServiceProvider(PluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
return _context.ResolveService(serviceType);
|
||||
Services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,16 +78,24 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
await using var hashStream = File.OpenRead(downloadPath);
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
||||
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
var actualSize = new FileInfo(downloadPath).Length;
|
||||
string actualHash;
|
||||
await using (var hashStream = File.OpenRead(downloadPath))
|
||||
{
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||
File.Delete(downloadPath);
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
|
||||
}
|
||||
|
||||
PluginManifest manifest;
|
||||
|
||||
@@ -215,6 +215,8 @@ internal sealed class AirAppMarketIndexDocument
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
public List<AirAppMarketSharedContractEntry> Contracts { get; init; } = [];
|
||||
|
||||
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
|
||||
|
||||
public static AirAppMarketIndexDocument Load(string json, string sourceName)
|
||||
@@ -236,6 +238,23 @@ internal sealed class AirAppMarketIndexDocument
|
||||
|
||||
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var contracts = Contracts ?? [];
|
||||
var normalizedContracts = new List<AirAppMarketSharedContractEntry>(contracts.Count);
|
||||
var seenContracts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contract in contracts)
|
||||
{
|
||||
var normalizedContract = contract.ValidateAndNormalize(sourceName);
|
||||
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
||||
if (!seenContracts.Add(contractKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' contains duplicate shared contract '{contractKey}'.");
|
||||
}
|
||||
|
||||
normalizedContracts.Add(normalizedContract);
|
||||
}
|
||||
|
||||
var plugins = Plugins ?? [];
|
||||
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -260,6 +279,10 @@ internal sealed class AirAppMarketIndexDocument
|
||||
GeneratedAt = GeneratedAt == default
|
||||
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
|
||||
: GeneratedAt,
|
||||
Contracts = normalizedContracts
|
||||
.OrderBy(contract => contract.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(contract => contract.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
Plugins = normalizedPlugins
|
||||
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
@@ -373,6 +396,56 @@ internal sealed class AirAppMarketIndexDocument
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketSharedContractEntry
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
public string AssemblyName { get; init; } = string.Empty;
|
||||
|
||||
public string DownloadUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public long PackageSizeBytes { get; init; }
|
||||
|
||||
public AirAppMarketSharedContractEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}' for a shared contract.");
|
||||
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for shared contract '{Id}'.");
|
||||
}
|
||||
|
||||
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}' for shared contract '{Id}'.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
|
||||
|
||||
if (PackageSizeBytes <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for shared contract '{Id}'.");
|
||||
}
|
||||
|
||||
return new AirAppMarketSharedContractEntry
|
||||
{
|
||||
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing a shared contract id."),
|
||||
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
AssemblyName = AirAppMarketIndexDocument.NormalizeValue(AssemblyName)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing assemblyName for shared contract '{Id}'."),
|
||||
DownloadUrl = normalizedDownloadUrl,
|
||||
Sha256 = normalizedSha,
|
||||
PackageSizeBytes = PackageSizeBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
@@ -12,6 +12,8 @@ using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
@@ -19,8 +21,12 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
private const string PendingDeletionFileName = ".pending-plugin-deletions.json";
|
||||
|
||||
private readonly PluginLoaderOptions _loaderOptions;
|
||||
private readonly PluginLoader _loader;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly PluginExportRegistry _exportRegistry = new();
|
||||
private readonly PluginSharedContractManager _sharedContractManager;
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
@@ -33,9 +39,12 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
public PluginRuntimeService()
|
||||
{
|
||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||
_sharedContractManager = new PluginSharedContractManager(
|
||||
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_hostServices = new PluginHostServiceProvider(_packageManager);
|
||||
_loader = new PluginLoader(CreateOptions());
|
||||
_hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle, _exportRegistry);
|
||||
_loaderOptions = CreateOptions();
|
||||
_loader = new PluginLoader(_loaderOptions);
|
||||
}
|
||||
|
||||
public string PluginsDirectory { get; }
|
||||
@@ -50,6 +59,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||
|
||||
public IPluginExportRegistry ExportRegistry => _exportRegistry;
|
||||
|
||||
public void LoadInstalledPlugins()
|
||||
{
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
@@ -100,6 +111,27 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RegisterSharedContractsForLoad(candidate.Manifest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var dependencyFailure = PluginLoadResult.Failure(candidate.SourcePath, candidate.Manifest, ex);
|
||||
_loadResults.Add(dependencyFailure);
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
candidate.Manifest,
|
||||
candidate.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
true,
|
||||
false,
|
||||
ex.Message,
|
||||
0,
|
||||
0));
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to prepare dependencies for '{candidate.Manifest.Id}': {ex}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var loadResult = candidate.SourceKind switch
|
||||
{
|
||||
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||
@@ -276,6 +308,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
_sharedContractManager.EnsureInstalled(manifest);
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
|
||||
@@ -307,6 +340,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
_sharedContractManager.EnsureInstalled(manifest);
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'.");
|
||||
@@ -318,16 +352,19 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
UnloadInstalledPlugins();
|
||||
_sharedContractManager.Dispose();
|
||||
}
|
||||
|
||||
private void UnloadInstalledPlugins()
|
||||
{
|
||||
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
|
||||
{
|
||||
_exportRegistry.RemoveExports(_loadedPlugins[i].Manifest.Id);
|
||||
_loadedPlugins[i].Dispose();
|
||||
}
|
||||
|
||||
_loadedPlugins.Clear();
|
||||
_exportRegistry.Clear();
|
||||
_loadResults.Clear();
|
||||
_catalog.Clear();
|
||||
_settingsPages.Clear();
|
||||
@@ -482,10 +519,23 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static string GetUserDataRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var options = new PluginLoaderOptions();
|
||||
AddSharedAssembly(options, typeof(App).Assembly);
|
||||
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
||||
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
@@ -516,6 +566,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
|
||||
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||
{
|
||||
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
|
||||
|
||||
foreach (var settingsPage in loadedPlugin.SettingsPages)
|
||||
{
|
||||
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
|
||||
@@ -527,6 +579,17 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
||||
{
|
||||
foreach (var assemblyName in _sharedContractManager.PrepareForLoad(manifest))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
_loaderOptions.SharedAssemblyNames.Add(assemblyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyPendingPluginDeletions()
|
||||
{
|
||||
var pendingPaths = ReadPendingPluginDeletions();
|
||||
@@ -679,17 +742,37 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
private sealed class PluginHostServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly IHostApplicationLifecycle _applicationLifecycle;
|
||||
private readonly IPluginExportRegistry _exportRegistry;
|
||||
|
||||
public PluginHostServiceProvider(IPluginPackageManager packageManager)
|
||||
public PluginHostServiceProvider(
|
||||
IPluginPackageManager packageManager,
|
||||
IHostApplicationLifecycle applicationLifecycle,
|
||||
IPluginExportRegistry exportRegistry)
|
||||
{
|
||||
_packageManager = packageManager;
|
||||
_applicationLifecycle = applicationLifecycle;
|
||||
_exportRegistry = exportRegistry;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
return serviceType == typeof(IPluginPackageManager)
|
||||
? _packageManager
|
||||
: null;
|
||||
if (serviceType == typeof(IPluginPackageManager))
|
||||
{
|
||||
return _packageManager;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IHostApplicationLifecycle))
|
||||
{
|
||||
return _applicationLifecycle;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IPluginExportRegistry))
|
||||
{
|
||||
return _exportRegistry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
272
LanMountainDesktop/plugins/PluginSharedContractManager.cs
Normal file
272
LanMountainDesktop/plugins/PluginSharedContractManager.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
internal sealed class PluginSharedContractManager : IDisposable
|
||||
{
|
||||
private readonly string _contractsDirectory;
|
||||
private readonly AirAppMarketIndexService _indexService;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, LoadedSharedContract> _loadedContracts =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginSharedContractManager(string cacheDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
||||
|
||||
_contractsDirectory = Path.Combine(
|
||||
GetSharedContractRootDirectory(),
|
||||
"SharedContracts");
|
||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(cacheDirectory));
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-SharedContracts/1.0");
|
||||
}
|
||||
|
||||
public string ContractsDirectory => _contractsDirectory;
|
||||
|
||||
public void EnsureInstalled(PluginManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
if (manifest.SharedContracts is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var document = LoadIndex(cancellationToken);
|
||||
foreach (var reference in manifest.SharedContracts)
|
||||
{
|
||||
EnsureInstalled(document, reference, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> PrepareForLoad(PluginManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
if (manifest.SharedContracts is not { Count: > 0 })
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var assemblyNames = new List<string>(manifest.SharedContracts.Count);
|
||||
foreach (var reference in manifest.SharedContracts)
|
||||
{
|
||||
var assemblyPath = GetInstalledAssemblyPath(reference);
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{manifest.Id}' requires shared contract '{reference.Id}' version '{reference.Version}', but '{assemblyPath}' is not installed. Install the dependency from the market first.");
|
||||
}
|
||||
|
||||
var loaded = LoadSharedAssembly(reference, assemblyPath);
|
||||
assemblyNames.Add(loaded.AssemblyName);
|
||||
}
|
||||
|
||||
return assemblyNames;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_indexService.Dispose();
|
||||
}
|
||||
|
||||
private void EnsureInstalled(
|
||||
AirAppMarketIndexDocument document,
|
||||
PluginSharedContractReference reference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = document.Contracts.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Id, reference.Id, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(candidate.Version, reference.Version, StringComparison.OrdinalIgnoreCase));
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared contract '{reference.Id}' version '{reference.Version}' is not published in the configured market index.");
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.AssemblyName, reference.AssemblyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared contract '{reference.Id}' version '{reference.Version}' expects assembly '{reference.AssemblyName}', but the market entry provides '{entry.AssemblyName}'.");
|
||||
}
|
||||
|
||||
var destinationPath = GetInstalledAssemblyPath(reference);
|
||||
if (IsInstalledAndMatches(destinationPath, entry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
var temporaryPath = destinationPath + ".download";
|
||||
try
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(entry.DownloadUrl, out var localSourcePath))
|
||||
{
|
||||
File.Copy(localSourcePath, temporaryPath, overwrite: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var response = _httpClient.GetAsync(entry.DownloadUrl, cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var responseStream = response.Content.ReadAsStreamAsync(cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
using var fileStream = File.Create(temporaryPath);
|
||||
responseStream.CopyTo(fileStream);
|
||||
}
|
||||
|
||||
ValidateInstalledFile(temporaryPath, entry);
|
||||
File.Move(temporaryPath, destinationPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private AirAppMarketIndexDocument LoadIndex(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = _indexService.LoadAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to load market index for shared contract resolution: {result.ErrorMessage ?? "Unknown error"}");
|
||||
}
|
||||
|
||||
return result.Document;
|
||||
}
|
||||
|
||||
private LoadedSharedContract LoadSharedAssembly(
|
||||
PluginSharedContractReference reference,
|
||||
string assemblyPath)
|
||||
{
|
||||
var assemblyName = AssemblyLoadContext.GetAssemblyName(assemblyPath).Name
|
||||
?? throw new InvalidOperationException($"Failed to determine assembly name of '{assemblyPath}'.");
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_loadedContracts.TryGetValue(assemblyName, out var existing))
|
||||
{
|
||||
if (!string.Equals(existing.ContractId, reference.Id, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(existing.ContractVersion, reference.Version, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared contract assembly '{assemblyName}' is already loaded as '{existing.ContractId}' version '{existing.ContractVersion}', so plugin dependency '{reference.Id}' version '{reference.Version}' cannot be activated in the same host process.");
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
var assembly = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.GetName().Name, assemblyName, StringComparison.OrdinalIgnoreCase))
|
||||
?? AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
|
||||
|
||||
var loaded = new LoadedSharedContract(reference.Id, reference.Version, assemblyName, assemblyPath, assembly);
|
||||
_loadedContracts[assemblyName] = loaded;
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInstalledAndMatches(string assemblyPath, AirAppMarketSharedContractEntry entry)
|
||||
{
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ValidateInstalledFile(assemblyPath, entry);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInstalledFile(string assemblyPath, AirAppMarketSharedContractEntry entry)
|
||||
{
|
||||
var actualSize = new FileInfo(assemblyPath).Length;
|
||||
if (actualSize != entry.PackageSizeBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared contract '{entry.Id}' version '{entry.Version}' size mismatch. Expected {entry.PackageSizeBytes}, actual {actualSize}.");
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(assemblyPath);
|
||||
var actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared contract '{entry.Id}' version '{entry.Version}' hash mismatch. Expected {entry.Sha256}, actual {actualHash}.");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetInstalledAssemblyPath(PluginSharedContractReference reference)
|
||||
{
|
||||
return Path.Combine(
|
||||
_contractsDirectory,
|
||||
Sanitize(reference.Id),
|
||||
Sanitize(reference.Version),
|
||||
reference.AssemblyName);
|
||||
}
|
||||
|
||||
private static string GetSharedContractRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record LoadedSharedContract(
|
||||
string ContractId,
|
||||
string ContractVersion,
|
||||
string AssemblyName,
|
||||
string AssemblyPath,
|
||||
Assembly Assembly);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
|
||||
- load plugin assemblies
|
||||
- integrate plugin settings pages and desktop components
|
||||
- expose market and plugin management in the host UI
|
||||
- build a plugin-scoped `IServiceCollection`/`ServiceProvider` for API `2.0.0` plugins
|
||||
- resolve shared contract assemblies into a version-isolated cache before plugin activation
|
||||
- expose explicit cross-plugin exports through `IPluginExportRegistry`
|
||||
|
||||
### Market install order
|
||||
|
||||
@@ -55,3 +58,9 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
|
||||
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
|
||||
4. Plugin details always come from the repository root `README.md`.
|
||||
5. Market installs are staged and take effect after restart.
|
||||
|
||||
### Dependency model
|
||||
|
||||
- Plugin-private managed and native NuGet dependencies remain plugin-local and are resolved through `AssemblyDependencyResolver`.
|
||||
- Shared contract assemblies are downloaded from the official market index, cached under `LocalAppData/LanMountainDesktop/SharedContracts/<id>/<version>/`, and loaded into the default context so host and plugins share the same contract types.
|
||||
- Different contract versions are isolated on disk. If two active plugins request incompatible versions of the same shared assembly name in one process, the host fails the later activation with a clear error instead of loading an ambiguous contract.
|
||||
|
||||
@@ -77,7 +77,7 @@ public partial class SettingsWindow
|
||||
Control content;
|
||||
try
|
||||
{
|
||||
content = contribution.Registration.ContentFactory();
|
||||
content = contribution.Registration.ContentFactory(contribution.Plugin.Services);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- Windows 是当前主要目标平台。
|
||||
- 已提供组件系统、插件系统、主题系统和设置系统。
|
||||
- 中文为主语言,英文为附加扩展语言。
|
||||
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`,SDK 版本由根目录 `global.json` 锁定。
|
||||
|
||||
### 运行说明
|
||||
|
||||
|
||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
8
run.md
8
run.md
@@ -8,12 +8,14 @@
|
||||
|
||||
- 安装 .NET SDK 10。
|
||||
- 桌面端建议在 Windows 上运行。
|
||||
- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
The repository entry solution is `LanMountainDesktop.slnx`, and the SDK version is pinned by the root `global.json`.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.sln -c Debug
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
Reference in New Issue
Block a user