mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4d0c4cd8 |
42
.trae/specs/localization-fix/checklist.md
Normal file
42
.trae/specs/localization-fix/checklist.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 本地化修复 Checklist
|
||||
|
||||
## MainWindow 修复
|
||||
- [ ] `TaskbarProfileDisplayNameTextBlock.Text` 在中文下显示"用户"(或保持动态)
|
||||
- [ ] `TaskbarProfileSettingsActionTextBlock.Text` 在中文下显示"设置"
|
||||
- [ ] `TaskbarProfileDesktopEditActionTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `TaskbarProfilePowerActionTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `TaskbarPowerBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `TaskbarPowerTitleTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `PowerShutdownTextBlock.Text` 在中文下显示"关机"
|
||||
- [ ] `PowerRestartTextBlock.Text` 在中文下显示"重启"
|
||||
- [ ] `PowerLogoutTextBlock.Text` 在中文下显示"注销"
|
||||
- [ ] `PowerSleepTextBlock.Text` 在中文下显示"睡眠"
|
||||
- [ ] `PowerLockTextBlock.Text` 在中文下显示"锁定屏幕"
|
||||
- [ ] `ComponentLibraryTitleTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `ComponentLibraryEmptyTextBlock.Text` 在中文下显示"左右滑动选择类别,点击进入,然后拖动组件到桌面放置。"
|
||||
- [ ] `ComponentLibraryBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `ComponentLibraryCollapsedChipTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
|
||||
## Launcher 修复
|
||||
- [ ] `SplashWindow` 在中文下显示中文启动文本
|
||||
- [ ] `DataLocationPromptWindow` 在中文下全部显示中文
|
||||
- [ ] `ErrorWindow` 在中文下全部显示中文
|
||||
- [ ] `LoadingDetailsWindow` 在中文下全部显示中文
|
||||
- [ ] `UpdateWindow` 在中文下显示中文标题
|
||||
|
||||
## 组件修复
|
||||
- [ ] `BrowserWidget` 在中文下显示"浏览器运行时不可用"
|
||||
- [ ] `WhiteboardWidget` 工具提示在中文下显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- [ ] `HolidayCalendarWidget` 在中文下显示"节假日倒计时"、"天"
|
||||
- [ ] `BilibiliHotSearchWidget` 在中文下显示"热门话题"
|
||||
- [ ] `WallpaperSettingsPage` 自定义颜色 Tooltip 在中文下显示"自定义颜色"
|
||||
|
||||
## 资源文件
|
||||
- [ ] `zh-CN.json` 包含所有新增键值
|
||||
- [ ] `en-US.json` 包含所有新增键值
|
||||
- [ ] Launcher 本地化文件包含所有新增键值
|
||||
|
||||
## 构建与质量
|
||||
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过,无错误
|
||||
- [ ] 无新增警告
|
||||
- [ ] 无遗漏的硬编码英文(通过 `grep -r 'Text="[a-zA-Z]'` 等检查)
|
||||
85
.trae/specs/localization-fix/spec.md
Normal file
85
.trae/specs/localization-fix/spec.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 本地化修复 Spec
|
||||
|
||||
## Why
|
||||
|
||||
- 项目在中文设置下,多处 UI 仍显示英文。
|
||||
- 主要问题集中在:
|
||||
1. `MainWindow.axaml` 中任务栏头像弹窗、电源菜单、组件库等文本硬编码为英文,且未被 `ApplyLocalization()` 覆盖。
|
||||
2. `LanMountainDesktop.Launcher` 的所有视图完全没有接入本地化系统。
|
||||
3. 部分组件(BrowserWidget、WhiteboardWidget、HolidayCalendarWidget 等)存在未覆盖的硬编码英文。
|
||||
4. 少量设置页面 Tooltip 硬编码英文。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. MainWindow.axaml 硬编码修复
|
||||
将以下硬编码文本改为由 `ApplyLocalization()` 通过 `L()` 动态设置:
|
||||
- 任务栏头像弹窗:`User` → `power.user` / `Settings` → `settings.title` / `Edit Desktop` → `button.component_library` / `Power` → `power.title`
|
||||
- 电源菜单:`Back` → `common.back` / `Power` → `power.title` / `Shutdown` → `power.shutdown` / `Restart` → `power.restart` / `Log Out` → `power.logout` / `Sleep` → `power.sleep` / `Lock Screen` → `power.lock_screen`
|
||||
- 组件库:`Widgets` → `component_library.title` / `Back` → `common.back` / `No components.` → `component_library.empty`
|
||||
- 悬浮芯片:`Widgets` → `component_library.title`
|
||||
|
||||
### 2. Launcher 视图本地化
|
||||
为 `LanMountainDesktop.Launcher/Views/` 下的窗口引入独立本地化机制(复用 `LocalizationService` 或内嵌资源字典):
|
||||
- `SplashWindow.axaml`:`LanMountain Desktop`、`Initializing...`
|
||||
- `DataLocationPromptWindow.axaml`:全部文本
|
||||
- `ErrorWindow.axaml`:全部文本
|
||||
- `LoadingDetailsWindow.axaml`:全部文本
|
||||
- `UpdateWindow.axaml`:`Update`
|
||||
|
||||
### 3. 组件硬编码修复
|
||||
- `BrowserWidget.axaml`:`Browser runtime unavailable.` → 新增键 `browser.widget.unavailable`
|
||||
- `WhiteboardWidget.axaml`:`Pen` / `Eraser` / `Clear` / `Export SVG` → 新增键 `whiteboard.tool.pen` 等
|
||||
- `HolidayCalendarWidget.axaml`:`Holiday countdown` / `Days` → 新增键 `holiday.widget.title` / `holiday.widget.days`
|
||||
- `BilibiliHotSearchWidget.axaml`:`Trending Topic` → 新增键 `bilihot.widget.trending_topic`
|
||||
- `WallpaperSettingsPage.axaml`:`Custom color` Tooltip → 复用 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
### 4. 本地化资源文件补充
|
||||
在 `zh-CN.json` 和 `en-US.json` 中补充上述新增键值。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml`
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
- `LanMountainDesktop.Launcher/Views/*.axaml`(多个文件)
|
||||
- `LanMountainDesktop/Views/Components/BrowserWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/WhiteboardWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/HolidayCalendarWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml`
|
||||
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
|
||||
- `LanMountainDesktop/Localization/zh-CN.json`
|
||||
- `LanMountainDesktop/Localization/en-US.json`
|
||||
- Affected behavior:
|
||||
- 中文设置下上述位置将正确显示中文。
|
||||
- Launcher 各窗口将支持中英文切换。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MainWindow 任务栏弹窗与电源菜单本地化
|
||||
系统 SHALL 在 `ApplyLocalization()` 中覆盖任务栏头像弹窗和电源菜单的所有文本。
|
||||
|
||||
#### Scenario: 中文设置下打开任务栏弹窗
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** 弹窗中显示"设置"、"桌面编辑"、"电源"等中文文本
|
||||
- **AND THEN** 电源菜单中显示"返回"、"关机"、"重启"、"注销"、"睡眠"、"锁定屏幕"等中文文本
|
||||
|
||||
### Requirement: Launcher 窗口本地化
|
||||
系统 SHALL 让 Launcher 的所有窗口文本通过本地化服务获取。
|
||||
|
||||
#### Scenario: 中文设置下启动应用
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** SplashWindow 显示中文启动文本
|
||||
- **AND THEN** 数据位置选择、错误页、加载详情页等显示中文
|
||||
|
||||
### Requirement: 组件与设置页硬编码修复
|
||||
系统 SHALL 移除或覆盖所有组件和设置页中的英文硬编码文本。
|
||||
|
||||
#### Scenario: 中文设置下查看各组件
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** BrowserWidget 显示"浏览器运行时不可用"
|
||||
- **AND THEN** WhiteboardWidget 工具提示显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- **AND THEN** HolidayCalendarWidget 显示"节假日倒计时"、"天"
|
||||
- **AND THEN** BilibiliHotSearchWidget 显示"热门话题"
|
||||
- **AND THEN** 壁纸设置页自定义颜色 Tooltip 显示"自定义颜色"
|
||||
39
.trae/specs/localization-fix/tasks.md
Normal file
39
.trae/specs/localization-fix/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 本地化修复 Tasks
|
||||
|
||||
## Task 1: MainWindow.axaml 硬编码文本移除与代码覆盖
|
||||
- [ ] 1.1 在 `MainWindow.axaml` 中,将任务栏头像弹窗的 `User`、`Settings`、`Edit Desktop`、`Power` 的 `Text` 属性改为空或绑定(保留 x:Name)
|
||||
- [ ] 1.2 在 `MainWindow.axaml` 中,将电源菜单的 `Back`、`Power`、`Shutdown`、`Restart`、`Log Out`、`Sleep`、`Lock Screen` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.3 在 `MainWindow.axaml` 中,将组件库的 `Widgets`、`Back`、`No components.` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.4 在 `MainWindow.axaml` 中,将悬浮芯片的 `Widgets` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.5 在 `MainWindow.SettingsHardCut.Stubs.cs` 的 `ApplyLocalization()` 中补充上述所有控件的 `L()` 赋值
|
||||
|
||||
## Task 2: Launcher 视图本地化
|
||||
- [ ] 2.1 在 `LanMountainDesktop.Launcher` 中引入 `LocalizationService`(或共享主应用服务)
|
||||
- [ ] 2.2 为 Launcher 创建独立的 `Localization/` 目录和 `zh-CN.json` / `en-US.json`
|
||||
- [ ] 2.3 修改 `SplashWindow.axaml`:将 `LanMountain Desktop`、`Initializing...` 改为动态绑定
|
||||
- [ ] 2.4 修改 `DataLocationPromptWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.5 修改 `ErrorWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.6 修改 `LoadingDetailsWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.7 修改 `UpdateWindow.axaml`:将 `Update` 改为动态绑定
|
||||
- [ ] 2.8 在 Launcher 启动流程中初始化语言设置
|
||||
|
||||
## Task 3: 组件硬编码修复
|
||||
- [ ] 3.1 `BrowserWidget.axaml`:将 `Browser runtime unavailable.` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.2 `WhiteboardWidget.axaml`:将 `Pen`、`Eraser`、`Clear`、`Export SVG` Tooltip 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.3 `HolidayCalendarWidget.axaml`:将 `Holiday countdown`、`Days` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.4 `BilibiliHotSearchWidget.axaml`:将 `Trending Topic` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.5 `WallpaperSettingsPage.axaml`:将 `Custom color` Tooltip 改为绑定到 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
## Task 4: 本地化资源文件补充
|
||||
- [ ] 4.1 在 `zh-CN.json` 中补充以下键值:
|
||||
- `browser.widget.unavailable`
|
||||
- `whiteboard.tool.pen`、`whiteboard.tool.eraser`、`whiteboard.tool.clear`、`whiteboard.tool.export_svg`
|
||||
- `holiday.widget.title`、`holiday.widget.days`
|
||||
- `bilihot.widget.trending_topic`
|
||||
- `power.user`(或复用现有键)
|
||||
- [ ] 4.2 在 `en-US.json` 中补充上述键值的英文版本
|
||||
- [ ] 4.3 为 Launcher 创建独立的本地化 JSON 文件并填充中英文
|
||||
|
||||
## Task 5: 验证
|
||||
- [ ] 5.1 执行 `dotnet build LanMountainDesktop.slnx -c Debug` 确保编译通过
|
||||
- [ ] 5.2 检查是否有遗漏的硬编码英文(通过正则搜索)
|
||||
@@ -9,8 +9,10 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
/// </summary>
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".lmdp";
|
||||
private const string ManifestFileName = "plugin.json";
|
||||
private const string LegacyManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".laapp";
|
||||
private const string LegacyPackageFileExtension = ".lmdp";
|
||||
private const string RuntimeDirectoryName = "runtime";
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
@@ -114,14 +116,16 @@ internal sealed class PluginInstallerService
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var entries = FindManifestEntries(archive, ManifestFileName);
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
entries = FindManifestEntries(archive, LegacyManifestFileName);
|
||||
}
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}' or '{LegacyManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
@@ -141,6 +145,13 @@ internal sealed class PluginInstallerService
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
|
||||
{
|
||||
return archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||
@@ -148,8 +159,11 @@ internal sealed class PluginInstallerService
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path =>
|
||||
path.EndsWith(PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Target framework: `net10.0`
|
||||
- Plugin SDK: `LanMountainDesktop.PluginSdk` `5.0.0`
|
||||
- Manifest: `plugin.json`
|
||||
- Package: `.laapp`
|
||||
- Runtime mode: `in-proc`
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
@@ -15,3 +23,19 @@ dotnet new lmd-plugin -n YourPluginName
|
||||
```
|
||||
|
||||
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||
|
||||
## Package contract
|
||||
|
||||
Every plugin package must contain:
|
||||
|
||||
- `plugin.json`
|
||||
- the entrance assembly declared by `entranceAssembly`
|
||||
- the `.deps.json` next to the entrance assembly
|
||||
|
||||
Optional package content:
|
||||
|
||||
- `Localization/*.json`
|
||||
- plugin assets and other managed dependencies
|
||||
- `airappmarket-entry.template.json` in the repository root for market publishing
|
||||
|
||||
Market publishing uses `market-manifest.json` with `schemaVersion`, `manifest`, `compatibility`, `repository`, `publication.packageSources`, and `capabilities`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -26,6 +27,96 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Equal("plugin_elevation_required", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_InstallsLaappWithPluginJson_InsideUserScope()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "sample.laapp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.Equal("plugin.install.sample", result.ManifestId);
|
||||
Assert.Equal("Sample Plugin", result.ManifestName);
|
||||
Assert.NotNull(result.InstalledPackagePath);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.EndsWith(".laapp", result.InstalledPackagePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var firstPackagePath = Path.Combine(_tempRoot, "sample-1.laapp");
|
||||
var secondPackagePath = Path.Combine(_tempRoot, "sample-2.laapp");
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
Assert.Single(Directory.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.TopDirectoryOnly));
|
||||
Assert.True(File.Exists(second.InstalledPackagePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_StillSupportsLegacyManifestJson()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "legacy.lmdp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
}
|
||||
|
||||
private static void CreatePluginPackage(string packagePath, string manifestFileName, string pluginId, string pluginName)
|
||||
{
|
||||
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
|
||||
var entry = archive.CreateEntry(manifestFileName);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(
|
||||
$$"""
|
||||
{
|
||||
"id": "{{pluginId}}",
|
||||
"name": "{{pluginName}}",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
||||
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginMarketIndexDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_WithNestedV2Entry_MapsDisplayFieldsAndWorkspacePath()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(CreateNestedIndexJson(), "test-index.json");
|
||||
var plugin = Assert.Single(document.Plugins);
|
||||
var source = Assert.Single(plugin.PackageSources);
|
||||
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin", plugin.Id);
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg", plugin.IconUrl);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop.SamplePlugin/main/README.md", plugin.ReadmeUrl);
|
||||
Assert.Equal("workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp", source.Url);
|
||||
Assert.Equal(PluginPackageSourceKind.WorkspaceLocal, source.SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichAsync_WhenRepositoryMetadataUnavailable_PreservesNestedDisplayFields()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(
|
||||
CreateNestedIndexJson("LanMountainDesktop.MissingPlugin"),
|
||||
"test-index.json");
|
||||
using var httpClient = new HttpClient(new NotFoundHandler());
|
||||
using var resolver = new AirAppMarketMetadataResolverService(httpClient);
|
||||
|
||||
var enriched = await resolver.EnrichAsync(document);
|
||||
var plugin = Assert.Single(enriched.Plugins);
|
||||
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("v0.4.0", plugin.ReleaseTag);
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin.0.4.0.laapp", plugin.ReleaseAssetName);
|
||||
Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin.Sha256);
|
||||
Assert.Equal(1024, plugin.PackageSizeBytes);
|
||||
}
|
||||
|
||||
private static string CreateNestedIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"schemaVersion": "2.0.0",
|
||||
"sourceId": "official",
|
||||
"sourceName": "LanAirApp",
|
||||
"generatedAt": "2026-04-29T00:00:00Z",
|
||||
"contracts": [],
|
||||
"plugins": [
|
||||
{
|
||||
"manifest": {
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "SDK v5 sample plugin.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "0.4.0",
|
||||
"apiVersion": "5.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll",
|
||||
"sharedContracts": []
|
||||
},
|
||||
"compatibility": {
|
||||
"minHostVersion": "0.0.1",
|
||||
"apiVersion": "5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"projectUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/wwiinnddyy/{{repositoryName}}/main/README.md",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg",
|
||||
"tags": [ "official", "sdk" ],
|
||||
"releaseNotes": "Reference plugin for SDK v5 validation."
|
||||
},
|
||||
"publication": {
|
||||
"releaseTag": "v0.4.0",
|
||||
"releaseAssetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"packageSizeBytes": 1024,
|
||||
"publishedAt": "2026-04-29T00:00:00Z",
|
||||
"updatedAt": "2026-04-29T00:00:00Z",
|
||||
"packageSources": [
|
||||
{
|
||||
"kind": "workspaceLocal",
|
||||
"path": "workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"assetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sizeBytes": 1024,
|
||||
"releaseTag": "v0.4.0",
|
||||
"priority": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"desktopComponents": [ "LanMountainDesktop.SamplePlugin.StatusClock" ],
|
||||
"settingsSections": [ "status" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class NotFoundHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("{}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -111,7 +112,7 @@ internal sealed class LauncherClient
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
$"plugin install --source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
};
|
||||
|
||||
return Process.Start(startInfo);
|
||||
@@ -130,7 +131,17 @@ internal sealed class LauncherClient
|
||||
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
|
||||
@@ -1236,7 +1236,31 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
repository,
|
||||
publication,
|
||||
sources,
|
||||
[]);
|
||||
BuildCapabilities(entry));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
if (entry.Capabilities is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var capabilities = new List<PluginCapabilityInfo>();
|
||||
capabilities.AddRange(entry.Capabilities.SharedContracts.Select(contract =>
|
||||
new PluginCapabilityInfo(contract.Id, contract.Version, contract.AssemblyName)));
|
||||
capabilities.AddRange(entry.Capabilities.DesktopComponents.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.SettingsSections.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.Exports.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.MessageTypes.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
|
||||
return capabilities
|
||||
.DistinctBy(capability => $"{capability.Id}@{capability.Version}@{capability.AssemblyName}")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||
|
||||
@@ -112,6 +112,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
? entry.PackageSources
|
||||
: entry.Publication?.PackageSources ?? [];
|
||||
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
||||
var existingManifest = entry.Manifest;
|
||||
var existingCompatibility = entry.Compatibility;
|
||||
var existingPublication = entry.Publication;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
@@ -142,9 +145,13 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
{
|
||||
MinHostVersion = FirstNonEmpty(
|
||||
template?.MinHostVersion,
|
||||
existingCompatibility?.MinHostVersion,
|
||||
entry.MinHostVersion),
|
||||
PluginApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
existingCompatibility?.PluginApiVersion,
|
||||
existingCompatibility?.ApiVersion,
|
||||
existingManifest?.ApiVersion,
|
||||
entry.ApiVersion)
|
||||
?? string.Empty
|
||||
}
|
||||
@@ -162,19 +169,24 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
},
|
||||
Publication = entry.Publication,
|
||||
Capabilities = entry.Capabilities,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, existingManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, existingManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, existingManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, existingManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, existingManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
existingCompatibility?.PluginApiVersion,
|
||||
existingCompatibility?.ApiVersion,
|
||||
existingManifest?.ApiVersion,
|
||||
entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, existingCompatibility?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
||||
Sha256 = entry.Sha256,
|
||||
PackageSizeBytes = entry.PackageSizeBytes,
|
||||
Sha256 = FirstNonEmpty(existingPublication?.Sha256, entry.Sha256) ?? string.Empty,
|
||||
PackageSizeBytes = existingPublication?.PackageSizeBytes > 0 ? existingPublication.PackageSizeBytes : entry.PackageSizeBytes,
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ReleaseTag = entry.ReleaseTag,
|
||||
ReleaseAssetName = entry.ReleaseAssetName,
|
||||
ReleaseTag = FirstNonEmpty(existingPublication?.ReleaseTag, entry.ReleaseTag) ?? string.Empty,
|
||||
ReleaseAssetName = FirstNonEmpty(existingPublication?.ReleaseAssetName, entry.ReleaseAssetName) ?? string.Empty,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
@@ -191,9 +203,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
.ToList()
|
||||
?? entry.SharedContracts,
|
||||
PackageSources = resolvedPackageSources,
|
||||
Md5 = entry.Md5,
|
||||
PublishedAt = entry.PublishedAt,
|
||||
UpdatedAt = entry.UpdatedAt,
|
||||
Md5 = FirstNonEmpty(existingPublication?.Md5, entry.Md5) ?? string.Empty,
|
||||
PublishedAt = existingPublication?.PublishedAt ?? entry.PublishedAt,
|
||||
UpdatedAt = existingPublication?.UpdatedAt ?? entry.UpdatedAt,
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -366,7 +366,17 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
|
||||
@@ -646,6 +646,8 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
public string MinHostVersion { get; init; } = string.Empty;
|
||||
|
||||
public string ApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public string PluginApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName)
|
||||
@@ -656,9 +658,13 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
|
||||
MinHostVersion,
|
||||
nameof(MinHostVersion),
|
||||
sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
|
||||
nameof(ApiVersion),
|
||||
sourceName),
|
||||
PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
PluginApiVersion,
|
||||
nameof(PluginApiVersion),
|
||||
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
|
||||
nameof(ApiVersion),
|
||||
sourceName)
|
||||
};
|
||||
}
|
||||
@@ -742,6 +748,8 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset;
|
||||
|
||||
public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId)
|
||||
@@ -755,9 +763,11 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
$"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedPath = AirAppMarketIndexDocument.NormalizeValue(Path);
|
||||
var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url)
|
||||
?? normalizedPath
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'.");
|
||||
$"Market index '{sourceName}' is missing package source url/path for plugin '{pluginId}'.");
|
||||
EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId);
|
||||
|
||||
return new AirAppMarketPluginPackageSourceEntry
|
||||
@@ -770,6 +780,7 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
_ => normalizedKind
|
||||
},
|
||||
Url = normalizedUrl,
|
||||
Path = normalizedPath ?? string.Empty,
|
||||
SourceKind = sourceKind
|
||||
};
|
||||
}
|
||||
@@ -1240,6 +1251,7 @@ internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
return compatibility is not null &&
|
||||
(!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) ||
|
||||
!string.IsNullOrWhiteSpace(compatibility.ApiVersion) ||
|
||||
!string.IsNullOrWhiteSpace(compatibility.PluginApiVersion));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user