mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4679ee006f | ||
|
|
6952cb2c3e | ||
|
|
d3356f3319 | ||
|
|
57c5e41a5c | ||
|
|
ce2b218dfa | ||
|
|
efdfa68dab |
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.
|
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
3
.github/README.md
vendored
@@ -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)
|
||||||
|
|||||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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:
|
||||||
|
|||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -492,3 +492,23 @@ 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
|
||||||
|
|
||||||
|
# LanMountainDesktop local packaging outputs
|
||||||
|
/build-installer/
|
||||||
|
/build-deb/
|
||||||
|
/dmg-temp/
|
||||||
|
/release-files/
|
||||||
|
/LanMountainDesktop.app/
|
||||||
|
/*.deb
|
||||||
|
/*.dmg
|
||||||
|
/*.AppImage
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ComponentSettingsServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Load_MigratesLegacySnapshotFileToCanonicalDocument()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
File.WriteAllText(
|
||||||
|
sandbox.SettingsPath,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"DesktopClockSecondHandMode": "Sweep",
|
||||||
|
"ImportedClassSchedules": [
|
||||||
|
{
|
||||||
|
"Id": "spring-2026",
|
||||||
|
"DisplayName": "Spring 2026",
|
||||||
|
"FilePath": "C:\\Schedules\\spring-2026.yaml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ActiveImportedClassScheduleId": "spring-2026"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
var snapshot = service.Load();
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.Single(snapshot.ImportedClassSchedules);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||||
|
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||||
|
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
File.WriteAllText(
|
||||||
|
sandbox.SettingsPath,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"DefaultSettings": {
|
||||||
|
"DesktopClockSecondHandMode": "Tick"
|
||||||
|
},
|
||||||
|
"InstanceSettings": {
|
||||||
|
"DesktopClock::clock-2x2": {
|
||||||
|
"DesktopClockSecondHandMode": "Sweep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginSettings": {
|
||||||
|
"DesktopClock::clock-2x2": {
|
||||||
|
"SampleFlag": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2");
|
||||||
|
var pluginSettings = service.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.True(pluginSettings.SampleFlag);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||||
|
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||||
|
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveForComponent(
|
||||||
|
"DesktopClock",
|
||||||
|
"clock-2x2",
|
||||||
|
new ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
DesktopClockSecondHandMode = "Sweep"
|
||||||
|
});
|
||||||
|
service.SaveForComponent(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2",
|
||||||
|
new ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
ImportedClassSchedules =
|
||||||
|
[
|
||||||
|
new ImportedClassScheduleSnapshot
|
||||||
|
{
|
||||||
|
Id = "spring-2026",
|
||||||
|
DisplayName = "Spring 2026",
|
||||||
|
FilePath = "C:\\Schedules\\spring-2026.yaml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ActiveImportedClassScheduleId = "spring-2026"
|
||||||
|
});
|
||||||
|
service.SavePluginSettings(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2",
|
||||||
|
new SamplePluginSettings
|
||||||
|
{
|
||||||
|
SampleFlag = true,
|
||||||
|
Title = "schedule-settings"
|
||||||
|
});
|
||||||
|
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
var reloadedService = sandbox.CreateService();
|
||||||
|
|
||||||
|
var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||||
|
var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2");
|
||||||
|
var pluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2");
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.Single(classScheduleSnapshot.ImportedClassSchedules);
|
||||||
|
Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId);
|
||||||
|
Assert.True(pluginSettings.SampleFlag);
|
||||||
|
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||||
|
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ComponentSettingsSandbox : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directoryPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.ComponentSettingsTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public ComponentSettingsSandbox()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directoryPath);
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||||
|
|
||||||
|
public ComponentSettingsService CreateService()
|
||||||
|
{
|
||||||
|
return new ComponentSettingsService(_directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directoryPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Temporary test directories are best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SamplePluginSettings
|
||||||
|
{
|
||||||
|
public bool SampleFlag { get; set; }
|
||||||
|
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class UiExceptionGuardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler()
|
||||||
|
{
|
||||||
|
var handlerCalled = false;
|
||||||
|
|
||||||
|
await UiExceptionGuard.RunGuardedUiActionAsync(
|
||||||
|
() => throw new InvalidOperationException("boom"),
|
||||||
|
"UnitTest.NonFatal",
|
||||||
|
onHandledException: ex =>
|
||||||
|
{
|
||||||
|
handlerCalled = ex is InvalidOperationException;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(handlerCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunGuardedUiActionAsync_RethrowsFatalException()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<OutOfMemoryException>(() =>
|
||||||
|
UiExceptionGuard.RunGuardedUiActionAsync(
|
||||||
|
() => throw new OutOfMemoryException("fatal"),
|
||||||
|
"UnitTest.Fatal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsFatalException_ReturnsExpectedClassification()
|
||||||
|
{
|
||||||
|
Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException()));
|
||||||
|
Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException()));
|
||||||
|
Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.0.31903.59
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginsInstallHelper", "LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj", "{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{5A20D5E9-BD27-4DD4-9F4F-97E0367DD2AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<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" />
|
||||||
|
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -8,6 +8,7 @@ using System.Linq;
|
|||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views;
|
using LanMountainDesktop.Views;
|
||||||
@@ -66,6 +67,7 @@ public partial class App : Application
|
|||||||
DataContext = new MainWindowViewModel(),
|
DataContext = new MainWindowViewModel(),
|
||||||
};
|
};
|
||||||
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
||||||
|
LogBrowserStartupDiagnostics();
|
||||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +270,11 @@ public partial class App : Application
|
|||||||
mainWindow.WindowState = WindowState.Normal;
|
mainWindow.WindowState = WindowState.Normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||||
|
{
|
||||||
|
mainWindow.WindowState = WindowState.FullScreen;
|
||||||
|
}
|
||||||
|
|
||||||
mainWindow.Activate();
|
mainWindow.Activate();
|
||||||
mainWindow.Topmost = true;
|
mainWindow.Topmost = true;
|
||||||
mainWindow.Topmost = false;
|
mainWindow.Topmost = false;
|
||||||
@@ -335,6 +342,34 @@ public partial class App : Application
|
|||||||
DisposeTrayIcon();
|
DisposeTrayIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogBrowserStartupDiagnostics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = new DesktopLayoutSettingsService().Load();
|
||||||
|
var browserPlacements = snapshot.DesktopComponentPlacements
|
||||||
|
.Where(placement => string.Equals(
|
||||||
|
placement.ComponentId,
|
||||||
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
var runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"StartupDiagnostics",
|
||||||
|
$"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " +
|
||||||
|
$"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " +
|
||||||
|
$"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " +
|
||||||
|
$"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " +
|
||||||
|
$"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " +
|
||||||
|
$"WebViewRuntimeMessage={runtimeAvailability.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
{
|
{
|
||||||
var snapshot = _appSettingsService.Load();
|
var snapshot = _appSettingsService.Load();
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -744,9 +744,9 @@
|
|||||||
"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.title": "App already running",
|
||||||
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
|
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||||
"single_instance.notice.button": "Got it"
|
"single_instance.notice.button": "OK"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -744,9 +744,9 @@
|
|||||||
"placement.stretch": "拉伸",
|
"placement.stretch": "拉伸",
|
||||||
"placement.center": "居中",
|
"placement.center": "居中",
|
||||||
"placement.tile": "平铺",
|
"placement.tile": "平铺",
|
||||||
"single_instance.notice.title": "应用已打开",
|
"single_instance.notice.title": "应用已经运行",
|
||||||
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
|
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||||
"single_instance.notice.button": "知道了"
|
"single_instance.notice.button": "确定"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,19 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
|
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||||
|
|
||||||
using var singleInstance = AcquireSingleInstance(args);
|
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||||
if (!singleInstance.IsPrimaryInstance)
|
if (!singleInstance.IsPrimaryInstance)
|
||||||
{
|
{
|
||||||
|
if (restartParentProcessId is not null)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"Startup",
|
||||||
|
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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.");
|
||||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||||
return;
|
return;
|
||||||
@@ -73,9 +82,8 @@ sealed class Program
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SingleInstanceService AcquireSingleInstance(string[] args)
|
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||||
{
|
{
|
||||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
|
||||||
var singleInstance = SingleInstanceService.CreateDefault();
|
var singleInstance = SingleInstanceService.CreateDefault();
|
||||||
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
|
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
|
||||||
{
|
{
|
||||||
@@ -156,6 +164,7 @@ sealed class Program
|
|||||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||||
{
|
{
|
||||||
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
||||||
|
eventArgs.SetObserved();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||||
@@ -12,6 +12,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,9 +31,19 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
private string _scopedPlacementId = string.Empty;
|
private string _scopedPlacementId = string.Empty;
|
||||||
|
|
||||||
public ComponentSettingsService()
|
public ComponentSettingsService()
|
||||||
|
: this(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop"))
|
||||||
{
|
{
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
}
|
||||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
|
||||||
|
internal ComponentSettingsService(string settingsDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settingsDirectory))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Settings directory cannot be null or whitespace.", nameof(settingsDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
||||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||||
}
|
}
|
||||||
@@ -345,10 +357,11 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
||||||
var loadedFromLegacy = false;
|
var loadDetails = ComponentSettingsLoadDetails.Empty;
|
||||||
if (hasFile)
|
if (hasFile)
|
||||||
{
|
{
|
||||||
loadedSnapshot = LoadSnapshotFromDisk();
|
loadDetails = LoadSnapshotFromDisk();
|
||||||
|
loadedSnapshot = loadDetails.Snapshot;
|
||||||
}
|
}
|
||||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||||
{
|
{
|
||||||
@@ -356,7 +369,10 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
{
|
{
|
||||||
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
||||||
};
|
};
|
||||||
loadedFromLegacy = true;
|
loadDetails = new ComponentSettingsLoadDetails(
|
||||||
|
loadedSnapshot,
|
||||||
|
ComponentSettingsDocumentFormat.LegacySnapshot,
|
||||||
|
true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -364,40 +380,44 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
||||||
if (loadedFromLegacy)
|
if (loadDetails.ShouldRewriteToCanonical)
|
||||||
{
|
{
|
||||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogLoadDetails(loadDetails.Format, loadDetails.ShouldRewriteToCanonical, normalizedSnapshot);
|
||||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||||
return normalizedSnapshot.Clone();
|
return normalizedSnapshot.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
|
private ComponentSettingsLoadDetails LoadSnapshotFromDisk()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(_settingsPath);
|
var json = File.ReadAllText(_settingsPath);
|
||||||
using var document = JsonDocument.Parse(json);
|
using var document = JsonDocument.Parse(json);
|
||||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
if (TryGetDocumentFormat(document.RootElement, out var format))
|
||||||
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
|
|
||||||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
|
|
||||||
document.RootElement.TryGetProperty("pluginSettings", out _)))
|
|
||||||
{
|
{
|
||||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||||
return NormalizeDocument(snapshot);
|
return new ComponentSettingsLoadDetails(
|
||||||
|
snapshot ?? new ComponentSettingsDocumentSnapshot(),
|
||||||
|
format,
|
||||||
|
format == ComponentSettingsDocumentFormat.PascalCaseDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||||
return new ComponentSettingsDocumentSnapshot
|
return new ComponentSettingsLoadDetails(
|
||||||
{
|
new ComponentSettingsDocumentSnapshot
|
||||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
{
|
||||||
};
|
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||||
|
},
|
||||||
|
ComponentSettingsDocumentFormat.LegacySnapshot,
|
||||||
|
true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
|
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
|
||||||
return new ComponentSettingsDocumentSnapshot();
|
return ComponentSettingsLoadDetails.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +704,79 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
_lastProbeUtc = probeTimeUtc;
|
_lastProbeUtc = probeTimeUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void ResetCacheForTests()
|
||||||
|
{
|
||||||
|
lock (CacheGate)
|
||||||
|
{
|
||||||
|
_cachedPath = null;
|
||||||
|
_cachedSnapshot = null;
|
||||||
|
_cachedWriteTimeUtc = DateTime.MinValue;
|
||||||
|
_lastProbeUtc = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogLoadDetails(
|
||||||
|
ComponentSettingsDocumentFormat format,
|
||||||
|
bool rewroteToCanonical,
|
||||||
|
ComponentSettingsDocumentSnapshot snapshot)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"ComponentSettings",
|
||||||
|
$"Loaded component settings document. Format={format}; RewroteToCanonical={rewroteToCanonical}; " +
|
||||||
|
$"InstanceSettings={snapshot.InstanceSettings.Count}; PluginSettings={snapshot.PluginSettings.Count}; Path={_settingsPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetDocumentFormat(
|
||||||
|
JsonElement rootElement,
|
||||||
|
out ComponentSettingsDocumentFormat format)
|
||||||
|
{
|
||||||
|
format = ComponentSettingsDocumentFormat.EmptyDocument;
|
||||||
|
if (rootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasDocumentProperties = false;
|
||||||
|
var requiresCanonicalRewrite = false;
|
||||||
|
foreach (var property in rootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!IsDocumentPropertyName(property.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDocumentProperties = true;
|
||||||
|
if (!IsCanonicalDocumentPropertyName(property.Name))
|
||||||
|
{
|
||||||
|
requiresCanonicalRewrite = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDocumentProperties)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
format = requiresCanonicalRewrite
|
||||||
|
? ComponentSettingsDocumentFormat.PascalCaseDocument
|
||||||
|
: ComponentSettingsDocumentFormat.CanonicalDocument;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDocumentPropertyName(string propertyName)
|
||||||
|
{
|
||||||
|
return string.Equals(propertyName, "defaultSettings", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(propertyName, "instanceSettings", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(propertyName, "pluginSettings", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCanonicalDocumentPropertyName(string propertyName)
|
||||||
|
{
|
||||||
|
return string.Equals(propertyName, "defaultSettings", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(propertyName, "instanceSettings", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(propertyName, "pluginSettings", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ComponentSettingsDocumentSnapshot
|
private sealed class ComponentSettingsDocumentSnapshot
|
||||||
{
|
{
|
||||||
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
|
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
|
||||||
@@ -771,4 +864,23 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
|
|
||||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct ComponentSettingsLoadDetails(
|
||||||
|
ComponentSettingsDocumentSnapshot Snapshot,
|
||||||
|
ComponentSettingsDocumentFormat Format,
|
||||||
|
bool ShouldRewriteToCanonical)
|
||||||
|
{
|
||||||
|
public static ComponentSettingsLoadDetails Empty { get; } = new(
|
||||||
|
new ComponentSettingsDocumentSnapshot(),
|
||||||
|
ComponentSettingsDocumentFormat.EmptyDocument,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ComponentSettingsDocumentFormat
|
||||||
|
{
|
||||||
|
EmptyDocument,
|
||||||
|
CanonicalDocument,
|
||||||
|
PascalCaseDocument,
|
||||||
|
LegacySnapshot
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal static class UiExceptionGuard
|
||||||
|
{
|
||||||
|
public static bool IsFatalException(Exception? exception)
|
||||||
|
{
|
||||||
|
return exception is OutOfMemoryException or AccessViolationException or StackOverflowException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void FireAndForgetGuarded(
|
||||||
|
Func<Task> action,
|
||||||
|
string actionName,
|
||||||
|
string? context = null,
|
||||||
|
Func<Exception, Task>? onHandledException = null)
|
||||||
|
{
|
||||||
|
_ = RunGuardedUiActionAsync(action, actionName, context, onHandledException);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RunGuardedUiActionAsync(
|
||||||
|
Func<Task> action,
|
||||||
|
string actionName,
|
||||||
|
string? context = null,
|
||||||
|
Func<Exception, Task>? onHandledException = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await action();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!IsFatalException(ex))
|
||||||
|
{
|
||||||
|
LogHandledException("GuardedUiAction", actionName, ex, context, isFatal: false);
|
||||||
|
if (onHandledException is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await onHandledException(ex);
|
||||||
|
}
|
||||||
|
catch (Exception handlerEx) when (!IsFatalException(handlerEx))
|
||||||
|
{
|
||||||
|
LogHandledException("GuardedUiActionHandler", actionName, handlerEx, context, isFatal: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildContext(params (string Key, object? Value)[] parts)
|
||||||
|
{
|
||||||
|
if (parts is null || parts.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
"; ",
|
||||||
|
Array.ConvertAll(parts, part => $"{part.Key}={part.Value ?? "<null>"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogHandledException(
|
||||||
|
string category,
|
||||||
|
string actionName,
|
||||||
|
Exception exception,
|
||||||
|
string? context,
|
||||||
|
bool isFatal)
|
||||||
|
{
|
||||||
|
var message =
|
||||||
|
$"Action={actionName}; ExceptionType={exception.GetType().FullName}; IsFatal={isFatal}; Context={context ?? string.Empty}";
|
||||||
|
if (isFatal)
|
||||||
|
{
|
||||||
|
AppLogger.Critical(category, message, exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Warn(category, message, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,21 +6,27 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using AvaloniaWebView;
|
using AvaloniaWebView;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using WebViewCore.Events;
|
using WebViewCore.Events;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
public partial class BrowserWidget : UserControl, IDesktopComponentWidget,
|
||||||
, IDesktopPageVisibilityAwareComponentWidget, IDisposable
|
IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||||
{
|
{
|
||||||
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
||||||
|
|
||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
|
private string _componentId = BuiltInComponentIds.DesktopBrowser;
|
||||||
|
private string _placementId = string.Empty;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private Uri _lastKnownUri = DefaultHomeUri;
|
private Uri _lastKnownUri = DefaultHomeUri;
|
||||||
private bool _isOnActiveDesktopPage;
|
private bool _isOnActiveDesktopPage;
|
||||||
|
private bool _isAttachedToVisualTree;
|
||||||
private bool _isEditMode;
|
private bool _isEditMode;
|
||||||
private bool _isWebViewActive = true;
|
private bool _isWebViewActive = true;
|
||||||
|
private bool _isWebViewFaulted;
|
||||||
private readonly WebView2RuntimeAvailability _runtimeAvailability;
|
private readonly WebView2RuntimeAvailability _runtimeAvailability;
|
||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
|
|
||||||
@@ -45,8 +51,8 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
ApplyRuntimeUnavailableState();
|
ApplyRuntimeUnavailableState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AddressTextBox.Text = DefaultHomeUri.ToString();
|
||||||
UpdateWebViewActiveState();
|
UpdateWebViewActiveState();
|
||||||
NavigateTo(DefaultHomeUri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -74,17 +80,15 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
_currentCellSize = Math.Max(1, cellSize);
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
|
||||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
||||||
RootBorder.Padding = new Thickness(
|
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||||
Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
|
||||||
|
|
||||||
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
||||||
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
||||||
AddressBarBorder.Padding = new Thickness(8, 6);
|
AddressBarBorder.Padding = new Thickness(8, 6);
|
||||||
|
|
||||||
var rowSpacing = 8d;
|
|
||||||
if (RootBorder.Child is Grid rootGrid)
|
if (RootBorder.Child is Grid rootGrid)
|
||||||
{
|
{
|
||||||
rootGrid.RowSpacing = rowSpacing;
|
rootGrid.RowSpacing = 8d;
|
||||||
}
|
}
|
||||||
|
|
||||||
var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36);
|
var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36);
|
||||||
@@ -111,16 +115,33 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
AddressTextBox.Height = buttonSize;
|
AddressTextBox.Height = buttonSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||||
|
{
|
||||||
|
_isOnActiveDesktopPage = isOnActivePage;
|
||||||
|
_isEditMode = isEditMode;
|
||||||
|
UpdateWebViewActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
|
? BuiltInComponentIds.DesktopBrowser
|
||||||
|
: componentId.Trim();
|
||||||
|
_placementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
|
_isAttachedToVisualTree = true;
|
||||||
ApplyTheme(force: true);
|
ApplyTheme(force: true);
|
||||||
UpdateWebViewActiveState();
|
UpdateWebViewActiveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
|
_isAttachedToVisualTree = false;
|
||||||
_isOnActiveDesktopPage = false;
|
_isOnActiveDesktopPage = false;
|
||||||
UpdateWebViewActiveState();
|
DeactivateWebView(clearUrl: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
@@ -202,28 +223,20 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_runtimeAvailability.IsAvailable)
|
if (!CanUseWebView())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isWebViewActive)
|
if (!TryReloadWebView("Refresh"))
|
||||||
{
|
{
|
||||||
return;
|
TryNavigate(DefaultHomeUri, "RefreshFallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BrowserWebView.Url is not null)
|
|
||||||
{
|
|
||||||
BrowserWebView.Reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigateTo(DefaultHomeUri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_runtimeAvailability.IsAvailable)
|
if (!CanUseWebView())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -233,7 +246,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_runtimeAvailability.IsAvailable)
|
if (!CanUseWebView())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -249,7 +262,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
private void NavigateFromAddressBar()
|
private void NavigateFromAddressBar()
|
||||||
{
|
{
|
||||||
if (!_runtimeAvailability.IsAvailable)
|
if (!CanUseWebView())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -269,7 +282,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
AddressTextBox.Text = uri.ToString();
|
AddressTextBox.Text = uri.ToString();
|
||||||
if (_isWebViewActive)
|
if (_isWebViewActive)
|
||||||
{
|
{
|
||||||
BrowserWebView.Url = uri;
|
TryNavigate(uri, "NavigateTo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,25 +297,16 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
AddressTextBox.Text = e.Url.ToString();
|
AddressTextBox.Text = e.Url.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
|
||||||
{
|
|
||||||
_isOnActiveDesktopPage = isOnActivePage;
|
|
||||||
_isEditMode = isEditMode;
|
|
||||||
UpdateWebViewActiveState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateWebViewActiveState()
|
private void UpdateWebViewActiveState()
|
||||||
{
|
{
|
||||||
if (!_runtimeAvailability.IsAvailable)
|
if (!_runtimeAvailability.IsAvailable || _isWebViewFaulted)
|
||||||
{
|
{
|
||||||
_isWebViewActive = false;
|
_isWebViewActive = false;
|
||||||
BrowserWebView.Url = null;
|
ApplyRuntimeUnavailableState();
|
||||||
BrowserWebView.IsVisible = false;
|
|
||||||
BrowserWebView.IsHitTestVisible = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
var shouldBeActive = _isAttachedToVisualTree && _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||||
if (_isWebViewActive == shouldBeActive)
|
if (_isWebViewActive == shouldBeActive)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -311,40 +315,118 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
|||||||
_isWebViewActive = shouldBeActive;
|
_isWebViewActive = shouldBeActive;
|
||||||
if (!_isWebViewActive)
|
if (!_isWebViewActive)
|
||||||
{
|
{
|
||||||
if (BrowserWebView.Url is Uri currentUri)
|
DeactivateWebView(clearUrl: false);
|
||||||
{
|
return;
|
||||||
_lastKnownUri = currentUri;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
BrowserWebView.IsHitTestVisible = false;
|
ActivateWebView();
|
||||||
BrowserWebView.IsVisible = false;
|
}
|
||||||
BrowserWebView.Url = null;
|
|
||||||
|
private void ActivateWebView()
|
||||||
|
{
|
||||||
|
if (_isWebViewFaulted || !_runtimeAvailability.IsAvailable)
|
||||||
|
{
|
||||||
|
ApplyRuntimeUnavailableState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
BrowserWebView.IsVisible = true;
|
BrowserWebView.IsVisible = true;
|
||||||
BrowserWebView.IsHitTestVisible = true;
|
BrowserWebView.IsHitTestVisible = true;
|
||||||
BrowserWebView.Url = _lastKnownUri;
|
RefreshButton.IsEnabled = true;
|
||||||
|
GoButton.IsEnabled = true;
|
||||||
|
AddressTextBox.IsEnabled = true;
|
||||||
|
UnavailableOverlay.IsVisible = false;
|
||||||
|
|
||||||
|
TryNavigate(_lastKnownUri, "Activate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeactivateWebView(bool clearUrl)
|
||||||
|
{
|
||||||
|
BrowserWebView.IsHitTestVisible = false;
|
||||||
|
BrowserWebView.IsVisible = false;
|
||||||
|
|
||||||
|
if (clearUrl)
|
||||||
|
{
|
||||||
|
TryClearWebViewUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReloadWebView(string action)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BrowserWebView.Reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||||
|
{
|
||||||
|
EnterFaultedState(action, ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryNavigate(Uri uri, string action)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BrowserWebView.Url = uri;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||||
|
{
|
||||||
|
EnterFaultedState(action, ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryClearWebViewUrl()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BrowserWebView.Url = null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanUseWebView()
|
||||||
|
{
|
||||||
|
return _runtimeAvailability.IsAvailable && !_isWebViewFaulted && _isWebViewActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyRuntimeUnavailableState()
|
private void ApplyRuntimeUnavailableState()
|
||||||
{
|
{
|
||||||
_isWebViewActive = false;
|
_isWebViewActive = false;
|
||||||
BrowserWebView.Url = null;
|
|
||||||
BrowserWebView.IsVisible = false;
|
BrowserWebView.IsVisible = false;
|
||||||
BrowserWebView.IsHitTestVisible = false;
|
BrowserWebView.IsHitTestVisible = false;
|
||||||
|
|
||||||
RefreshButton.IsEnabled = false;
|
RefreshButton.IsEnabled = false;
|
||||||
GoButton.IsEnabled = false;
|
GoButton.IsEnabled = false;
|
||||||
AddressTextBox.IsEnabled = false;
|
AddressTextBox.IsEnabled = false;
|
||||||
AddressTextBox.Text = string.Empty;
|
AddressTextBox.Text = _lastKnownUri.ToString();
|
||||||
|
|
||||||
UnavailableMessageTextBlock.Text = string.IsNullOrWhiteSpace(_runtimeAvailability.Message)
|
UnavailableMessageTextBlock.Text = _isWebViewFaulted
|
||||||
? "WebView runtime unavailable."
|
? "The browser component is temporarily unavailable. Restart the app to retry."
|
||||||
: _runtimeAvailability.Message;
|
: string.IsNullOrWhiteSpace(_runtimeAvailability.Message)
|
||||||
|
? "WebView runtime unavailable."
|
||||||
|
: _runtimeAvailability.Message;
|
||||||
UnavailableOverlay.IsVisible = true;
|
UnavailableOverlay.IsVisible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnterFaultedState(string action, Exception ex)
|
||||||
|
{
|
||||||
|
_isWebViewFaulted = true;
|
||||||
|
_isWebViewActive = false;
|
||||||
|
AppLogger.Warn(
|
||||||
|
"BrowserWidget",
|
||||||
|
$"Browser component faulted. Action={action}; ComponentId={_componentId}; PlacementId={_placementId}; RuntimeAvailability={_runtimeAvailability.IsAvailable}; RuntimeVersion={_runtimeAvailability.Version ?? string.Empty}; CurrentUrl={_lastKnownUri}",
|
||||||
|
ex);
|
||||||
|
TryClearWebViewUrl();
|
||||||
|
ApplyRuntimeUnavailableState();
|
||||||
|
}
|
||||||
|
|
||||||
private static Uri? TryNormalizeUri(string? rawText)
|
private static Uri? TryNormalizeUri(string? rawText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rawText))
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
internal sealed class DesktopComponentFailureView : UserControl, IDesktopComponentWidget
|
||||||
|
{
|
||||||
|
private readonly Border _rootBorder;
|
||||||
|
private readonly TextBlock _titleBlock;
|
||||||
|
private readonly TextBlock _summaryBlock;
|
||||||
|
private readonly TextBlock _statusBlock;
|
||||||
|
private readonly Button _toggleDetailsButton;
|
||||||
|
private readonly Button _copyReportButton;
|
||||||
|
private readonly Border _detailsBorder;
|
||||||
|
private readonly TextBox _reportTextBox;
|
||||||
|
private readonly string _componentId;
|
||||||
|
private readonly string? _placementId;
|
||||||
|
private readonly string _reportText;
|
||||||
|
private bool _detailsVisible;
|
||||||
|
|
||||||
|
public DesktopComponentFailureView(
|
||||||
|
string componentName,
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? pageIndex,
|
||||||
|
string action,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
_componentId = componentId;
|
||||||
|
_placementId = placementId;
|
||||||
|
_reportText = BuildReport(componentName, componentId, placementId, pageIndex, action, exception);
|
||||||
|
|
||||||
|
_titleBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(componentName) ? "组件暂时不可用" : componentName,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
_summaryBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "该组件已临时停用,并由信息占位保留原位置。你可以展开详情或复制错误报告。",
|
||||||
|
Foreground = CreateBrush("#FFD6DEE9"),
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
_statusBlock = new TextBlock
|
||||||
|
{
|
||||||
|
IsVisible = false,
|
||||||
|
Foreground = CreateBrush("#FF93C5FD"),
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
};
|
||||||
|
|
||||||
|
_toggleDetailsButton = CreateButton("查看错误信息", OnToggleDetailsClick);
|
||||||
|
_copyReportButton = CreateButton("复制错误报告", OnCopyReportClick);
|
||||||
|
|
||||||
|
_reportTextBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = _reportText,
|
||||||
|
IsReadOnly = true,
|
||||||
|
AcceptsReturn = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
MinHeight = 96,
|
||||||
|
MaxHeight = 220,
|
||||||
|
Background = CreateBrush("#CC0F172A"),
|
||||||
|
Foreground = CreateBrush("#FFE2E8F0"),
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Padding = new Thickness(8)
|
||||||
|
};
|
||||||
|
|
||||||
|
_detailsBorder = new Border
|
||||||
|
{
|
||||||
|
IsVisible = false,
|
||||||
|
Background = CreateBrush("#660F172A"),
|
||||||
|
BorderBrush = CreateBrush("#33475569"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Child = _reportTextBox
|
||||||
|
};
|
||||||
|
|
||||||
|
_rootBorder = new Border
|
||||||
|
{
|
||||||
|
Background = CreateBrush("#D91E293B"),
|
||||||
|
BorderBrush = CreateBrush("#336B7280"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(18),
|
||||||
|
Padding = new Thickness(14),
|
||||||
|
ClipToBounds = true,
|
||||||
|
Child = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 8,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_titleBlock,
|
||||||
|
_summaryBlock,
|
||||||
|
new WrapPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
ItemSpacing = 8,
|
||||||
|
LineSpacing = 8,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_toggleDetailsButton,
|
||||||
|
_copyReportButton
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_statusBlock,
|
||||||
|
_detailsBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Content = _rootBorder;
|
||||||
|
ApplyCellSize(48);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
var normalized = Math.Max(1, cellSize);
|
||||||
|
_rootBorder.CornerRadius = new CornerRadius(Math.Clamp(normalized * 0.24, 12, 24));
|
||||||
|
_rootBorder.Padding = new Thickness(Math.Clamp(normalized * 0.24, 10, 18));
|
||||||
|
_titleBlock.FontSize = Math.Clamp(normalized * 0.36, 14, 22);
|
||||||
|
_summaryBlock.FontSize = Math.Clamp(normalized * 0.24, 11, 15);
|
||||||
|
_statusBlock.FontSize = Math.Clamp(normalized * 0.22, 10, 13);
|
||||||
|
_toggleDetailsButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14);
|
||||||
|
_copyReportButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14);
|
||||||
|
_toggleDetailsButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6);
|
||||||
|
_copyReportButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6);
|
||||||
|
_reportTextBox.FontSize = Math.Clamp(normalized * 0.2, 10, 13);
|
||||||
|
_reportTextBox.MaxHeight = Math.Clamp(normalized * 5.2, 120, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button CreateButton(string text, EventHandler<RoutedEventArgs> clickHandler)
|
||||||
|
{
|
||||||
|
var button = new Button
|
||||||
|
{
|
||||||
|
Content = text,
|
||||||
|
Background = CreateBrush("#80334155"),
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
BorderBrush = CreateBrush("#335B6575"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left
|
||||||
|
};
|
||||||
|
button.Click += clickHandler;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleDetailsClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_detailsVisible = !_detailsVisible;
|
||||||
|
_detailsBorder.IsVisible = _detailsVisible;
|
||||||
|
_toggleDetailsButton.Content = _detailsVisible ? "隐藏错误信息" : "查看错误信息";
|
||||||
|
UpdateStatus(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCopyReportClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
CopyReportAsync,
|
||||||
|
"DesktopComponentFailureView.CopyReport",
|
||||||
|
UiExceptionGuard.BuildContext(
|
||||||
|
("ComponentId", _componentId),
|
||||||
|
("PlacementId", _placementId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyReportAsync()
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
var clipboard = topLevel?.Clipboard;
|
||||||
|
if (clipboard is null)
|
||||||
|
{
|
||||||
|
UpdateStatus("当前环境不支持复制错误报告。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clipboard.SetTextAsync(_reportText);
|
||||||
|
UpdateStatus("错误报告已复制到剪贴板。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatus(string? message)
|
||||||
|
{
|
||||||
|
_statusBlock.Text = message ?? string.Empty;
|
||||||
|
_statusBlock.IsVisible = !string.IsNullOrWhiteSpace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildReport(
|
||||||
|
string componentName,
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? pageIndex,
|
||||||
|
string action,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("LanMountainDesktop Component Failure Report");
|
||||||
|
builder.AppendLine($"GeneratedAt: {DateTimeOffset.Now:O}");
|
||||||
|
builder.AppendLine($"AppVersion: {version}");
|
||||||
|
builder.AppendLine($"Action: {action}");
|
||||||
|
builder.AppendLine($"ComponentName: {componentName}");
|
||||||
|
builder.AppendLine($"ComponentId: {componentId}");
|
||||||
|
builder.AppendLine($"PlacementId: {placementId ?? string.Empty}");
|
||||||
|
builder.AppendLine($"PageIndex: {pageIndex?.ToString() ?? string.Empty}");
|
||||||
|
builder.AppendLine($"ExceptionType: {exception.GetType().FullName}");
|
||||||
|
builder.AppendLine($"ExceptionMessage: {exception.Message}");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(exception.ToString());
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IBrush CreateBrush(string colorHex)
|
||||||
|
{
|
||||||
|
return new SolidColorBrush(Color.Parse(colorHex));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1522,7 +1522,7 @@ public partial class MainWindow
|
|||||||
placement.PlacementId = Guid.NewGuid().ToString("N");
|
placement.PlacementId = Guid.NewGuid().ToString("N");
|
||||||
}
|
}
|
||||||
|
|
||||||
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId);
|
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId, placement.PageIndex);
|
||||||
if (component is null)
|
if (component is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -1956,23 +1956,54 @@ public partial class MainWindow
|
|||||||
return onLeft || onRight || onTop || onBottom;
|
return onLeft || onRight || onTop || onBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Control? CreateDesktopComponentControl(string componentId, string? placementId = null)
|
private Control? CreateDesktopComponentControl(string componentId, string? placementId = null, int? pageIndex = null)
|
||||||
{
|
{
|
||||||
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
|
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var component = runtimeDescriptor.CreateControl(
|
return CreateDesktopComponentControl(runtimeDescriptor, _currentDesktopCellSize, placementId, pageIndex, "DesktopSurface");
|
||||||
_currentDesktopCellSize,
|
}
|
||||||
_timeZoneService,
|
|
||||||
_weatherDataService,
|
private Control? CreateDesktopComponentControl(
|
||||||
_recommendationInfoService,
|
DesktopComponentRuntimeDescriptor runtimeDescriptor,
|
||||||
_calculatorDataService,
|
double cellSize,
|
||||||
_componentSettingsService,
|
string? placementId,
|
||||||
placementId);
|
int? pageIndex,
|
||||||
component.Classes.Add(DesktopComponentClass);
|
string action)
|
||||||
return component;
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var component = runtimeDescriptor.CreateControl(
|
||||||
|
cellSize,
|
||||||
|
_timeZoneService,
|
||||||
|
_weatherDataService,
|
||||||
|
_recommendationInfoService,
|
||||||
|
_calculatorDataService,
|
||||||
|
_componentSettingsService,
|
||||||
|
placementId);
|
||||||
|
component.Classes.Add(DesktopComponentClass);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"ComponentRuntime",
|
||||||
|
$"Action={action}; ComponentId={runtimeDescriptor.Definition.Id}; PlacementId={placementId ?? string.Empty}; PageIndex={pageIndex?.ToString() ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||||
|
ex);
|
||||||
|
|
||||||
|
var failureView = new DesktopComponentFailureView(
|
||||||
|
runtimeDescriptor.Definition.DisplayName,
|
||||||
|
runtimeDescriptor.Definition.Id,
|
||||||
|
placementId,
|
||||||
|
pageIndex,
|
||||||
|
action,
|
||||||
|
ex);
|
||||||
|
failureView.ApplyCellSize(cellSize);
|
||||||
|
failureView.Classes.Add(DesktopComponentClass);
|
||||||
|
return failureView;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CollapseComponentLibraryPanel()
|
private void CollapseComponentLibraryPanel()
|
||||||
@@ -3113,13 +3144,16 @@ public partial class MainWindow
|
|||||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||||
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
||||||
|
|
||||||
var previewControl = descriptor.CreateControl(
|
var previewControl = CreateDesktopComponentControl(
|
||||||
|
descriptor,
|
||||||
renderCellSize,
|
renderCellSize,
|
||||||
_timeZoneService,
|
placementId: null,
|
||||||
_weatherDataService,
|
pageIndex: null,
|
||||||
_recommendationInfoService,
|
action: "ComponentLibraryPreview");
|
||||||
_calculatorDataService,
|
if (previewControl is null)
|
||||||
_componentSettingsService);
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||||
previewControl.IsHitTestVisible = false;
|
previewControl.IsHitTestVisible = false;
|
||||||
previewControl.Focusable = false;
|
previewControl.Focusable = false;
|
||||||
|
|||||||
@@ -1,59 +1,58 @@
|
|||||||
using System;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
public partial class MainWindow
|
public partial class MainWindow
|
||||||
{
|
{
|
||||||
private readonly DispatcherTimer _singleInstanceNoticeTimer = new()
|
private bool _isSingleInstancePromptVisible;
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromSeconds(6)
|
|
||||||
};
|
|
||||||
|
|
||||||
internal void ShowSingleInstanceNotice()
|
internal void ShowSingleInstanceNotice()
|
||||||
{
|
{
|
||||||
|
void ShowPrompt()
|
||||||
|
{
|
||||||
|
UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
ShowSingleInstanceNoticeCoreAsync,
|
||||||
|
"MainWindow.ShowSingleInstanceNotice");
|
||||||
|
}
|
||||||
|
|
||||||
if (Dispatcher.UIThread.CheckAccess())
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
{
|
{
|
||||||
ShowSingleInstanceNoticeCore();
|
ShowPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(ShowSingleInstanceNoticeCore, DispatcherPriority.Send);
|
Dispatcher.UIThread.Post(ShowPrompt, DispatcherPriority.Send);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowSingleInstanceNoticeCore()
|
private async Task ShowSingleInstanceNoticeCoreAsync()
|
||||||
{
|
{
|
||||||
SingleInstanceNoticeTitleTextBlock.Text = L(
|
if (_isSingleInstancePromptVisible)
|
||||||
"single_instance.notice.title",
|
{
|
||||||
"App already open");
|
return;
|
||||||
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();
|
_isSingleInstancePromptVisible = true;
|
||||||
_singleInstanceNoticeTimer.Tick -= OnSingleInstanceNoticeTimerTick;
|
|
||||||
_singleInstanceNoticeTimer.Tick += OnSingleInstanceNoticeTimerTick;
|
|
||||||
_singleInstanceNoticeTimer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSingleInstanceNoticeButtonClick(object? sender, RoutedEventArgs e)
|
try
|
||||||
{
|
{
|
||||||
HideSingleInstanceNotice();
|
var dialog = new ContentDialog
|
||||||
}
|
{
|
||||||
|
Title = L("single_instance.notice.title", "应用已经运行"),
|
||||||
|
Content = L(
|
||||||
|
"single_instance.notice.description",
|
||||||
|
"应用已经运行,无需多次点击打开。"),
|
||||||
|
PrimaryButtonText = L("single_instance.notice.button", "确定"),
|
||||||
|
DefaultButton = ContentDialogButton.Primary
|
||||||
|
};
|
||||||
|
|
||||||
private void OnSingleInstanceNoticeTimerTick(object? sender, EventArgs e)
|
await dialog.ShowAsync(this);
|
||||||
{
|
}
|
||||||
HideSingleInstanceNotice();
|
finally
|
||||||
}
|
{
|
||||||
|
_isSingleInstancePromptVisible = false;
|
||||||
private void HideSingleInstanceNotice()
|
}
|
||||||
{
|
|
||||||
_singleInstanceNoticeTimer.Stop();
|
|
||||||
SingleInstanceNoticeDock.IsVisible = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,51 +471,6 @@
|
|||||||
|
|
||||||
<StackPanel Grid.Row="1"
|
<StackPanel Grid.Row="1"
|
||||||
Spacing="12">
|
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 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"
|
<Border x:Name="PendingRestartDock"
|
||||||
IsVisible="False"
|
IsVisible="False"
|
||||||
Classes="glass-panel"
|
Classes="glass-panel"
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
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.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -37,6 +39,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
private readonly AirAppMarketReadmeService _readmeService;
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
private readonly AirAppMarketIconService _iconService;
|
private readonly AirAppMarketIconService _iconService;
|
||||||
private readonly Version? _hostVersion;
|
private readonly Version? _hostVersion;
|
||||||
|
private readonly CancellationTokenSource _lifetimeCts = new();
|
||||||
|
|
||||||
private readonly TextBox _searchTextBox;
|
private readonly TextBox _searchTextBox;
|
||||||
private readonly Button _refreshButton;
|
private readonly Button _refreshButton;
|
||||||
@@ -57,6 +60,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
private bool _isRefreshing;
|
private bool _isRefreshing;
|
||||||
private bool _isInstalling;
|
private bool _isInstalling;
|
||||||
private bool _hasLoadedOnce;
|
private bool _hasLoadedOnce;
|
||||||
|
private bool _isDisposed;
|
||||||
|
private bool _isAttachedToVisualTree;
|
||||||
|
|
||||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||||
{
|
{
|
||||||
@@ -106,16 +111,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
_detailBorder = CreatePanelShell(18);
|
_detailBorder = CreatePanelShell(18);
|
||||||
|
|
||||||
Content = BuildLayout();
|
Content = BuildLayout();
|
||||||
AttachedToVisualTree += async (_, _) =>
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
{
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
if (_hasLoadedOnce)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_hasLoadedOnce = true;
|
|
||||||
await RefreshAsync();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshInstalledSnapshot()
|
public void RefreshInstalledSnapshot()
|
||||||
@@ -134,18 +131,47 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
_lifetimeCts.Cancel();
|
||||||
|
|
||||||
foreach (var bitmap in _iconBitmaps.Values)
|
foreach (var bitmap in _iconBitmaps.Values)
|
||||||
{
|
{
|
||||||
bitmap?.Dispose();
|
bitmap?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
_iconBitmaps.Clear();
|
_iconBitmaps.Clear();
|
||||||
|
_lifetimeCts.Dispose();
|
||||||
_iconService.Dispose();
|
_iconService.Dispose();
|
||||||
_readmeService.Dispose();
|
_readmeService.Dispose();
|
||||||
_installService.Dispose();
|
_installService.Dispose();
|
||||||
_indexService.Dispose();
|
_indexService.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttachedToVisualTree = true;
|
||||||
|
if (_hasLoadedOnce)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasLoadedOnce = true;
|
||||||
|
UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
RefreshAsync,
|
||||||
|
"PluginMarket.InitialLoad",
|
||||||
|
BuildMarketContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttachedToVisualTree = false;
|
||||||
|
}
|
||||||
|
|
||||||
private Control BuildLayout()
|
private Control BuildLayout()
|
||||||
{
|
{
|
||||||
var root = new Grid
|
var root = new Grid
|
||||||
@@ -197,14 +223,23 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
|
private void OnRefreshClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
await RefreshAsync();
|
UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
RefreshAsync,
|
||||||
|
"PluginMarket.Refresh",
|
||||||
|
BuildMarketContext(),
|
||||||
|
ex => HandleTopLevelUiActionExceptionAsync(
|
||||||
|
ex,
|
||||||
|
F(
|
||||||
|
"market.status.load_failed_format",
|
||||||
|
"Failed to load the plugin market: {0}",
|
||||||
|
DescribeException(ex))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshAsync()
|
private async Task RefreshAsync()
|
||||||
{
|
{
|
||||||
if (_isRefreshing)
|
if (_isRefreshing || _isDisposed || _lifetimeCts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,11 +252,19 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
{
|
{
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|
||||||
var result = await _indexService.LoadAsync();
|
var result = await _indexService.LoadAsync(_lifetimeCts.Token);
|
||||||
|
if (!CanUpdateUi())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.Success || result.Document is null)
|
if (!result.Success || result.Document is null)
|
||||||
{
|
{
|
||||||
_document = null;
|
_document = null;
|
||||||
_selectedPlugin = null;
|
_selectedPlugin = null;
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Refresh failed. Source=None; Warning={result.WarningMessage ?? string.Empty}; Error={result.ErrorMessage ?? string.Empty}; Context={BuildMarketContext()}");
|
||||||
SetStatus(
|
SetStatus(
|
||||||
F(
|
F(
|
||||||
"market.status.load_failed_format",
|
"market.status.load_failed_format",
|
||||||
@@ -235,6 +278,9 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
_document = result.Document;
|
_document = result.Document;
|
||||||
_marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl;
|
_marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl;
|
||||||
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
|
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Refresh completed. Source={result.Source}; PluginCount={result.Document.Plugins.Count}; SourceLocation={result.SourceLocation ?? string.Empty}; Warning={result.WarningMessage ?? string.Empty}; Context={BuildMarketContext()}");
|
||||||
|
|
||||||
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
|
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
|
||||||
? F(
|
? F(
|
||||||
@@ -251,15 +297,43 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
RebuildSurface();
|
RebuildSurface();
|
||||||
await EnsureReadmeLoadedAsync(_selectedPlugin);
|
await EnsureReadmeLoadedAsync(_selectedPlugin);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Info("PluginMarket", $"Refresh canceled because the view is being disposed. Context={BuildMarketContext()}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Refresh threw unexpectedly. ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext()}",
|
||||||
|
ex);
|
||||||
|
if (CanUpdateUi())
|
||||||
|
{
|
||||||
|
SetStatus(
|
||||||
|
F(
|
||||||
|
"market.status.load_failed_format",
|
||||||
|
"Failed to load the plugin market: {0}",
|
||||||
|
DescribeException(ex)),
|
||||||
|
ErrorBrush);
|
||||||
|
_document = null;
|
||||||
|
_selectedPlugin = null;
|
||||||
|
RebuildSurface();
|
||||||
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_isRefreshing = false;
|
_isRefreshing = false;
|
||||||
_refreshButton.IsEnabled = true;
|
_refreshButton.IsEnabled = !_isDisposed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RebuildSurface()
|
private void RebuildSurface()
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var filteredPlugins = GetFilteredPlugins();
|
var filteredPlugins = GetFilteredPlugins();
|
||||||
_selectedPlugin = filteredPlugins.Count > 0
|
_selectedPlugin = filteredPlugins.Count > 0
|
||||||
? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins)
|
? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins)
|
||||||
@@ -396,7 +470,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
Content = selectGrid
|
Content = selectGrid
|
||||||
};
|
};
|
||||||
selectButton.Click += async (_, _) => await SelectPluginAsync(plugin);
|
selectButton.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
() => SelectPluginAsync(plugin),
|
||||||
|
"PluginMarket.SelectPlugin",
|
||||||
|
BuildMarketContext(plugin));
|
||||||
|
|
||||||
var rightPanel = new StackPanel
|
var rightPanel = new StackPanel
|
||||||
{
|
{
|
||||||
@@ -623,13 +700,22 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
HorizontalAlignment = HorizontalAlignment.Right
|
HorizontalAlignment = HorizontalAlignment.Right
|
||||||
};
|
};
|
||||||
|
|
||||||
button.Click += async (_, _) =>
|
button.Click += (_, _) => UiExceptionGuard.FireAndForgetGuarded(
|
||||||
{
|
async () =>
|
||||||
_selectedPlugin = plugin;
|
{
|
||||||
RebuildSurface();
|
_selectedPlugin = plugin;
|
||||||
await EnsureReadmeLoadedAsync(plugin);
|
RebuildSurface();
|
||||||
await InstallSelectedPluginAsync(plugin);
|
await EnsureReadmeLoadedAsync(plugin);
|
||||||
};
|
await InstallSelectedPluginAsync(plugin);
|
||||||
|
},
|
||||||
|
"PluginMarket.InstallPlugin",
|
||||||
|
BuildMarketContext(plugin),
|
||||||
|
ex => HandleTopLevelUiActionExceptionAsync(
|
||||||
|
ex,
|
||||||
|
F(
|
||||||
|
"market.status.install_failed_format",
|
||||||
|
"Failed to install plugin: {0}",
|
||||||
|
DescribeException(ex))));
|
||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
@@ -643,7 +729,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
|
|
||||||
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
|
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
|
||||||
{
|
{
|
||||||
if (_isInstalling)
|
if (_isInstalling || _isDisposed || _lifetimeCts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -658,7 +744,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _installService.InstallAsync(plugin);
|
var result = await _installService.InstallAsync(plugin, _lifetimeCts.Token);
|
||||||
|
if (!CanUpdateUi())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.Success || result.Manifest is null)
|
if (!result.Success || result.Manifest is null)
|
||||||
{
|
{
|
||||||
SetStatus(
|
SetStatus(
|
||||||
@@ -679,6 +770,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
SuccessBrush);
|
SuccessBrush);
|
||||||
RebuildSurface();
|
RebuildSurface();
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Install canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}");
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_isInstalling = false;
|
_isInstalling = false;
|
||||||
@@ -690,6 +787,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin)
|
private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin)
|
||||||
{
|
{
|
||||||
if (plugin is null ||
|
if (plugin is null ||
|
||||||
|
_isDisposed ||
|
||||||
_readmeContents.ContainsKey(plugin.Id) ||
|
_readmeContents.ContainsKey(plugin.Id) ||
|
||||||
string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -702,19 +800,30 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var readme = await _readmeService.LoadAsync(plugin);
|
var readme = await _readmeService.LoadAsync(plugin, _lifetimeCts.Token);
|
||||||
_readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme)
|
_readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme)
|
||||||
? T("market.detail.readme_empty", "README is empty.")
|
? T("market.detail.readme_empty", "README is empty.")
|
||||||
: readme.Trim();
|
: readme.Trim();
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"README load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"README load failed. PluginId={plugin.Id}; ExceptionType={ex.GetType().FullName}; Classification={ClassifyException(ex)}; Context={BuildMarketContext(plugin)}",
|
||||||
|
ex);
|
||||||
_readmeErrors[plugin.Id] = ex.Message;
|
_readmeErrors[plugin.Id] = ex.Message;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_loadingReadmePluginId = null;
|
_loadingReadmePluginId = null;
|
||||||
if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
if (CanUpdateUi() &&
|
||||||
|
string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
BuildDetailPanel();
|
BuildDetailPanel();
|
||||||
}
|
}
|
||||||
@@ -724,6 +833,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
private async Task EnsureIconLoadedAsync(AirAppMarketPluginEntry? plugin)
|
private async Task EnsureIconLoadedAsync(AirAppMarketPluginEntry? plugin)
|
||||||
{
|
{
|
||||||
if (plugin is null ||
|
if (plugin is null ||
|
||||||
|
_isDisposed ||
|
||||||
_iconBitmaps.ContainsKey(plugin.Id) ||
|
_iconBitmaps.ContainsKey(plugin.Id) ||
|
||||||
!_loadingIconPluginIds.Add(plugin.Id))
|
!_loadingIconPluginIds.Add(plugin.Id))
|
||||||
{
|
{
|
||||||
@@ -732,7 +842,13 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin);
|
_iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin, _lifetimeCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Icon load canceled because the view is being disposed. PluginId={plugin.Id}; Context={BuildMarketContext(plugin)}");
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -741,8 +857,61 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_loadingIconPluginIds.Remove(plugin.Id);
|
_loadingIconPluginIds.Remove(plugin.Id);
|
||||||
|
if (CanUpdateUi())
|
||||||
|
{
|
||||||
|
RebuildSurface();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleTopLevelUiActionExceptionAsync(Exception ex, string fallbackStatus)
|
||||||
|
{
|
||||||
|
if (CanUpdateUi())
|
||||||
|
{
|
||||||
|
SetStatus(fallbackStatus, ErrorBrush);
|
||||||
RebuildSurface();
|
RebuildSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanUpdateUi()
|
||||||
|
{
|
||||||
|
return !_isDisposed && _isAttachedToVisualTree && !_lifetimeCts.IsCancellationRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildMarketContext(AirAppMarketPluginEntry? plugin = null)
|
||||||
|
{
|
||||||
|
return UiExceptionGuard.BuildContext(
|
||||||
|
("SelectedPluginId", _selectedPlugin?.Id),
|
||||||
|
("PluginId", plugin?.Id),
|
||||||
|
("Source", _marketSourceDisplay),
|
||||||
|
("IsRefreshing", _isRefreshing),
|
||||||
|
("IsInstalling", _isInstalling),
|
||||||
|
("IsDisposed", _isDisposed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ClassifyException(Exception ex)
|
||||||
|
{
|
||||||
|
return ex switch
|
||||||
|
{
|
||||||
|
OperationCanceledException => "Canceled",
|
||||||
|
TimeoutException => "Timeout",
|
||||||
|
HttpRequestException => "Network",
|
||||||
|
IOException => "IO",
|
||||||
|
_ => "Unexpected"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeException(Exception ex)
|
||||||
|
{
|
||||||
|
return ex switch
|
||||||
|
{
|
||||||
|
OperationCanceledException => "The request timed out or was canceled.",
|
||||||
|
TimeoutException => "The request timed out.",
|
||||||
|
HttpRequestException => ex.Message,
|
||||||
|
_ => ex.Message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetReadmeContent(AirAppMarketPluginEntry plugin)
|
private string GetReadmeContent(AirAppMarketPluginEntry plugin)
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
null,
|
null,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
networkError = ex;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
networkError = ex;
|
networkError = ex;
|
||||||
@@ -71,10 +75,14 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
|||||||
null,
|
null,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
networkError = ex;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
networkError = ex;
|
networkError = ex;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var actualSize = new FileInfo(downloadPath).Length;
|
||||||
string actualHash;
|
string actualHash;
|
||||||
await using (var hashStream = File.OpenRead(downloadPath))
|
await using (var hashStream = File.OpenRead(downloadPath))
|
||||||
{
|
{
|
||||||
@@ -87,11 +88,14 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,25 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
UiExceptionGuard.FireAndForgetGuarded(
|
||||||
|
OnInstallPluginPackageAsync,
|
||||||
|
"PluginSettings.InstallPackage",
|
||||||
|
context: "Page=PluginSettings",
|
||||||
|
onHandledException: ex =>
|
||||||
|
{
|
||||||
|
SetPackageImportStatus(
|
||||||
|
F(
|
||||||
|
"settings.plugins.install_failed_format",
|
||||||
|
"Failed to install plugin package: {0}",
|
||||||
|
ex.Message),
|
||||||
|
isError: true);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnInstallPluginPackageAsync()
|
||||||
{
|
{
|
||||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
if (runtime is null)
|
if (runtime is 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
|
- 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
- Windows 是当前主要目标平台。
|
- 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。
|
- 安装 .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
|
||||||
|
|||||||
Reference in New Issue
Block a user