Compare commits

...

6 Commits

Author SHA1 Message Date
lincube
6952cb2c3e 0.5.19.1 2026-03-12 10:07:23 +08:00
lincube
d3356f3319 0.5.19
插件系统V2
2026-03-12 09:22:03 +08:00
lincube
57c5e41a5c 0.5.18 2026-03-12 00:34:49 +08:00
lincube
ce2b218dfa 0.5.17 2026-03-12 00:18:04 +08:00
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
46 changed files with 1421 additions and 384 deletions

17
.github/FIX_REPORT.md vendored
View File

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

3
.github/README.md vendored
View File

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

View File

@@ -9,7 +9,7 @@ on:
env: env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln Solution_Name: LanMountainDesktop.slnx
jobs: jobs:
build-windows: build-windows:
@@ -71,10 +71,10 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore - name: Restore
run: dotnet restore run: dotnet restore ${{ env.Solution_Name }}
- name: Build - 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 - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -101,10 +101,10 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore - name: Restore
run: dotnet restore run: dotnet restore ${{ env.Solution_Name }}
- name: Build - 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 - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

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

View File

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

10
.gitignore vendored
View File

@@ -492,3 +492,13 @@ nul
/_build_verify_plugin_services /_build_verify_plugin_services
/LanMountainDesktop.PluginSdk/_build_verify_*/ /LanMountainDesktop.PluginSdk/_build_verify_*/
/_build_obj /_build_obj
# LanMountainDesktop local workspace files
/.arts/
/.knox/
/.lingma/
/.tmp/
/publish-test/
/validator-restore.log
/temp_old_main.axaml
/temp_old_main_utf8.axaml

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginSdk;
public interface IPlugin public interface IPlugin
{ {
void Initialize(IPluginContext context); void Initialize(HostBuilderContext context, IServiceCollection services);
} }

View File

@@ -1,27 +1,6 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk; 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);
} }

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

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

View File

@@ -1,15 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.0</Version> <Version>2.0.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" /> <Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" /> <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> </ItemGroup>
</Project> </Project>

View File

@@ -1,8 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginSdk;
public abstract class PluginBase : IPlugin public abstract class PluginBase : IPlugin
{ {
public virtual void Initialize(IPluginContext context) public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
{ {
} }
} }

View File

@@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration
public PluginDesktopComponentRegistration( public PluginDesktopComponentRegistration(
string componentId, string componentId,
string displayName, string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory, Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece", string iconKey = "PuzzlePiece",
string category = "Plugins", string category = "Plugins",
int minWidthCells = 2, int minWidthCells = 2,
@@ -40,13 +40,42 @@ public sealed class PluginDesktopComponentRegistration
CornerRadiusResolver = cornerRadiusResolver; 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 ComponentId { get; }
public string DisplayName { get; } public string DisplayName { get; }
public string? DisplayNameLocalizationKey { get; } public string? DisplayNameLocalizationKey { get; }
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; } public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; } public string IconKey { get; }

View File

@@ -26,7 +26,7 @@ public sealed class PluginLocalizer
public string LanguageCode { get; } public string LanguageCode { get; }
public static PluginLocalizer Create(IPluginContext context) public static PluginLocalizer Create(IPluginRuntimeContext context)
{ {
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties)); return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));

View File

@@ -9,7 +9,8 @@ public sealed record PluginManifest(
string? Description = null, string? Description = null,
string? Author = null, string? Author = null,
string? Version = null, string? Version = null,
string? ApiVersion = null) string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -57,6 +58,7 @@ public sealed record PluginManifest(
private PluginManifest NormalizeAndValidate(string manifestPath) private PluginManifest NormalizeAndValidate(string manifestPath)
{ {
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalized = this with var normalized = this with
{ {
Id = RequireValue(Id, nameof(Id), manifestPath), Id = RequireValue(Id, nameof(Id), manifestPath),
@@ -65,7 +67,8 @@ public sealed record PluginManifest(
Description = NormalizeOptionalValue(Description), Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author), Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version), Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts
}; };
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
@@ -82,7 +85,41 @@ public sealed record PluginManifest(
if (requestedVersion.Major != currentVersion.Major) if (requestedVersion.Major != currentVersion.Major)
{ {
throw new InvalidOperationException( 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; return normalized;

View File

@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo 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 ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp"; public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data"; public const string DataDirectoryName = "Data";

View File

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

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginServiceExportDescriptor(
string ProviderPluginId,
Type ContractType,
object ServiceInstance);

View File

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

View File

@@ -7,7 +7,7 @@ public sealed class PluginSettingsPageRegistration
public PluginSettingsPageRegistration( public PluginSettingsPageRegistration(
string id, string id,
string title, string title,
Func<Control> contentFactory, Func<IServiceProvider, Control> contentFactory,
int sortOrder = 0) int sortOrder = 0)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(id);
@@ -20,11 +20,20 @@ public sealed class PluginSettingsPageRegistration
SortOrder = sortOrder; 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 Id { get; }
public string Title { get; } public string Title { get; }
public int SortOrder { get; } public int SortOrder { get; }
public Func<Control> ContentFactory { get; } public Func<IServiceProvider, Control> ContentFactory { get; }
} }

View File

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

View File

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

View File

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

6
LanMountainDesktop.slnx Normal file
View 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>

View File

@@ -271,6 +271,10 @@ public partial class App : Application
mainWindow.Activate(); mainWindow.Activate();
mainWindow.Topmost = true; mainWindow.Topmost = true;
mainWindow.Topmost = false; mainWindow.Topmost = false;
if (mainWindow is MainWindow lanMountainMainWindow)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -47,6 +47,8 @@
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" /> <PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" /> <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="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" /> <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" /> <PackageReference Include="PortAudioSharp2" Version="1.0.6" />

View File

@@ -743,7 +743,10 @@
"placement.fit": "Fit", "placement.fit": "Fit",
"placement.stretch": "Stretch", "placement.stretch": "Stretch",
"placement.center": "Center", "placement.center": "Center",
"placement.tile": "Tile" "placement.tile": "Tile",
} "single_instance.notice.title": "App already open",
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
"single_instance.notice.button": "Got it"
}

View File

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

View File

@@ -1,8 +1,10 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.WebView.Desktop; using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using System;
using System.Threading.Tasks;
namespace LanMountainDesktop; namespace LanMountainDesktop;
@@ -17,12 +19,11 @@ sealed class Program
AppLogger.Initialize(); AppLogger.Initialize();
RegisterGlobalExceptionLogging(); RegisterGlobalExceptionLogging();
using var singleInstance = SingleInstanceService.CreateDefault(); using var singleInstance = AcquireSingleInstance(args);
if (!singleInstance.IsPrimaryInstance) if (!singleInstance.IsPrimaryInstance)
{ {
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running."); AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
var notified = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2)); _ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
ShowAlreadyRunningNotice(notified);
return; return;
} }
@@ -72,6 +73,42 @@ sealed class Program
return builder; return builder;
} }
private static SingleInstanceService AcquireSingleInstance(string[] args)
{
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var singleInstance = SingleInstanceService.CreateDefault();
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
{
return singleInstance;
}
AppLogger.Info(
"Startup",
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
singleInstance.Dispose();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
while (DateTime.UtcNow < deadline)
{
var retryInstance = SingleInstanceService.CreateDefault();
if (retryInstance.IsPrimaryInstance)
{
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
return retryInstance;
}
retryInstance.Dispose();
Thread.Sleep(150);
}
AppLogger.Warn(
"Startup",
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
return SingleInstanceService.CreateDefault();
}
private static string LoadConfiguredRenderMode() private static string LoadConfiguredRenderMode()
{ {
try try
@@ -85,14 +122,25 @@ sealed class Program
} }
} }
private static void ShowAlreadyRunningNotice(bool notifiedPrimaryInstance) private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
{ {
const string caption = "LanMountainDesktop"; try
var message = notifiedPrimaryInstance {
? "应用已打开,不需要多开了。\r\n\r\n已为你切换到正在运行的阑山桌面。" using var process = Process.GetProcessById(processId);
: "应用已打开,不需要多开了。\r\n\r\n请切换到正在运行的阑山桌面。"; var remaining = deadlineUtc - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
WindowsNativeDialogService.ShowInformation(caption, message); {
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
}
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
}
} }
private static void RegisterGlobalExceptionLogging() private static void RegisterGlobalExceptionLogging()

View File

@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Services;
public static class AppRestartService public static class AppRestartService
{ {
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
public static bool TryRestartApplication() public static bool TryRestartApplication()
{ {
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest( return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
@@ -75,6 +77,21 @@ public static class AppRestartService
return null; return null;
} }
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo( private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath, string executablePath,
string? entryAssemblyPath, string? entryAssemblyPath,
@@ -88,6 +105,7 @@ public static class AppRestartService
}; };
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -110,6 +128,7 @@ public static class AppRestartService
startInfo.ArgumentList.Add(entryAssemblyPath); startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs); AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo; return startInfo;
} }
@@ -117,10 +136,34 @@ public static class AppRestartService
{ {
for (var i = 1; i < commandLineArgs.Count; i++) for (var i = 1; i < commandLineArgs.Count; i++)
{ {
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
startInfo.ArgumentList.Add(commandLineArgs[i]); startInfo.ArgumentList.Add(commandLineArgs[i]);
} }
} }
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;
if (string.IsNullOrWhiteSpace(argument) ||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return int.TryParse(
argument[RestartParentPidArgumentPrefix.Length..],
out processId) && processId > 0;
}
private static string? NormalizeExistingPath(string? path) private static string? NormalizeExistingPath(string? path)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))

View File

@@ -118,13 +118,13 @@ public static class DesktopComponentRegistryFactory
contribution.Plugin.Manifest, contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory, contribution.Plugin.Context.PluginDirectory,
contribution.Plugin.Context.DataDirectory, contribution.Plugin.Context.DataDirectory,
contribution.Plugin.Context.Services, contribution.Plugin.Services,
contribution.Plugin.Context.Properties, contribution.Plugin.Context.Properties,
contribution.Registration.ComponentId, contribution.Registration.ComponentId,
context.PlacementId, context.PlacementId,
context.CellSize); context.CellSize);
return contribution.Registration.ControlFactory(pluginContext); return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.Plugins; namespace LanMountainDesktop.Plugins;
@@ -18,9 +19,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
string assemblyPath, string assemblyPath,
Assembly assembly, Assembly assembly,
IPlugin plugin, IPlugin plugin,
IPluginContext context, IPluginRuntimeContext runtimeContext,
IServiceProvider services,
IReadOnlyList<PluginSettingsPageRegistration> settingsPages, IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents, IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
IReadOnlyList<IHostedService> hostedServices,
PluginLoadContext loadContext) PluginLoadContext loadContext)
{ {
Manifest = manifest; Manifest = manifest;
@@ -28,9 +32,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
AssemblyPath = assemblyPath; AssemblyPath = assemblyPath;
Assembly = assembly; Assembly = assembly;
Plugin = plugin; Plugin = plugin;
Context = context; RuntimeContext = runtimeContext;
Services = services;
SettingsPages = settingsPages; SettingsPages = settingsPages;
DesktopComponents = desktopComponents; DesktopComponents = desktopComponents;
ExportedServices = exportedServices;
HostedServices = hostedServices;
LoadContext = loadContext; LoadContext = loadContext;
} }
@@ -44,14 +51,22 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
public IPlugin Plugin { get; } 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<PluginSettingsPageRegistration> SettingsPages { get; }
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; } public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
public PluginLoadContext LoadContext { get; } public PluginLoadContext LoadContext { get; }
private IReadOnlyList<IHostedService> HostedServices { get; }
public void Dispose() public void Dispose()
{ {
DisposeAsync().AsTask().GetAwaiter().GetResult(); DisposeAsync().AsTask().GetAwaiter().GetResult();
@@ -64,6 +79,18 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
return; 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) if (Plugin is IAsyncDisposable asyncDisposable)
{ {
await asyncDisposable.DisposeAsync(); await asyncDisposable.DisposeAsync();
@@ -73,11 +100,20 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
disposable.Dispose(); 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(); await asyncContext.DisposeAsync();
} }
else if (Context is IDisposable disposableContext) else if (RuntimeContext is IDisposable disposableContext)
{ {
disposableContext.Dispose(); disposableContext.Dispose();
} }

View File

@@ -85,7 +85,7 @@ public partial class MainWindow
Control content; Control content;
try try
{ {
content = contribution.Registration.ContentFactory(); content = contribution.Registration.ContentFactory(contribution.Plugin.Services);
} }
catch (Exception ex) catch (Exception ex)
{ {

View 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();
}
}
}

View File

@@ -12,6 +12,8 @@ using System.Threading.Tasks;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.Plugins; namespace LanMountainDesktop.Plugins;
@@ -135,19 +137,45 @@ public sealed class PluginLoader
{ {
PluginLoadContext? loadContext = null; PluginLoadContext? loadContext = null;
IPlugin? plugin = null; IPlugin? plugin = null;
PluginContext? context = null; PluginRuntimeContext? runtimeContext = null;
ServiceProvider? pluginServices = null;
IReadOnlyList<IHostedService> hostedServices = Array.Empty<IHostedService>();
try try
{ {
Directory.CreateDirectory(dataDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames); loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
var pluginType = ResolvePluginType(assembly); var pluginType = ResolvePluginType(assembly);
plugin = CreatePluginInstance(pluginType); 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); plugin.Initialize(hostBuilderContext, serviceCollection);
var settingsPages = context.GetSettingsPagesSnapshot();
var desktopComponents = context.GetDesktopComponentsSnapshot(); 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( var loadedPlugin = new LoadedPlugin(
manifest, manifest,
@@ -155,17 +183,22 @@ public sealed class PluginLoader
assemblyPath, assemblyPath,
assembly, assembly,
plugin, plugin,
context, runtimeContext,
pluginServices,
settingsPages, settingsPages,
desktopComponents, desktopComponents,
exportedServices,
hostedServices,
loadContext); loadContext);
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin); return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
} }
catch (Exception ex) catch (Exception ex)
{ {
StopHostedServices(hostedServices);
DisposeInstance(pluginServices);
DisposeInstance(plugin); DisposeInstance(plugin);
DisposeInstance(context); DisposeInstance(runtimeContext);
loadContext?.Unload(); loadContext?.Unload();
return PluginLoadResult.Failure(sourcePath, manifest, ex); return PluginLoadResult.Failure(sourcePath, manifest, ex);
} }
@@ -243,23 +276,124 @@ public sealed class PluginLoader
} }
} }
private PluginContext CreateContext( private PluginRuntimeContext CreateRuntimeContext(
PluginManifest manifest, PluginManifest manifest,
string pluginDirectory, string pluginDirectory,
string dataDirectory, string dataDirectory,
IServiceProvider? services,
IReadOnlyDictionary<string, object?>? properties) IReadOnlyDictionary<string, object?>? properties)
{ {
Directory.CreateDirectory(dataDirectory); return new PluginRuntimeContext(
return new PluginContext(
manifest, manifest,
pluginDirectory, pluginDirectory,
dataDirectory, dataDirectory,
services ?? NullServiceProvider.Instance,
CreateReadOnlyProperties(properties)); 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( private IReadOnlyList<PluginCandidate> DiscoverCandidates(
string pluginsRootDirectory, string pluginsRootDirectory,
List<PluginLoadResult> preparationFailures) List<PluginLoadResult> preparationFailures)
@@ -436,6 +570,27 @@ public sealed class PluginLoader
return new ReadOnlyDictionary<string, object?>(map); 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) private static Type ResolvePluginType(Assembly assembly)
{ {
var candidateTypes = GetLoadableTypes(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 = public PluginRuntimeContext(
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(
PluginManifest manifest, PluginManifest manifest,
string pluginDirectory, string pluginDirectory,
string dataDirectory, string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties) IReadOnlyDictionary<string, object?> properties)
{ {
Manifest = manifest; Manifest = manifest;
PluginDirectory = pluginDirectory; PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory; DataDirectory = dataDirectory;
_hostServices = services;
Services = new PluginCompositeServiceProvider(this);
Properties = properties; Properties = properties;
Services = NullServiceProvider.Instance;
RegisterBuiltInService<IPluginContext>(this);
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
} }
public PluginManifest Manifest { get; } public PluginManifest Manifest { get; }
@@ -579,7 +719,7 @@ public sealed class PluginLoader
public string DataDirectory { get; } public string DataDirectory { get; }
public IServiceProvider Services { get; } public IServiceProvider Services { get; private set; }
public IReadOnlyDictionary<string, object?> Properties { get; } public IReadOnlyDictionary<string, object?> Properties { get; }
@@ -602,181 +742,9 @@ public sealed class PluginLoader
return false; return false;
} }
public void RegisterService<TService>(TService service) public void SetServices(IServiceProvider services)
where TService : class
{ {
RegisterServiceCore(typeof(TService), service, allowOverride: false); Services = services ?? throw new ArgumentNullException(nameof(services));
}
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);
} }
} }

View File

@@ -78,16 +78,24 @@ internal sealed class AirAppMarketInstallService : IDisposable
} }
} }
await using var hashStream = File.OpenRead(downloadPath); var actualSize = new FileInfo(downloadPath).Length;
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); string actualHash;
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); 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)) 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); File.Delete(downloadPath);
return new AirAppMarketInstallResult( return new AirAppMarketInstallResult(
false, false,
null, 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; PluginManifest manifest;

View File

@@ -215,6 +215,8 @@ internal sealed class AirAppMarketIndexDocument
public DateTimeOffset GeneratedAt { get; init; } public DateTimeOffset GeneratedAt { get; init; }
public List<AirAppMarketSharedContractEntry> Contracts { get; init; } = [];
public List<AirAppMarketPluginEntry> Plugins { get; init; } = []; public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
public static AirAppMarketIndexDocument Load(string json, string sourceName) public static AirAppMarketIndexDocument Load(string json, string sourceName)
@@ -236,6 +238,23 @@ internal sealed class AirAppMarketIndexDocument
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName) 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 plugins = Plugins ?? [];
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count); var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -260,6 +279,10 @@ internal sealed class AirAppMarketIndexDocument
GeneratedAt = GeneratedAt == default GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.") ? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt, : GeneratedAt,
Contracts = normalizedContracts
.OrderBy(contract => contract.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(contract => contract.Version, StringComparer.OrdinalIgnoreCase)
.ToList(),
Plugins = normalizedPlugins Plugins = normalizedPlugins
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToList() .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 internal sealed class AirAppMarketPluginEntry
{ {
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;

View File

@@ -12,6 +12,8 @@ using Avalonia.Markup.Xaml;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins; using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -19,9 +21,12 @@ public sealed class PluginRuntimeService : IDisposable
{ {
private const string PendingDeletionFileName = ".pending-plugin-deletions.json"; private const string PendingDeletionFileName = ".pending-plugin-deletions.json";
private readonly PluginLoaderOptions _loaderOptions;
private readonly PluginLoader _loader; private readonly PluginLoader _loader;
private readonly AppSettingsService _appSettingsService = new(); private readonly AppSettingsService _appSettingsService = new();
private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService(); private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService();
private readonly PluginExportRegistry _exportRegistry = new();
private readonly PluginSharedContractManager _sharedContractManager;
private readonly IServiceProvider _hostServices; private readonly IServiceProvider _hostServices;
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly List<LoadedPlugin> _loadedPlugins = []; private readonly List<LoadedPlugin> _loadedPlugins = [];
@@ -34,9 +39,12 @@ public sealed class PluginRuntimeService : IDisposable
public PluginRuntimeService() public PluginRuntimeService()
{ {
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
_sharedContractManager = new PluginSharedContractManager(
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
_packageManager = new PluginRuntimePackageManager(this); _packageManager = new PluginRuntimePackageManager(this);
_hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle); _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle, _exportRegistry);
_loader = new PluginLoader(CreateOptions()); _loaderOptions = CreateOptions();
_loader = new PluginLoader(_loaderOptions);
} }
public string PluginsDirectory { get; } public string PluginsDirectory { get; }
@@ -51,6 +59,8 @@ public sealed class PluginRuntimeService : IDisposable
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents; public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
public IPluginExportRegistry ExportRegistry => _exportRegistry;
public void LoadInstalledPlugins() public void LoadInstalledPlugins()
{ {
Directory.CreateDirectory(PluginsDirectory); Directory.CreateDirectory(PluginsDirectory);
@@ -101,6 +111,27 @@ public sealed class PluginRuntimeService : IDisposable
continue; 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 var loadResult = candidate.SourceKind switch
{ {
PluginCatalogSourceKind.Package => _loader.LoadFromPackage( PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
@@ -277,6 +308,7 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory); Directory.CreateDirectory(PluginsDirectory);
var manifest = ReadManifestFromPackage(fullPackagePath); var manifest = ReadManifestFromPackage(fullPackagePath);
_sharedContractManager.EnsureInstalled(manifest);
AppLogger.Info( AppLogger.Info(
"PluginRuntime", "PluginRuntime",
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'."); $"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
@@ -308,6 +340,7 @@ public sealed class PluginRuntimeService : IDisposable
} }
var manifest = ReadManifestFromPackage(fullPackagePath); var manifest = ReadManifestFromPackage(fullPackagePath);
_sharedContractManager.EnsureInstalled(manifest);
AppLogger.Info( AppLogger.Info(
"PluginRuntime", "PluginRuntime",
$"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'."); $"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'.");
@@ -319,16 +352,19 @@ public sealed class PluginRuntimeService : IDisposable
public void Dispose() public void Dispose()
{ {
UnloadInstalledPlugins(); UnloadInstalledPlugins();
_sharedContractManager.Dispose();
} }
private void UnloadInstalledPlugins() private void UnloadInstalledPlugins()
{ {
for (var i = _loadedPlugins.Count - 1; i >= 0; i--) for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
{ {
_exportRegistry.RemoveExports(_loadedPlugins[i].Manifest.Id);
_loadedPlugins[i].Dispose(); _loadedPlugins[i].Dispose();
} }
_loadedPlugins.Clear(); _loadedPlugins.Clear();
_exportRegistry.Clear();
_loadResults.Clear(); _loadResults.Clear();
_catalog.Clear(); _catalog.Clear();
_settingsPages.Clear(); _settingsPages.Clear();
@@ -483,10 +519,23 @@ public sealed class PluginRuntimeService : IDisposable
: path + Path.DirectorySeparatorChar; : 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() private static PluginLoaderOptions CreateOptions()
{ {
var options = new PluginLoaderOptions(); var options = new PluginLoaderOptions();
AddSharedAssembly(options, typeof(App).Assembly); AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{ {
@@ -517,6 +566,8 @@ public sealed class PluginRuntimeService : IDisposable
private void CollectContributions(LoadedPlugin loadedPlugin) private void CollectContributions(LoadedPlugin loadedPlugin)
{ {
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
foreach (var settingsPage in loadedPlugin.SettingsPages) foreach (var settingsPage in loadedPlugin.SettingsPages)
{ {
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage)); _settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
@@ -528,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() private void ApplyPendingPluginDeletions()
{ {
var pendingPaths = ReadPendingPluginDeletions(); var pendingPaths = ReadPendingPluginDeletions();
@@ -681,13 +743,16 @@ public sealed class PluginRuntimeService : IDisposable
{ {
private readonly IPluginPackageManager _packageManager; private readonly IPluginPackageManager _packageManager;
private readonly IHostApplicationLifecycle _applicationLifecycle; private readonly IHostApplicationLifecycle _applicationLifecycle;
private readonly IPluginExportRegistry _exportRegistry;
public PluginHostServiceProvider( public PluginHostServiceProvider(
IPluginPackageManager packageManager, IPluginPackageManager packageManager,
IHostApplicationLifecycle applicationLifecycle) IHostApplicationLifecycle applicationLifecycle,
IPluginExportRegistry exportRegistry)
{ {
_packageManager = packageManager; _packageManager = packageManager;
_applicationLifecycle = applicationLifecycle; _applicationLifecycle = applicationLifecycle;
_exportRegistry = exportRegistry;
} }
public object? GetService(Type serviceType) public object? GetService(Type serviceType)
@@ -702,6 +767,11 @@ public sealed class PluginRuntimeService : IDisposable
return _applicationLifecycle; return _applicationLifecycle;
} }
if (serviceType == typeof(IPluginExportRegistry))
{
return _exportRegistry;
}
return null; return null;
} }
} }

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

View File

@@ -47,6 +47,9 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
- load plugin assemblies - load plugin assemblies
- integrate plugin settings pages and desktop components - integrate plugin settings pages and desktop components
- expose market and plugin management in the host UI - 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 ### 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`. 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`. 4. Plugin details always come from the repository root `README.md`.
5. Market installs are staged and take effect after restart. 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.

View File

@@ -77,7 +77,7 @@ public partial class SettingsWindow
Control content; Control content;
try try
{ {
content = contribution.Registration.ContentFactory(); content = contribution.Registration.ContentFactory(contribution.Plugin.Services);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

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

6
global.json Normal file
View File

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

8
run.md
View File

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