mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df740e3df |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AirAppMarketLoadResult> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AirAppMarketInstallResult> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AirAppMarketPluginEntry> Plugins { get; init; } = [];
|
|
||||||
|
|
||||||
public static AirAppMarketIndexDocument Load(string json, string sourceName)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
|
||||||
|
|
||||||
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
|
|
||||||
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<AirAppMarketPluginEntry>(plugins.Count);
|
|
||||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var plugin in plugins)
|
|
||||||
{
|
|
||||||
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
|
|
||||||
if (!seenIds.Add(normalizedPlugin.Id))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedPlugins.Add(normalizedPlugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new 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<string> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
|
||||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
|
||||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
|
||||||
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
|
||||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
|
||||||
<PluginReleaseOutputDirectory>..\..\releases\</PluginReleaseOutputDirectory>
|
|
||||||
<PluginReleasePackagePath>$(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp</PluginReleasePackagePath>
|
|
||||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\</LegacyLoosePluginOutputDirectory>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
|
||||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
|
||||||
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
|
||||||
<MakeDir Directories="$(PluginReleaseOutputDirectory)" />
|
|
||||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
|
||||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
|
||||||
<Delete Files="$(PluginReleasePackagePath)" TreatErrorsAsWarnings="true" />
|
|
||||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
|
||||||
<Copy SourceFiles="$(PluginPackagePath)" DestinationFiles="$(PluginReleasePackagePath)" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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": "安装中…"
|
|
||||||
}
|
|
||||||
@@ -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<IPluginPackageManager>()
|
|
||||||
?? 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, InstalledPluginInfo> _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<string>(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<AirAppMarketPluginEntry> 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<AirAppMarketPluginEntry> 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<AirAppMarketPluginEntry> 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 => "已安装",
|
|
||||||
_ => "安装"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -338,10 +338,10 @@
|
|||||||
"settings.plugins.source_manifest": "Loose manifest",
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||||
"settings.plugins.market_header": "Official Market",
|
"settings.nav.plugin_market": "Plugin Market",
|
||||||
"settings.plugins.market_desc": "Browse plugins from the official LanAirApp source and stage installs.",
|
"settings.plugin_market.title": "Plugin Market",
|
||||||
"settings.plugins.market_hint": "Use the official market source hosted in LanAirApp to discover and stage plugin installs.",
|
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||||
"settings.plugins.market_unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
"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.search_placeholder": "Search plugins",
|
||||||
"market.toolbar.refresh": "Refresh",
|
"market.toolbar.refresh": "Refresh",
|
||||||
"market.status.loading": "Loading the official plugin market...",
|
"market.status.loading": "Loading the official plugin market...",
|
||||||
|
|||||||
@@ -338,10 +338,10 @@
|
|||||||
"settings.plugins.source_manifest": "散装清单",
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||||
"settings.plugins.market_header": "官方市场",
|
"settings.nav.plugin_market": "插件市场",
|
||||||
"settings.plugins.market_desc": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
"settings.plugin_market.title": "插件市场",
|
||||||
"settings.plugins.market_hint": "这里使用托管在 LanAirApp 仓库中的官方市场索引来发现插件并暂存安装。",
|
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||||
"settings.plugins.market_unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||||
"market.toolbar.search_placeholder": "搜索插件",
|
"market.toolbar.search_placeholder": "搜索插件",
|
||||||
"market.toolbar.refresh": "刷新",
|
"market.toolbar.refresh": "刷新",
|
||||||
"market.status.loading": "正在加载官方插件市场...",
|
"market.status.loading": "正在加载官方插件市场...",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
private readonly string _owner;
|
private readonly string _owner;
|
||||||
private readonly string _repo;
|
private readonly string _repo;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ResumableDownloadService _downloadService;
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
|
|
||||||
public GitHubReleaseUpdateService(
|
public GitHubReleaseUpdateService(
|
||||||
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
_ownsHttpClient = false;
|
_ownsHttpClient = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_downloadService = new ResumableDownloadService(_httpClient);
|
||||||
|
|
||||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||||
{
|
{
|
||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
_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.");
|
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
var progressAdapter = progress is null
|
||||||
{
|
? null
|
||||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(
|
var result = await _downloadService.DownloadAsync(
|
||||||
asset.BrowserDownloadUrl,
|
asset.BrowserDownloadUrl,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
destinationFilePath,
|
||||||
cancellationToken);
|
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
|
||||||
|
progressAdapter,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
return result.Success
|
||||||
{
|
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
||||||
return new UpdateDownloadResult(
|
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||||
|
|||||||
940
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
940
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
@@ -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<DownloadResult> DownloadAsync(
|
||||||
|
string source,
|
||||||
|
string destinationFilePath,
|
||||||
|
DownloadOptions? options = null,
|
||||||
|
IProgress<DownloadProgressInfo>? 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<DownloadResult> CopyLocalFileAsync(
|
||||||
|
string sourceFilePath,
|
||||||
|
string destinationFilePath,
|
||||||
|
DownloadOptions options,
|
||||||
|
IProgress<DownloadProgressInfo>? 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<DownloadResult> DownloadRemoteFileAsync(
|
||||||
|
Uri sourceUri,
|
||||||
|
string destinationFilePath,
|
||||||
|
DownloadOptions options,
|
||||||
|
IProgress<DownloadProgressInfo>? 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<DownloadResult> DownloadRemoteSequentiallyAsync(
|
||||||
|
Uri sourceUri,
|
||||||
|
string destinationFilePath,
|
||||||
|
string tempFilePath,
|
||||||
|
string metadataFilePath,
|
||||||
|
long? totalBytes,
|
||||||
|
bool allowResume,
|
||||||
|
DownloadOptions options,
|
||||||
|
IProgress<DownloadProgressInfo>? 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<DownloadResult> DownloadRemoteInParallelAsync(
|
||||||
|
Uri sourceUri,
|
||||||
|
string destinationFilePath,
|
||||||
|
string tempFilePath,
|
||||||
|
string metadataFilePath,
|
||||||
|
long totalBytes,
|
||||||
|
DownloadOptions options,
|
||||||
|
IProgress<DownloadProgressInfo>? 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<int> 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<byte>.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<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RemoteProbeResult> 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<DownloadProgressInfo>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = ArrayPool<byte>.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<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReportProgress(
|
||||||
|
IProgress<DownloadProgressInfo>? 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<DownloadMetadata> 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<SerializableDownloadMetadata>(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<DownloadSegmentState> 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<SerializableDownloadSegment> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ public partial class MainWindow
|
|||||||
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
||||||
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
|
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
|
||||||
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
|
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");
|
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
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.");
|
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||||
|
|
||||||
ApplyPluginSettingsLocalization();
|
ApplyPluginSettingsLocalization();
|
||||||
|
ApplyPluginMarketSettingsLocalization();
|
||||||
|
|
||||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ public partial class MainWindow
|
|||||||
UpdateSettingsPanel is null ||
|
UpdateSettingsPanel is null ||
|
||||||
LauncherSettingsPanel is null ||
|
LauncherSettingsPanel is null ||
|
||||||
AboutSettingsPanel is null ||
|
AboutSettingsPanel is null ||
|
||||||
PluginSettingsPanel is null)
|
PluginSettingsPanel is null ||
|
||||||
|
PluginMarketSettingsPanel is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ public partial class MainWindow
|
|||||||
AboutSettingsPanel.IsVisible = tag == "About";
|
AboutSettingsPanel.IsVisible = tag == "About";
|
||||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||||
|
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
|
||||||
UpdatePluginSettingsPageVisibility(tag);
|
UpdatePluginSettingsPageVisibility(tag);
|
||||||
|
|
||||||
if (tag == "Launcher")
|
if (tag == "Launcher")
|
||||||
@@ -140,6 +142,16 @@ public partial class MainWindow
|
|||||||
RenderLauncherHiddenItemsList();
|
RenderLauncherHiddenItemsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tag == "Plugins")
|
||||||
|
{
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag == "PluginMarket")
|
||||||
|
{
|
||||||
|
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
if (tag == "Grid")
|
if (tag == "Grid")
|
||||||
{
|
{
|
||||||
UpdateGridPreviewLayout();
|
UpdateGridPreviewLayout();
|
||||||
|
|||||||
@@ -436,6 +436,11 @@
|
|||||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||||
</ui:NavigationViewItem.IconSource>
|
</ui:NavigationViewItem.IconSource>
|
||||||
</ui:NavigationViewItem>
|
</ui:NavigationViewItem>
|
||||||
|
<ui:NavigationViewItem x:Name="SettingsNavPluginMarketItem" Content="插件市场" Tag="PluginMarket" ToolTip.Tip="插件市场">
|
||||||
|
<ui:NavigationViewItem.IconSource>
|
||||||
|
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||||
|
</ui:NavigationViewItem.IconSource>
|
||||||
|
</ui:NavigationViewItem>
|
||||||
</ui:NavigationView.MenuItems>
|
</ui:NavigationView.MenuItems>
|
||||||
|
|
||||||
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
||||||
@@ -459,6 +464,7 @@
|
|||||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||||
|
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ui:NavigationView>
|
</ui:NavigationView>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public partial class SettingsWindow
|
|||||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
|
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
|
||||||
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
|
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
|
||||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
|
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
|
||||||
|
AddSettingsNavItem(SettingsSecondaryNavHost, "PluginMarket", Symbol.PuzzlePiece, "Plugin Market");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
|
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -229,6 +230,7 @@ public partial class SettingsWindow
|
|||||||
AboutSettingsPanel.IsVisible = tag == "About";
|
AboutSettingsPanel.IsVisible = tag == "About";
|
||||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||||
|
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
|
||||||
UpdatePluginSettingsPageVisibility(tag);
|
UpdatePluginSettingsPageVisibility(tag);
|
||||||
|
|
||||||
if (tag == "Launcher")
|
if (tag == "Launcher")
|
||||||
@@ -236,6 +238,16 @@ public partial class SettingsWindow
|
|||||||
RenderLauncherHiddenItemsList();
|
RenderLauncherHiddenItemsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tag == "Plugins")
|
||||||
|
{
|
||||||
|
PluginSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag == "PluginMarket")
|
||||||
|
{
|
||||||
|
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
if (tag == "Grid")
|
if (tag == "Grid")
|
||||||
{
|
{
|
||||||
UpdateGridPreviewLayout();
|
UpdateGridPreviewLayout();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public partial class SettingsWindow
|
|||||||
SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About"));
|
SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About"));
|
||||||
SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher"));
|
SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher"));
|
||||||
SetSettingsNavItemLabel(GetSettingsNavItem("Plugins"), L("settings.nav.plugins", "Plugins"));
|
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");
|
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
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.");
|
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||||
|
|
||||||
ApplyPluginSettingsLocalization();
|
ApplyPluginSettingsLocalization();
|
||||||
|
ApplyPluginMarketSettingsLocalization();
|
||||||
|
|
||||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||||
|
|||||||
@@ -253,6 +253,7 @@
|
|||||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||||
|
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void ApplyPluginMarketSettingsLocalization()
|
||||||
|
{
|
||||||
|
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ public partial class MainWindow
|
|||||||
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(group => group.Key, group => group.Count(), 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)
|
foreach (var contribution in contributions)
|
||||||
{
|
{
|
||||||
var tag = BuildPluginSettingsTag(contribution);
|
var tag = BuildPluginSettingsTag(contribution);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ResumableDownloadService _downloadService;
|
||||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||||
private readonly string _downloadsDirectory;
|
private readonly string _downloadsDirectory;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
Timeout = TimeSpan.FromMinutes(2)
|
Timeout = TimeSpan.FromMinutes(2)
|
||||||
};
|
};
|
||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
|
_downloadService = new ResumableDownloadService(_httpClient);
|
||||||
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
|
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,21 +48,27 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||||
{
|
{
|
||||||
await using var sourceStream = File.OpenRead(localPackagePath);
|
var localCopyResult = await _downloadService.DownloadAsync(
|
||||||
await using var destinationStream = File.Create(downloadPath);
|
localPackagePath,
|
||||||
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
downloadPath,
|
||||||
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!localCopyResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.GetAsync(
|
var downloadResult = await _downloadService.DownloadAsync(
|
||||||
resolvedDownloadUrl,
|
resolvedDownloadUrl,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
downloadPath,
|
||||||
cancellationToken);
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
response.EnsureSuccessStatusCode();
|
cancellationToken: cancellationToken);
|
||||||
|
if (!downloadResult.Success)
|
||||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
{
|
||||||
await using var destinationStream = File.Create(downloadPath);
|
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
|
||||||
await responseStream.CopyToAsync(destinationStream, cancellationToken);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var hashStream = File.OpenRead(downloadPath);
|
await using var hashStream = File.OpenRead(downloadPath);
|
||||||
|
|||||||
25
LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml
Normal file
25
LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="960"
|
||||||
|
d:DesignHeight="1000"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage">
|
||||||
|
|
||||||
|
<StackPanel x:Name="PluginMarketPanel"
|
||||||
|
Spacing="16">
|
||||||
|
<TextBlock x:Name="PluginMarketPanelTitleTextBlock"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="Plugin Market" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="PluginMarketPanelSubtitleTextBlock"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Browse plugins from the official LanAirApp source and stage installs." />
|
||||||
|
|
||||||
|
<ContentControl x:Name="PluginMarketContentHost" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
71
LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs
Normal file
71
LanMountainDesktop/plugins/PluginMarketSettingsPage.axaml.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
|
|
||||||
private readonly AppSettingsService _appSettingsService = new();
|
private readonly AppSettingsService _appSettingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private PluginMarketEmbeddedView? _pluginMarketView;
|
|
||||||
private string? _packageImportStatusMessage;
|
private string? _packageImportStatusMessage;
|
||||||
private bool _packageImportStatusIsError;
|
private bool _packageImportStatusIsError;
|
||||||
|
|
||||||
@@ -34,13 +33,6 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
public void RefreshFromRuntime()
|
public void RefreshFromRuntime()
|
||||||
{
|
{
|
||||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
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);
|
UpdateInstallerUi(runtime);
|
||||||
if (runtime is null)
|
if (runtime is null)
|
||||||
{
|
{
|
||||||
@@ -48,33 +40,13 @@ public partial class PluginSettingsPage : UserControl
|
|||||||
PluginRuntimeSummaryPanel.Children.Clear();
|
PluginRuntimeSummaryPanel.Children.Clear();
|
||||||
PluginCatalogItemsHost.Children.Clear();
|
PluginCatalogItemsHost.Children.Clear();
|
||||||
PluginRestartHintTextBlock.IsVisible = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsurePluginMarketView(runtime);
|
|
||||||
_pluginMarketView?.RefreshLocalization();
|
|
||||||
_pluginMarketView?.RefreshInstalledSnapshot();
|
|
||||||
BuildRuntimeSummary(runtime);
|
BuildRuntimeSummary(runtime);
|
||||||
BuildPluginCatalog(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)
|
private void UpdateInstallerUi(PluginRuntimeService? runtime)
|
||||||
{
|
{
|
||||||
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
||||||
|
|||||||
@@ -83,24 +83,5 @@
|
|||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="settings-expander-shell">
|
|
||||||
<ui:SettingsExpander x:Name="PluginMarketSettingsExpander"
|
|
||||||
Header="Official Market"
|
|
||||||
Description="Browse plugins from the official LanAirApp source and stage installs."
|
|
||||||
IsExpanded="True">
|
|
||||||
<ui:SettingsExpander.IconSource>
|
|
||||||
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
|
||||||
</ui:SettingsExpander.IconSource>
|
|
||||||
<ui:SettingsExpander.Footer>
|
|
||||||
<StackPanel Spacing="10">
|
|
||||||
<TextBlock x:Name="PluginMarketDescriptionTextBlock"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Text="Use the official market source hosted in LanAirApp to discover and stage plugin installs." />
|
|
||||||
<ContentControl x:Name="PluginMarketContentHost" />
|
|
||||||
</StackPanel>
|
|
||||||
</ui:SettingsExpander.Footer>
|
|
||||||
</ui:SettingsExpander>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow
|
||||||
|
{
|
||||||
|
private void ApplyPluginMarketSettingsLocalization()
|
||||||
|
{
|
||||||
|
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Plugin Marketplace">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="marketBg" x1="0" x2="1" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#0EA5E9"/>
|
|
||||||
<stop offset="100%" stop-color="#22C55E"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#marketBg)"/>
|
|
||||||
<path d="M43 36h42c3.866 0 7 3.134 7 7v42c0 3.866-3.134 7-7 7H43c-3.866 0-7-3.134-7-7V43c0-3.866 3.134-7 7-7Z" fill="#FFFFFF" fill-opacity="0.16"/>
|
|
||||||
<path d="M52 52h24a12 12 0 0 1 0 24H52a8 8 0 0 1 0-16h4" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
|
||||||
<circle cx="84" cy="84" r="14" fill="#FFFFFF"/>
|
|
||||||
<path d="M84 76v16M76 84h16" stroke="#0EA5E9" stroke-linecap="round" stroke-width="8"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 835 B |
@@ -2,31 +2,8 @@
|
|||||||
"schemaVersion": "1.0.0",
|
"schemaVersion": "1.0.0",
|
||||||
"sourceId": "official.lanmountaindesktop",
|
"sourceId": "official.lanmountaindesktop",
|
||||||
"sourceName": "LanMountainDesktop Official Market",
|
"sourceName": "LanMountainDesktop Official Market",
|
||||||
"generatedAt": "2026-03-10T01:30:00Z",
|
"generatedAt": "2026-03-10T11:10:00Z",
|
||||||
"plugins": [
|
"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",
|
"id": "LanMountainDesktop.SamplePlugin",
|
||||||
"name": "LanMountain Sample Plugin",
|
"name": "LanMountain Sample Plugin",
|
||||||
|
|||||||
Reference in New Issue
Block a user