using System.Globalization; using System.Text.Json; using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginMarketplace; internal static class AirAppMarketDefaults { public const string DefaultIndexUrl = "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json"; } internal enum AirAppMarketLoadSource { Network = 0, Cache = 1 } internal enum AirAppMarketInstallState { NotInstalled = 0, UpdateAvailable = 1, Installed = 2 } internal sealed record AirAppMarketLoadResult( bool Success, AirAppMarketIndexDocument? Document, AirAppMarketLoadSource? Source, string? WarningMessage, string? ErrorMessage); internal sealed record AirAppMarketInstallResult( bool Success, PluginPackageInstallResult? InstallResult, string? ErrorMessage); internal sealed class AirAppMarketIndexDocument { 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 Plugins { get; init; } = []; public static AirAppMarketIndexDocument Load(string json, string sourceName) { ArgumentException.ThrowIfNullOrWhiteSpace(json); ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); var document = JsonSerializer.Deserialize( json.TrimStart('\uFEFF'), SerializerOptions); if (document is null) { throw new InvalidOperationException($"Failed to parse market index '{sourceName}'."); } return document.ValidateAndNormalize(sourceName); } private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName) { var plugins = Plugins ?? []; var normalizedPlugins = new List(plugins.Count); var seenIds = new HashSet(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 AirAppMarketIndexDocument { 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 .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase) .ToList() }; } private 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 void EnsureUrl(string url, string propertyName, string sourceName) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { throw new InvalidOperationException( $"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'."); } } 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 sealed class AirAppMarketPluginEntry { 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 Tags { get; init; } = []; public DateTimeOffset PublishedAt { get; init; } public DateTimeOffset UpdatedAt { get; init; } public string ReleaseNotes { get; init; } = string.Empty; public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName) { var normalizedTags = (Tags ?? []) .Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag)) .Where(tag => !string.IsNullOrWhiteSpace(tag)) .Select(tag => tag!) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) .ToList(); var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'."); if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch))) { throw new InvalidOperationException( $"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'."); } var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'."); var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'."); var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'."); var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl) ?? throw new InvalidOperationException( $"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'."); AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName); AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName); AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName); AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, 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 AirAppMarketPluginEntry { Id = AirAppMarketIndexDocument.NormalizeValue(Id) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."), Name = AirAppMarketIndexDocument.NormalizeValue(Name) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."), Description = AirAppMarketIndexDocument.NormalizeValue(Description) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."), Author = AirAppMarketIndexDocument.NormalizeValue(Author) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."), Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName), MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName), DownloadUrl = normalizedDownloadUrl, Sha256 = normalizedSha, PackageSizeBytes = PackageSizeBytes, IconUrl = normalizedIconUrl, HomepageUrl = normalizedHomepageUrl, RepositoryUrl = normalizedRepositoryUrl, Tags = normalizedTags, PublishedAt = PublishedAt, UpdatedAt = UpdatedAt, ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.") }; } public string GetVersionSummary() { return string.Format( CultureInfo.InvariantCulture, "v{0} | API {1} | Host >= {2}", Version, ApiVersion, MinHostVersion); } }