mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.5.6
插件系统再进化
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -490,4 +490,5 @@ nul
|
|||||||
/_build_verify_sample_plugin_capabilities
|
/_build_verify_sample_plugin_capabilities
|
||||||
/_build_verify_plugin_page_host
|
/_build_verify_plugin_page_host
|
||||||
/_build_verify_plugin_services
|
/_build_verify_plugin_services
|
||||||
|
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||||
/_build_obj
|
/_build_obj
|
||||||
|
|||||||
25
LanAirApp/README.md
Normal file
25
LanAirApp/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# LanAirApp
|
||||||
|
|
||||||
|
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
|
||||||
|
|
||||||
|
这里集中放置:
|
||||||
|
- 插件开发标准
|
||||||
|
- 插件打包与构建工具
|
||||||
|
- 插件开发与打包文档
|
||||||
|
- 示例插件
|
||||||
|
|
||||||
|
目录结构:
|
||||||
|
- `docs/`:插件开发文档、打包文档
|
||||||
|
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
|
||||||
|
- `standards/`:插件标准文件与模板
|
||||||
|
- `tools/`:插件打包与构建工具
|
||||||
|
|
||||||
|
面向用户的安装流程:
|
||||||
|
1. 将插件构建或打包为 `.laapp` 文件。
|
||||||
|
2. 打开 `设置 -> 插件`。
|
||||||
|
3. 点击 `打开 .laapp 插件包`。
|
||||||
|
4. 选择插件包完成安装。
|
||||||
|
|
||||||
|
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`。
|
||||||
|
|
||||||
|
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin`、`IPluginContext`、清单模型与扩展注册接口。
|
||||||
41
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
41
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 插件开发文档
|
||||||
|
|
||||||
|
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
|
||||||
|
|
||||||
|
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`。
|
||||||
|
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
|
||||||
|
|
||||||
|
## 必需文件
|
||||||
|
- `plugin.json`
|
||||||
|
- `plugin.json` 中声明的入口程序集
|
||||||
|
- 使用插件入口特性标记的入口类型
|
||||||
|
|
||||||
|
## 推荐开发流程
|
||||||
|
1. 以 `LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
|
||||||
|
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
|
||||||
|
3. 实现 `IPlugin` 或继承 `PluginBase`。
|
||||||
|
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
|
||||||
|
5. 将输出内容打包为 `.laapp` 文件。
|
||||||
|
|
||||||
|
## 运行时能力
|
||||||
|
- 插件可以注册自己的设置页。
|
||||||
|
- 插件可以注册自己的桌面组件。
|
||||||
|
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
|
||||||
|
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
|
||||||
|
|
||||||
|
## 多语言建议
|
||||||
|
- 插件应当内置 `Localization/zh-CN.json` 与 `Localization/en-US.json`。
|
||||||
|
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
|
||||||
|
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
|
||||||
|
|
||||||
|
## 目录建议
|
||||||
|
一个标准插件项目建议至少包含:
|
||||||
|
- `plugin.json`
|
||||||
|
- `Localization/zh-CN.json`
|
||||||
|
- `Localization/en-US.json`
|
||||||
|
- 插件程序集与依赖文件
|
||||||
|
|
||||||
|
## 示例项目与工具
|
||||||
|
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
|
||||||
|
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
|
||||||
|
- 标准模板:`LanAirApp/standards/plugin.template.json`
|
||||||
34
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
34
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 插件打包文档
|
||||||
|
|
||||||
|
LanMountainDesktop 插件的安装包格式固定为 `.laapp`。
|
||||||
|
|
||||||
|
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
|
||||||
|
|
||||||
|
## `.laapp` 格式说明
|
||||||
|
- 本质上是一个标准 zip 压缩包
|
||||||
|
- 包根目录必须包含 `plugin.json`
|
||||||
|
- 包根目录还必须包含入口程序集及其依赖
|
||||||
|
|
||||||
|
## 建议打包内容
|
||||||
|
- `plugin.json`
|
||||||
|
- `YourPlugin.dll`
|
||||||
|
- 依赖程序集
|
||||||
|
- `Localization/zh-CN.json`
|
||||||
|
- `Localization/en-US.json`
|
||||||
|
- 插件运行所需的其他资源文件
|
||||||
|
|
||||||
|
## 使用打包工具
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用内安装流程
|
||||||
|
1. 打开 `设置 -> 插件`
|
||||||
|
2. 点击 `打开 .laapp 插件包`
|
||||||
|
3. 选择要安装的插件包
|
||||||
|
4. 如果插件注册了设置页或组件,安装后重启应用
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
|
||||||
|
- 包内应尽量避免无关开发产物。
|
||||||
|
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
<PluginPackageOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
||||||
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
16
LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md
Normal file
16
LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# LanMountainDesktop.SamplePlugin
|
||||||
|
|
||||||
|
这是阑山桌面的**示例开发插件**。
|
||||||
|
|
||||||
|
它用于演示以下能力:
|
||||||
|
- 插件入口与 `plugin.json` 清单
|
||||||
|
- 插件服务注册
|
||||||
|
- 插件设置页注册
|
||||||
|
- 插件桌面组件注册
|
||||||
|
- 插件内通信与状态更新
|
||||||
|
- `.laapp` 打包与安装流程
|
||||||
|
- 插件多语言资源组织方式
|
||||||
|
|
||||||
|
如果你要开发自己的插件,建议以这个目录为模板开始。
|
||||||
|
|
||||||
|
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
|
||||||
11
LanAirApp/samples/README.md
Normal file
11
LanAirApp/samples/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 示例插件
|
||||||
|
|
||||||
|
本目录用于存放阑山桌面的示例开发插件。
|
||||||
|
|
||||||
|
当前示例:
|
||||||
|
- `LanMountainDesktop.SamplePlugin`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
|
||||||
|
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
|
||||||
|
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。
|
||||||
11
LanAirApp/standards/README.md
Normal file
11
LanAirApp/standards/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 插件标准文件
|
||||||
|
|
||||||
|
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
|
||||||
|
|
||||||
|
当前标准:
|
||||||
|
- 安装包扩展名:`.laapp`
|
||||||
|
- 插件清单文件名:`plugin.json`
|
||||||
|
- 多语言资源目录:`Localization/`
|
||||||
|
- 建议内置语言文件:`zh-CN.json`、`en-US.json`
|
||||||
|
|
||||||
|
创建新插件时,建议优先参考本目录中的模板文件。
|
||||||
9
LanAirApp/standards/plugin.template.json
Normal file
9
LanAirApp/standards/plugin.template.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "LanMountainDesktop.YourPlugin",
|
||||||
|
"name": "Your Plugin",
|
||||||
|
"description": "Describe what your plugin adds to LanMountainDesktop.",
|
||||||
|
"author": "Your Name",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "1.0.0",
|
||||||
|
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
return await RunAsync(args);
|
||||||
|
|
||||||
|
static async Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
PrintUsage();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? inputDirectory = null;
|
||||||
|
string? outputPath = null;
|
||||||
|
var overwrite = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
switch (args[i])
|
||||||
|
{
|
||||||
|
case "--input":
|
||||||
|
inputDirectory = ReadValue(args, ref i, "--input");
|
||||||
|
break;
|
||||||
|
case "--output":
|
||||||
|
outputPath = ReadValue(args, ref i, "--output");
|
||||||
|
break;
|
||||||
|
case "--overwrite":
|
||||||
|
overwrite = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inputDirectory))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Missing required argument '--input'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullInputDirectory = Path.GetFullPath(inputDirectory);
|
||||||
|
if (!Directory.Exists(fullInputDirectory))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
|
||||||
|
if (!File.Exists(manifestPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
|
||||||
|
manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = PluginManifest.Load(manifestPath);
|
||||||
|
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
|
||||||
|
if (!File.Exists(entranceAssemblyPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
|
||||||
|
entranceAssemblyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath ??= Path.Combine(
|
||||||
|
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
|
||||||
|
BuildPackageFileName(manifest.Id));
|
||||||
|
|
||||||
|
var fullOutputPath = Path.GetFullPath(outputPath);
|
||||||
|
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
|
||||||
|
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(destinationDirectory))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(destinationDirectory);
|
||||||
|
if (File.Exists(fullOutputPath))
|
||||||
|
{
|
||||||
|
if (!overwrite)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(fullOutputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Run(() => ZipFile.CreateFromDirectory(
|
||||||
|
fullInputDirectory,
|
||||||
|
fullOutputPath,
|
||||||
|
CompressionLevel.Optimal,
|
||||||
|
includeBaseDirectory: false));
|
||||||
|
|
||||||
|
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
|
||||||
|
{
|
||||||
|
var nextIndex = index + 1;
|
||||||
|
if (nextIndex >= args.Count)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Missing value for '{optionName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
index = nextIndex;
|
||||||
|
return args[nextIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildPackageFileName(string pluginId)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return safeName + PluginSdkInfo.PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintUsage()
|
||||||
|
{
|
||||||
|
Console.WriteLine("LanMountainDesktop.PluginPackager");
|
||||||
|
Console.WriteLine("Usage:");
|
||||||
|
Console.WriteLine(" --input <plugin build directory> Required");
|
||||||
|
Console.WriteLine(" --output <path to .laapp> Optional");
|
||||||
|
Console.WriteLine(" --overwrite Optional");
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public static class PluginHostPropertyKeys
|
||||||
|
{
|
||||||
|
public const string HostApplicationName = "HostApplicationName";
|
||||||
|
public const string HostVersion = "HostVersion";
|
||||||
|
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
|
||||||
|
public const string HostLanguageCode = "HostLanguageCode";
|
||||||
|
}
|
||||||
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginLocalizer
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public PluginLocalizer(string pluginDirectory, string? languageCode)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||||
|
|
||||||
|
PluginDirectory = pluginDirectory;
|
||||||
|
LanguageCode = NormalizeLanguageCode(languageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PluginDirectory { get; }
|
||||||
|
|
||||||
|
public string LanguageCode { get; }
|
||||||
|
|
||||||
|
public static PluginLocalizer Create(IPluginContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginLocalizer Create(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetString(string key, string fallback)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
|
||||||
|
var primaryTable = LoadLanguageTable(LanguageCode);
|
||||||
|
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var fallbackTable = LoadLanguageTable("en-US");
|
||||||
|
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Format(string key, string fallback, params object[] args)
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeLanguageCode(string? languageCode)
|
||||||
|
{
|
||||||
|
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "en-US"
|
||||||
|
: "zh-CN";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
|
||||||
|
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
|
||||||
|
rawValue is string languageCode
|
||||||
|
? NormalizeLanguageCode(languageCode)
|
||||||
|
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> LoadLanguageTable(string languageCode)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(languageCode, out var table))
|
||||||
|
{
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
|
||||||
|
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||||
|
if (data is not null)
|
||||||
|
{
|
||||||
|
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep empty localization table for plugin resilience.
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache[languageCode] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ VisualStudioVersion = 17.0.31903.59
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -23,6 +25,10 @@ Global
|
|||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
|
|||||||
@@ -307,6 +307,15 @@
|
|||||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||||
"settings.plugins.toggle_state_enabled": "enabled",
|
"settings.plugins.toggle_state_enabled": "enabled",
|
||||||
"settings.plugins.toggle_state_disabled": "disabled",
|
"settings.plugins.toggle_state_disabled": "disabled",
|
||||||
|
"settings.plugins.install_button": "Open .laapp package",
|
||||||
|
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
||||||
|
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||||
|
"settings.plugins.install_picker_title": "Select plugin package",
|
||||||
|
"settings.plugins.install_file_type": ".laapp plugin package",
|
||||||
|
"settings.plugins.install_picker_unavailable": "Storage provider is unavailable.",
|
||||||
|
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
||||||
|
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||||
|
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
||||||
"settings.plugins.source_package": ".laapp package",
|
"settings.plugins.source_package": ".laapp package",
|
||||||
"settings.plugins.source_manifest": "Loose manifest",
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
|||||||
@@ -307,6 +307,15 @@
|
|||||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||||
"settings.plugins.toggle_state_enabled": "启用",
|
"settings.plugins.toggle_state_enabled": "启用",
|
||||||
"settings.plugins.toggle_state_disabled": "禁用",
|
"settings.plugins.toggle_state_disabled": "禁用",
|
||||||
|
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||||
|
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||||
|
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||||
|
"settings.plugins.install_picker_title": "选择插件安装包",
|
||||||
|
"settings.plugins.install_file_type": ".laapp 插件包",
|
||||||
|
"settings.plugins.install_picker_unavailable": "文件存储提供程序不可用。",
|
||||||
|
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||||
|
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||||
|
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
||||||
"settings.plugins.source_package": ".laapp 包",
|
"settings.plugins.source_package": ".laapp 包",
|
||||||
"settings.plugins.source_manifest": "散装清单",
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace LanMountainDesktop.Services;
|
|||||||
public static class PendingRestartStateService
|
public static class PendingRestartStateService
|
||||||
{
|
{
|
||||||
public const string RenderModeReason = "RenderMode";
|
public const string RenderModeReason = "RenderMode";
|
||||||
|
public const string PluginCatalogReason = "PluginCatalog";
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -278,26 +278,7 @@ public partial class MainWindow
|
|||||||
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||||
|
|
||||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
ApplyPluginSettingsLocalization();
|
||||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
|
||||||
PluginSystemSettingsExpander.Description = L(
|
|
||||||
"settings.plugins.runtime_desc",
|
|
||||||
"Review plugin runtime state and load results.");
|
|
||||||
PluginSystemDescriptionTextBlock.Text = L(
|
|
||||||
"settings.plugins.runtime_hint",
|
|
||||||
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
|
||||||
PluginSystemStatusTextBlock.Text = L(
|
|
||||||
"settings.plugins.runtime_status",
|
|
||||||
"Plugin runtime status will appear here after plugin discovery completes.");
|
|
||||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
|
||||||
InstalledPluginsSettingsExpander.Description = L(
|
|
||||||
"settings.plugins.installed_desc",
|
|
||||||
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
|
||||||
PluginRestartHintTextBlock.Text = L(
|
|
||||||
"settings.plugins.restart_hint",
|
|
||||||
"Plugin enable state changes take effect after restarting the app.");
|
|
||||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
|
||||||
PluginSettingsPanel.RefreshFromRuntime();
|
|
||||||
|
|
||||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
|
|||||||
@@ -2741,14 +2741,6 @@ public partial class MainWindow
|
|||||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||||
|
|
||||||
// --- PluginSettingsPage (Added for completeness) ---
|
|
||||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
|
||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
|
||||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
|
||||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -206,13 +206,5 @@ public partial class SettingsWindow
|
|||||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||||
|
|
||||||
// --- PluginSettingsPage (Added for completeness) ---
|
|
||||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
|
||||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
|
||||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
|
||||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
|
||||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
|
||||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,16 +110,7 @@ public partial class SettingsWindow
|
|||||||
LauncherHiddenItemsDescriptionTextBlock.Text = L("settings.launcher.hidden_hint", "Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
LauncherHiddenItemsDescriptionTextBlock.Text = L("settings.launcher.hidden_hint", "Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||||
|
|
||||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
ApplyPluginSettingsLocalization();
|
||||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
|
||||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
|
||||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
|
||||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
|
||||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
|
||||||
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
|
||||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
|
||||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
|
||||||
PluginSettingsPanel.RefreshFromRuntime();
|
|
||||||
|
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||||
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
|
}
|
||||||
@@ -139,6 +139,30 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void RefreshPluginSettingsNavigation()
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.MenuItems is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||||
|
{
|
||||||
|
var navItem = SettingsNavView.MenuItems
|
||||||
|
.OfType<NavigationViewItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (navItem is not null)
|
||||||
|
{
|
||||||
|
SettingsNavView.MenuItems.Remove(navItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pluginSettingsPageHosts.Clear();
|
||||||
|
InitializePluginSettingsNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
private string? GetSelectedSettingsTabTag()
|
private string? GetSelectedSettingsTabTag()
|
||||||
{
|
{
|
||||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void ApplyPluginSettingsLocalization()
|
||||||
|
{
|
||||||
|
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||||
|
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||||
|
PluginSystemSettingsExpander.Description = L(
|
||||||
|
"settings.plugins.runtime_desc",
|
||||||
|
"Review plugin runtime state and load results.");
|
||||||
|
PluginSystemDescriptionTextBlock.Text = L(
|
||||||
|
"settings.plugins.runtime_hint",
|
||||||
|
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||||
|
PluginSystemStatusTextBlock.Text = L(
|
||||||
|
"settings.plugins.runtime_status",
|
||||||
|
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||||
|
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
|
InstalledPluginsSettingsExpander.Description = L(
|
||||||
|
"settings.plugins.installed_desc",
|
||||||
|
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||||
|
PluginRestartHintTextBlock.Text = L(
|
||||||
|
"settings.plugins.restart_hint",
|
||||||
|
"Plugin enable state changes take effect after restarting the app.");
|
||||||
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.Loader;
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
public sealed class PluginLoadContext : AssemblyLoadContext
|
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
namespace LanMountainDesktop.PluginSdk;
|
using System;
|
||||||
|
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
public sealed record PluginLoadResult(
|
public sealed record PluginLoadResult(
|
||||||
string SourcePath,
|
string SourcePath,
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
public sealed class PluginLoader
|
public sealed class PluginLoader
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
namespace LanMountainDesktop.PluginSdk;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
public sealed class PluginLoaderOptions
|
public sealed class PluginLoaderOptions
|
||||||
{
|
{
|
||||||
@@ -9,6 +9,7 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
@@ -174,6 +175,36 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PluginManifest InstallPluginPackage(string packagePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||||
|
|
||||||
|
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||||
|
if (!File.Exists(fullPackagePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(Path.GetExtension(fullPackagePath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package must use the '{PluginSdkInfo.PackageFileExtension}' extension.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(PluginsDirectory);
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||||
|
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||||
|
|
||||||
|
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
|
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
File.Copy(fullPackagePath, destinationPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
UnloadInstalledPlugins();
|
UnloadInstalledPlugins();
|
||||||
@@ -269,6 +300,42 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||||
|
{
|
||||||
|
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||||
|
{
|
||||||
|
if (string.Equals(
|
||||||
|
Path.GetFullPath(existingPackagePath),
|
||||||
|
Path.GetFullPath(packagePathToKeep),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||||
|
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(existingPackagePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore unrelated or invalid packages during replacement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInstalledPackageFileName(string pluginId)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
private static string EnsureTrailingSeparator(string path)
|
private static string EnsureTrailingSeparator(string path)
|
||||||
{
|
{
|
||||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.SettingsPages;
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
public partial class PluginSettingsPage : UserControl
|
public partial class PluginSettingsPage : UserControl
|
||||||
{
|
{
|
||||||
|
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
|
||||||
|
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
|
||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private string? _packageImportStatusMessage;
|
||||||
|
private bool _packageImportStatusIsError;
|
||||||
|
|
||||||
public PluginSettingsPage()
|
public PluginSettingsPage()
|
||||||
{
|
{
|
||||||
@@ -24,6 +33,7 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
public void RefreshFromRuntime()
|
public void RefreshFromRuntime()
|
||||||
{
|
{
|
||||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
UpdateInstallerUi(runtime);
|
||||||
if (runtime is null)
|
if (runtime is null)
|
||||||
{
|
{
|
||||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||||
@@ -37,6 +47,24 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
BuildPluginCatalog(runtime);
|
BuildPluginCatalog(runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateInstallerUi(PluginRuntimeService? runtime)
|
||||||
|
{
|
||||||
|
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
||||||
|
InstallPluginPackageButton.IsEnabled = runtime is not null;
|
||||||
|
PluginPackageImportHintTextBlock.Text = runtime is null
|
||||||
|
? L(
|
||||||
|
"settings.plugins.install_unavailable",
|
||||||
|
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now.")
|
||||||
|
: F(
|
||||||
|
"settings.plugins.install_hint_format",
|
||||||
|
"Open a .laapp package to install it into: {0}",
|
||||||
|
runtime.PluginsDirectory);
|
||||||
|
|
||||||
|
PluginPackageImportStatusTextBlock.IsVisible = !string.IsNullOrWhiteSpace(_packageImportStatusMessage);
|
||||||
|
PluginPackageImportStatusTextBlock.Text = _packageImportStatusMessage ?? string.Empty;
|
||||||
|
PluginPackageImportStatusTextBlock.Foreground = _packageImportStatusIsError ? ErrorBrush : SuccessBrush;
|
||||||
|
}
|
||||||
|
|
||||||
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
||||||
{
|
{
|
||||||
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
||||||
@@ -165,6 +193,116 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||||
|
if (runtime is null)
|
||||||
|
{
|
||||||
|
SetPackageImportStatus(
|
||||||
|
L(
|
||||||
|
"settings.plugins.install_unavailable",
|
||||||
|
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now."),
|
||||||
|
isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
var storageProvider = topLevel?.StorageProvider;
|
||||||
|
if (storageProvider is null)
|
||||||
|
{
|
||||||
|
SetPackageImportStatus(
|
||||||
|
L("settings.plugins.install_picker_unavailable", "Storage provider is unavailable."),
|
||||||
|
isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = L("settings.plugins.install_picker_title", "Select plugin package"),
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter =
|
||||||
|
[
|
||||||
|
new FilePickerFileType(L("settings.plugins.install_file_type", ".laapp plugin package"))
|
||||||
|
{
|
||||||
|
Patterns = [$"*{PluginSdkInfo.PackageFileExtension}"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? temporaryPackagePath = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
temporaryPackagePath = await CopyPackageToTemporaryFileAsync(files[0]);
|
||||||
|
if (string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||||
|
{
|
||||||
|
SetPackageImportStatus(
|
||||||
|
L("settings.plugins.install_copy_failed", "Failed to copy the selected .laapp package."),
|
||||||
|
isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||||
|
runtime.LoadInstalledPlugins();
|
||||||
|
RefreshPluginNavigation(topLevel);
|
||||||
|
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||||
|
RefreshFromRuntime();
|
||||||
|
SetPackageImportStatus(
|
||||||
|
F(
|
||||||
|
"settings.plugins.install_success_format",
|
||||||
|
"Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||||
|
manifest.Name),
|
||||||
|
isError: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetPackageImportStatus(
|
||||||
|
F(
|
||||||
|
"settings.plugins.install_failed_format",
|
||||||
|
"Failed to install plugin package: {0}",
|
||||||
|
ex.Message),
|
||||||
|
isError: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(temporaryPackagePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore temporary file cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPluginNavigation(TopLevel? topLevel)
|
||||||
|
{
|
||||||
|
switch (topLevel)
|
||||||
|
{
|
||||||
|
case MainWindow mainWindow:
|
||||||
|
mainWindow.RefreshPluginSettingsNavigation();
|
||||||
|
break;
|
||||||
|
case SettingsWindow settingsWindow:
|
||||||
|
settingsWindow.RefreshPluginSettingsNavigation();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPackageImportStatus(string message, bool isError)
|
||||||
|
{
|
||||||
|
_packageImportStatusMessage = string.IsNullOrWhiteSpace(message) ? null : message;
|
||||||
|
_packageImportStatusIsError = isError;
|
||||||
|
UpdateInstallerUi((Application.Current as App)?.PluginRuntimeService);
|
||||||
|
}
|
||||||
|
|
||||||
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||||
{
|
{
|
||||||
var source = entry.IsPackage
|
var source = entry.IsPackage
|
||||||
@@ -215,8 +353,35 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
{
|
{
|
||||||
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(file.Name);
|
||||||
|
if (string.IsNullOrWhiteSpace(extension))
|
||||||
|
{
|
||||||
|
extension = PluginSdkInfo.PackageFileExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var temporaryDirectory = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"PluginImports");
|
||||||
|
Directory.CreateDirectory(temporaryDirectory);
|
||||||
|
|
||||||
|
var temporaryPackagePath = Path.Combine(
|
||||||
|
temporaryDirectory,
|
||||||
|
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
|
||||||
|
|
||||||
|
await using var sourceStream = await file.OpenReadAsync();
|
||||||
|
await using var destinationStream = File.Create(temporaryPackagePath);
|
||||||
|
await sourceStream.CopyToAsync(destinationStream);
|
||||||
|
return temporaryPackagePath;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,24 @@
|
|||||||
</ui:SettingsExpander.IconSource>
|
</ui:SettingsExpander.IconSource>
|
||||||
<ui:SettingsExpander.Footer>
|
<ui:SettingsExpander.Footer>
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||||
|
Padding="14">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Button x:Name="InstallPluginPackageButton"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Click="OnInstallPluginPackageClick"
|
||||||
|
Content="Open .laapp package" />
|
||||||
|
<TextBlock x:Name="PluginPackageImportHintTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Open a .laapp package to install it into the local plugin directory." />
|
||||||
|
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="False" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<TextBlock x:Name="PluginRestartHintTextBlock"
|
<TextBlock x:Name="PluginRestartHintTextBlock"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
33
LanMountainDesktop/plugins/README.md
Normal file
33
LanMountainDesktop/plugins/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 宿主侧插件运行时
|
||||||
|
|
||||||
|
这个目录用于归档阑山桌面宿主侧的插件相关实现。
|
||||||
|
|
||||||
|
职责范围:
|
||||||
|
- 已安装插件的发现
|
||||||
|
- `.laapp` 安装包安装与替换
|
||||||
|
- 插件运行时加载
|
||||||
|
- 插件贡献的设置页与桌面组件接入
|
||||||
|
- 宿主侧插件设置页的安装、显示与刷新
|
||||||
|
|
||||||
|
当前宿主侧核心文件:
|
||||||
|
- `PluginLoader.cs`
|
||||||
|
- `PluginLoadContext.cs`
|
||||||
|
- `PluginLoaderOptions.cs`
|
||||||
|
- `PluginLoadResult.cs`
|
||||||
|
- `LoadedPlugin.cs`
|
||||||
|
- `PluginRuntimeService.cs`
|
||||||
|
- `PluginContributions.cs`
|
||||||
|
- `PluginCatalogEntry.cs`
|
||||||
|
- `PluginSettingsPage.axaml`
|
||||||
|
- `PluginSettingsPage.Host.cs`
|
||||||
|
- `MainWindow.PluginSettingsHost.cs`
|
||||||
|
- `SettingsWindow.PluginSettingsHost.cs`
|
||||||
|
- `MainWindow.PluginSettingsLocalization.cs`
|
||||||
|
- `SettingsWindow.PluginSettingsLocalization.cs`
|
||||||
|
- `MainWindow.PluginSettingsControls.cs`
|
||||||
|
- `SettingsWindow.PluginSettingsControls.cs`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
|
||||||
|
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
|
||||||
|
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow
|
||||||
|
{
|
||||||
|
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||||
|
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||||
|
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||||
|
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||||
|
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||||
|
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||||
|
}
|
||||||
@@ -139,6 +139,30 @@ public partial class SettingsWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void RefreshPluginSettingsNavigation()
|
||||||
|
{
|
||||||
|
if (SettingsNavView?.MenuItems is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||||
|
{
|
||||||
|
var navItem = SettingsNavView.MenuItems
|
||||||
|
.OfType<NavigationViewItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (navItem is not null)
|
||||||
|
{
|
||||||
|
SettingsNavView.MenuItems.Remove(navItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pluginSettingsPageHosts.Clear();
|
||||||
|
InitializePluginSettingsNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
private string? GetSelectedSettingsTabTag()
|
private string? GetSelectedSettingsTabTag()
|
||||||
{
|
{
|
||||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow
|
||||||
|
{
|
||||||
|
private void ApplyPluginSettingsLocalization()
|
||||||
|
{
|
||||||
|
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||||
|
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||||
|
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||||
|
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||||
|
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||||
|
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
|
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||||
|
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||||
|
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user