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 @@
+
+
+
+
+
+
+
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.PluginSettings.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs
similarity index 89%
rename from LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs
rename to LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs
index 9faa3e1..0eab9bd 100644
--- a/LanMountainDesktop/Views/SettingsWindow.PluginSettings.cs
+++ b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs
@@ -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()
+ .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/SettingsWindow.PluginSettingsLocalization.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs
new file mode 100644
index 0000000..707de45
--- /dev/null
+++ b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsLocalization.cs
@@ -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();
+ }
+}