mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
This commit is contained in:
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
|
||||
public sealed record GitHubReleaseAsset(
|
||||
string Name,
|
||||
string BrowserDownloadUrl,
|
||||
long SizeBytes);
|
||||
long SizeBytes,
|
||||
string? Sha256 = null);
|
||||
|
||||
public sealed record GitHubReleaseInfo(
|
||||
string TagName,
|
||||
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
|
||||
string LatestVersionText,
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
string? FilePath,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
bool HashVerified = false,
|
||||
string? ExpectedHash = null,
|
||||
string? ActualHash = null);
|
||||
|
||||
public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
{
|
||||
@@ -169,6 +175,80 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "Repository information is not configured.",
|
||||
ForceMode: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var release = includePrerelease
|
||||
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
|
||||
: await GetLatestStableReleaseAsync(cancellationToken);
|
||||
|
||||
if (release is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "No release data was returned from GitHub.",
|
||||
ForceMode: true);
|
||||
}
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: ex.Message,
|
||||
ForceMode: true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -206,9 +286,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
progressAdapter,
|
||||
cancellationToken);
|
||||
|
||||
return result.Success
|
||||
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
||||
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
if (!result.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
}
|
||||
|
||||
var filePath = result.FilePath ?? destinationFilePath;
|
||||
var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified)
|
||||
{
|
||||
return new UpdateDownloadResult(
|
||||
false,
|
||||
filePath,
|
||||
$"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}",
|
||||
false,
|
||||
asset.Sha256,
|
||||
actualHash);
|
||||
}
|
||||
|
||||
return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash);
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (File.Exists(destinationFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(destinationFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete existing file for redownload: {destinationFilePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var partFile = destinationFilePath + ".part";
|
||||
if (File.Exists(partFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(partFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete part file for redownload: {partFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var packageFile = destinationFilePath + ".download";
|
||||
if (File.Exists(packageFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(packageFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete package file for redownload: {packageFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return await DownloadAssetAsync(asset, destinationFilePath, downloadSource, maxParallelSegments, progress, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<(bool Success, string? Hash)> VerifyFileHashAsync(
|
||||
string filePath,
|
||||
string? expectedHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||
{
|
||||
var computedHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||
return (true, computedHash);
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||
var verified = string.Equals(
|
||||
expectedHash?.Trim().ToLowerInvariant(),
|
||||
actualHash?.Trim().ToLowerInvariant(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return (verified, actualHash);
|
||||
}
|
||||
|
||||
public static async Task<string?> ComputeFileSha256Async(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
filePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to compute SHA256 for file: {filePath}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||
@@ -343,13 +542,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
|
||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null));
|
||||
}
|
||||
}
|
||||
|
||||
var sha256Map = BuildSha256MapFromAssets(assets, element);
|
||||
|
||||
if (sha256Map.Count > 0)
|
||||
{
|
||||
assets = assets.Select(a =>
|
||||
sha256Map.TryGetValue(a.Name, out var hash)
|
||||
? a with { Sha256 = hash }
|
||||
: a).ToList();
|
||||
}
|
||||
|
||||
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSha256MapFromAssets(List<GitHubReleaseAsset> assets, JsonElement releaseElement)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (asset.Name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) ||
|
||||
asset.Name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var baseName = asset.Name[..asset.Name.LastIndexOf('.')];
|
||||
var targetAsset = assets.FirstOrDefault(a =>
|
||||
a.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (targetAsset is not null && !map.ContainsKey(targetAsset.Name))
|
||||
{
|
||||
map[targetAsset.Name] = asset.BrowserDownloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseElement.TryGetProperty("body", out var bodyNode) &&
|
||||
bodyNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var body = bodyNode.GetString() ?? string.Empty;
|
||||
ParseSha256FromBody(body, assets, map);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void ParseSha256FromBody(string body, List<GitHubReleaseAsset> assets, Dictionary<string, string> map)
|
||||
{
|
||||
var lines = body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmedLine = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith("#"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var hash = parts[0];
|
||||
var fileName = parts[1];
|
||||
|
||||
if (hash.Length == 64 && IsHexString(hash))
|
||||
{
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (asset.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Equals("*" + asset.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!map.ContainsKey(asset.Name))
|
||||
{
|
||||
map[asset.Name] = hash.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||
|
||||
@@ -67,7 +67,8 @@ public sealed record UpdateSettingsState(
|
||||
string? PendingUpdateInstallerPath,
|
||||
string? PendingUpdateVersion,
|
||||
long? PendingUpdatePublishedAtUtcMs,
|
||||
long? LastUpdateCheckUtcMs);
|
||||
long? LastUpdateCheckUtcMs,
|
||||
string? PendingUpdateSha256);
|
||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||
public enum PluginPackageSourceKind
|
||||
{
|
||||
@@ -175,14 +176,6 @@ public sealed record PluginCatalogItemInfo(
|
||||
|
||||
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
|
||||
|
||||
public IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
|
||||
Manifest.SharedContracts
|
||||
.Select(contract => new PluginCatalogDependencyInfo(
|
||||
contract.Id,
|
||||
contract.Version,
|
||||
contract.AssemblyName))
|
||||
.ToArray();
|
||||
|
||||
public DateTimeOffset PublishedAt => Publication.PublishedAt;
|
||||
|
||||
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
|
||||
@@ -192,82 +185,6 @@ public sealed record PluginCatalogItemInfo(
|
||||
public string ReleaseAssetName => Publication.ReleaseAssetName;
|
||||
|
||||
public string ReleaseNotes => Repository.ReleaseNotes;
|
||||
|
||||
public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
|
||||
{
|
||||
return new PluginMarketPluginInfo(
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Author,
|
||||
item.Version,
|
||||
item.ApiVersion,
|
||||
item.MinHostVersion,
|
||||
item.DownloadUrl,
|
||||
item.ReleaseTag,
|
||||
item.ReleaseAssetName,
|
||||
item.IconUrl,
|
||||
item.ReadmeUrl,
|
||||
item.HomepageUrl,
|
||||
item.RepositoryUrl,
|
||||
item.Tags.ToArray(),
|
||||
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
|
||||
dependency.Id,
|
||||
dependency.Version,
|
||||
dependency.AssemblyName)).ToArray(),
|
||||
item.PublishedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
|
||||
{
|
||||
return new PluginCatalogItemInfo(
|
||||
new PluginCatalogManifestInfo(
|
||||
plugin.Id,
|
||||
plugin.Name,
|
||||
plugin.Description,
|
||||
plugin.Author,
|
||||
plugin.Version,
|
||||
plugin.ApiVersion,
|
||||
string.Empty,
|
||||
plugin.Dependencies
|
||||
.Select(dependency => new PluginCatalogSharedContractInfo(
|
||||
dependency.Id,
|
||||
dependency.Version,
|
||||
dependency.AssemblyName))
|
||||
.ToArray()),
|
||||
new PluginCatalogCompatibilityInfo(
|
||||
plugin.MinHostVersion,
|
||||
plugin.ApiVersion),
|
||||
new PluginCatalogRepositoryInfo(
|
||||
plugin.IconUrl,
|
||||
plugin.RepositoryUrl,
|
||||
plugin.ReadmeUrl,
|
||||
plugin.HomepageUrl,
|
||||
plugin.RepositoryUrl,
|
||||
plugin.Tags,
|
||||
string.Empty),
|
||||
new PluginCatalogPublicationInfo(
|
||||
plugin.ReleaseTag,
|
||||
plugin.ReleaseAssetName,
|
||||
plugin.PublishedAt,
|
||||
plugin.UpdatedAt,
|
||||
0,
|
||||
string.Empty,
|
||||
null),
|
||||
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
|
||||
? []
|
||||
: [
|
||||
new PluginPackageSourceInfo(
|
||||
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
|
||||
? PluginPackageSourceKind.RawFallback
|
||||
: PluginPackageSourceKind.ReleaseAsset,
|
||||
plugin.DownloadUrl,
|
||||
string.Empty,
|
||||
0)
|
||||
],
|
||||
[]);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogIndexResult(
|
||||
@@ -277,19 +194,7 @@ public sealed record PluginCatalogIndexResult(
|
||||
string? Source,
|
||||
string? SourceLocation,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
|
||||
{
|
||||
return new PluginMarketIndexResult(
|
||||
result.Success,
|
||||
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
|
||||
result.Source,
|
||||
result.SourceLocation,
|
||||
result.WarningMessage,
|
||||
result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record PluginInstallDiagnostic(
|
||||
string Code,
|
||||
@@ -302,73 +207,6 @@ public sealed record PluginCatalogInstallResult(
|
||||
string? PluginName,
|
||||
PluginManifest? InstalledManifest,
|
||||
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
|
||||
{
|
||||
return new PluginMarketInstallResult(
|
||||
result.Success,
|
||||
result.PluginId,
|
||||
result.PluginName,
|
||||
result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogDependencyInfo(
|
||||
string Id,
|
||||
string Version,
|
||||
string AssemblyName)
|
||||
{
|
||||
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
|
||||
{
|
||||
return new PluginMarketDependencyInfo(
|
||||
dependency.Id,
|
||||
dependency.Version,
|
||||
dependency.AssemblyName);
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
|
||||
public sealed record PluginMarketDependencyInfo(
|
||||
string Id,
|
||||
string Version,
|
||||
string AssemblyName);
|
||||
|
||||
[Obsolete("Use PluginCatalogItemInfo instead.")]
|
||||
public sealed record PluginMarketPluginInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string Author,
|
||||
string Version,
|
||||
string ApiVersion,
|
||||
string MinHostVersion,
|
||||
string DownloadUrl,
|
||||
string ReleaseTag,
|
||||
string ReleaseAssetName,
|
||||
string IconUrl,
|
||||
string ReadmeUrl,
|
||||
string HomepageUrl,
|
||||
string RepositoryUrl,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
[Obsolete("Use PluginCatalogIndexResult instead.")]
|
||||
public sealed record PluginMarketIndexResult(
|
||||
bool Success,
|
||||
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
||||
string? Source,
|
||||
string? SourceLocation,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage);
|
||||
|
||||
[Obsolete("Use PluginCatalogInstallResult instead.")]
|
||||
public sealed record PluginMarketInstallResult(
|
||||
bool Success,
|
||||
string? PluginId,
|
||||
string? PluginName,
|
||||
string? ErrorMessage);
|
||||
|
||||
public interface IPluginCatalogSourceProvider
|
||||
@@ -488,6 +326,7 @@ public interface IUpdateSettingsService
|
||||
UpdateSettingsState Get();
|
||||
void Save(UpdateSettingsState state);
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -495,6 +334,13 @@ public interface IUpdateSettingsService
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ILauncherCatalogService
|
||||
@@ -523,13 +369,6 @@ public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
|
||||
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
[Obsolete("Use IPluginCatalogSettingsService instead.")]
|
||||
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
|
||||
{
|
||||
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
||||
new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IApplicationInfoService
|
||||
{
|
||||
string GetAppVersionText();
|
||||
@@ -554,8 +393,6 @@ public interface ISettingsFacadeService
|
||||
ILauncherPolicyService LauncherPolicy { get; }
|
||||
IPluginManagementSettingsService PluginManagement { get; }
|
||||
IPluginCatalogSettingsService PluginCatalog { get; }
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
IPluginMarketSettingsService PluginMarket { get; }
|
||||
IApplicationInfoService ApplicationInfo { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot.PendingUpdateInstallerPath,
|
||||
snapshot.PendingUpdateVersion,
|
||||
snapshot.PendingUpdatePublishedAtUtcMs,
|
||||
snapshot.LastUpdateCheckUtcMs);
|
||||
snapshot.LastUpdateCheckUtcMs,
|
||||
snapshot.PendingUpdateSha256);
|
||||
}
|
||||
|
||||
public void Save(UpdateSettingsState state)
|
||||
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
|
||||
? state.LastUpdateCheckUtcMs
|
||||
: null;
|
||||
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
|
||||
? null
|
||||
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
||||
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
||||
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
|
||||
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateSha256)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.RedownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
maxParallelSegments,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_releaseUpdateService.Dispose();
|
||||
@@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
|
||||
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
|
||||
{
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private AirAppMarketIndexService _indexService;
|
||||
private AirAppMarketInstallService? _installService;
|
||||
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
|
||||
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
_pluginRuntimeService = pluginRuntimeService;
|
||||
|
||||
@@ -875,11 +905,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
||||
return LoadCatalogCoreAsync(cancellationToken);
|
||||
}
|
||||
|
||||
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<PluginCatalogInstallResult> InstallAsync(
|
||||
string pluginId,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -887,13 +912,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
||||
return InstallCatalogCoreAsync(pluginId, cancellationToken);
|
||||
}
|
||||
|
||||
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
|
||||
string pluginId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -1055,23 +1073,25 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
||||
|
||||
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
|
||||
var sources = entry.GetPackageSourcesInInstallOrder();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sourceKind = entry.HasReleaseDownloadMetadata
|
||||
? PluginPackageSourceKind.ReleaseAsset
|
||||
: PluginPackageSourceKind.RawFallback;
|
||||
|
||||
return
|
||||
[
|
||||
new PluginPackageSourceInfo(
|
||||
sourceKind,
|
||||
entry.DownloadUrl,
|
||||
return sources
|
||||
.Select(source => new PluginPackageSourceInfo(
|
||||
source.SourceKind switch
|
||||
{
|
||||
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset,
|
||||
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback,
|
||||
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal,
|
||||
_ => PluginPackageSourceKind.RawFallback
|
||||
},
|
||||
source.Url,
|
||||
entry.Sha256,
|
||||
entry.PackageSizeBytes)
|
||||
];
|
||||
entry.PackageSizeBytes))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
|
||||
@@ -1165,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
||||
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
|
||||
{
|
||||
private readonly UpdateSettingsService _updateSettingsService;
|
||||
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
|
||||
private readonly PluginCatalogSettingsService _pluginCatalogSettingsService;
|
||||
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
|
||||
private readonly WeatherSettingsService _weatherSettingsService;
|
||||
|
||||
@@ -1188,9 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
LauncherPolicy = new LauncherPolicyService();
|
||||
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
||||
PluginManagement = _pluginManagementSettingsService;
|
||||
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
||||
PluginCatalog = _pluginMarketSettingsService;
|
||||
PluginMarket = _pluginMarketSettingsService;
|
||||
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
|
||||
PluginCatalog = _pluginCatalogSettingsService;
|
||||
ApplicationInfo = new ApplicationInfoService();
|
||||
}
|
||||
|
||||
@@ -1224,20 +1243,18 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
|
||||
public IPluginCatalogSettingsService PluginCatalog { get; }
|
||||
|
||||
public IPluginMarketSettingsService PluginMarket { get; }
|
||||
|
||||
public IApplicationInfoService ApplicationInfo { get; }
|
||||
|
||||
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_weatherSettingsService.Dispose();
|
||||
_updateSettingsService.Dispose();
|
||||
_pluginMarketSettingsService.Dispose();
|
||||
_pluginCatalogSettingsService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
|
||||
public sealed record UpdatePendingInfo(
|
||||
string InstallerPath,
|
||||
string VersionText,
|
||||
DateTimeOffset? PublishedAt);
|
||||
DateTimeOffset? PublishedAt,
|
||||
string? Sha256 = null);
|
||||
|
||||
public sealed record UpdateVerifyResult(
|
||||
bool Success,
|
||||
bool HashMatched,
|
||||
string? ExpectedHash,
|
||||
string? ActualHash,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record UpdateInstallerLaunchResult(
|
||||
bool Success,
|
||||
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
|
||||
UpdateSettingsValues.ChannelPreview,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var result = await _settingsFacade.Update.CheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken);
|
||||
var result = isForce
|
||||
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken)
|
||||
: await _settingsFacade.Update.CheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken);
|
||||
|
||||
SaveState(state with
|
||||
{
|
||||
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
|
||||
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
||||
File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null);
|
||||
var verifyResult = await VerifyPendingUpdateAsync();
|
||||
if (verifyResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
|
||||
}
|
||||
|
||||
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var existingPending = GetPendingUpdate(state);
|
||||
|
||||
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(existingPending.InstallerPath);
|
||||
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
ClearPendingUpdate();
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||
|
||||
state = _settingsFacade.Update.Get();
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UpdateDownloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var pending = GetPendingUpdate(state);
|
||||
|
||||
if (pending is null)
|
||||
{
|
||||
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
|
||||
}
|
||||
|
||||
if (!File.Exists(pending.InstallerPath))
|
||||
{
|
||||
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
||||
}
|
||||
|
||||
var expectedHash = pending.Sha256;
|
||||
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
||||
|
||||
if (string.IsNullOrEmpty(expectedHash))
|
||||
{
|
||||
return new UpdateVerifyResult(true, true, null, actualHash, null);
|
||||
}
|
||||
|
||||
var hashMatched = string.Equals(
|
||||
expectedHash?.Trim().ToLowerInvariant(),
|
||||
actualHash?.Trim().ToLowerInvariant(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new UpdateVerifyResult(
|
||||
hashMatched,
|
||||
hashMatched,
|
||||
expectedHash,
|
||||
actualHash,
|
||||
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
public async Task AutoCheckIfEnabledAsync(
|
||||
Version currentVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
|
||||
try
|
||||
{
|
||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
|
||||
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
||||
{
|
||||
return;
|
||||
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = null,
|
||||
PendingUpdateVersion = null,
|
||||
PendingUpdatePublishedAtUtcMs = null
|
||||
PendingUpdatePublishedAtUtcMs = null,
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
|
||||
return new UpdatePendingInfo(
|
||||
installerPath,
|
||||
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
||||
publishedAt);
|
||||
publishedAt,
|
||||
state.PendingUpdateSha256);
|
||||
}
|
||||
|
||||
private void SaveState(UpdateSettingsState state)
|
||||
|
||||
Reference in New Issue
Block a user