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

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

View File

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

View File

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

View File

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

View File

@@ -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));
}