Support .laapp/plugin.json and improve market models

Add support for the new plugin package contract (.laapp + plugin.json) while keeping backward compatibility with legacy .lmdp/manifest.json, and improve market metadata resolution and launcher handling.

Key changes:
- LanMountainDesktop.Launcher: PluginInstallerService now recognizes plugin.json and .laapp, preserves legacy manifest/package names, searches for manifests with a helper, and removes existing packages matching either extension.
- LanMountainDesktop.PluginTemplate: README updated to document .laapp, plugin.json, runtime contract and packaging expectations.
- Tests: New and extended tests for PluginInstallerService and a PluginMarketIndexDocumentTests covering nested index parsing and metadata enrichment.
- LauncherClient & PluginMarketInstallService: ResolveLauncherPath now probes multiple candidate locations (useful for dev and packaged layouts); LauncherClient also adjusted launcher arguments to use the updated CLI form.
- SettingsDomainServices: Added BuildCapabilities to safely build capability lists from entries (null checks, projection, de-dup via DistinctBy).
- AirAppMarketMetadataResolverService & PluginMarketModels: Prefer existing manifest/publication/compatibility values when enriching entries, add ApiVersion/Path fields, normalize compatibility logic and package source URL/path handling; handle Sha256/size/publication dates more robustly.
- Misc: Added localization spec/checklist/tasks under .trae for a localization fix initiative.

These changes enable the new plugin packaging format, improve robustness of market data enrichment, make launcher discovery more flexible for different environments, and add tests and docs to cover the new behaviors.
This commit is contained in:
lincube
2026-04-30 00:02:52 +08:00
parent eb066b53f1
commit fc4d0c4cd8
12 changed files with 519 additions and 28 deletions

View File

@@ -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