From 4df740e3dfc1ff1bf129361ec25368c96f5b6293 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 10 Mar 2026 14:56:05 +0800 Subject: [PATCH] 0.5.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多线程 --- .../AirAppMarketCacheService.cs | 41 - .../AirAppMarketIndexService.cs | 77 -- .../AirAppMarketInstallService.cs | 83 -- .../AirAppMarketModels.cs | 299 ------ ...anMountainDesktop.PluginMarketplace.csproj | 35 - .../Localization/en-US.json | 37 - .../Localization/zh-CN.json | 37 - .../PluginMarketplacePlugin.cs | 47 - .../PluginMarketplaceSettingsView.cs | 727 -------------- .../plugin.json | 9 - LanMountainDesktop/Localization/en-US.json | 8 +- LanMountainDesktop/Localization/zh-CN.json | 8 +- .../Services/GitHubReleaseUpdateService.cs | 66 +- .../Services/ResumableDownloadService.cs | 940 ++++++++++++++++++ .../Views/MainWindow.Localization.cs | 2 + .../Views/MainWindow.Settings.cs | 14 +- LanMountainDesktop/Views/MainWindow.axaml | 6 + .../Views/SettingsWindow.Core.cs | 12 + .../Views/SettingsWindow.Localization.cs | 2 + LanMountainDesktop/Views/SettingsWindow.axaml | 1 + ...Window.PluginMarketSettingsLocalization.cs | 9 + .../plugins/MainWindow.PluginSettingsHost.cs | 2 +- .../plugins/PluginMarketInstallService.cs | 30 +- .../plugins/PluginMarketSettingsPage.axaml | 25 + .../plugins/PluginMarketSettingsPage.axaml.cs | 71 ++ .../plugins/PluginSettingsPage.Host.cs | 28 - .../plugins/PluginSettingsPage.axaml | 19 - ...Window.PluginMarketSettingsLocalization.cs | 9 + airappmarket/assets/plugin-marketplace.svg | 13 - airappmarket/index.json | 25 +- 30 files changed, 1134 insertions(+), 1548 deletions(-) delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs delete mode 100644 LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json create mode 100644 LanMountainDesktop/Services/ResumableDownloadService.cs create mode 100644 LanMountainDesktop/plugins/MainWindow.PluginMarketSettingsLocalization.cs create mode 100644 LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml create mode 100644 LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs create mode 100644 LanMountainDesktop/plugins/SettingsWindow.PluginMarketSettingsLocalization.cs delete mode 100644 airappmarket/assets/plugin-marketplace.svg diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs deleted file mode 100644 index ad2370a..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace LanMountainDesktop.PluginMarketplace; - -internal sealed class AirAppMarketCacheService -{ - private readonly string _cacheDirectory; - - public AirAppMarketCacheService(string dataDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); - _cacheDirectory = Path.Combine(dataDirectory, "cache"); - } - - public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json"); - - public void SaveIndexJson(string json) - { - ArgumentException.ThrowIfNullOrWhiteSpace(json); - Directory.CreateDirectory(_cacheDirectory); - File.WriteAllText(CacheFilePath, json); - } - - public bool TryReadIndexJson(out string json) - { - try - { - if (!File.Exists(CacheFilePath)) - { - json = string.Empty; - return false; - } - - json = File.ReadAllText(CacheFilePath); - return !string.IsNullOrWhiteSpace(json); - } - catch - { - json = string.Empty; - return false; - } - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs deleted file mode 100644 index 39d3e66..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Net.Http.Headers; - -namespace LanMountainDesktop.PluginMarketplace; - -internal sealed class AirAppMarketIndexService : IDisposable -{ - private readonly AirAppMarketCacheService _cacheService; - private readonly HttpClient _httpClient; - - public AirAppMarketIndexService(AirAppMarketCacheService cacheService) - { - _cacheService = cacheService; - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(20) - }; - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); - _httpClient.DefaultRequestHeaders.Accept.Add( - new MediaTypeWithQualityHeaderValue("application/json")); - } - - public async Task LoadAsync(CancellationToken cancellationToken = default) - { - Exception? networkError = null; - - try - { - using var response = await _httpClient.GetAsync( - AirAppMarketDefaults.DefaultIndexUrl, - cancellationToken); - var json = await response.Content.ReadAsStringAsync(cancellationToken); - response.EnsureSuccessStatusCode(); - - var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl); - _cacheService.SaveIndexJson(json); - return new AirAppMarketLoadResult(true, document, AirAppMarketLoadSource.Network, null, null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - networkError = ex; - } - - if (_cacheService.TryReadIndexJson(out var cachedJson)) - { - try - { - var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath); - return new AirAppMarketLoadResult( - true, - cachedDocument, - AirAppMarketLoadSource.Cache, - networkError?.Message, - null); - } - catch (Exception cacheEx) - { - return new AirAppMarketLoadResult( - false, - null, - null, - null, - $"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}"); - } - } - - return new AirAppMarketLoadResult(false, null, null, null, networkError?.Message ?? "Unknown network error"); - } - - public void Dispose() - { - _httpClient.Dispose(); - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs deleted file mode 100644 index c9b1d66..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Security.Cryptography; -using LanMountainDesktop.PluginSdk; - -namespace LanMountainDesktop.PluginMarketplace; - -internal sealed class AirAppMarketInstallService : IDisposable -{ - private readonly IPluginPackageManager _packageManager; - private readonly HttpClient _httpClient; - private readonly string _downloadsDirectory; - - public AirAppMarketInstallService(IPluginPackageManager packageManager, string dataDirectory) - { - _packageManager = packageManager; - _downloadsDirectory = Path.Combine(dataDirectory, "downloads"); - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromMinutes(2) - }; - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); - } - - public async Task InstallAsync( - AirAppMarketPluginEntry plugin, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(plugin); - - Directory.CreateDirectory(_downloadsDirectory); - var downloadPath = Path.Combine( - _downloadsDirectory, - $"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp"); - - try - { - using var response = await _httpClient.GetAsync( - plugin.DownloadUrl, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - response.EnsureSuccessStatusCode(); - - await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken)) - await using (var destinationStream = File.Create(downloadPath)) - { - await responseStream.CopyToAsync(destinationStream, cancellationToken); - } - - await using var hashStream = File.OpenRead(downloadPath); - var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); - var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); - if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) - { - File.Delete(downloadPath); - return new AirAppMarketInstallResult( - false, - null, - $"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}."); - } - - var installResult = _packageManager.InstallPackage(downloadPath); - return new AirAppMarketInstallResult(true, installResult, null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return new AirAppMarketInstallResult(false, null, ex.Message); - } - } - - public void Dispose() - { - _httpClient.Dispose(); - } - - private static string SanitizeFileName(string value) - { - var invalidChars = Path.GetInvalidFileNameChars(); - return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs deleted file mode 100644 index 79a681c..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs +++ /dev/null @@ -1,299 +0,0 @@ -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); - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj deleted file mode 100644 index 8accea2..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - net10.0 - enable - enable - 1.0.0 - true - bin\$(Configuration)\$(TargetFramework)\content\ - false - false - ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\ - $(PluginPackageOutputDirectory)$(AssemblyName).laapp - ..\..\releases\ - $(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp - ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\ - - - - - - - - - - - - - - - - - - - diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json deleted file mode 100644 index dfc0e3f..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "market.page_title": "Plugin Marketplace", - "market.toolbar.search_placeholder": "Search plugins", - "market.toolbar.refresh": "Refresh", - "market.status.loading": "Loading the official plugin marketplace...", - "market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.", - "market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}", - "market.status.load_failed_format": "Failed to load plugin marketplace: {0}", - "market.status.installing_format": "Downloading and staging plugin '{0}'...", - "market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.", - "market.status.install_failed_format": "Failed to install plugin: {0}", - "market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.", - "market.list.empty": "The plugin marketplace has not been loaded yet.", - "market.list.no_results": "No plugins match the current search.", - "market.card.subtitle_format": "{0} · v{1}", - "market.card.loaded": "Loaded", - "market.card.pending_restart": "Restart required", - "market.detail.placeholder": "Select a plugin from the left to inspect details.", - "market.detail.author": "Author", - "market.detail.version": "Version", - "market.detail.api_version": "API Version", - "market.detail.min_host_version": "Minimum Host Version", - "market.detail.installed_version": "Installed Version", - "market.detail.not_installed": "Not installed", - "market.detail.market_source": "Market Source", - "market.detail.homepage": "Homepage", - "market.detail.repository": "Repository", - "market.detail.release_notes": "Release Notes", - "market.detail.state.not_installed": "Not installed", - "market.detail.state.update_available": "Update available", - "market.detail.state.installed": "Installed", - "market.detail.unknown": "Unknown", - "market.button.install": "Install", - "market.button.update": "Update", - "market.button.installed": "Installed", - "market.button.installing": "Installing..." -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json deleted file mode 100644 index 9eb4d7c..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "market.page_title": "插件市场", - "market.toolbar.search_placeholder": "搜索插件", - "market.toolbar.refresh": "刷新", - "market.status.loading": "正在加载官方插件市场…", - "market.status.loaded_network_format": "已从官方源加载 {0} 个插件。", - "market.status.loaded_cache_format": "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}", - "market.status.load_failed_format": "加载插件市场失败:{0}", - "market.status.installing_format": "正在下载并暂存插件“{0}”…", - "market.status.install_success_format": "插件“{0}”已暂存完成,重启应用后生效。", - "market.status.install_failed_format": "安装插件失败:{0}", - "market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。", - "market.list.empty": "插件市场尚未加载。", - "market.list.no_results": "没有匹配的插件。", - "market.card.subtitle_format": "{0} · v{1}", - "market.card.loaded": "已加载", - "market.card.pending_restart": "需重启", - "market.detail.placeholder": "从左侧选择一个插件以查看详情。", - "market.detail.author": "作者", - "market.detail.version": "版本", - "market.detail.api_version": "API 版本", - "market.detail.min_host_version": "最低宿主版本", - "market.detail.installed_version": "当前已安装版本", - "market.detail.not_installed": "未安装", - "market.detail.market_source": "市场源", - "market.detail.homepage": "主页", - "market.detail.repository": "仓库", - "market.detail.release_notes": "发布说明", - "market.detail.state.not_installed": "未安装", - "market.detail.state.update_available": "可更新", - "market.detail.state.installed": "已安装", - "market.detail.unknown": "未知", - "market.button.install": "安装", - "market.button.update": "更新", - "market.button.installed": "已安装", - "market.button.installing": "安装中…" -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs deleted file mode 100644 index 56e04f1..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs +++ /dev/null @@ -1,47 +0,0 @@ -using LanMountainDesktop.PluginSdk; - -namespace LanMountainDesktop.PluginMarketplace; - -[PluginEntrance] -public sealed class PluginMarketplacePlugin : PluginBase, IDisposable -{ - private AirAppMarketIndexService? _indexService; - private AirAppMarketInstallService? _installService; - - public override void Initialize(IPluginContext context) - { - Directory.CreateDirectory(context.DataDirectory); - - var localizer = PluginLocalizer.Create(context); - var packageManager = context.GetService() - ?? throw new InvalidOperationException( - "The host does not expose IPluginPackageManager. LanMountainDesktop.PluginMarketplace requires a newer host build."); - - var cacheService = new AirAppMarketCacheService(context.DataDirectory); - _indexService = new AirAppMarketIndexService(cacheService); - _installService = new AirAppMarketInstallService(packageManager, context.DataDirectory); - - context.RegisterService(cacheService); - context.RegisterService(_indexService); - context.RegisterService(_installService); - - context.RegisterSettingsPage(new PluginSettingsPageRegistration( - "marketplace", - localizer.GetString("market.page_title", "插件市场"), - () => new PluginMarketplaceSettingsView( - context, - localizer, - packageManager, - _indexService, - _installService), - sortOrder: -100)); - } - - public void Dispose() - { - _installService?.Dispose(); - _indexService?.Dispose(); - _installService = null; - _indexService = null; - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs deleted file mode 100644 index f81e544..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System.Globalization; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Media; -using LanMountainDesktop.PluginSdk; - -namespace LanMountainDesktop.PluginMarketplace; - -internal sealed class PluginMarketplaceSettingsView : UserControl -{ - private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000")); - private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1F0EA5E9")); - private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E")); - private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700")); - private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C")); - - private readonly PluginLocalizer _localizer; - private readonly IPluginPackageManager _packageManager; - private readonly AirAppMarketIndexService _indexService; - private readonly AirAppMarketInstallService _installService; - private readonly Version? _hostVersion; - - private readonly TextBox _searchTextBox; - private readonly Button _refreshButton; - private readonly TextBlock _statusTextBlock; - private readonly StackPanel _pluginListHost; - private readonly Border _detailBorder; - - private AirAppMarketIndexDocument? _document; - private AirAppMarketPluginEntry? _selectedPlugin; - private Dictionary _installedPlugins = new(StringComparer.OrdinalIgnoreCase); - private bool _isRefreshing; - private bool _isInstalling; - private bool _hasLoadedOnce; - - public PluginMarketplaceSettingsView( - IPluginContext context, - PluginLocalizer localizer, - IPluginPackageManager packageManager, - AirAppMarketIndexService indexService, - AirAppMarketInstallService installService) - { - _localizer = localizer; - _packageManager = packageManager; - _indexService = indexService; - _installService = installService; - _hostVersion = context.TryGetProperty(PluginHostPropertyKeys.HostVersion, out var hostVersionText) && - AirAppMarketIndexDocument.TryParseVersion(hostVersionText, out var parsedHostVersion) - ? parsedHostVersion - : null; - - _searchTextBox = new TextBox - { - MinWidth = 240, - Watermark = T("market.toolbar.search_placeholder", "搜索插件") - }; - _searchTextBox.PropertyChanged += (_, e) => - { - if (e.Property == TextBox.TextProperty) - { - RebuildSurface(); - } - }; - - _refreshButton = new Button - { - Content = T("market.toolbar.refresh", "刷新"), - HorizontalAlignment = HorizontalAlignment.Left - }; - _refreshButton.Click += OnRefreshClick; - - _statusTextBlock = new TextBlock - { - Text = T("market.status.loading", "正在加载官方插件市场…"), - TextWrapping = TextWrapping.Wrap, - Foreground = WarningBrush - }; - - _pluginListHost = new StackPanel - { - Spacing = 10 - }; - - _detailBorder = CreatePanelShell(); - - Content = BuildLayout(); - AttachedToVisualTree += async (_, _) => - { - if (_hasLoadedOnce) - { - return; - } - - _hasLoadedOnce = true; - await RefreshAsync(); - }; - } - - private Control BuildLayout() - { - var root = new Grid - { - RowDefinitions = new RowDefinitions("Auto,*"), - RowSpacing = 16 - }; - - var toolbar = new Grid - { - ColumnDefinitions = new ColumnDefinitions("*,Auto"), - ColumnSpacing = 12 - }; - - toolbar.Children.Add(new StackPanel - { - Spacing = 8, - Children = - { - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 10, - Children = - { - _searchTextBox, - _refreshButton - } - }, - _statusTextBlock - } - }); - - var contentGrid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("360,*"), - ColumnSpacing = 16 - }; - - var listShell = CreatePanelShell(); - listShell.Child = new ScrollViewer - { - Content = _pluginListHost - }; - - contentGrid.Children.Add(listShell); - contentGrid.Children.Add(_detailBorder); - Grid.SetColumn(_detailBorder, 1); - - root.Children.Add(toolbar); - root.Children.Add(contentGrid); - Grid.SetRow(contentGrid, 1); - - return root; - } - - private async void OnRefreshClick(object? sender, RoutedEventArgs e) - { - await RefreshAsync(); - } - - private async Task RefreshAsync() - { - if (_isRefreshing) - { - return; - } - - _isRefreshing = true; - _refreshButton.IsEnabled = false; - SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush); - - try - { - _installedPlugins = _packageManager - .GetInstalledPlugins() - .ToDictionary(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase); - - var result = await _indexService.LoadAsync(); - if (!result.Success || result.Document is null) - { - _document = null; - _selectedPlugin = null; - SetStatus( - F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")), - ErrorBrush); - RebuildSurface(); - return; - } - - _document = result.Document; - _selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins); - - var statusMessage = result.Source == AirAppMarketLoadSource.Cache - ? F( - "market.status.loaded_cache_format", - "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}", - result.Document.Plugins.Count, - result.WarningMessage ?? T("market.detail.unknown", "未知错误")) - : F( - "market.status.loaded_network_format", - "已从官方源加载 {0} 个插件。", - result.Document.Plugins.Count); - - SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush); - RebuildSurface(); - } - finally - { - _isRefreshing = false; - _refreshButton.IsEnabled = true; - } - } - - private void RebuildSurface() - { - var filteredPlugins = GetFilteredPlugins(); - if (filteredPlugins.Count > 0) - { - _selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins); - } - else - { - _selectedPlugin = null; - } - - BuildPluginList(filteredPlugins); - BuildDetailPanel(); - } - - private List GetFilteredPlugins() - { - if (_document is null) - { - return []; - } - - var query = (_searchTextBox.Text ?? string.Empty).Trim(); - var source = _document.Plugins; - if (string.IsNullOrWhiteSpace(query)) - { - return source.ToList(); - } - - return source - .Where(plugin => - plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || - plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - - private void BuildPluginList(IReadOnlyList plugins) - { - _pluginListHost.Children.Clear(); - - if (_document is null) - { - _pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。"))); - return; - } - - if (plugins.Count == 0) - { - _pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。"))); - return; - } - - foreach (var plugin in plugins) - { - _pluginListHost.Children.Add(CreatePluginCard(plugin)); - } - } - - private Control CreatePluginCard(AirAppMarketPluginEntry plugin) - { - var installState = ResolveInstallState(plugin, out var installedPlugin); - var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase); - - var button = new Button - { - HorizontalContentAlignment = HorizontalAlignment.Stretch, - Padding = new Thickness(0), - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Content = new Border - { - Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14), - Child = new StackPanel - { - Spacing = 10, - Children = - { - new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 12, - Children = - { - CreateMonogramIcon(plugin.Name, 42), - new StackPanel - { - Spacing = 4, - Children = - { - new TextBlock - { - Text = plugin.Name, - FontSize = 16, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version), - Foreground = Brushes.Gray, - TextWrapping = TextWrapping.Wrap - } - } - } - } - }, - new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap, - MaxHeight = 56 - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - Children = - { - CreateStateChip(T(StateKey(installState), StateFallback(installState))), - CreateStateChip(installedPlugin?.IsLoaded == true - ? T("market.card.loaded", "已加载") - : T("market.card.pending_restart", "需重启")), - new TextBlock - { - Text = string.Join(" ", plugin.Tags.Take(3)), - VerticalAlignment = VerticalAlignment.Center, - Foreground = Brushes.Gray - } - } - } - } - } - } - }; - - button.Click += (_, _) => - { - _selectedPlugin = plugin; - RebuildSurface(); - }; - - return button; - } - - private void BuildDetailPanel() - { - if (_selectedPlugin is null) - { - _detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。")); - return; - } - - var plugin = _selectedPlugin; - var installState = ResolveInstallState(plugin, out var installedPlugin); - var isCompatible = IsCompatibleWithHost(plugin); - var installButton = new Button - { - Content = _isInstalling - ? T("market.button.installing", "安装中…") - : T(ButtonKey(installState), ButtonFallback(installState)), - IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed, - HorizontalAlignment = HorizontalAlignment.Left, - MinWidth = 120 - }; - installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin); - - var detailPanel = new StackPanel - { - Spacing = 14, - Children = - { - new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 14, - Children = - { - CreateMonogramIcon(plugin.Name, 64), - new StackPanel - { - Spacing = 4, - Children = - { - new TextBlock - { - Text = plugin.Name, - FontSize = 24, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap - } - } - } - } - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - Children = - { - CreateStateChip(T(StateKey(installState), StateFallback(installState))), - CreateStateChip(plugin.GetVersionSummary()), - CreateStateChip(string.Join(", ", plugin.Tags)) - } - }, - installButton, - CreateInfoRow(T("market.detail.author", "作者"), plugin.Author), - CreateInfoRow(T("market.detail.version", "版本"), plugin.Version), - CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion), - CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion), - CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")), - CreateInfoRow(T("market.detail.market_source", "市场源"), AirAppMarketDefaults.DefaultIndexUrl), - CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl), - CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl), - new TextBlock - { - Text = T("market.detail.release_notes", "发布说明"), - FontSize = 18, - FontWeight = FontWeight.SemiBold - }, - new Border - { - Background = SurfaceBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14), - Child = new TextBlock - { - Text = plugin.ReleaseNotes, - TextWrapping = TextWrapping.Wrap - } - } - } - }; - - if (!isCompatible) - { - detailPanel.Children.Insert( - 3, - new TextBlock - { - Text = F( - "market.status.host_incompatible_format", - "当前宿主版本过低,至少需要 {0}。", - plugin.MinHostVersion), - Foreground = ErrorBrush, - TextWrapping = TextWrapping.Wrap - }); - } - - _detailBorder.Child = new ScrollViewer - { - Content = detailPanel - }; - } - - private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin) - { - if (_isInstalling) - { - return; - } - - _isInstalling = true; - BuildDetailPanel(); - SetStatus( - F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name), - WarningBrush); - - try - { - var result = await _installService.InstallAsync(plugin); - if (!result.Success || result.InstallResult is null) - { - SetStatus( - F( - "market.status.install_failed_format", - "安装插件失败:{0}", - result.ErrorMessage ?? T("market.detail.unknown", "未知错误")), - ErrorBrush); - return; - } - - _installedPlugins = _packageManager - .GetInstalledPlugins() - .ToDictionary(item => item.Manifest.Id, StringComparer.OrdinalIgnoreCase); - SetStatus( - F( - "market.status.install_success_format", - "插件“{0}”已暂存完成,重启应用后生效。", - result.InstallResult.Manifest.Name), - SuccessBrush); - RebuildSurface(); - } - finally - { - _isInstalling = false; - BuildDetailPanel(); - } - } - - private AirAppMarketPluginEntry? ResolveSelectedPlugin( - string? selectedPluginId, - IReadOnlyList plugins) - { - if (plugins.Count == 0) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(selectedPluginId)) - { - var existing = plugins.FirstOrDefault(plugin => - string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - { - return existing; - } - } - - return plugins[0]; - } - - private AirAppMarketInstallState ResolveInstallState( - AirAppMarketPluginEntry plugin, - out InstalledPluginInfo? installedPlugin) - { - if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin)) - { - return AirAppMarketInstallState.NotInstalled; - } - - return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0 - ? AirAppMarketInstallState.UpdateAvailable - : AirAppMarketInstallState.Installed; - } - - private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin) - { - if (_hostVersion is null || - !AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) || - minHostVersion is null) - { - return true; - } - - return _hostVersion >= minHostVersion; - } - - private void SetStatus(string message, IBrush foreground) - { - _statusTextBlock.Text = message; - _statusTextBlock.Foreground = foreground; - } - - private static int CompareVersions(string? left, string? right) - { - if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion)) - { - leftVersion = new Version(0, 0, 0); - } - - if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion)) - { - rightVersion = new Version(0, 0, 0); - } - - return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0)); - } - - private Border CreatePanelShell() - { - return new Border - { - Background = SurfaceBrush, - CornerRadius = new CornerRadius(18), - Padding = new Thickness(16) - }; - } - - private Control CreateEmptyState(string text) - { - return new Border - { - Background = SurfaceBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(18), - Child = new TextBlock - { - Text = text, - TextWrapping = TextWrapping.Wrap - } - }; - } - - private Border CreateMonogramIcon(string text, double size) - { - var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant(); - return new Border - { - Width = size, - Height = size, - CornerRadius = new CornerRadius(size / 2), - Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")), - Child = new TextBlock - { - Text = glyph, - FontSize = Math.Max(16, size * 0.36), - FontWeight = FontWeight.Bold, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - TextAlignment = TextAlignment.Center - } - }; - } - - private Border CreateStateChip(string text) - { - return new Border - { - Background = new SolidColorBrush(Color.Parse("#22000000")), - CornerRadius = new CornerRadius(999), - Padding = new Thickness(10, 4), - Child = new TextBlock - { - Text = text, - FontSize = 12 - } - }; - } - - private Control CreateInfoRow(string label, string value) - { - return new StackPanel - { - Spacing = 4, - Children = - { - new TextBlock - { - Text = label, - FontSize = 12, - Foreground = Brushes.Gray - }, - new TextBlock - { - Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value, - TextWrapping = TextWrapping.Wrap - } - } - }; - } - - private string T(string key, string fallback) - { - return _localizer.GetString(key, fallback); - } - - private string F(string key, string fallback, params object[] args) - { - return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args); - } - - private static string StateKey(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available", - AirAppMarketInstallState.Installed => "market.detail.state.installed", - _ => "market.detail.state.not_installed" - }; - } - - private static string StateFallback(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.UpdateAvailable => "可更新", - AirAppMarketInstallState.Installed => "已安装", - _ => "未安装" - }; - } - - private static string ButtonKey(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.UpdateAvailable => "market.button.update", - AirAppMarketInstallState.Installed => "market.button.installed", - _ => "market.button.install" - }; - } - - private static string ButtonFallback(AirAppMarketInstallState state) - { - return state switch - { - AirAppMarketInstallState.UpdateAvailable => "更新", - AirAppMarketInstallState.Installed => "已安装", - _ => "安装" - }; - } -} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json deleted file mode 100644 index f0189d8..0000000 --- a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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", - "entranceAssembly": "LanMountainDesktop.PluginMarketplace.dll" -} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 6f059da..0656856 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -338,10 +338,10 @@ "settings.plugins.source_manifest": "Loose manifest", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}", - "settings.plugins.market_header": "Official Market", - "settings.plugins.market_desc": "Browse plugins from the official LanAirApp source and stage installs.", - "settings.plugins.market_hint": "Use the official market source hosted in LanAirApp to discover and stage plugin installs.", - "settings.plugins.market_unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.", + "settings.nav.plugin_market": "Plugin Market", + "settings.plugin_market.title": "Plugin Market", + "settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.", + "settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.", "market.toolbar.search_placeholder": "Search plugins", "market.toolbar.refresh": "Refresh", "market.status.loading": "Loading the official plugin market...", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index d50a3b5..d05f4a3 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -338,10 +338,10 @@ "settings.plugins.source_manifest": "散装清单", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "设置页:{0} | 组件:{1}", - "settings.plugins.market_header": "官方市场", - "settings.plugins.market_desc": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。", - "settings.plugins.market_hint": "这里使用托管在 LanAirApp 仓库中的官方市场索引来发现插件并暂存安装。", - "settings.plugins.market_unavailable": "插件运行时不可用,暂时无法打开官方市场。", + "settings.nav.plugin_market": "插件市场", + "settings.plugin_market.title": "插件市场", + "settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。", + "settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。", "market.toolbar.search_placeholder": "搜索插件", "market.toolbar.refresh": "刷新", "market.status.loading": "正在加载官方插件市场...", diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index f2bc437..174b7ae 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable private readonly string _owner; private readonly string _repo; private readonly HttpClient _httpClient; + private readonly ResumableDownloadService _downloadService; private readonly bool _ownsHttpClient; public GitHubReleaseUpdateService( @@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable _ownsHttpClient = false; } + _downloadService = new ResumableDownloadService(_httpClient); + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) { _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0"); @@ -187,59 +190,20 @@ public sealed class GitHubReleaseUpdateService : IDisposable return new UpdateDownloadResult(false, null, "Destination file path is empty."); } - try - { - var directory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } + var progressAdapter = progress is null + ? null + : new Progress(info => progress.Report(info.Progress)); - using var response = await _httpClient.GetAsync( - asset.BrowserDownloadUrl, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + var result = await _downloadService.DownloadAsync( + asset.BrowserDownloadUrl, + destinationFilePath, + new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null), + progressAdapter, + cancellationToken); - if (!response.IsSuccessStatusCode) - { - return new UpdateDownloadResult( - false, - null, - $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"); - } - - var contentLength = response.Content.Headers.ContentLength ?? - (asset.SizeBytes > 0 ? asset.SizeBytes : -1); - - await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var destinationStream = File.Create(destinationFilePath); - - var buffer = new byte[81920]; - long totalRead = 0; - int read; - while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0) - { - await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - totalRead += read; - - if (contentLength > 0) - { - progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d)); - } - } - - progress?.Report(1d); - - return new UpdateDownloadResult(true, destinationFilePath, null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return new UpdateDownloadResult(false, null, ex.Message); - } + return result.Success + ? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null) + : new UpdateDownloadResult(false, null, result.ErrorMessage); } public async Task GetReleaseByTagAsync( diff --git a/LanMountainDesktop/Services/ResumableDownloadService.cs b/LanMountainDesktop/Services/ResumableDownloadService.cs new file mode 100644 index 0000000..4d3c523 --- /dev/null +++ b/LanMountainDesktop/Services/ResumableDownloadService.cs @@ -0,0 +1,940 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed record DownloadProgressInfo( + long DownloadedBytes, + long? TotalBytes, + double Progress, + bool IsResuming, + bool IsParallel); + +public sealed record DownloadOptions( + long? ExpectedSizeBytes = null, + int MaxParallelSegments = 4, + int ParallelThresholdBytes = 8 * 1024 * 1024, + int BufferSize = 128 * 1024); + +public sealed record DownloadResult( + bool Success, + string? FilePath, + string? ErrorMessage, + bool UsedResume, + bool UsedParallelDownload); + +public sealed class ResumableDownloadService +{ + private static readonly JsonSerializerOptions MetadataSerializerOptions = new() + { + WriteIndented = false + }; + + private readonly HttpClient _httpClient; + + public ResumableDownloadService(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadAsync( + string source, + string destinationFilePath, + DownloadOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + var normalizedOptions = NormalizeOptions(options); + try + { + if (File.Exists(source)) + { + return await CopyLocalFileAsync( + source, + destinationFilePath, + normalizedOptions, + progress, + cancellationToken); + } + + if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) || + (sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps)) + { + return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false); + } + + return await DownloadRemoteFileAsync( + sourceUri, + destinationFilePath, + normalizedOptions, + progress, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DownloadResult(false, null, ex.Message, false, false); + } + } + + private async Task CopyLocalFileAsync( + string sourceFilePath, + string destinationFilePath, + DownloadOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var fullSourcePath = Path.GetFullPath(sourceFilePath); + var fullDestinationPath = Path.GetFullPath(destinationFilePath); + var totalBytes = new FileInfo(fullSourcePath).Length; + + var tempFilePath = BuildTempFilePath(fullDestinationPath); + var metadataFilePath = BuildMetadataFilePath(fullDestinationPath); + PrepareDestination(fullDestinationPath); + + if (CanReuseCompletedDestination(fullDestinationPath, totalBytes)) + { + progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false)); + CleanupPartialArtifacts(tempFilePath, metadataFilePath); + return new DownloadResult(true, fullDestinationPath, null, false, false); + } + + long existingBytes = 0; + if (File.Exists(tempFilePath)) + { + existingBytes = new FileInfo(tempFilePath).Length; + if (existingBytes > totalBytes) + { + ResetPartialArtifacts(tempFilePath, metadataFilePath); + existingBytes = 0; + } + } + + if (!File.Exists(tempFilePath)) + { + await using var tempCreateStream = new FileStream( + tempFilePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + } + + if (existingBytes >= totalBytes) + { + CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); + progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false)); + return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); + } + + await using var sourceStream = new FileStream( + fullSourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var destinationStream = new FileStream( + tempFilePath, + FileMode.Open, + FileAccess.Write, + FileShare.Read, + options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + if (existingBytes > 0) + { + sourceStream.Seek(existingBytes, SeekOrigin.Begin); + destinationStream.Seek(existingBytes, SeekOrigin.Begin); + } + + await CopyStreamAsync( + sourceStream, + destinationStream, + existingBytes, + totalBytes, + isResuming: existingBytes > 0, + isParallel: false, + options.BufferSize, + progress, + cancellationToken); + + CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath); + return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false); + } + + private async Task DownloadRemoteFileAsync( + Uri sourceUri, + string destinationFilePath, + DownloadOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var fullDestinationPath = Path.GetFullPath(destinationFilePath); + var tempFilePath = BuildTempFilePath(fullDestinationPath); + var metadataFilePath = BuildMetadataFilePath(fullDestinationPath); + PrepareDestination(fullDestinationPath); + + var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken); + var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes; + if (CanReuseCompletedDestination(fullDestinationPath, totalBytes)) + { + progress?.Report(new DownloadProgressInfo( + totalBytes ?? new FileInfo(fullDestinationPath).Length, + totalBytes, + 1d, + false, + false)); + CleanupPartialArtifacts(tempFilePath, metadataFilePath); + return new DownloadResult(true, fullDestinationPath, null, false, false); + } + + var canUseParallel = probe.SupportsRanges && + totalBytes is > 0 && + totalBytes.Value >= options.ParallelThresholdBytes && + options.MaxParallelSegments > 1; + + try + { + var result = canUseParallel + ? await DownloadRemoteInParallelAsync( + sourceUri, + fullDestinationPath, + tempFilePath, + metadataFilePath, + totalBytes!.Value, + options, + progress, + cancellationToken) + : await DownloadRemoteSequentiallyAsync( + sourceUri, + fullDestinationPath, + tempFilePath, + metadataFilePath, + totalBytes, + probe.SupportsRanges, + options, + progress, + cancellationToken); + + return result; + } + catch (RangeRequestNotSupportedException) + { + ResetPartialArtifacts(tempFilePath, metadataFilePath); + return await DownloadRemoteSequentiallyAsync( + sourceUri, + fullDestinationPath, + tempFilePath, + metadataFilePath, + totalBytes, + allowResume: false, + options, + progress, + cancellationToken); + } + } + + private async Task DownloadRemoteSequentiallyAsync( + Uri sourceUri, + string destinationFilePath, + string tempFilePath, + string metadataFilePath, + long? totalBytes, + bool allowResume, + DownloadOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = 0; + if (File.Exists(tempFilePath)) + { + existingBytes = new FileInfo(tempFilePath).Length; + if (totalBytes is > 0 && existingBytes > totalBytes.Value) + { + ResetPartialArtifacts(tempFilePath, metadataFilePath); + existingBytes = 0; + } + } + + if (!allowResume && existingBytes > 0) + { + ResetPartialArtifacts(tempFilePath, metadataFilePath); + existingBytes = 0; + } + + if (totalBytes is > 0 && existingBytes >= totalBytes.Value) + { + CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); + progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, existingBytes > 0, false)); + return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri); + if (allowResume && existingBytes > 0) + { + request.Headers.Range = new RangeHeaderValue(existingBytes, null); + } + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (allowResume && existingBytes > 0) + { + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && totalBytes is > 0 && existingBytes == totalBytes) + { + CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); + progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false)); + return new DownloadResult(true, destinationFilePath, null, true, false); + } + + if (response.StatusCode != HttpStatusCode.PartialContent) + { + throw new RangeRequestNotSupportedException("The server did not honor the resume range request."); + } + } + + response.EnsureSuccessStatusCode(); + + await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var destinationStream = new FileStream( + tempFilePath, + existingBytes > 0 ? FileMode.Open : FileMode.Create, + FileAccess.Write, + FileShare.Read, + options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + if (existingBytes > 0) + { + destinationStream.Seek(existingBytes, SeekOrigin.Begin); + } + + var effectiveTotalBytes = totalBytes; + if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0) + { + effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value; + } + + await CopyStreamAsync( + sourceStream, + destinationStream, + existingBytes, + effectiveTotalBytes, + isResuming: existingBytes > 0, + isParallel: false, + options.BufferSize, + progress, + cancellationToken); + + CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); + return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false); + } + + private async Task DownloadRemoteInParallelAsync( + Uri sourceUri, + string destinationFilePath, + string tempFilePath, + string metadataFilePath, + long totalBytes, + DownloadOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var requestedSegments = Math.Min(options.MaxParallelSegments, CalculateRecommendedSegments(totalBytes)); + var metadata = await LoadOrCreateMetadataAsync( + sourceUri, + tempFilePath, + metadataFilePath, + totalBytes, + requestedSegments, + cancellationToken); + + await using (var tempStream = new FileStream( + tempFilePath, + FileMode.OpenOrCreate, + FileAccess.Write, + FileShare.ReadWrite, + options.BufferSize, + FileOptions.Asynchronous | FileOptions.RandomAccess)) + { + if (tempStream.Length != totalBytes) + { + tempStream.SetLength(totalBytes); + } + } + + var initialDownloadedBytes = metadata.Segments.Sum(segment => segment.CompletedBytes); + ReportProgress(progress, initialDownloadedBytes, totalBytes, initialDownloadedBytes > 0, true); + + if (initialDownloadedBytes >= totalBytes) + { + CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); + return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true); + } + + long downloadedBytes = initialDownloadedBytes; + var metadataWriter = new MetadataWriter(metadataFilePath, metadata); + + try + { + var tasks = metadata.Segments + .Where(segment => segment.CompletedBytes < segment.Length) + .Select(segment => DownloadSegmentAsync( + sourceUri, + tempFilePath, + segment, + options.BufferSize, + delta => + { + var currentDownloaded = Interlocked.Add(ref downloadedBytes, delta); + ReportProgress(progress, currentDownloaded, totalBytes, initialDownloadedBytes > 0, true); + }, + metadataWriter, + cancellationToken)) + .ToArray(); + + await Task.WhenAll(tasks); + await metadataWriter.FlushAsync(cancellationToken); + } + catch + { + await metadataWriter.FlushAsync(cancellationToken); + throw; + } + + CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath); + ReportProgress(progress, totalBytes, totalBytes, initialDownloadedBytes > 0, true); + return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true); + } + + private async Task DownloadSegmentAsync( + Uri sourceUri, + string tempFilePath, + DownloadSegmentState segment, + int bufferSize, + Action reportDownloadedBytes, + MetadataWriter metadataWriter, + CancellationToken cancellationToken) + { + var rangeStart = segment.Start + segment.CompletedBytes; + if (rangeStart > segment.EndInclusive) + { + return; + } + + using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri); + request.Headers.Range = new RangeHeaderValue(rangeStart, segment.EndInclusive); + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode != HttpStatusCode.PartialContent) + { + throw new RangeRequestNotSupportedException( + $"The server returned HTTP {(int)response.StatusCode} for range {rangeStart}-{segment.EndInclusive}."); + } + + response.EnsureSuccessStatusCode(); + + var contentRange = response.Content.Headers.ContentRange; + if (contentRange?.From != rangeStart || contentRange.To != segment.EndInclusive) + { + throw new RangeRequestNotSupportedException("The server returned an unexpected content range."); + } + + await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var destinationStream = new FileStream( + tempFilePath, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite, + bufferSize, + FileOptions.Asynchronous | FileOptions.RandomAccess); + destinationStream.Seek(rangeStart, SeekOrigin.Begin); + + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + while (segment.CompletedBytes < segment.Length) + { + var remainingBytes = segment.Length - segment.CompletedBytes; + var readSize = (int)Math.Min(buffer.Length, remainingBytes); + var read = await sourceStream.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken); + if (read <= 0) + { + throw new EndOfStreamException( + $"Unexpected end of stream while downloading range {segment.Start}-{segment.EndInclusive}."); + } + + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + segment.CompletedBytes += read; + reportDownloadedBytes(read); + metadataWriter.MarkDirty(); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task ProbeRemoteFileAsync(Uri sourceUri, CancellationToken cancellationToken) + { + long? totalBytes = null; + var supportsRanges = false; + + try + { + using var headRequest = new HttpRequestMessage(HttpMethod.Head, sourceUri); + using var headResponse = await _httpClient.SendAsync( + headRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (headResponse.IsSuccessStatusCode) + { + totalBytes = headResponse.Content.Headers.ContentLength; + supportsRanges = headResponse.Headers.AcceptRanges.Any( + value => string.Equals(value, "bytes", StringComparison.OrdinalIgnoreCase)); + } + } + catch + { + // Fall back to a small range probe when HEAD is unsupported or blocked. + } + + if (supportsRanges && totalBytes is > 0) + { + return new RemoteProbeResult(totalBytes, true); + } + + using var rangeRequest = new HttpRequestMessage(HttpMethod.Get, sourceUri); + rangeRequest.Headers.Range = new RangeHeaderValue(0, 0); + + using var rangeResponse = await _httpClient.SendAsync( + rangeRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (rangeResponse.StatusCode == HttpStatusCode.PartialContent) + { + totalBytes = rangeResponse.Content.Headers.ContentRange?.Length ?? totalBytes; + return new RemoteProbeResult(totalBytes, true); + } + + rangeResponse.EnsureSuccessStatusCode(); + totalBytes ??= rangeResponse.Content.Headers.ContentLength; + return new RemoteProbeResult(totalBytes, false); + } + + private static async Task CopyStreamAsync( + Stream sourceStream, + Stream destinationStream, + long initialDownloadedBytes, + long? totalBytes, + bool isResuming, + bool isParallel, + int bufferSize, + IProgress? progress, + CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + var downloadedBytes = initialDownloadedBytes; + try + { + ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); + while (true) + { + var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (read <= 0) + { + break; + } + + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + downloadedBytes += read; + ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); + } + + await destinationStream.FlushAsync(cancellationToken); + ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static void ReportProgress( + IProgress? progress, + long downloadedBytes, + long? totalBytes, + bool isResuming, + bool isParallel) + { + if (progress is null) + { + return; + } + + double normalizedProgress; + if (totalBytes is > 0) + { + normalizedProgress = Math.Clamp(downloadedBytes / (double)totalBytes.Value, 0d, 1d); + } + else + { + normalizedProgress = 0d; + } + + progress.Report(new DownloadProgressInfo( + downloadedBytes, + totalBytes, + normalizedProgress, + isResuming, + isParallel)); + } + + private static async Task LoadOrCreateMetadataAsync( + Uri sourceUri, + string tempFilePath, + string metadataFilePath, + long totalBytes, + int segmentCount, + CancellationToken cancellationToken) + { + if (File.Exists(metadataFilePath)) + { + try + { + var json = await File.ReadAllTextAsync(metadataFilePath, cancellationToken); + var metadata = JsonSerializer.Deserialize(json); + if (metadata is not null) + { + var normalizedMetadata = metadata.ToRuntime(); + if (string.Equals(normalizedMetadata.Source, sourceUri.ToString(), StringComparison.OrdinalIgnoreCase) && + normalizedMetadata.TotalBytes == totalBytes && + normalizedMetadata.Segments.Count > 0) + { + return normalizedMetadata.Normalize(); + } + } + } + catch + { + // Reset invalid metadata below. + } + } + + ResetPartialArtifacts(tempFilePath, metadataFilePath); + var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount); + var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions); + await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken); + return createdMetadata; + } + + private static DownloadOptions NormalizeOptions(DownloadOptions? options) + { + var normalized = options ?? new DownloadOptions(); + var maxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8); + var parallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes); + var bufferSize = Math.Max(16 * 1024, normalized.BufferSize); + return normalized with + { + MaxParallelSegments = maxParallelSegments, + ParallelThresholdBytes = parallelThresholdBytes, + BufferSize = bufferSize + }; + } + + private static int CalculateRecommendedSegments(long totalBytes) + { + if (totalBytes < 16 * 1024 * 1024) + { + return 2; + } + + if (totalBytes < 64 * 1024 * 1024) + { + return 4; + } + + return 6; + } + + private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes) + { + if (!File.Exists(destinationFilePath)) + { + return false; + } + + if (expectedSizeBytes is not > 0) + { + return false; + } + + return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value; + } + + private static void PrepareDestination(string destinationFilePath) + { + var directory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + } + + private static void CompleteDownload(string tempFilePath, string destinationFilePath, string metadataFilePath) + { + if (!File.Exists(tempFilePath)) + { + return; + } + + File.Move(tempFilePath, destinationFilePath, overwrite: true); + if (File.Exists(metadataFilePath)) + { + File.Delete(metadataFilePath); + } + } + + private static void CleanupPartialArtifacts(string tempFilePath, string metadataFilePath) + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + + if (File.Exists(metadataFilePath)) + { + File.Delete(metadataFilePath); + } + } + + private static void ResetPartialArtifacts(string tempFilePath, string metadataFilePath) + { + CleanupPartialArtifacts(tempFilePath, metadataFilePath); + } + + private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part"; + + private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json"; + + private sealed record RemoteProbeResult(long? TotalBytes, bool SupportsRanges); + + private sealed class RangeRequestNotSupportedException : InvalidOperationException + { + public RangeRequestNotSupportedException(string message) + : base(message) + { + } + } + + private sealed class MetadataWriter + { + private readonly string _metadataFilePath; + private readonly DownloadMetadata _metadata; + private readonly SemaphoreSlim _writeGate = new(1, 1); + private long _lastPersistedTickCount; + private int _dirty; + + public MetadataWriter(string metadataFilePath, DownloadMetadata metadata) + { + _metadataFilePath = metadataFilePath; + _metadata = metadata; + _lastPersistedTickCount = Environment.TickCount64; + } + + public void MarkDirty() + { + Interlocked.Exchange(ref _dirty, 1); + var now = Environment.TickCount64; + if (now - Interlocked.Read(ref _lastPersistedTickCount) < 750) + { + return; + } + + _ = Task.Run(async () => + { + try + { + await FlushAsync(CancellationToken.None); + } + catch + { + // The final flush still runs on completion/cancellation. + } + }); + } + + public async Task FlushAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _dirty, 0) == 0 && File.Exists(_metadataFilePath)) + { + return; + } + + await _writeGate.WaitAsync(cancellationToken); + try + { + var json = JsonSerializer.Serialize(_metadata.ToSerializable(), MetadataSerializerOptions); + await File.WriteAllTextAsync(_metadataFilePath, json, cancellationToken); + Interlocked.Exchange(ref _lastPersistedTickCount, Environment.TickCount64); + } + finally + { + _writeGate.Release(); + } + } + } + + private sealed class DownloadMetadata + { + public string Source { get; init; } = string.Empty; + + public long TotalBytes { get; init; } + + public List Segments { get; init; } = []; + + public static DownloadMetadata Create(string source, long totalBytes, int segmentCount) + { + var segments = SplitIntoSegments(totalBytes, segmentCount) + .Select(range => new DownloadSegmentState(range.Start, range.EndInclusive, 0)) + .ToList(); + + return new DownloadMetadata + { + Source = source, + TotalBytes = totalBytes, + Segments = segments + }; + } + + public DownloadMetadata Normalize() + { + foreach (var segment in Segments) + { + segment.CompletedBytes = Math.Clamp(segment.CompletedBytes, 0, segment.Length); + } + + return this; + } + + public SerializableDownloadMetadata ToSerializable() + { + return new SerializableDownloadMetadata + { + Source = Source, + TotalBytes = TotalBytes, + Segments = Segments + .Select(segment => new SerializableDownloadSegment + { + Start = segment.Start, + EndInclusive = segment.EndInclusive, + CompletedBytes = segment.CompletedBytes + }) + .ToList() + }; + } + } + + private sealed class DownloadSegmentState + { + public DownloadSegmentState(long start, long endInclusive, long completedBytes) + { + Start = start; + EndInclusive = endInclusive; + CompletedBytes = completedBytes; + } + + public long Start { get; } + + public long EndInclusive { get; } + + public long Length => EndInclusive - Start + 1; + + public long CompletedBytes { get; set; } + } + + private sealed class SerializableDownloadMetadata + { + public string Source { get; init; } = string.Empty; + + public long TotalBytes { get; init; } + + public List Segments { get; init; } = []; + + public DownloadMetadata ToRuntime() + { + return new DownloadMetadata + { + Source = Source, + TotalBytes = TotalBytes, + Segments = Segments + .Select(segment => new DownloadSegmentState( + segment.Start, + segment.EndInclusive, + segment.CompletedBytes)) + .ToList() + }; + } + } + + private sealed class SerializableDownloadSegment + { + public long Start { get; init; } + + public long EndInclusive { get; init; } + + public long CompletedBytes { get; init; } + } + + private static IEnumerable<(long Start, long EndInclusive)> SplitIntoSegments(long totalBytes, int segmentCount) + { + if (totalBytes <= 0) + { + yield break; + } + + var normalizedSegmentCount = Math.Max(1, segmentCount); + var segmentSize = totalBytes / normalizedSegmentCount; + var remainder = totalBytes % normalizedSegmentCount; + long start = 0; + + for (var index = 0; index < normalizedSegmentCount; index++) + { + var currentSegmentSize = segmentSize + (index < remainder ? 1 : 0); + if (currentSegmentSize <= 0) + { + continue; + } + + var endInclusive = start + currentSegmentSize - 1; + yield return (start, endInclusive); + start = endInclusive + 1; + } + } +} diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index c7e56af..d8d5671 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -119,6 +119,7 @@ public partial class MainWindow SettingsNavUpdateItem.Content = L("settings.nav.update", "Update"); SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher"); SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins"); + SettingsNavPluginMarketItem.Content = L("settings.nav.plugin_market", "Plugin Market"); WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper"); WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement"); @@ -283,6 +284,7 @@ public partial class MainWindow LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); ApplyPluginSettingsLocalization(); + ApplyPluginMarketSettingsLocalization(); SettingsNavAboutItem.Content = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 46dcc14..116d10b 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -115,7 +115,8 @@ public partial class MainWindow UpdateSettingsPanel is null || LauncherSettingsPanel is null || AboutSettingsPanel is null || - PluginSettingsPanel is null) + PluginSettingsPanel is null || + PluginMarketSettingsPanel is null) { return; } @@ -133,6 +134,7 @@ public partial class MainWindow AboutSettingsPanel.IsVisible = tag == "About"; LauncherSettingsPanel.IsVisible = tag == "Launcher"; PluginSettingsPanel.IsVisible = tag == "Plugins"; + PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket"; UpdatePluginSettingsPageVisibility(tag); if (tag == "Launcher") @@ -140,6 +142,16 @@ public partial class MainWindow RenderLauncherHiddenItemsList(); } + if (tag == "Plugins") + { + PluginSettingsPanel.RefreshFromRuntime(); + } + + if (tag == "PluginMarket") + { + PluginMarketSettingsPanel.RefreshFromRuntime(); + } + if (tag == "Grid") { UpdateGridPreviewLayout(); diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 9d75e88..9cf8fc9 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -436,6 +436,11 @@ + + + + + + diff --git a/LanMountainDesktop/Views/SettingsWindow.Core.cs b/LanMountainDesktop/Views/SettingsWindow.Core.cs index fca92fa..55bbcf7 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Core.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Core.cs @@ -69,6 +69,7 @@ public partial class SettingsWindow AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update"); AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About"); AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins"); + AddSettingsNavItem(SettingsSecondaryNavHost, "PluginMarket", Symbol.PuzzlePiece, "Plugin Market"); } private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e) @@ -229,6 +230,7 @@ public partial class SettingsWindow AboutSettingsPanel.IsVisible = tag == "About"; LauncherSettingsPanel.IsVisible = tag == "Launcher"; PluginSettingsPanel.IsVisible = tag == "Plugins"; + PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket"; UpdatePluginSettingsPageVisibility(tag); if (tag == "Launcher") @@ -236,6 +238,16 @@ public partial class SettingsWindow RenderLauncherHiddenItemsList(); } + if (tag == "Plugins") + { + PluginSettingsPanel.RefreshFromRuntime(); + } + + if (tag == "PluginMarket") + { + PluginMarketSettingsPanel.RefreshFromRuntime(); + } + if (tag == "Grid") { UpdateGridPreviewLayout(); diff --git a/LanMountainDesktop/Views/SettingsWindow.Localization.cs b/LanMountainDesktop/Views/SettingsWindow.Localization.cs index 2449b17..f45686c 100644 --- a/LanMountainDesktop/Views/SettingsWindow.Localization.cs +++ b/LanMountainDesktop/Views/SettingsWindow.Localization.cs @@ -74,6 +74,7 @@ public partial class SettingsWindow SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About")); SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher")); SetSettingsNavItemLabel(GetSettingsNavItem("Plugins"), L("settings.nav.plugins", "Plugins")); + SetSettingsNavItemLabel(GetSettingsNavItem("PluginMarket"), L("settings.nav.plugin_market", "Plugin Market")); WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper"); WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement"); @@ -177,6 +178,7 @@ public partial class SettingsWindow LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items."); ApplyPluginSettingsLocalization(); + ApplyPluginMarketSettingsLocalization(); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText()); diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index 94e194c..8290b1f 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -253,6 +253,7 @@ + diff --git a/LanMountainDesktop/plugins/MainWindow.PluginMarketSettingsLocalization.cs b/LanMountainDesktop/plugins/MainWindow.PluginMarketSettingsLocalization.cs new file mode 100644 index 0000000..7a4ae85 --- /dev/null +++ b/LanMountainDesktop/plugins/MainWindow.PluginMarketSettingsLocalization.cs @@ -0,0 +1,9 @@ +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + private void ApplyPluginMarketSettingsLocalization() + { + PluginMarketSettingsPanel.RefreshFromRuntime(); + } +} diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs index deb6179..6e7d3fc 100644 --- a/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs @@ -39,7 +39,7 @@ public partial class MainWindow .GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); - var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1; + var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginMarketItem) + 1; foreach (var contribution in contributions) { var tag = BuildPluginSettingsTag(contribution); diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index 4073259..271dfa9 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -14,6 +14,7 @@ internal sealed class AirAppMarketInstallService : IDisposable { private readonly PluginRuntimeService _runtime; private readonly HttpClient _httpClient; + private readonly ResumableDownloadService _downloadService; private readonly AirAppMarketReleaseResolverService _releaseResolverService; private readonly string _downloadsDirectory; @@ -26,6 +27,7 @@ internal sealed class AirAppMarketInstallService : IDisposable Timeout = TimeSpan.FromMinutes(2) }; _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); + _downloadService = new ResumableDownloadService(_httpClient); _releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient); } @@ -46,21 +48,27 @@ internal sealed class AirAppMarketInstallService : IDisposable if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) { - await using var sourceStream = File.OpenRead(localPackagePath); - await using var destinationStream = File.Create(downloadPath); - await sourceStream.CopyToAsync(destinationStream, cancellationToken); + var localCopyResult = await _downloadService.DownloadAsync( + localPackagePath, + downloadPath, + new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), + cancellationToken: cancellationToken); + if (!localCopyResult.Success) + { + return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage); + } } else { - using var response = await _httpClient.GetAsync( + var downloadResult = await _downloadService.DownloadAsync( resolvedDownloadUrl, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - response.EnsureSuccessStatusCode(); - - await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var destinationStream = File.Create(downloadPath); - await responseStream.CopyToAsync(destinationStream, cancellationToken); + downloadPath, + new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), + cancellationToken: cancellationToken); + if (!downloadResult.Success) + { + return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage); + } } await using var hashStream = File.OpenRead(downloadPath); diff --git a/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml b/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml new file mode 100644 index 0000000..6f97bb1 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs b/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs new file mode 100644 index 0000000..36bc920 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs @@ -0,0 +1,71 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.SettingsPages; + +public partial class PluginMarketSettingsPage : UserControl +{ + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private PluginMarketEmbeddedView? _pluginMarketView; + + public PluginMarketSettingsPage() + { + InitializeComponent(); + AttachedToVisualTree += (_, _) => RefreshFromRuntime(); + } + + public void RefreshFromRuntime() + { + PluginMarketPanelTitleTextBlock.Text = L("settings.plugin_market.title", "Plugin Market"); + PluginMarketPanelSubtitleTextBlock.Text = L( + "settings.plugin_market.subtitle", + "Browse plugins from the official LanAirApp source and stage installs."); + + var runtime = (Application.Current as App)?.PluginRuntimeService; + if (runtime is null) + { + PluginMarketContentHost.Content = CreateUnavailableState(); + return; + } + + if (_pluginMarketView is null) + { + _pluginMarketView = new PluginMarketEmbeddedView(runtime); + } + + _pluginMarketView.RefreshLocalization(); + _pluginMarketView.RefreshInstalledSnapshot(); + + if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView)) + { + PluginMarketContentHost.Content = _pluginMarketView; + } + } + + private Control CreateUnavailableState() + { + return new Border + { + Background = new SolidColorBrush(Color.Parse("#14000000")), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(16), + Child = new TextBlock + { + Text = L( + "settings.plugin_market.unavailable", + "Plugin runtime is not available, so the official market cannot be opened right now."), + TextWrapping = TextWrapping.Wrap, + Foreground = PluginMarketPanelSubtitleTextBlock.Foreground + } + }; + } + + private string L(string key, string fallback) + { + var snapshot = _appSettingsService.Load(); + return _localizationService.GetString(snapshot.LanguageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs index 7da1f99..bfeffe2 100644 --- a/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs +++ b/LanMountainDesktop/plugins/PluginSettingsPage.Host.cs @@ -21,7 +21,6 @@ public partial class PluginSettingsPage : UserControl private readonly AppSettingsService _appSettingsService = new(); private readonly LocalizationService _localizationService = new(); - private PluginMarketEmbeddedView? _pluginMarketView; private string? _packageImportStatusMessage; private bool _packageImportStatusIsError; @@ -34,13 +33,6 @@ public partial class PluginSettingsPage : UserControl public void RefreshFromRuntime() { var runtime = (Application.Current as App)?.PluginRuntimeService; - PluginMarketSettingsExpander.Header = L("settings.plugins.market_header", "Official Market"); - PluginMarketSettingsExpander.Description = L( - "settings.plugins.market_desc", - "Browse plugins from the official LanAirApp source and stage installs."); - PluginMarketDescriptionTextBlock.Text = L( - "settings.plugins.market_hint", - "Use the official market source hosted in LanAirApp to discover and stage plugin installs."); UpdateInstallerUi(runtime); if (runtime is null) { @@ -48,33 +40,13 @@ public partial class PluginSettingsPage : UserControl PluginRuntimeSummaryPanel.Children.Clear(); PluginCatalogItemsHost.Children.Clear(); PluginRestartHintTextBlock.IsVisible = false; - PluginMarketContentHost.Content = CreateSummaryLine( - L("settings.plugins.market_unavailable", "Plugin runtime is not available, so the official market cannot be opened right now.")); return; } - EnsurePluginMarketView(runtime); - _pluginMarketView?.RefreshLocalization(); - _pluginMarketView?.RefreshInstalledSnapshot(); BuildRuntimeSummary(runtime); BuildPluginCatalog(runtime); } - private void EnsurePluginMarketView(PluginRuntimeService runtime) - { - if (_pluginMarketView is null) - { - _pluginMarketView = new PluginMarketEmbeddedView(runtime); - PluginMarketContentHost.Content = _pluginMarketView; - return; - } - - if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView)) - { - PluginMarketContentHost.Content = _pluginMarketView; - } - } - private void UpdateInstallerUi(PluginRuntimeService? runtime) { InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package"); diff --git a/LanMountainDesktop/plugins/PluginSettingsPage.axaml b/LanMountainDesktop/plugins/PluginSettingsPage.axaml index ee6a96b..c2de80a 100644 --- a/LanMountainDesktop/plugins/PluginSettingsPage.axaml +++ b/LanMountainDesktop/plugins/PluginSettingsPage.axaml @@ -83,24 +83,5 @@ - - - - - - - - - - - - - diff --git a/LanMountainDesktop/plugins/SettingsWindow.PluginMarketSettingsLocalization.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginMarketSettingsLocalization.cs new file mode 100644 index 0000000..c5246da --- /dev/null +++ b/LanMountainDesktop/plugins/SettingsWindow.PluginMarketSettingsLocalization.cs @@ -0,0 +1,9 @@ +namespace LanMountainDesktop.Views; + +public partial class SettingsWindow +{ + private void ApplyPluginMarketSettingsLocalization() + { + PluginMarketSettingsPanel.RefreshFromRuntime(); + } +} diff --git a/airappmarket/assets/plugin-marketplace.svg b/airappmarket/assets/plugin-marketplace.svg deleted file mode 100644 index 430a034..0000000 --- a/airappmarket/assets/plugin-marketplace.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/airappmarket/index.json b/airappmarket/index.json index bea9cc9..9b1b828 100644 --- a/airappmarket/index.json +++ b/airappmarket/index.json @@ -2,31 +2,8 @@ "schemaVersion": "1.0.0", "sourceId": "official.lanmountaindesktop", "sourceName": "LanMountainDesktop Official Market", - "generatedAt": "2026-03-10T01:30:00Z", + "generatedAt": "2026-03-10T11:10: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",