diff --git a/.trae/specs/localization-fix/checklist.md b/.trae/specs/localization-fix/checklist.md new file mode 100644 index 0000000..024811b --- /dev/null +++ b/.trae/specs/localization-fix/checklist.md @@ -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]'` 等检查) diff --git a/.trae/specs/localization-fix/spec.md b/.trae/specs/localization-fix/spec.md new file mode 100644 index 0000000..2b5ee06 --- /dev/null +++ b/.trae/specs/localization-fix/spec.md @@ -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 显示"自定义颜色" diff --git a/.trae/specs/localization-fix/tasks.md b/.trae/specs/localization-fix/tasks.md new file mode 100644 index 0000000..215176e --- /dev/null +++ b/.trae/specs/localization-fix/tasks.md @@ -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 检查是否有遗漏的硬编码英文(通过正则搜索) diff --git a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs index 60beff7..215bbe6 100644 --- a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs +++ b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs @@ -9,8 +9,10 @@ namespace LanMountainDesktop.Launcher.Services; /// 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 diff --git a/LanMountainDesktop.PluginTemplate/README.md b/LanMountainDesktop.PluginTemplate/README.md index 8a58ce2..4a43c63 100644 --- a/LanMountainDesktop.PluginTemplate/README.md +++ b/LanMountainDesktop.PluginTemplate/README.md @@ -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`. diff --git a/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs index e477da8..4500fe5 100644 --- a/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs +++ b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs @@ -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 diff --git a/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs b/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs new file mode 100644 index 0000000..224cd7b --- /dev/null +++ b/LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs @@ -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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + RequestMessage = request, + Content = new StringContent("{}") + }); + } + } +} diff --git a/LanMountainDesktop/Services/LauncherClient.cs b/LanMountainDesktop/Services/LauncherClient.cs index 63fdea5..bd68fe7 100644 --- a/LanMountainDesktop/Services/LauncherClient.cs +++ b/LanMountainDesktop/Services/LauncherClient.cs @@ -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) diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 822cfc6..5cdd095 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -1236,7 +1236,31 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi repository, publication, sources, - []); + BuildCapabilities(entry)); + } + + private static IReadOnlyList BuildCapabilities(AirAppMarketPluginEntry entry) + { + if (entry.Capabilities is null) + { + return []; + } + + var capabilities = new List(); + 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 BuildPackageSources(AirAppMarketPluginEntry entry) diff --git a/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs b/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs index 6aed29f..abd7ca5 100644 --- a/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs +++ b/LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs @@ -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 }; } diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index 772a8e2..fa4e9bd 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -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) diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index 66e1cd2..2a9552e 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -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)); }