插件市场
This commit is contained in:
lincube
2026-03-10 09:55:49 +08:00
parent d33d8d3391
commit cdffaa16eb
45 changed files with 4483 additions and 365 deletions

23
airappmarket/README.md Normal file
View File

@@ -0,0 +1,23 @@
# AirAppMarket
`airappmarket/` 是阑山桌面的官方插件市场源目录。
当前阶段职责:
- 提供官方插件市场索引 `index.json`
- 提供索引 schema
- 提供静态图标资产
- 提供本地与 CI 使用的索引校验工具
Bootstrap 方式:
1. 用户先通过阑山桌面内置的 `设置 -> 插件 -> 打开 .laapp 插件包` 手动安装 `LanMountainDesktop.PluginMarketplace`
2. 市场插件启动后,会从这里的官方索引拉取插件列表。
3. 后续插件安装与更新都通过市场插件完成。
官方索引地址:
`https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json`
约束:
- 这里只维护官方市场源,不做多源聚合。
- 第一阶段不提供独立 GitHub Pages 页面。
- 索引中的下载链接默认指向本仓库已提交的 `.laapp` 发布包。

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Plugin Marketplace">
<defs>
<linearGradient id="marketBg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#0EA5E9"/>
<stop offset="100%" stop-color="#22C55E"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#marketBg)"/>
<path d="M43 36h42c3.866 0 7 3.134 7 7v42c0 3.866-3.134 7-7 7H43c-3.866 0-7-3.134-7-7V43c0-3.866 3.134-7 7-7Z" fill="#FFFFFF" fill-opacity="0.16"/>
<path d="M52 52h24a12 12 0 0 1 0 24H52a8 8 0 0 1 0-16h4" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
<circle cx="84" cy="84" r="14" fill="#FFFFFF"/>
<path d="M84 76v16M76 84h16" stroke="#0EA5E9" stroke-linecap="round" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 835 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Sample Plugin">
<defs>
<linearGradient id="sampleBg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#F59E0B"/>
<stop offset="100%" stop-color="#EF4444"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#sampleBg)"/>
<path d="M52 32c0-6.627 5.373-12 12-12s12 5.373 12 12v8h8c6.627 0 12 5.373 12 12s-5.373 12-12 12h-8v32c0 6.627-5.373 12-12 12s-12-5.373-12-12V64h-8c-6.627 0-12-5.373-12-12s5.373-12 12-12h8v-8Z" fill="#FFFFFF" fill-opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

54
airappmarket/index.json Normal file
View File

@@ -0,0 +1,54 @@
{
"schemaVersion": "1.0.0",
"sourceId": "official.lanmountaindesktop",
"sourceName": "LanMountainDesktop Official Market",
"generatedAt": "2026-03-10T01:30:00Z",
"plugins": [
{
"id": "LanMountainDesktop.PluginMarketplace",
"name": "LanMountain Plugin Marketplace",
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"minHostVersion": "1.0.0",
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.PluginMarketplace.1.0.0.laapp",
"sha256": "51e73bf834c4c3f8a32bc711e9db62b954e24a3577e580d6faa4c3986ce70e0b",
"packageSizeBytes": 1704353,
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/plugin-marketplace.svg",
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
"tags": [
"official",
"market",
"settings"
],
"publishedAt": "2026-03-10T01:30:00Z",
"updatedAt": "2026-03-10T01:30:00Z",
"releaseNotes": "Bootstrap plugin for the official LanMountainDesktop marketplace. Install it once from a local .laapp package, then use it to discover and stage future plugin installs and updates."
},
{
"id": "LanMountainDesktop.SamplePlugin",
"name": "LanMountain Sample Plugin",
"description": "Example plugin used to validate PluginSdk loading and isolation.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"minHostVersion": "1.0.0",
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.SamplePlugin.1.0.0.laapp",
"sha256": "c092f9d215ee0f1e436bc49b919dd9a75b3838e950c72c46dd7e41807557125c",
"packageSizeBytes": 1703398,
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/sample-plugin.svg",
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
"tags": [
"example",
"official",
"sdk"
],
"publishedAt": "2026-03-10T01:30:00Z",
"updatedAt": "2026-03-10T01:30:00Z",
"releaseNotes": "Reference plugin for SDK validation. Includes a settings page, a desktop widget, localization resources, service registration, and plugin message bus usage."
}
]
}

View File

@@ -0,0 +1,137 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/schema/airappmarket-index.schema.json",
"title": "AirAppMarket Index",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"sourceId",
"sourceName",
"generatedAt",
"plugins"
],
"properties": {
"schemaVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"sourceId": {
"type": "string",
"minLength": 1
},
"sourceName": {
"type": "string",
"minLength": 1
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"plugins": {
"type": "array",
"items": {
"$ref": "#/$defs/plugin"
}
}
},
"$defs": {
"plugin": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"description",
"author",
"version",
"apiVersion",
"minHostVersion",
"downloadUrl",
"sha256",
"packageSizeBytes",
"iconUrl",
"homepageUrl",
"repositoryUrl",
"tags",
"publishedAt",
"updatedAt",
"releaseNotes"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"author": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"apiVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"minHostVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
},
"downloadUrl": {
"type": "string",
"format": "uri"
},
"sha256": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
},
"packageSizeBytes": {
"type": "integer",
"minimum": 1
},
"iconUrl": {
"type": "string",
"format": "uri"
},
"homepageUrl": {
"type": "string",
"format": "uri"
},
"repositoryUrl": {
"type": "string",
"format": "uri"
},
"tags": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"publishedAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"releaseNotes": {
"type": "string",
"minLength": 1
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,247 @@
using System.Text.Json;
return await RunAsync(args);
static Task<int> RunAsync(string[] args)
{
try
{
var indexPath = args.Length > 0
? Path.GetFullPath(args[0])
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "index.json"));
var schemaPath = args.Length > 1
? Path.GetFullPath(args[1])
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(indexPath)!, "schema", "airappmarket-index.schema.json"));
if (!File.Exists(indexPath))
{
throw new FileNotFoundException($"Market index '{indexPath}' was not found.", indexPath);
}
if (!File.Exists(schemaPath))
{
throw new FileNotFoundException($"Market schema '{schemaPath}' was not found.", schemaPath);
}
JsonDocument.Parse(File.ReadAllText(schemaPath));
var document = MarketIndex.Load(File.ReadAllText(indexPath), indexPath);
Console.WriteLine($"Validated '{indexPath}'.");
Console.WriteLine($"Source: {document.SourceName} ({document.SourceId})");
Console.WriteLine($"Plugins: {document.Plugins.Count}");
return Task.FromResult(0);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return Task.FromResult(1);
}
}
internal sealed class MarketIndex
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<MarketPlugin> Plugins { get; init; } = [];
public static MarketIndex Load(string json, string sourceName)
{
var document = JsonSerializer.Deserialize<MarketIndex>(
json.TrimStart('\uFEFF'),
SerializerOptions) ?? throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
return document.ValidateAndNormalize(sourceName);
}
private MarketIndex ValidateAndNormalize(string sourceName)
{
var normalizedPlugins = new List<MarketPlugin>(Plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in Plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new MarketIndex
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
};
}
internal static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
internal static void EnsureUrl(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{normalized}' for '{propertyName}'.");
}
}
}
internal sealed class MarketPlugin
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public MarketPlugin ValidateAndNormalize(string sourceName)
{
var tagSource = Tags ?? [];
var normalizedTags = tagSource
.Select(MarketIndex.NormalizeValue)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedTags.Count != tagSource.Count(tag => !string.IsNullOrWhiteSpace(tag)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate or blank tags for plugin '{Id}'.");
}
var normalizedSha = MarketIndex.RequireValue(Sha256, nameof(Sha256), sourceName).ToLowerInvariant();
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
MarketIndex.EnsureUrl(DownloadUrl, nameof(DownloadUrl), sourceName);
MarketIndex.EnsureUrl(IconUrl, nameof(IconUrl), sourceName);
MarketIndex.EnsureUrl(HomepageUrl, nameof(HomepageUrl), sourceName);
MarketIndex.EnsureUrl(RepositoryUrl, nameof(RepositoryUrl), sourceName);
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new MarketPlugin
{
Id = MarketIndex.RequireValue(Id, nameof(Id), sourceName),
Name = MarketIndex.RequireValue(Name, nameof(Name), sourceName),
Description = MarketIndex.RequireValue(Description, nameof(Description), sourceName),
Author = MarketIndex.RequireValue(Author, nameof(Author), sourceName),
Version = MarketIndex.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = MarketIndex.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = MarketIndex.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = MarketIndex.RequireValue(DownloadUrl, nameof(DownloadUrl), sourceName),
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = MarketIndex.RequireValue(IconUrl, nameof(IconUrl), sourceName),
HomepageUrl = MarketIndex.RequireValue(HomepageUrl, nameof(HomepageUrl), sourceName),
RepositoryUrl = MarketIndex.RequireValue(RepositoryUrl, nameof(RepositoryUrl), sourceName),
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = MarketIndex.RequireValue(ReleaseNotes, nameof(ReleaseNotes), sourceName)
};
}
}