mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
This commit is contained in:
@@ -6,7 +6,9 @@ public static class SettingsCategories
|
||||
public const string Appearance = "Appearance";
|
||||
public const string Components = "Components";
|
||||
public const string Plugins = "Plugins";
|
||||
public const string PluginMarket = "PluginMarket";
|
||||
public const string PluginCatalog = "PluginCatalog";
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
public const string PluginMarket = PluginCatalog;
|
||||
public const string Update = "Update";
|
||||
public const string About = "About";
|
||||
public const string Advanced = "Advanced";
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum SettingsPageCategory
|
||||
Appearance = 10,
|
||||
Components = 20,
|
||||
Plugins = 30,
|
||||
PluginCatalog = 35,
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Helpers;
|
||||
|
||||
public static class PluginMarketMarkdownHelper
|
||||
public static class PluginCatalogMarkdownHelper
|
||||
{
|
||||
private static Markdown.Avalonia.Markdown? _engine;
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||
"settings.update.download_threads_label": "Download Threads",
|
||||
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||
"settings.update.force_check_label": "Force Check Update",
|
||||
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
|
||||
"settings.update.status_force_checking": "Force checking GitHub releases...",
|
||||
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
|
||||
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
|
||||
"settings.update.install_now_button": "Install Now",
|
||||
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
||||
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
||||
@@ -525,10 +530,10 @@
|
||||
"settings.plugins.source_manifest": "Loose manifest",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||
"settings.nav.plugin_market": "Plugin Market",
|
||||
"settings.plugin_market.title": "Plugin Market",
|
||||
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||
"settings.nav.plugin_catalog": "Plugin Catalog",
|
||||
"settings.plugin_catalog.title": "Plugin Catalog",
|
||||
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
|
||||
"settings.update.status_idle": "No update check has been performed yet.",
|
||||
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
|
||||
"settings.update.download_threads_label": "ダウンロードスレッド",
|
||||
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
|
||||
"settings.update.force_check_label": "強制アップデート確認",
|
||||
"settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。",
|
||||
"settings.update.status_force_checking": "GitHubリリースを強制確認中...",
|
||||
"settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。",
|
||||
"settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。",
|
||||
"settings.update.install_now_button": "今すぐインストール",
|
||||
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
|
||||
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
|
||||
@@ -477,7 +482,7 @@
|
||||
"settings.plugins.refresh_button": "プラグインを更新",
|
||||
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
|
||||
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
|
||||
"settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。",
|
||||
"settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
|
||||
"settings.plugins.marketplace_header": "マーケットプレイス",
|
||||
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
|
||||
"settings.plugins.delete_button_short": "削除",
|
||||
@@ -525,10 +530,10 @@
|
||||
"settings.plugins.source_manifest": "ルーズマニフェスト",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
|
||||
"settings.nav.plugin_market": "プラグインマーケット",
|
||||
"settings.plugin_market.title": "プラグインマーケット",
|
||||
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||
"settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。",
|
||||
"settings.nav.plugin_catalog": "プラグインカタログ",
|
||||
"settings.plugin_catalog.title": "プラグインカタログ",
|
||||
"settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||
"settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
|
||||
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
|
||||
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
|
||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||
@@ -537,15 +542,15 @@
|
||||
"settings.window.drawer_default": "詳細",
|
||||
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||
"market.toolbar.refresh": "更新",
|
||||
"market.status.loading": "公式プラグインマーケットをロード中...",
|
||||
"market.status.loading": "公式プラグインカタログをロード中...",
|
||||
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
|
||||
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
|
||||
"market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}",
|
||||
"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.empty": "プラグインカタログはまだロードされていません。",
|
||||
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "ロード済み",
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
|
||||
"settings.update.download_threads_label": "다운로드 스레드 수",
|
||||
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
|
||||
"settings.update.force_check_label": "강제 업데이트 확인",
|
||||
"settings.update.force_check_desc": "버전 비교를 무시하고 GitHub에서 강제로 최신 버전을 가져옵니다.",
|
||||
"settings.update.status_force_checking": "GitHub 릴리스 강제 확인 중...",
|
||||
"settings.update.status_force_no_asset": "릴리스를 찾았지만 호환되는 설치 프로그램이 없습니다.",
|
||||
"settings.update.status_force_available_format": "릴리스 {0}을(를) 사용할 수 있습니다. '다운로드 및 설치'를 클릭하세요.",
|
||||
"settings.update.install_now_button": "지금 설치",
|
||||
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
|
||||
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
|
||||
@@ -476,8 +481,8 @@
|
||||
"settings.plugins.refresh_button": "플러그인 새로고침",
|
||||
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
|
||||
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
|
||||
"settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.",
|
||||
"settings.plugins.marketplace_header": "플러그인 마켓",
|
||||
"settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.",
|
||||
"settings.plugins.marketplace_header": "플러그인 카탈로그",
|
||||
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
|
||||
"settings.plugins.delete_button_short": "삭제",
|
||||
"settings.plugins.install_button_short": "설치",
|
||||
@@ -524,10 +529,10 @@
|
||||
"settings.plugins.source_manifest": "매니페스트 파일",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
|
||||
"settings.nav.plugin_market": "플러그인 마켓",
|
||||
"settings.plugin_market.title": "플러그인 마켓",
|
||||
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||
"settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.",
|
||||
"settings.nav.plugin_catalog": "플러그인 카탈로그",
|
||||
"settings.plugin_catalog.title": "플러그인 카탈로그",
|
||||
"settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||
"settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.",
|
||||
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
|
||||
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
|
||||
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||
@@ -536,15 +541,15 @@
|
||||
"settings.window.drawer_default": "상세 정보",
|
||||
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||
"market.toolbar.refresh": "새로고침",
|
||||
"market.status.loading": "공식 플러그인 마켓 로딩 중...",
|
||||
"market.status.loading": "공식 플러그인 카탈로그 로딩 중...",
|
||||
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
|
||||
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}",
|
||||
"market.status.load_failed_format": "플러그인 마켓 로드 실패: {0}",
|
||||
"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.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.",
|
||||
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "로드됨",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
||||
"settings.update.download_threads_label": "下载线程数",
|
||||
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
||||
"settings.update.force_check_label": "强制检查更新",
|
||||
"settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。",
|
||||
"settings.update.status_force_checking": "正在强制检查 GitHub Release...",
|
||||
"settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。",
|
||||
"settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。",
|
||||
"settings.update.install_now_button": "立即安装",
|
||||
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
||||
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
||||
@@ -471,8 +476,8 @@
|
||||
"settings.plugins.refresh_button": "刷新插件",
|
||||
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
||||
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
||||
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
||||
"settings.plugins.marketplace_header": "插件市场",
|
||||
"settings.plugins.refresh_failed": "加载插件目录索引失败。",
|
||||
"settings.plugins.marketplace_header": "插件目录",
|
||||
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
|
||||
"settings.plugins.delete_button_short": "删除",
|
||||
"settings.plugins.install_button_short": "安装",
|
||||
@@ -519,10 +524,10 @@
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"settings.nav.plugin_market": "插件市场",
|
||||
"settings.plugin_market.title": "插件市场",
|
||||
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"settings.nav.plugin_catalog": "插件目录",
|
||||
"settings.plugin_catalog.title": "插件目录",
|
||||
"settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
|
||||
"settings.update.status_idle": "尚未执行更新检查。",
|
||||
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
||||
"settings.update.status_check_failed": "检查更新失败。",
|
||||
@@ -531,15 +536,15 @@
|
||||
"settings.window.drawer_default": "详情",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
"market.status.loading": "正在加载官方插件市场...",
|
||||
"market.status.loading": "正在加载官方插件目录...",
|
||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||
"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.empty": "插件目录尚未加载。",
|
||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "已加载",
|
||||
|
||||
@@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public long? LastUpdateCheckUtcMs { get; set; }
|
||||
|
||||
public string? PendingUpdateSha256 { get; set; }
|
||||
|
||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||
|
||||
public List<string> PinnedTaskbarActions { get; set; } =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-row-button">
|
||||
<Style Selector="Button.plugin-catalog-row-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -275,11 +275,11 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-row-button:pointerover">
|
||||
<Style Selector="Button.plugin-catalog-row-button:pointerover">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button">
|
||||
<Style Selector="Button.plugin-catalog-icon-button">
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -290,11 +290,11 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button:pointerover">
|
||||
<Style Selector="Button.plugin-catalog-icon-button:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
|
||||
<Style Selector="Button.plugin-catalog-icon-button fi|SymbolIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
</Style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public enum PluginMarketPrimaryActionState
|
||||
public enum PluginCatalogPrimaryActionState
|
||||
{
|
||||
Install,
|
||||
Update,
|
||||
@@ -24,13 +24,13 @@ public enum PluginMarketPrimaryActionState
|
||||
Incompatible
|
||||
}
|
||||
|
||||
public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
|
||||
{
|
||||
private readonly LocalizationService _localizationService;
|
||||
private readonly string _languageCode;
|
||||
private bool _isLoadingIcon;
|
||||
|
||||
public PluginMarketItemViewModel(
|
||||
public PluginCatalogItemViewModel(
|
||||
PluginCatalogItemInfo plugin,
|
||||
LocalizationService localizationService,
|
||||
string languageCode)
|
||||
@@ -104,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
public bool HasIcon => IconBitmap is not null;
|
||||
|
||||
public PluginMarketPrimaryActionState ActionState { get; private set; }
|
||||
public PluginCatalogPrimaryActionState ActionState { get; private set; }
|
||||
|
||||
partial void OnIconBitmapChanged(Bitmap? value)
|
||||
{
|
||||
@@ -164,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
{
|
||||
if (IsInstalling)
|
||||
{
|
||||
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install;
|
||||
ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install;
|
||||
ActionSymbol = Symbol.ArrowClockwise;
|
||||
ActionTooltip = L("market.button.installing", "Installing...");
|
||||
IsActionEnabled = false;
|
||||
@@ -173,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
if (!IsCompatibleWithHost)
|
||||
{
|
||||
ActionState = PluginMarketPrimaryActionState.Incompatible;
|
||||
ActionState = PluginCatalogPrimaryActionState.Incompatible;
|
||||
ActionSymbol = Symbol.Warning;
|
||||
ActionTooltip = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
@@ -185,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
if (RequiresRestart)
|
||||
{
|
||||
ActionState = PluginMarketPrimaryActionState.RestartRequired;
|
||||
ActionState = PluginCatalogPrimaryActionState.RestartRequired;
|
||||
ActionSymbol = Symbol.ArrowClockwise;
|
||||
ActionTooltip = L("market.button.restart", "Restart to apply");
|
||||
IsActionEnabled = true;
|
||||
@@ -194,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
if (IsUpdateAvailable)
|
||||
{
|
||||
ActionState = PluginMarketPrimaryActionState.Update;
|
||||
ActionState = PluginCatalogPrimaryActionState.Update;
|
||||
ActionSymbol = Symbol.ArrowSync;
|
||||
ActionTooltip = L("market.button.update", "Update");
|
||||
IsActionEnabled = true;
|
||||
@@ -203,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
if (IsInstalled)
|
||||
{
|
||||
ActionState = PluginMarketPrimaryActionState.Installed;
|
||||
ActionState = PluginCatalogPrimaryActionState.Installed;
|
||||
ActionSymbol = Symbol.CheckmarkCircle;
|
||||
ActionTooltip = L("market.button.installed", "Installed");
|
||||
IsActionEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ActionState = PluginMarketPrimaryActionState.Install;
|
||||
ActionState = PluginCatalogPrimaryActionState.Install;
|
||||
ActionSymbol = Symbol.ArrowDownload;
|
||||
ActionTooltip = L("market.button.install", "Install");
|
||||
IsActionEnabled = true;
|
||||
@@ -242,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
|
||||
{
|
||||
private readonly LocalizationService _localizationService;
|
||||
private readonly string _languageCode;
|
||||
private readonly AirAppMarketReadmeService _readmeService;
|
||||
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
|
||||
private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
|
||||
private bool _isInitialized;
|
||||
|
||||
public PluginMarketDetailViewModel(
|
||||
PluginMarketItemViewModel item,
|
||||
public PluginCatalogDetailViewModel(
|
||||
PluginCatalogItemViewModel item,
|
||||
LocalizationService localizationService,
|
||||
string languageCode,
|
||||
AirAppMarketReadmeService readmeService,
|
||||
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
|
||||
Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
|
||||
{
|
||||
Item = item;
|
||||
_localizationService = localizationService;
|
||||
@@ -273,7 +273,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
|
||||
}
|
||||
|
||||
public PluginMarketItemViewModel Item { get; }
|
||||
public PluginCatalogItemViewModel Item { get; }
|
||||
|
||||
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
|
||||
|
||||
@@ -375,7 +375,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
||||
@@ -386,9 +386,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Version? _hostVersion;
|
||||
private bool _isInitialized;
|
||||
private bool _hasLoadedMarket;
|
||||
private bool _hasLoadedCatalog;
|
||||
|
||||
public PluginMarketSettingsPageViewModel(
|
||||
public PluginCatalogSettingsPageViewModel(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
LocalizationService localizationService,
|
||||
AirAppMarketIconService iconService,
|
||||
@@ -402,16 +402,16 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
|
||||
RefreshLocalizedText();
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
|
||||
}
|
||||
|
||||
public event Action<string?>? RestartRequested;
|
||||
|
||||
public event Action<PluginMarketItemViewModel>? DetailsRequested;
|
||||
public event Action<PluginCatalogItemViewModel>? DetailsRequested;
|
||||
|
||||
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
|
||||
public ObservableCollection<PluginCatalogItemViewModel> CatalogPlugins { get; } = [];
|
||||
|
||||
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
|
||||
public ObservableCollection<PluginCatalogItemViewModel> FilteredPlugins { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
@@ -454,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item)
|
||||
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
|
||||
{
|
||||
return new PluginMarketDetailViewModel(
|
||||
return new PluginCatalogDetailViewModel(
|
||||
item,
|
||||
_localizationService,
|
||||
_languageCode,
|
||||
@@ -475,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
|
||||
RefreshInstalledSnapshot();
|
||||
|
||||
var result = await _pluginCatalog.LoadCatalogAsync();
|
||||
if (!result.Success)
|
||||
{
|
||||
_hasLoadedMarket = false;
|
||||
MarketPlugins.Clear();
|
||||
_hasLoadedCatalog = false;
|
||||
CatalogPlugins.Clear();
|
||||
FilteredPlugins.Clear();
|
||||
ShowEmptyState = true;
|
||||
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
||||
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
|
||||
: result.ErrorMessage;
|
||||
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||
? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown")
|
||||
? L("market.status.load_failed_format", "Failed to load the plugin catalog: Unknown")
|
||||
: string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("market.status.load_failed_format", "Failed to load the plugin market: {0}"),
|
||||
L("market.status.load_failed_format", "Failed to load the plugin catalog: {0}"),
|
||||
result.ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
_hasLoadedMarket = true;
|
||||
MarketPlugins.Clear();
|
||||
_hasLoadedCatalog = true;
|
||||
CatalogPlugins.Clear();
|
||||
foreach (var plugin in result.Plugins)
|
||||
{
|
||||
var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode);
|
||||
var item = new PluginCatalogItemViewModel(plugin, _localizationService, _languageCode);
|
||||
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
|
||||
MarketPlugins.Add(item);
|
||||
CatalogPlugins.Add(item);
|
||||
_ = item.EnsureIconLoadedAsync(_iconService);
|
||||
}
|
||||
|
||||
@@ -513,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
? string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
|
||||
MarketPlugins.Count,
|
||||
CatalogPlugins.Count,
|
||||
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
|
||||
: string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
|
||||
MarketPlugins.Count);
|
||||
CatalogPlugins.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -527,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDetails(PluginMarketItemViewModel? item)
|
||||
private void OpenDetails(PluginCatalogItemViewModel? item)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
@@ -538,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item)
|
||||
private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item)
|
||||
{
|
||||
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
|
||||
}
|
||||
|
||||
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item)
|
||||
private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item)
|
||||
{
|
||||
if (item.IsInstalling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired)
|
||||
if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired)
|
||||
{
|
||||
RestartRequested?.Invoke(RestartRequiredMessage);
|
||||
return;
|
||||
@@ -614,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
|
||||
private void RefreshItemStates()
|
||||
{
|
||||
foreach (var item in MarketPlugins)
|
||||
foreach (var item in CatalogPlugins)
|
||||
{
|
||||
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
|
||||
}
|
||||
@@ -642,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
FilteredPlugins.Clear();
|
||||
|
||||
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
|
||||
IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
|
||||
var query = SearchText?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
@@ -660,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
ShowEmptyState = FilteredPlugins.Count == 0;
|
||||
EmptyStateText = !_hasLoadedMarket
|
||||
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
||||
EmptyStateText = !_hasLoadedCatalog
|
||||
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
|
||||
: string.IsNullOrWhiteSpace(query)
|
||||
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
|
||||
: L("market.list.no_results", "No plugins match the current search.");
|
||||
@@ -669,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.plugin_market.title", "Plugin Market");
|
||||
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
|
||||
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
|
||||
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
|
||||
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
||||
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
|
||||
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
@@ -1517,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _installNowButtonText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _redownloadButtonText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _latestVersionText = string.Empty;
|
||||
|
||||
@@ -1556,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _downloadThreadsDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceCheckUpdateLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceCheckUpdateDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _stableChannelText = string.Empty;
|
||||
|
||||
@@ -1619,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public bool IsInstallButtonVisible => HasPendingInstaller;
|
||||
|
||||
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
||||
|
||||
public string DownloadThreadsValueText =>
|
||||
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -1838,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
await CheckForUpdatesCoreAsync(isForce: false);
|
||||
}
|
||||
|
||||
private bool CanForceCheckUpdate() => !IsBusy;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanForceCheckUpdate))]
|
||||
private async Task ForceCheckUpdateAsync()
|
||||
{
|
||||
await CheckForUpdatesCoreAsync(isForce: true);
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1845,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
IsDownloadProgressVisible = false;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion);
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
RefreshLastCheckedFromSettings();
|
||||
|
||||
@@ -1863,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
ApplyCheckResultDisplay(result);
|
||||
if (!result.IsUpdateAvailable)
|
||||
if (!result.IsUpdateAvailable && !isForce)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.PreferredAsset is null)
|
||||
{
|
||||
UpdateStatus = L(
|
||||
"settings.update.status_asset_missing",
|
||||
"A new release is available, but no compatible installer was found.");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
||||
: L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1884,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
UpdateStatus = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
||||
isForce
|
||||
? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.")
|
||||
: L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
||||
result.LatestVersionText);
|
||||
}
|
||||
finally
|
||||
@@ -1926,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
|
||||
}
|
||||
|
||||
private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||
private async Task RedownloadUpdateAsync()
|
||||
{
|
||||
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
|
||||
{
|
||||
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsDownloading = true;
|
||||
IsDownloadProgressVisible = true;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer...");
|
||||
|
||||
var progress = new Progress<double>(value =>
|
||||
{
|
||||
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||
DownloadProgressText = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||
DownloadProgressValue);
|
||||
});
|
||||
|
||||
var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
UpdateStatus = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"),
|
||||
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyPendingState(_settingsFacade.Update.Get());
|
||||
UpdateStatus = downloadResult.HashVerified
|
||||
? BuildPendingReadyStatus()
|
||||
: string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
|
||||
downloadResult.ActualHash ?? "N/A");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsDownloading = false;
|
||||
IsDownloadProgressVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.update.title", "Update");
|
||||
@@ -1939,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
RedownloadButtonText = L("settings.update.redownload_button", "Redownload");
|
||||
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
|
||||
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
|
||||
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
|
||||
@@ -2147,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDownloadButtonVisible));
|
||||
OnPropertyChanged(nameof(IsInstallButtonVisible));
|
||||
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
||||
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
||||
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
||||
|
||||
@@ -473,6 +473,11 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
_dialogSessionId = null;
|
||||
_dialogSessionLabel = string.Empty;
|
||||
DialogRenameTextBox.Text = string.Empty;
|
||||
DialogOverlayBorder.IsVisible = false;
|
||||
if (_currentSnapshot is not null)
|
||||
{
|
||||
RenderSnapshot(_currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
||||
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer"
|
||||
x:DataType="vm:PluginMarketDetailViewModel">
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer"
|
||||
x:DataType="vm:PluginCatalogDetailViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container"
|
||||
Margin="0,0,0,8">
|
||||
@@ -41,7 +41,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Classes="plugin-market-icon-button"
|
||||
Classes="plugin-catalog-icon-button"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding PerformPrimaryActionCommand}"
|
||||
IsEnabled="{Binding Item.IsActionEnabled}"
|
||||
@@ -103,7 +103,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
|
||||
Markdown="{Binding ReadmeMarkdown}"
|
||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -3,14 +3,14 @@ using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PluginMarketDetailDrawer : UserControl
|
||||
public partial class PluginCatalogDetailDrawer : UserControl
|
||||
{
|
||||
public PluginMarketDetailDrawer()
|
||||
public PluginCatalogDetailDrawer()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel)
|
||||
public PluginCatalogDetailDrawer(PluginCatalogDetailViewModel viewModel)
|
||||
{
|
||||
DataContext = viewModel;
|
||||
InitializeComponent();
|
||||
@@ -3,9 +3,9 @@
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogSettingsPage"
|
||||
x:Name="Root"
|
||||
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
||||
x:DataType="vm:PluginCatalogSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||
@@ -47,7 +47,7 @@
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
|
||||
<DataTemplate x:DataType="vm:PluginCatalogItemViewModel">
|
||||
<Border Classes="settings-list-item">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
@@ -70,7 +70,7 @@
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="plugin-market-row-button"
|
||||
Classes="plugin-catalog-row-button"
|
||||
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Spacing="4"
|
||||
@@ -83,7 +83,7 @@
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Classes="plugin-market-icon-button"
|
||||
Classes="plugin-catalog-icon-button"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
@@ -9,21 +9,21 @@ using LanMountainDesktop.ViewModels;
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
[SettingsPageInfo(
|
||||
"plugin-market",
|
||||
"Plugin Market",
|
||||
SettingsPageCategory.PluginMarket,
|
||||
"plugin-catalog",
|
||||
"Plugin Catalog",
|
||||
SettingsPageCategory.PluginCatalog,
|
||||
IconKey = "ShoppingBag",
|
||||
SortOrder = 35,
|
||||
TitleLocalizationKey = "settings.plugin_market.title",
|
||||
DescriptionLocalizationKey = "settings.plugin_market.subtitle")]
|
||||
public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
TitleLocalizationKey = "settings.plugin_catalog.title",
|
||||
DescriptionLocalizationKey = "settings.plugin_catalog.subtitle")]
|
||||
public partial class PluginCatalogSettingsPage : SettingsPageBase
|
||||
{
|
||||
public PluginMarketSettingsPage()
|
||||
public PluginCatalogSettingsPage()
|
||||
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
|
||||
{
|
||||
}
|
||||
|
||||
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel)
|
||||
public PluginCatalogSettingsPage(PluginCatalogSettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
ViewModel.RestartRequested += OnRestartRequested;
|
||||
@@ -32,7 +32,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PluginMarketSettingsPageViewModel ViewModel { get; }
|
||||
public PluginCatalogSettingsPageViewModel ViewModel { get; }
|
||||
|
||||
public override async void OnNavigatedTo(object? parameter)
|
||||
{
|
||||
@@ -44,22 +44,22 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
await ViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel()
|
||||
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var localizationService = new LocalizationService();
|
||||
return new PluginMarketSettingsPageViewModel(
|
||||
return new PluginCatalogSettingsPageViewModel(
|
||||
settingsFacade,
|
||||
localizationService,
|
||||
new AirAppMarketIconService(),
|
||||
new AirAppMarketReadmeService());
|
||||
}
|
||||
|
||||
private static PluginMarketSettingsPageViewModel CreateDesignTimeViewModel()
|
||||
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var localizationService = new LocalizationService();
|
||||
var viewModel = new PluginMarketSettingsPageViewModel(
|
||||
var viewModel = new PluginCatalogSettingsPageViewModel(
|
||||
settingsFacade,
|
||||
localizationService,
|
||||
new AirAppMarketIconService(),
|
||||
@@ -68,8 +68,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
var previewHostVersion = new Version(1, 2, 0);
|
||||
var items = new[]
|
||||
{
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"news-tiles",
|
||||
"News Tiles",
|
||||
"Brings editorial news cards and ticker rows to the desktop.",
|
||||
@@ -91,8 +91,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
localizationService,
|
||||
installedPlugin: null,
|
||||
previewHostVersion),
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"workspace-pulse",
|
||||
"Workspace Pulse",
|
||||
"Tracks active projects and shows a compact productivity summary.",
|
||||
@@ -125,8 +125,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
true,
|
||||
null),
|
||||
previewHostVersion),
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"glass-panels",
|
||||
"Glass Panels",
|
||||
"Adds experimental acrylic surfaces for plugin-powered widgets.",
|
||||
@@ -152,7 +152,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
viewModel.MarketPlugins.Add(item);
|
||||
viewModel.CatalogPlugins.Add(item);
|
||||
viewModel.FilteredPlugins.Add(item);
|
||||
}
|
||||
|
||||
@@ -167,24 +167,87 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
|
||||
}
|
||||
|
||||
private async void OnDetailsRequested(PluginMarketItemViewModel item)
|
||||
private async void OnDetailsRequested(PluginCatalogItemViewModel item)
|
||||
{
|
||||
var detailViewModel = ViewModel.CreateDetailViewModel(item);
|
||||
var drawer = new PluginMarketDetailDrawer(detailViewModel);
|
||||
var drawer = new PluginCatalogDetailDrawer(detailViewModel);
|
||||
OpenDrawer(drawer, detailViewModel.DrawerTitle);
|
||||
await detailViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private static PluginMarketItemViewModel CreateMarketItem(
|
||||
PluginMarketPluginInfo plugin,
|
||||
private static PluginCatalogItemViewModel CreateCatalogItemViewModel(
|
||||
PluginCatalogItemInfo plugin,
|
||||
LocalizationService localizationService,
|
||||
InstalledPluginInfo? installedPlugin,
|
||||
Version hostVersion)
|
||||
{
|
||||
var languageCode = localizationService.NormalizeLanguageCode(
|
||||
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
|
||||
var item = new PluginMarketItemViewModel(plugin, localizationService, languageCode);
|
||||
var item = new PluginCatalogItemViewModel(plugin, localizationService, languageCode);
|
||||
item.ApplyInstallState(installedPlugin, hostVersion);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static PluginCatalogItemInfo CreateCatalogItem(
|
||||
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,
|
||||
string[] tags,
|
||||
PluginCatalogSharedContractInfo[] sharedContracts,
|
||||
DateTimeOffset publishedAt,
|
||||
DateTimeOffset updatedAt)
|
||||
{
|
||||
return new PluginCatalogItemInfo(
|
||||
new PluginCatalogManifestInfo(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
author,
|
||||
version,
|
||||
apiVersion,
|
||||
string.Empty,
|
||||
sharedContracts),
|
||||
new PluginCatalogCompatibilityInfo(
|
||||
minHostVersion,
|
||||
apiVersion),
|
||||
new PluginCatalogRepositoryInfo(
|
||||
iconUrl,
|
||||
homepageUrl,
|
||||
readmeUrl,
|
||||
homepageUrl,
|
||||
repositoryUrl,
|
||||
tags,
|
||||
string.Empty),
|
||||
new PluginCatalogPublicationInfo(
|
||||
releaseTag,
|
||||
releaseAssetName,
|
||||
publishedAt,
|
||||
updatedAt,
|
||||
0,
|
||||
string.Empty,
|
||||
null),
|
||||
string.IsNullOrWhiteSpace(downloadUrl)
|
||||
? []
|
||||
: [
|
||||
new PluginPackageSourceInfo(
|
||||
string.IsNullOrWhiteSpace(releaseTag)
|
||||
? LanMountainDesktop.Services.Settings.PluginPackageSourceKind.RawFallback
|
||||
: LanMountainDesktop.Services.Settings.PluginPackageSourceKind.ReleaseAsset,
|
||||
downloadUrl,
|
||||
string.Empty,
|
||||
0)
|
||||
],
|
||||
[]);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
|
||||
Markdown="{Binding MarkdownContent}"
|
||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -141,6 +141,9 @@
|
||||
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
||||
Content="{Binding DownloadButtonText}"
|
||||
IsVisible="{Binding IsDownloadButtonVisible}" />
|
||||
<Button Command="{Binding RedownloadUpdateCommand}"
|
||||
Content="{Binding RedownloadButtonText}"
|
||||
IsVisible="{Binding IsRedownloadButtonVisible}" />
|
||||
<Button Classes="settings-accent-button"
|
||||
Command="{Binding InstallPendingUpdateCommand}"
|
||||
Content="{Binding InstallNowButtonText}"
|
||||
@@ -172,6 +175,14 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem Content="{Binding ForceCheckUpdateLabel}"
|
||||
Description="{Binding ForceCheckUpdateDescription}"
|
||||
IsClickEnabled="True"
|
||||
Command="{Binding ForceCheckUpdateCommand}">
|
||||
<ui:SettingsExpanderItem.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowSync" />
|
||||
</ui:SettingsExpanderItem.IconSource>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Classes="settings-expander-card"
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services.PluginMarket;
|
||||
|
||||
internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketIndexDocument> EnrichAsync(
|
||||
AirAppMarketIndexDocument document,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (document.Plugins.Count == 0)
|
||||
{
|
||||
return document;
|
||||
}
|
||||
|
||||
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
|
||||
foreach (var plugin in document.Plugins)
|
||||
{
|
||||
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return new AirAppMarketIndexDocument
|
||||
{
|
||||
SchemaVersion = document.SchemaVersion,
|
||||
SourceId = document.SourceId,
|
||||
SourceName = document.SourceName,
|
||||
GeneratedAt = document.GeneratedAt,
|
||||
Contracts = document.Contracts,
|
||||
Plugins = enrichedPlugins
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
|
||||
AirAppMarketPluginEntry entry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
|
||||
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||
PluginManifest? manifest = null;
|
||||
AirAppMarketRepositoryTemplate? template = null;
|
||||
|
||||
foreach (var branch in branchCandidates)
|
||||
{
|
||||
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is not null && template is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
|
||||
var resolvedManifest = manifest;
|
||||
var resolvedPackageSources = entry.PackageSources.Count > 0
|
||||
? entry.PackageSources
|
||||
: entry.Publication?.PackageSources ?? [];
|
||||
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
|
||||
Manifest = resolvedManifest is null
|
||||
? entry.Manifest
|
||||
: new AirAppMarketPluginManifestEntry
|
||||
{
|
||||
Id = resolvedManifest.Id,
|
||||
Name = resolvedManifest.Name,
|
||||
Description = resolvedManifest.Description ?? string.Empty,
|
||||
Author = resolvedManifest.Author ?? string.Empty,
|
||||
Version = resolvedManifest.Version ?? string.Empty,
|
||||
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
|
||||
EntranceAssembly = resolvedManifest.EntranceAssembly,
|
||||
SharedContracts = resolvedManifest.SharedContracts?
|
||||
.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||
{
|
||||
Id = contract.Id,
|
||||
Version = contract.Version,
|
||||
AssemblyName = contract.AssemblyName
|
||||
})
|
||||
.ToList()
|
||||
?? []
|
||||
},
|
||||
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
|
||||
? new AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
MinHostVersion = FirstNonEmpty(
|
||||
template?.MinHostVersion,
|
||||
entry.MinHostVersion),
|
||||
PluginApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
entry.ApiVersion)
|
||||
?? string.Empty
|
||||
}
|
||||
: null,
|
||||
Repository = new AirAppMarketPluginRepositoryEntry
|
||||
{
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||
?? string.Empty,
|
||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
},
|
||||
Publication = entry.Publication,
|
||||
Capabilities = entry.Capabilities,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
||||
Sha256 = entry.Sha256,
|
||||
PackageSizeBytes = entry.PackageSizeBytes,
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ReleaseTag = entry.ReleaseTag,
|
||||
ReleaseAssetName = entry.ReleaseAssetName,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||
?? string.Empty,
|
||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||
SharedContracts = resolvedManifest?.SharedContracts
|
||||
?.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||
{
|
||||
Id = contract.Id,
|
||||
Version = contract.Version,
|
||||
AssemblyName = contract.AssemblyName
|
||||
})
|
||||
.ToList()
|
||||
?? entry.SharedContracts,
|
||||
PackageSources = resolvedPackageSources,
|
||||
Md5 = entry.Md5,
|
||||
PublishedAt = entry.PublishedAt,
|
||||
UpdatedAt = entry.UpdatedAt,
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string branch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
|
||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
return PluginManifest.Load(stream, candidateUrl);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string branch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
|
||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new List<string>(4);
|
||||
|
||||
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
|
||||
!string.IsNullOrWhiteSpace(cachedBranch))
|
||||
{
|
||||
candidates.Add(cachedBranch);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(defaultBranch))
|
||||
{
|
||||
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
|
||||
candidates.Add(defaultBranch);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.Add("main");
|
||||
candidates.Add("master");
|
||||
|
||||
return candidates
|
||||
.Where(branch => !string.IsNullOrWhiteSpace(branch))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<string?> TryGetDefaultBranchAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
|
||||
{
|
||||
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to conventional branches.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatRepositoryKey(string owner, string repositoryName)
|
||||
{
|
||||
return $"{owner.Trim()}/{repositoryName.Trim()}";
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
|
||||
{
|
||||
foreach (var list in lists)
|
||||
{
|
||||
if (list is null || list.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = list
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private sealed record AirAppMarketRepositoryTemplate(
|
||||
string? MinHostVersion,
|
||||
string? IconUrl,
|
||||
string? ProjectUrl,
|
||||
string? ReadmeUrl,
|
||||
string? HomepageUrl,
|
||||
string? RepositoryUrl,
|
||||
List<string>? Tags,
|
||||
string? ReleaseNotes);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public sealed class AirAppMarketIconService : IDisposable
|
||||
}
|
||||
|
||||
public async Task<Bitmap> LoadAsync(
|
||||
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
|
||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
||||
internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
private readonly AirAppMarketCacheService _cacheService;
|
||||
private readonly AirAppMarketMetadataResolverService _metadataResolver;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
||||
@@ -22,6 +23,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
||||
@@ -34,6 +36,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
|
||||
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
@@ -66,6 +69,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
@@ -93,6 +97,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
try
|
||||
{
|
||||
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
||||
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
cachedDocument,
|
||||
@@ -124,6 +129,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_metadataResolver.Dispose();
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
var localCopyResult = await _downloadService.DownloadAsync(
|
||||
localPackagePath,
|
||||
attemptPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!localCopyResult.Success)
|
||||
{
|
||||
@@ -208,7 +208,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
var downloadResult = await _downloadService.DownloadAsync(
|
||||
resolvedDownloadUrl,
|
||||
attemptPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
@@ -231,14 +231,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||
return new AirAppMarketVerificationResult(
|
||||
false,
|
||||
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
|
||||
$"Package verification failed. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(plugin.Sha256) &&
|
||||
!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Package hash verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'.");
|
||||
return new AirAppMarketVerificationResult(
|
||||
false,
|
||||
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}.");
|
||||
}
|
||||
|
||||
return new AirAppMarketVerificationResult(true, null);
|
||||
|
||||
@@ -678,30 +678,39 @@ internal sealed class AirAppMarketPluginRepositoryEntry
|
||||
|
||||
public AirAppMarketPluginRepositoryEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.iconUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
|
||||
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.projectUrl."),
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
|
||||
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.readmeUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.homepageUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
|
||||
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."),
|
||||
nameof(RepositoryUrl),
|
||||
sourceName);
|
||||
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedIconUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedProjectUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedProjectUrl,
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReadmeUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedHomepageUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedTags = (Tags ?? [])
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
@@ -718,8 +727,7 @@ internal sealed class AirAppMarketPluginRepositoryEntry
|
||||
HomepageUrl = normalizedHomepageUrl,
|
||||
RepositoryUrl = normalizedRepositoryUrl,
|
||||
Tags = normalizedTags,
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.releaseNotes.")
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -808,30 +816,20 @@ internal sealed class AirAppMarketPluginPublicationEntry
|
||||
|
||||
public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId)
|
||||
{
|
||||
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
ReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
sourceName);
|
||||
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing publication.releaseAssetName for plugin '{pluginId}'.");
|
||||
|
||||
if (PublishedAt == default || UpdatedAt == default)
|
||||
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
|
||||
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing valid publication timestamps for plugin '{pluginId}'.");
|
||||
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
normalizedReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
if (PackageSizeBytes <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing publication.sha256 for plugin '{pluginId}'.");
|
||||
if (normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName) ?? string.Empty;
|
||||
var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSha256) &&
|
||||
(normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'.");
|
||||
@@ -845,8 +843,6 @@ internal sealed class AirAppMarketPluginPublicationEntry
|
||||
$"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
|
||||
|
||||
return new AirAppMarketPluginPublicationEntry
|
||||
{
|
||||
ReleaseTag = normalizedReleaseTag,
|
||||
@@ -1039,120 +1035,10 @@ internal sealed class AirAppMarketPluginEntry
|
||||
? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId)
|
||||
: null;
|
||||
|
||||
var resolvedName = FirstNonEmpty(
|
||||
normalizedManifest?.Name,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Name))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name.");
|
||||
var resolvedDescription = FirstNonEmpty(
|
||||
normalizedManifest?.Description,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Description))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description.");
|
||||
var resolvedAuthor = FirstNonEmpty(
|
||||
normalizedManifest?.Author,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Author))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author.");
|
||||
var resolvedVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
FirstNonEmpty(normalizedManifest?.Version, Version),
|
||||
nameof(Version),
|
||||
sourceName);
|
||||
var resolvedApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
FirstNonEmpty(
|
||||
normalizedCompatibility?.PluginApiVersion,
|
||||
normalizedManifest?.ApiVersion,
|
||||
ApiVersion),
|
||||
nameof(ApiVersion),
|
||||
sourceName);
|
||||
var resolvedMinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
FirstNonEmpty(normalizedCompatibility?.MinHostVersion, MinHostVersion),
|
||||
nameof(MinHostVersion),
|
||||
sourceName);
|
||||
|
||||
var resolvedIconUrl = FirstNonEmpty(
|
||||
normalizedRepository?.IconUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(IconUrl))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin iconUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(resolvedIconUrl, nameof(IconUrl), sourceName);
|
||||
var resolvedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
FirstNonEmpty(
|
||||
normalizedRepository?.ProjectUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin projectUrl."),
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
var resolvedReadmeUrl = FirstNonEmpty(
|
||||
normalizedRepository?.ReadmeUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin readmeUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(resolvedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
var resolvedHomepageUrl = FirstNonEmpty(
|
||||
normalizedRepository?.HomepageUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(HomepageUrl))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin homepageUrl.");
|
||||
AirAppMarketIndexDocument.EnsureUrl(resolvedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
var resolvedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
FirstNonEmpty(
|
||||
normalizedRepository?.RepositoryUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl."),
|
||||
nameof(RepositoryUrl),
|
||||
sourceName);
|
||||
|
||||
var resolvedReleaseTag = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseTag,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseTag));
|
||||
var resolvedReleaseAssetName = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseAssetName,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName));
|
||||
if (string.IsNullOrWhiteSpace(resolvedReleaseTag) != string.IsNullOrWhiteSpace(resolvedReleaseAssetName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedReleaseTag))
|
||||
{
|
||||
resolvedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
resolvedReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes;
|
||||
if (resolvedPackageSize <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{resolvedPackageSize}' for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
var resolvedSha256 = FirstNonEmpty(
|
||||
normalizedPublication?.Sha256,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant())
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing SHA-256 for plugin '{resolvedPluginId}'.");
|
||||
if (resolvedSha256.Length != 64 || resolvedSha256.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{resolvedSha256}' for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
var resolvedMd5 = FirstNonEmpty(
|
||||
normalizedPublication?.Md5,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant())
|
||||
?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedMd5) &&
|
||||
(resolvedMd5.Length != 32 || resolvedMd5.Any(ch => !Uri.IsHexDigit(ch))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid MD5 '{resolvedMd5}' for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
var resolvedPackageSources = NormalizePackageSources(
|
||||
normalizedPublication?.PackageSources,
|
||||
normalizedPublication?.PackageSources ?? PackageSources,
|
||||
sourceName,
|
||||
resolvedPluginId,
|
||||
resolvedReleaseTag,
|
||||
resolvedReleaseAssetName,
|
||||
resolvedRepositoryUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl));
|
||||
if (resolvedPackageSources.Count == 0)
|
||||
{
|
||||
@@ -1160,19 +1046,84 @@ internal sealed class AirAppMarketPluginEntry
|
||||
$"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
var resolvedDownloadUrl = resolvedPackageSources[0].Url;
|
||||
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt;
|
||||
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt;
|
||||
if (resolvedPublishedAt == default || resolvedUpdatedAt == default)
|
||||
var resolvedRepositoryUrl = FirstNonEmpty(
|
||||
normalizedRepository?.RepositoryUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl));
|
||||
if (string.IsNullOrWhiteSpace(resolvedRepositoryUrl))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{resolvedPluginId}'.");
|
||||
throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl.");
|
||||
}
|
||||
|
||||
var resolvedDownloadUrl = FirstNonEmpty(
|
||||
resolvedPackageSources.FirstOrDefault()?.Url,
|
||||
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedName = FirstNonEmpty(
|
||||
normalizedManifest?.Name,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Name))
|
||||
?? string.Empty;
|
||||
var resolvedDescription = FirstNonEmpty(
|
||||
normalizedManifest?.Description,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Description))
|
||||
?? string.Empty;
|
||||
var resolvedAuthor = FirstNonEmpty(
|
||||
normalizedManifest?.Author,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Author))
|
||||
?? string.Empty;
|
||||
var resolvedVersion = FirstNonEmpty(
|
||||
normalizedManifest?.Version,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Version))
|
||||
?? string.Empty;
|
||||
var resolvedApiVersion = FirstNonEmpty(
|
||||
normalizedCompatibility?.PluginApiVersion,
|
||||
normalizedManifest?.ApiVersion,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ApiVersion))
|
||||
?? string.Empty;
|
||||
var resolvedMinHostVersion = FirstNonEmpty(
|
||||
normalizedCompatibility?.MinHostVersion,
|
||||
AirAppMarketIndexDocument.NormalizeValue(MinHostVersion))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedIconUrl = FirstNonEmpty(
|
||||
normalizedRepository?.IconUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(IconUrl))
|
||||
?? string.Empty;
|
||||
var resolvedProjectUrl = FirstNonEmpty(
|
||||
normalizedRepository?.ProjectUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl))
|
||||
?? string.Empty;
|
||||
var resolvedReadmeUrl = FirstNonEmpty(
|
||||
normalizedRepository?.ReadmeUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl))
|
||||
?? string.Empty;
|
||||
var resolvedHomepageUrl = FirstNonEmpty(
|
||||
normalizedRepository?.HomepageUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(HomepageUrl))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedReleaseTag = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseTag,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseTag))
|
||||
?? string.Empty;
|
||||
var resolvedReleaseAssetName = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseAssetName,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName))
|
||||
?? string.Empty;
|
||||
var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes;
|
||||
var resolvedSha256 = FirstNonEmpty(
|
||||
normalizedPublication?.Sha256,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant())
|
||||
?? string.Empty;
|
||||
var resolvedMd5 = FirstNonEmpty(
|
||||
normalizedPublication?.Md5,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant())
|
||||
?? string.Empty;
|
||||
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt;
|
||||
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt;
|
||||
|
||||
var resolvedDependencies = NormalizeDependencies(
|
||||
normalizedManifest?.SharedContracts,
|
||||
normalizedCapabilities?.SharedContracts,
|
||||
SharedContracts,
|
||||
normalizedManifest?.SharedContracts ?? SharedContracts,
|
||||
sourceName,
|
||||
resolvedPluginId);
|
||||
var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? [])
|
||||
@@ -1185,8 +1136,7 @@ internal sealed class AirAppMarketPluginEntry
|
||||
var resolvedReleaseNotes = FirstNonEmpty(
|
||||
normalizedRepository?.ReleaseNotes,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing release notes for plugin '{resolvedPluginId}'.");
|
||||
?? string.Empty;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
@@ -1225,6 +1175,13 @@ internal sealed class AirAppMarketPluginEntry
|
||||
|
||||
public string GetVersionSummary()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Version) &&
|
||||
string.IsNullOrWhiteSpace(ApiVersion) &&
|
||||
string.IsNullOrWhiteSpace(MinHostVersion))
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"v{0} | API {1} | Host >= {2}",
|
||||
@@ -1309,21 +1266,13 @@ internal sealed class AirAppMarketPluginEntry
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? manifestDependencies,
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? capabilityDependencies,
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? legacyDependencies,
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies,
|
||||
string sourceName,
|
||||
string pluginId)
|
||||
{
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry> dependencies = manifestDependencies is { Count: > 0 }
|
||||
? manifestDependencies
|
||||
: capabilityDependencies is { Count: > 0 }
|
||||
? capabilityDependencies
|
||||
: legacyDependencies ?? [];
|
||||
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>(dependencies.Count);
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
|
||||
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dependency in dependencies)
|
||||
foreach (var dependency in dependencies ?? [])
|
||||
{
|
||||
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||
@@ -1343,9 +1292,6 @@ internal sealed class AirAppMarketPluginEntry
|
||||
IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources,
|
||||
string sourceName,
|
||||
string pluginId,
|
||||
string? releaseTag,
|
||||
string? releaseAssetName,
|
||||
string repositoryUrl,
|
||||
string? legacyDownloadUrl)
|
||||
{
|
||||
var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count + 1);
|
||||
@@ -1364,42 +1310,16 @@ internal sealed class AirAppMarketPluginEntry
|
||||
var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl))
|
||||
{
|
||||
var legacyKind = !string.IsNullOrWhiteSpace(releaseTag) && !string.IsNullOrWhiteSpace(releaseAssetName)
|
||||
? PluginPackageSourceKind.ReleaseAsset
|
||||
: PluginPackageSourceKind.RawFallback;
|
||||
var legacySource = new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = legacyKind switch
|
||||
{
|
||||
PluginPackageSourceKind.ReleaseAsset => "releaseAsset",
|
||||
PluginPackageSourceKind.RawFallback => "rawFallback",
|
||||
PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal",
|
||||
_ => "rawFallback"
|
||||
},
|
||||
Kind = "rawFallback",
|
||||
Url = normalizedLegacyDownloadUrl,
|
||||
SourceKind = legacyKind
|
||||
SourceKind = PluginPackageSourceKind.RawFallback
|
||||
};
|
||||
normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId));
|
||||
return normalizedSources;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(releaseTag) &&
|
||||
!string.IsNullOrWhiteSpace(releaseAssetName) &&
|
||||
AirAppMarketDefaults.TryParseGitHubRepositoryUrl(repositoryUrl, out var owner, out var repositoryName))
|
||||
{
|
||||
var releaseUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
||||
owner,
|
||||
repositoryName,
|
||||
releaseTag,
|
||||
releaseAssetName);
|
||||
normalizedSources.Add(new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = "releaseAsset",
|
||||
Url = releaseUrl,
|
||||
SourceKind = PluginPackageSourceKind.ReleaseAsset
|
||||
}.ValidateAndNormalize(sourceName, pluginId));
|
||||
}
|
||||
|
||||
return normalizedSources;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class AirAppMarketReadmeService : IDisposable
|
||||
}
|
||||
|
||||
public async Task<string> LoadAsync(
|
||||
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
|
||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
Reference in New Issue
Block a user