From 9c89c0844818173f183932d80ebf16926e8e8dd7 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 10 Mar 2026 00:04:33 +0800 Subject: [PATCH] 0.5.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件系统再进化 --- .gitignore | 1 + LanAirApp/README.md | 25 +++ LanAirApp/docs/PLUGIN_DEVELOPMENT.md | 41 ++++ LanAirApp/docs/PLUGIN_PACKAGING.md | 34 ++++ .../LanMountainDesktop.SamplePlugin.csproj | 6 +- .../LanMountainDesktop.SamplePlugin/README.md | 16 ++ .../SamplePlugin.cs | 0 .../SamplePluginRuntimeStatus.cs | 0 .../SamplePluginSettingsView.cs | 0 .../SamplePluginStatusClockWidget.cs | 0 .../plugin.json | 0 LanAirApp/samples/README.md | 11 ++ LanAirApp/standards/README.md | 11 ++ LanAirApp/standards/plugin.template.json | 9 + .../LanMountainDesktop.PluginPackager.csproj | 15 ++ .../Program.cs | 136 ++++++++++++++ .../LanMountainDesktop.PluginSdk.csproj | 1 + .../PluginHostPropertyKeys.cs | 9 + .../PluginLocalizer.cs | 114 ++++++++++++ LanMountainDesktop.sln | 8 +- LanMountainDesktop/Localization/en-US.json | 9 + LanMountainDesktop/Localization/zh-CN.json | 9 + .../Services/PendingRestartStateService.cs | 1 + .../Views/MainWindow.Localization.cs | 21 +-- .../Views/MainWindow.Settings.cs | 8 - .../Views/SettingsWindow.Controls.cs | 8 - .../Views/SettingsWindow.Localization.cs | 11 +- .../plugins}/LoadedPlugin.cs | 7 +- .../MainWindow.PluginSettingsControls.cs | 14 ++ .../MainWindow.PluginSettingsHost.cs} | 24 +++ .../MainWindow.PluginSettingsLocalization.cs | 28 +++ .../PluginCatalogEntry.cs | 0 .../PluginContributions.cs | 1 + .../plugins}/PluginLoadContext.cs | 8 +- .../plugins}/PluginLoadResult.cs | 6 +- .../plugins}/PluginLoader.cs | 10 +- .../plugins}/PluginLoaderOptions.cs | 7 +- .../PluginRuntimeService.cs | 67 +++++++ .../PluginSettingsPage.Host.cs} | 175 +++++++++++++++++- .../PluginSettingsPage.axaml | 18 ++ LanMountainDesktop/plugins/README.md | 33 ++++ .../SettingsWindow.PluginSettingsControls.cs | 14 ++ .../SettingsWindow.PluginSettingsHost.cs} | 24 +++ ...ttingsWindow.PluginSettingsLocalization.cs | 18 ++ 44 files changed, 898 insertions(+), 60 deletions(-) create mode 100644 LanAirApp/README.md create mode 100644 LanAirApp/docs/PLUGIN_DEVELOPMENT.md create mode 100644 LanAirApp/docs/PLUGIN_PACKAGING.md rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/LanMountainDesktop.SamplePlugin.csproj (70%) create mode 100644 LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/SamplePlugin.cs (100%) rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/SamplePluginRuntimeStatus.cs (100%) rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/SamplePluginSettingsView.cs (100%) rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/SamplePluginStatusClockWidget.cs (100%) rename {LanMountainDesktop.SamplePlugin => LanAirApp/samples/LanMountainDesktop.SamplePlugin}/plugin.json (100%) create mode 100644 LanAirApp/samples/README.md create mode 100644 LanAirApp/standards/README.md create mode 100644 LanAirApp/standards/plugin.template.json create mode 100644 LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj create mode 100644 LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginLocalizer.cs rename {LanMountainDesktop.PluginSdk => LanMountainDesktop/plugins}/LoadedPlugin.cs (93%) create mode 100644 LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs rename LanMountainDesktop/{Views/MainWindow.PluginSettings.cs => plugins/MainWindow.PluginSettingsHost.cs} (89%) create mode 100644 LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs rename LanMountainDesktop/{Services => plugins}/PluginCatalogEntry.cs (100%) rename LanMountainDesktop/{Services => plugins}/PluginContributions.cs (90%) rename {LanMountainDesktop.PluginSdk => LanMountainDesktop/plugins}/PluginLoadContext.cs (93%) rename {LanMountainDesktop.PluginSdk => LanMountainDesktop/plugins}/PluginLoadResult.cs (87%) rename {LanMountainDesktop.PluginSdk => LanMountainDesktop/plugins}/PluginLoader.cs (99%) rename {LanMountainDesktop.PluginSdk => LanMountainDesktop/plugins}/PluginLoaderOptions.cs (86%) rename LanMountainDesktop/{Services => plugins}/PluginRuntimeService.cs (82%) rename LanMountainDesktop/{Views/SettingsPages/PluginSettingsPage.axaml.cs => plugins/PluginSettingsPage.Host.cs} (54%) rename LanMountainDesktop/{Views/SettingsPages => plugins}/PluginSettingsPage.axaml (76%) create mode 100644 LanMountainDesktop/plugins/README.md create mode 100644 LanMountainDesktop/plugins/SettingsWindow.PluginSettingsControls.cs rename LanMountainDesktop/{Views/SettingsWindow.PluginSettings.cs => plugins/SettingsWindow.PluginSettingsHost.cs} (89%) create mode 100644 LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs diff --git a/.gitignore b/.gitignore index 4a36ddc..4ba827f 100644 --- a/.gitignore +++ b/.gitignore @@ -490,4 +490,5 @@ nul /_build_verify_sample_plugin_capabilities /_build_verify_plugin_page_host /_build_verify_plugin_services +/LanMountainDesktop.PluginSdk/_build_verify_*/ /_build_obj diff --git a/LanAirApp/README.md b/LanAirApp/README.md new file mode 100644 index 0000000..75e2c53 --- /dev/null +++ b/LanAirApp/README.md @@ -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`、清单模型与扩展注册接口。 diff --git a/LanAirApp/docs/PLUGIN_DEVELOPMENT.md b/LanAirApp/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000..7632928 --- /dev/null +++ b/LanAirApp/docs/PLUGIN_DEVELOPMENT.md @@ -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` diff --git a/LanAirApp/docs/PLUGIN_PACKAGING.md b/LanAirApp/docs/PLUGIN_PACKAGING.md new file mode 100644 index 0000000..d71c037 --- /dev/null +++ b/LanAirApp/docs/PLUGIN_PACKAGING.md @@ -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` 是标准安装格式,建议不要对外分发散装目录。 diff --git a/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj similarity index 70% rename from LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj index d1bf59d..11a8e77 100644 --- a/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj @@ -9,13 +9,13 @@ bin\$(Configuration)\$(TargetFramework)\content\ false false - ..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\ + ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\ $(PluginPackageOutputDirectory)$(AssemblyName).laapp - ..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\ + ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\ - + diff --git a/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md new file mode 100644 index 0000000..651f815 --- /dev/null +++ b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md @@ -0,0 +1,16 @@ +# LanMountainDesktop.SamplePlugin + +这是阑山桌面的**示例开发插件**。 + +它用于演示以下能力: +- 插件入口与 `plugin.json` 清单 +- 插件服务注册 +- 插件设置页注册 +- 插件桌面组件注册 +- 插件内通信与状态更新 +- `.laapp` 打包与安装流程 +- 插件多语言资源组织方式 + +如果你要开发自己的插件,建议以这个目录为模板开始。 + +这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。 diff --git a/LanMountainDesktop.SamplePlugin/SamplePlugin.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs similarity index 100% rename from LanMountainDesktop.SamplePlugin/SamplePlugin.cs rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePlugin.cs diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs similarity index 100% rename from LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginRuntimeStatus.cs diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs similarity index 100% rename from LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginSettingsView.cs diff --git a/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs similarity index 100% rename from LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/SamplePluginStatusClockWidget.cs diff --git a/LanMountainDesktop.SamplePlugin/plugin.json b/LanAirApp/samples/LanMountainDesktop.SamplePlugin/plugin.json similarity index 100% rename from LanMountainDesktop.SamplePlugin/plugin.json rename to LanAirApp/samples/LanMountainDesktop.SamplePlugin/plugin.json diff --git a/LanAirApp/samples/README.md b/LanAirApp/samples/README.md new file mode 100644 index 0000000..6e6bf40 --- /dev/null +++ b/LanAirApp/samples/README.md @@ -0,0 +1,11 @@ +# 示例插件 + +本目录用于存放阑山桌面的示例开发插件。 + +当前示例: +- `LanMountainDesktop.SamplePlugin` + +说明: +- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。 +- 开发新插件时,建议直接从这个示例插件复制一份再修改。 +- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。 diff --git a/LanAirApp/standards/README.md b/LanAirApp/standards/README.md new file mode 100644 index 0000000..3472230 --- /dev/null +++ b/LanAirApp/standards/README.md @@ -0,0 +1,11 @@ +# 插件标准文件 + +这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。 + +当前标准: +- 安装包扩展名:`.laapp` +- 插件清单文件名:`plugin.json` +- 多语言资源目录:`Localization/` +- 建议内置语言文件:`zh-CN.json`、`en-US.json` + +创建新插件时,建议优先参考本目录中的模板文件。 diff --git a/LanAirApp/standards/plugin.template.json b/LanAirApp/standards/plugin.template.json new file mode 100644 index 0000000..1852860 --- /dev/null +++ b/LanAirApp/standards/plugin.template.json @@ -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" +} diff --git a/LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj b/LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj new file mode 100644 index 0000000..4735444 --- /dev/null +++ b/LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + 1.0.0 + + + + + + + diff --git a/LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs b/LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs new file mode 100644 index 0000000..7e3d171 --- /dev/null +++ b/LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs @@ -0,0 +1,136 @@ +using System.IO.Compression; +using LanMountainDesktop.PluginSdk; + +return await RunAsync(args); + +static async Task 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 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 Required"); + Console.WriteLine(" --output Optional"); + Console.WriteLine(" --overwrite Optional"); +} diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj index 3e94d17..f9a5f8f 100644 --- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj +++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj @@ -8,6 +8,7 @@ + diff --git a/LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs b/LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs new file mode 100644 index 0000000..83c965a --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs @@ -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"; +} diff --git a/LanMountainDesktop.PluginSdk/PluginLocalizer.cs b/LanMountainDesktop.PluginSdk/PluginLocalizer.cs new file mode 100644 index 0000000..c1e905c --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginLocalizer.cs @@ -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> _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 properties) + { + ArgumentNullException.ThrowIfNull(properties); + + return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) && + rawValue is string languageCode + ? NormalizeLanguageCode(languageCode) + : NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name); + } + + private Dictionary LoadLanguageTable(string languageCode) + { + if (_cache.TryGetValue(languageCode, out var table)) + { + return table; + } + + var result = new Dictionary(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>(json, JsonOptions); + if (data is not null) + { + result = new Dictionary(data, StringComparer.OrdinalIgnoreCase); + } + } + } + catch + { + // Keep empty localization table for plugin resilience. + } + + _cache[languageCode] = result; + return result; + } +} diff --git a/LanMountainDesktop.sln b/LanMountainDesktop.sln index 8b052b3..243bd0e 100644 --- a/LanMountainDesktop.sln +++ b/LanMountainDesktop.sln @@ -5,7 +5,9 @@ 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", "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 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}" EndProject @@ -23,6 +25,10 @@ Global {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 diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index bb54415..97cd619 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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_state_enabled": "enabled", "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_manifest": "Loose manifest", "settings.plugins.subtitle_format": "{0} | {1} | {2}", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index eb43df9..f98b367 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -307,6 +307,15 @@ "settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。", "settings.plugins.toggle_state_enabled": "启用", "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_manifest": "散装清单", "settings.plugins.subtitle_format": "{0} | {1} | {2}", diff --git a/LanMountainDesktop/Services/PendingRestartStateService.cs b/LanMountainDesktop/Services/PendingRestartStateService.cs index 70a4ede..43e51fb 100644 --- a/LanMountainDesktop/Services/PendingRestartStateService.cs +++ b/LanMountainDesktop/Services/PendingRestartStateService.cs @@ -6,6 +6,7 @@ namespace LanMountainDesktop.Services; public static class PendingRestartStateService { public const string RenderModeReason = "RenderMode"; + public const string PluginCatalogReason = "PluginCatalog"; private static readonly object Gate = new(); private static readonly HashSet PendingReasons = new(StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index aeb2309..645c8ae 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -278,26 +278,7 @@ public partial class MainWindow "Right-click an icon in launcher to hide it. Hidden entries appear here."); LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); - 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(); + ApplyPluginSettingsLocalization(); SettingsNavAboutItem.Content = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 3e7d330..0cf18ae 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -2741,14 +2741,6 @@ public partial class MainWindow internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsDescriptionTextBlock")!; - // --- PluginSettingsPage (Added for completeness) --- - internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl("PluginSettingsPanelTitleTextBlock")!; - internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl("PluginSystemSettingsExpander")!; - internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; - internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; - internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; - internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; - internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; } diff --git a/LanMountainDesktop/Views/SettingsWindow.Controls.cs b/LanMountainDesktop/Views/SettingsWindow.Controls.cs index 3eee9be..f86aec5 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Controls.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Controls.cs @@ -206,13 +206,5 @@ public partial class SettingsWindow internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsEmptyTextBlock")!; internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl("LauncherHiddenItemsDescriptionTextBlock")!; - // --- PluginSettingsPage (Added for completeness) --- - internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl("PluginSettingsPanelTitleTextBlock")!; - internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl("PluginSystemSettingsExpander")!; - internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; - internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; - internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; - internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; - internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; } diff --git a/LanMountainDesktop/Views/SettingsWindow.Localization.cs b/LanMountainDesktop/Views/SettingsWindow.Localization.cs index 77a5fb3..a3abc8d 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Localization.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Localization.cs @@ -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."); LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); - 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(); + ApplyPluginSettingsLocalization(); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText()); diff --git a/LanMountainDesktop.PluginSdk/LoadedPlugin.cs b/LanMountainDesktop/plugins/LoadedPlugin.cs similarity index 93% rename from LanMountainDesktop.PluginSdk/LoadedPlugin.cs rename to LanMountainDesktop/plugins/LoadedPlugin.cs index 5bff05e..fee7b13 100644 --- a/LanMountainDesktop.PluginSdk/LoadedPlugin.cs +++ b/LanMountainDesktop/plugins/LoadedPlugin.cs @@ -1,7 +1,12 @@ +using System; +using System.Collections.Generic; using System.Reflection; using System.Threading; +using System.Threading.Tasks; -namespace LanMountainDesktop.PluginSdk; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; public sealed class LoadedPlugin : IDisposable, IAsyncDisposable { diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs new file mode 100644 index 0000000..0eead31 --- /dev/null +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsControls.cs @@ -0,0 +1,14 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl("PluginSettingsPanelTitleTextBlock")!; + internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl("PluginSystemSettingsExpander")!; + internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl("PluginSystemDescriptionTextBlock")!; + internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl("PluginSystemStatusTextBlock")!; + internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl("InstalledPluginsSettingsExpander")!; + internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl("PluginRestartHintTextBlock")!; + internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl("PluginCatalogEmptyTextBlock")!; +} diff --git a/LanMountainDesktop/Views/MainWindow.PluginSettings.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs similarity index 89% rename from LanMountainDesktop/Views/MainWindow.PluginSettings.cs rename to LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs index d0a34f3..deb6179 100644 --- a/LanMountainDesktop/Views/MainWindow.PluginSettings.cs +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs @@ -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() + .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() { return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString(); diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs new file mode 100644 index 0000000..16d7975 --- /dev/null +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsLocalization.cs @@ -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(); + } +} diff --git a/LanMountainDesktop/Services/PluginCatalogEntry.cs b/LanMountainDesktop/plugins/PluginCatalogEntry.cs similarity index 100% rename from LanMountainDesktop/Services/PluginCatalogEntry.cs rename to LanMountainDesktop/plugins/PluginCatalogEntry.cs diff --git a/LanMountainDesktop/Services/PluginContributions.cs b/LanMountainDesktop/plugins/PluginContributions.cs similarity index 90% rename from LanMountainDesktop/Services/PluginContributions.cs rename to LanMountainDesktop/plugins/PluginContributions.cs index 343a4e9..687a91f 100644 --- a/LanMountainDesktop/Services/PluginContributions.cs +++ b/LanMountainDesktop/plugins/PluginContributions.cs @@ -1,3 +1,4 @@ +using LanMountainDesktop.Plugins; using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.Services; diff --git a/LanMountainDesktop.PluginSdk/PluginLoadContext.cs b/LanMountainDesktop/plugins/PluginLoadContext.cs similarity index 93% rename from LanMountainDesktop.PluginSdk/PluginLoadContext.cs rename to LanMountainDesktop/plugins/PluginLoadContext.cs index e48c327..4403f92 100644 --- a/LanMountainDesktop.PluginSdk/PluginLoadContext.cs +++ b/LanMountainDesktop/plugins/PluginLoadContext.cs @@ -1,7 +1,13 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.Loader; -namespace LanMountainDesktop.PluginSdk; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; public sealed class PluginLoadContext : AssemblyLoadContext { diff --git a/LanMountainDesktop.PluginSdk/PluginLoadResult.cs b/LanMountainDesktop/plugins/PluginLoadResult.cs similarity index 87% rename from LanMountainDesktop.PluginSdk/PluginLoadResult.cs rename to LanMountainDesktop/plugins/PluginLoadResult.cs index dd0586f..38d75b8 100644 --- a/LanMountainDesktop.PluginSdk/PluginLoadResult.cs +++ b/LanMountainDesktop/plugins/PluginLoadResult.cs @@ -1,4 +1,8 @@ -namespace LanMountainDesktop.PluginSdk; +using System; + +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; public sealed record PluginLoadResult( string SourcePath, diff --git a/LanMountainDesktop.PluginSdk/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs similarity index 99% rename from LanMountainDesktop.PluginSdk/PluginLoader.cs rename to LanMountainDesktop/plugins/PluginLoader.cs index b664efe..2022423 100644 --- a/LanMountainDesktop.PluginSdk/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -1,10 +1,18 @@ +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.IO.Compression; +using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; +using System.Threading; +using System.Threading.Tasks; -namespace LanMountainDesktop.PluginSdk; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; public sealed class PluginLoader { diff --git a/LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs b/LanMountainDesktop/plugins/PluginLoaderOptions.cs similarity index 86% rename from LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs rename to LanMountainDesktop/plugins/PluginLoaderOptions.cs index b335397..b4f4bf2 100644 --- a/LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs +++ b/LanMountainDesktop/plugins/PluginLoaderOptions.cs @@ -1,4 +1,9 @@ -namespace LanMountainDesktop.PluginSdk; +using System; +using System.Collections.Generic; + +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; public sealed class PluginLoaderOptions { diff --git a/LanMountainDesktop/Services/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs similarity index 82% rename from LanMountainDesktop/Services/PluginRuntimeService.cs rename to LanMountainDesktop/plugins/PluginRuntimeService.cs index f3d6cd2..5635e08 100644 --- a/LanMountainDesktop/Services/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -9,6 +9,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; using LanMountainDesktop.Models; +using LanMountainDesktop.Plugins; using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.Services; @@ -174,6 +175,36 @@ public sealed class PluginRuntimeService : IDisposable 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() { UnloadInstalledPlugins(); @@ -269,6 +300,42 @@ public sealed class PluginRuntimeService : IDisposable 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) { return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) diff --git a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs similarity index 54% rename from LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs rename to LanMountainDesktop/plugins/PluginSettingsPage.Host.cs index cbe7315..c528018 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml.cs +++ b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs @@ -1,19 +1,28 @@ -using System; +using System; using System.Globalization; +using System.IO; using System.Linq; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Platform.Storage; using FluentAvalonia.UI.Controls; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.SettingsPages; 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 LocalizationService _localizationService = new(); + private string? _packageImportStatusMessage; + private bool _packageImportStatusIsError; public PluginSettingsPage() { @@ -24,6 +33,7 @@ public partial class PluginSettingsPage : UserControl public void RefreshFromRuntime() { var runtime = (Application.Current as App)?.PluginRuntimeService; + UpdateInstallerUi(runtime); if (runtime is null) { PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available."); @@ -37,6 +47,24 @@ public partial class PluginSettingsPage : UserControl 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) { 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")); } + 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) { var source = entry.IsPackage @@ -215,8 +353,35 @@ public partial class PluginSettingsPage : UserControl { return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args); } + + private static async Task 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; + } + } } - - - - diff --git a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml b/LanMountainDesktop/plugins/PluginSettingsPage.axaml similarity index 76% rename from LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml rename to LanMountainDesktop/plugins/PluginSettingsPage.axaml index a499838..8363d0a 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginSettingsPage.axaml +++ b/LanMountainDesktop/plugins/PluginSettingsPage.axaml @@ -51,6 +51,24 @@ + + +