更新功能优化、插件市场优化,反正就是优化了很多东西
This commit is contained in:
lincube
2026-03-25 11:27:30 +08:00
parent 74703582e7
commit 372b5b7adc
28 changed files with 1360 additions and 572 deletions

View File

@@ -6,7 +6,9 @@ public static class SettingsCategories
public const string Appearance = "Appearance"; public const string Appearance = "Appearance";
public const string Components = "Components"; public const string Components = "Components";
public const string Plugins = "Plugins"; 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 Update = "Update";
public const string About = "About"; public const string About = "About";
public const string Advanced = "Advanced"; public const string Advanced = "Advanced";

View File

@@ -6,6 +6,8 @@ public enum SettingsPageCategory
Appearance = 10, Appearance = 10,
Components = 20, Components = 20,
Plugins = 30, Plugins = 30,
PluginCatalog = 35,
[Obsolete("Use PluginCatalog instead.")]
PluginMarket = 35, PluginMarket = 35,
About = 40 About = 40
} }

View File

@@ -6,7 +6,7 @@ using Markdown.Avalonia;
namespace LanMountainDesktop.Helpers; namespace LanMountainDesktop.Helpers;
public static class PluginMarketMarkdownHelper public static class PluginCatalogMarkdownHelper
{ {
private static Markdown.Avalonia.Markdown? _engine; private static Markdown.Avalonia.Markdown? _engine;

View File

@@ -418,6 +418,11 @@
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.", "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_label": "Download Threads",
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.", "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.install_now_button": "Install Now",
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.", "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.", "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.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}", "settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market", "settings.nav.plugin_catalog": "Plugin Catalog",
"settings.plugin_market.title": "Plugin Market", "settings.plugin_catalog.title": "Plugin Catalog",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.", "settings.plugin_catalog.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.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_idle": "No update check has been performed yet.",
"settings.update.status_preferences_saved": "Update preferences saved.", "settings.update.status_preferences_saved": "Update preferences saved.",
"settings.update.status_check_failed": "Failed to check for updates.", "settings.update.status_check_failed": "Failed to check for updates.",

View File

@@ -418,6 +418,11 @@
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。", "settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
"settings.update.download_threads_label": "ダウンロードスレッド", "settings.update.download_threads_label": "ダウンロードスレッド",
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。", "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.install_now_button": "今すぐインストール",
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。", "settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。", "settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
@@ -477,7 +482,7 @@
"settings.plugins.refresh_button": "プラグインを更新", "settings.plugins.refresh_button": "プラグインを更新",
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。", "settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。", "settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
"settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。", "settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
"settings.plugins.marketplace_header": "マーケットプレイス", "settings.plugins.marketplace_header": "マーケットプレイス",
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。", "settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
"settings.plugins.delete_button_short": "削除", "settings.plugins.delete_button_short": "削除",
@@ -525,10 +530,10 @@
"settings.plugins.source_manifest": "ルーズマニフェスト", "settings.plugins.source_manifest": "ルーズマニフェスト",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}", "settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
"settings.nav.plugin_market": "プラグインマーケット", "settings.nav.plugin_catalog": "プラグインカタログ",
"settings.plugin_market.title": "プラグインマーケット", "settings.plugin_catalog.title": "プラグインカタログ",
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。", "settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
"settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。", "settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。", "settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
"settings.update.status_preferences_saved": "アップデート設定が保存されました。", "settings.update.status_preferences_saved": "アップデート設定が保存されました。",
"settings.update.status_check_failed": "アップデートの確認に失敗しました。", "settings.update.status_check_failed": "アップデートの確認に失敗しました。",
@@ -537,15 +542,15 @@
"settings.window.drawer_default": "詳細", "settings.window.drawer_default": "詳細",
"market.toolbar.search_placeholder": "プラグインを検索", "market.toolbar.search_placeholder": "プラグインを検索",
"market.toolbar.refresh": "更新", "market.toolbar.refresh": "更新",
"market.status.loading": "公式プラグインマーケットをロード中...", "market.status.loading": "公式プラグインカタログをロード中...",
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。", "market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}", "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.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。", "market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}", "market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。", "market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
"market.list.empty": "プラグインマーケットはまだロードされていません。", "market.list.empty": "プラグインカタログはまだロードされていません。",
"market.list.no_results": "現在の検索に一致するプラグインはありません。", "market.list.no_results": "現在の検索に一致するプラグインはありません。",
"market.card.subtitle_format": "{0} | v{1}", "market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "ロード済み", "market.card.loaded": "ロード済み",

View File

@@ -418,6 +418,11 @@
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.", "settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
"settings.update.download_threads_label": "다운로드 스레드 수", "settings.update.download_threads_label": "다운로드 스레드 수",
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.", "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.install_now_button": "지금 설치",
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.", "settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.", "settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
@@ -476,8 +481,8 @@
"settings.plugins.refresh_button": "플러그인 새로고침", "settings.plugins.refresh_button": "플러그인 새로고침",
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.", "settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.", "settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
"settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.", "settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.",
"settings.plugins.marketplace_header": "플러그인 마켓", "settings.plugins.marketplace_header": "플러그인 카탈로그",
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.", "settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
"settings.plugins.delete_button_short": "삭제", "settings.plugins.delete_button_short": "삭제",
"settings.plugins.install_button_short": "설치", "settings.plugins.install_button_short": "설치",
@@ -524,10 +529,10 @@
"settings.plugins.source_manifest": "매니페스트 파일", "settings.plugins.source_manifest": "매니페스트 파일",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}", "settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
"settings.nav.plugin_market": "플러그인 마켓", "settings.nav.plugin_catalog": "플러그인 카탈로그",
"settings.plugin_market.title": "플러그인 마켓", "settings.plugin_catalog.title": "플러그인 카탈로그",
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.", "settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
"settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.", "settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.",
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.", "settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.", "settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
"settings.update.status_check_failed": "업데이트 확인 실패.", "settings.update.status_check_failed": "업데이트 확인 실패.",
@@ -536,15 +541,15 @@
"settings.window.drawer_default": "상세 정보", "settings.window.drawer_default": "상세 정보",
"market.toolbar.search_placeholder": "플러그인 검색", "market.toolbar.search_placeholder": "플러그인 검색",
"market.toolbar.refresh": "새로고침", "market.toolbar.refresh": "새로고침",
"market.status.loading": "공식 플러그인 마켓 로딩 중...", "market.status.loading": "공식 플러그인 카탈로그 로딩 중...",
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.", "market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}", "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.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...",
"market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.", "market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
"market.status.install_failed_format": "플러그인 설치 실패: {0}", "market.status.install_failed_format": "플러그인 설치 실패: {0}",
"market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.", "market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.",
"market.list.empty": "플러그인 마켓이 아직 로드되지 않았습니다.", "market.list.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.",
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.", "market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
"market.card.subtitle_format": "{0} | v{1}", "market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "로드됨", "market.card.loaded": "로드됨",

View File

@@ -413,6 +413,11 @@
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。", "settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
"settings.update.download_threads_label": "下载线程数", "settings.update.download_threads_label": "下载线程数",
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。", "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.install_now_button": "立即安装",
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。", "settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。", "settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
@@ -471,8 +476,8 @@
"settings.plugins.refresh_button": "刷新插件", "settings.plugins.refresh_button": "刷新插件",
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。", "settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。", "settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
"settings.plugins.refresh_failed": "加载插件市场索引失败。", "settings.plugins.refresh_failed": "加载插件目录索引失败。",
"settings.plugins.marketplace_header": "插件市场", "settings.plugins.marketplace_header": "插件目录",
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。", "settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
"settings.plugins.delete_button_short": "删除", "settings.plugins.delete_button_short": "删除",
"settings.plugins.install_button_short": "安装", "settings.plugins.install_button_short": "安装",
@@ -519,10 +524,10 @@
"settings.plugins.source_manifest": "散装清单", "settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}", "settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"settings.nav.plugin_market": "插件市场", "settings.nav.plugin_catalog": "插件目录",
"settings.plugin_market.title": "插件市场", "settings.plugin_catalog.title": "插件目录",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。", "settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。", "settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
"settings.update.status_idle": "尚未执行更新检查。", "settings.update.status_idle": "尚未执行更新检查。",
"settings.update.status_preferences_saved": "更新偏好已保存。", "settings.update.status_preferences_saved": "更新偏好已保存。",
"settings.update.status_check_failed": "检查更新失败。", "settings.update.status_check_failed": "检查更新失败。",
@@ -531,15 +536,15 @@
"settings.window.drawer_default": "详情", "settings.window.drawer_default": "详情",
"market.toolbar.search_placeholder": "搜索插件", "market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新", "market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...", "market.status.loading": "正在加载官方插件目录...",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。", "market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}", "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.installing_format": "正在下载并暂存插件“{0}”...",
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。", "market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}", "market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。", "market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。", "market.list.empty": "插件目录尚未加载。",
"market.list.no_results": "没有匹配当前搜索的插件。", "market.list.no_results": "没有匹配当前搜索的插件。",
"market.card.subtitle_format": "{0} | v{1}", "market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "已加载", "market.card.loaded": "已加载",

View File

@@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot
public long? LastUpdateCheckUtcMs { get; set; } public long? LastUpdateCheckUtcMs { get; set; }
public string? PendingUpdateSha256 { get; set; }
public List<string> TopStatusComponentIds { get; set; } = []; public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } = public List<string> PinnedTaskbarActions { get; set; } =

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
public sealed record GitHubReleaseAsset( public sealed record GitHubReleaseAsset(
string Name, string Name,
string BrowserDownloadUrl, string BrowserDownloadUrl,
long SizeBytes); long SizeBytes,
string? Sha256 = null);
public sealed record GitHubReleaseInfo( public sealed record GitHubReleaseInfo(
string TagName, string TagName,
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
string LatestVersionText, string LatestVersionText,
GitHubReleaseInfo? Release, GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset, GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage); string? ErrorMessage,
bool ForceMode = false);
public sealed record UpdateDownloadResult( public sealed record UpdateDownloadResult(
bool Success, bool Success,
string? FilePath, string? FilePath,
string? ErrorMessage); string? ErrorMessage,
bool HashVerified = false,
string? ExpectedHash = null,
string? ActualHash = null);
public sealed class GitHubReleaseUpdateService : IDisposable 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( public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset, GitHubReleaseAsset asset,
string destinationFilePath, string destinationFilePath,
@@ -206,9 +286,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
progressAdapter, progressAdapter,
cancellationToken); cancellationToken);
return result.Success if (!result.Success)
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null) {
: new UpdateDownloadResult(false, null, result.ErrorMessage); 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( public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
@@ -343,13 +542,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
continue; 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); 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) private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{ {
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows()) if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())

View File

@@ -67,7 +67,8 @@ public sealed record UpdateSettingsState(
string? PendingUpdateInstallerPath, string? PendingUpdateInstallerPath,
string? PendingUpdateVersion, string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs, long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs); long? LastUpdateCheckUtcMs,
string? PendingUpdateSha256);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds); public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public enum PluginPackageSourceKind public enum PluginPackageSourceKind
{ {
@@ -175,14 +176,6 @@ public sealed record PluginCatalogItemInfo(
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts; 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 PublishedAt => Publication.PublishedAt;
public DateTimeOffset UpdatedAt => Publication.UpdatedAt; public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
@@ -192,82 +185,6 @@ public sealed record PluginCatalogItemInfo(
public string ReleaseAssetName => Publication.ReleaseAssetName; public string ReleaseAssetName => Publication.ReleaseAssetName;
public string ReleaseNotes => Repository.ReleaseNotes; 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( public sealed record PluginCatalogIndexResult(
@@ -277,19 +194,7 @@ public sealed record PluginCatalogIndexResult(
string? Source, string? Source,
string? SourceLocation, string? SourceLocation,
string? WarningMessage, string? WarningMessage,
string? ErrorMessage) 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);
}
}
public sealed record PluginInstallDiagnostic( public sealed record PluginInstallDiagnostic(
string Code, string Code,
@@ -302,73 +207,6 @@ public sealed record PluginCatalogInstallResult(
string? PluginName, string? PluginName,
PluginManifest? InstalledManifest, PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics, 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); string? ErrorMessage);
public interface IPluginCatalogSourceProvider public interface IPluginCatalogSourceProvider
@@ -488,6 +326,7 @@ public interface IUpdateSettingsService
UpdateSettingsState Get(); UpdateSettingsState Get();
void Save(UpdateSettingsState state); void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync( Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset, GitHubReleaseAsset asset,
string destinationFilePath, string destinationFilePath,
@@ -495,6 +334,13 @@ public interface IUpdateSettingsService
int maxParallelSegments, int maxParallelSegments,
IProgress<double>? progress = null, IProgress<double>? progress = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
} }
public interface ILauncherCatalogService public interface ILauncherCatalogService
@@ -523,13 +369,6 @@ public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default); 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 public interface IApplicationInfoService
{ {
string GetAppVersionText(); string GetAppVersionText();
@@ -554,8 +393,6 @@ public interface ISettingsFacadeService
ILauncherPolicyService LauncherPolicy { get; } ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; } IPluginManagementSettingsService PluginManagement { get; }
IPluginCatalogSettingsService PluginCatalog { get; } IPluginCatalogSettingsService PluginCatalog { get; }
[Obsolete("Use PluginCatalog instead.")]
IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; } IApplicationInfoService ApplicationInfo { get; }
} }

View File

@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.PendingUpdateInstallerPath, snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion, snapshot.PendingUpdateVersion,
snapshot.PendingUpdatePublishedAtUtcMs, snapshot.PendingUpdatePublishedAtUtcMs,
snapshot.LastUpdateCheckUtcMs); snapshot.LastUpdateCheckUtcMs,
snapshot.PendingUpdateSha256);
} }
public void Save(UpdateSettingsState state) public void Save(UpdateSettingsState state)
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0 snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
? state.LastUpdateCheckUtcMs ? state.LastUpdateCheckUtcMs
: null; : null;
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
? null
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
_settingsService.SaveSnapshot( _settingsService.SaveSnapshot(
SettingsScope.App, SettingsScope.App,
snapshot, snapshot,
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion), nameof(AppSettingsSnapshot.PendingUpdateVersion),
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs), 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); 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( public Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset, GitHubReleaseAsset asset,
string destinationFilePath, string destinationFilePath,
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
cancellationToken); 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() public void Dispose()
{ {
_releaseUpdateService.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 PluginRuntimeService? _pluginRuntimeService;
private AirAppMarketIndexService _indexService; private AirAppMarketIndexService _indexService;
private AirAppMarketInstallService? _installService; private AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService) public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
{ {
_pluginRuntimeService = pluginRuntimeService; _pluginRuntimeService = pluginRuntimeService;
@@ -875,11 +905,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
return LoadCatalogCoreAsync(cancellationToken); return LoadCatalogCoreAsync(cancellationToken);
} }
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
{
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
public Task<PluginCatalogInstallResult> InstallAsync( public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId, string pluginId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -887,13 +912,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
return InstallCatalogCoreAsync(pluginId, cancellationToken); 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) private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
{ {
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false); var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
@@ -1055,23 +1073,25 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry) private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
{ {
if (string.IsNullOrWhiteSpace(entry.DownloadUrl)) var sources = entry.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{ {
return []; return [];
} }
var sourceKind = entry.HasReleaseDownloadMetadata return sources
? PluginPackageSourceKind.ReleaseAsset .Select(source => new PluginPackageSourceInfo(
: PluginPackageSourceKind.RawFallback; source.SourceKind switch
{
return LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset,
[ LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback,
new PluginPackageSourceInfo( LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal,
sourceKind, _ => PluginPackageSourceKind.RawFallback
entry.DownloadUrl, },
source.Url,
entry.Sha256, entry.Sha256,
entry.PackageSizeBytes) entry.PackageSizeBytes))
]; .ToArray();
} }
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources( private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
@@ -1165,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
{ {
private readonly UpdateSettingsService _updateSettingsService; private readonly UpdateSettingsService _updateSettingsService;
private readonly PluginMarketSettingsService _pluginMarketSettingsService; private readonly PluginCatalogSettingsService _pluginCatalogSettingsService;
private readonly PluginManagementSettingsService _pluginManagementSettingsService; private readonly PluginManagementSettingsService _pluginManagementSettingsService;
private readonly WeatherSettingsService _weatherSettingsService; private readonly WeatherSettingsService _weatherSettingsService;
@@ -1188,9 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
LauncherPolicy = new LauncherPolicyService(); LauncherPolicy = new LauncherPolicyService();
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService); _pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService; PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService); _pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
PluginCatalog = _pluginMarketSettingsService; PluginCatalog = _pluginCatalogSettingsService;
PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService(); ApplicationInfo = new ApplicationInfoService();
} }
@@ -1224,20 +1243,18 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginCatalogSettingsService PluginCatalog { get; } public IPluginCatalogSettingsService PluginCatalog { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; } public IApplicationInfoService ApplicationInfo { get; }
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService) public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{ {
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService); _pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService); _pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
} }
public void Dispose() public void Dispose()
{ {
_weatherSettingsService.Dispose(); _weatherSettingsService.Dispose();
_updateSettingsService.Dispose(); _updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose(); _pluginCatalogSettingsService.Dispose();
} }
} }

View File

@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
public sealed record UpdatePendingInfo( public sealed record UpdatePendingInfo(
string InstallerPath, string InstallerPath,
string VersionText, 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( public sealed record UpdateInstallerLaunchResult(
bool Success, bool Success,
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
public async Task<UpdateCheckResult> CheckForUpdatesAsync( public async Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion, Version currentVersion,
bool isForce = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var state = _settingsFacade.Update.Get(); var state = _settingsFacade.Update.Get();
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
UpdateSettingsValues.ChannelPreview, UpdateSettingsValues.ChannelPreview,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
var result = await _settingsFacade.Update.CheckForUpdatesAsync( var result = isForce
currentVersion, ? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
includePrerelease, currentVersion,
cancellationToken); includePrerelease,
cancellationToken)
: await _settingsFacade.Update.CheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken);
SaveState(state with SaveState(state with
{ {
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
return result; return result;
} }
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
CancellationToken cancellationToken = default)
{
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
}
public async Task<UpdateDownloadResult> DownloadReleaseAsync( public async Task<UpdateDownloadResult> DownloadReleaseAsync(
UpdateCheckResult checkResult, UpdateCheckResult checkResult,
IProgress<double>? progress = null, IProgress<double>? progress = null,
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) && string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath)) 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); Directory.CreateDirectory(_updatesDirectory);
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null ? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
}); });
} }
return result; 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( public async Task AutoCheckIfEnabledAsync(
Version currentVersion, Version currentVersion,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
try try
{ {
// Always check for updates on startup (removed AutoCheckUpdates check) // 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) if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{ {
return; return;
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
{ {
PendingUpdateInstallerPath = null, PendingUpdateInstallerPath = null,
PendingUpdateVersion = null, PendingUpdateVersion = null,
PendingUpdatePublishedAtUtcMs = null PendingUpdatePublishedAtUtcMs = null,
PendingUpdateSha256 = null
}); });
} }
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
return new UpdatePendingInfo( return new UpdatePendingInfo(
installerPath, installerPath,
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion, string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
publishedAt); publishedAt,
state.PendingUpdateSha256);
} }
private void SaveState(UpdateSettingsState state) private void SaveState(UpdateSettingsState state)

View File

@@ -267,7 +267,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
</Style> </Style>
<Style Selector="Button.plugin-market-row-button"> <Style Selector="Button.plugin-catalog-row-button">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
@@ -275,11 +275,11 @@
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
</Style> </Style>
<Style Selector="Button.plugin-market-row-button:pointerover"> <Style Selector="Button.plugin-catalog-row-button:pointerover">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
</Style> </Style>
<Style Selector="Button.plugin-market-icon-button"> <Style Selector="Button.plugin-catalog-icon-button">
<Setter Property="Width" Value="36" /> <Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" /> <Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
@@ -290,11 +290,11 @@
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
</Style> </Style>
<Style Selector="Button.plugin-market-icon-button:pointerover"> <Style Selector="Button.plugin-catalog-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" /> <Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
</Style> </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="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" /> <Setter Property="FontSize" Value="16" />
</Style> </Style>

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ViewModels; namespace LanMountainDesktop.ViewModels;
public enum PluginMarketPrimaryActionState public enum PluginCatalogPrimaryActionState
{ {
Install, Install,
Update, Update,
@@ -24,13 +24,13 @@ public enum PluginMarketPrimaryActionState
Incompatible Incompatible
} }
public sealed partial class PluginMarketItemViewModel : ViewModelBase public sealed partial class PluginCatalogItemViewModel : ViewModelBase
{ {
private readonly LocalizationService _localizationService; private readonly LocalizationService _localizationService;
private readonly string _languageCode; private readonly string _languageCode;
private bool _isLoadingIcon; private bool _isLoadingIcon;
public PluginMarketItemViewModel( public PluginCatalogItemViewModel(
PluginCatalogItemInfo plugin, PluginCatalogItemInfo plugin,
LocalizationService localizationService, LocalizationService localizationService,
string languageCode) string languageCode)
@@ -104,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public bool HasIcon => IconBitmap is not null; public bool HasIcon => IconBitmap is not null;
public PluginMarketPrimaryActionState ActionState { get; private set; } public PluginCatalogPrimaryActionState ActionState { get; private set; }
partial void OnIconBitmapChanged(Bitmap? value) partial void OnIconBitmapChanged(Bitmap? value)
{ {
@@ -164,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
{ {
if (IsInstalling) if (IsInstalling)
{ {
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install; ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowClockwise; ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.installing", "Installing..."); ActionTooltip = L("market.button.installing", "Installing...");
IsActionEnabled = false; IsActionEnabled = false;
@@ -173,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (!IsCompatibleWithHost) if (!IsCompatibleWithHost)
{ {
ActionState = PluginMarketPrimaryActionState.Incompatible; ActionState = PluginCatalogPrimaryActionState.Incompatible;
ActionSymbol = Symbol.Warning; ActionSymbol = Symbol.Warning;
ActionTooltip = string.Format( ActionTooltip = string.Format(
CultureInfo.CurrentCulture, CultureInfo.CurrentCulture,
@@ -185,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (RequiresRestart) if (RequiresRestart)
{ {
ActionState = PluginMarketPrimaryActionState.RestartRequired; ActionState = PluginCatalogPrimaryActionState.RestartRequired;
ActionSymbol = Symbol.ArrowClockwise; ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.restart", "Restart to apply"); ActionTooltip = L("market.button.restart", "Restart to apply");
IsActionEnabled = true; IsActionEnabled = true;
@@ -194,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsUpdateAvailable) if (IsUpdateAvailable)
{ {
ActionState = PluginMarketPrimaryActionState.Update; ActionState = PluginCatalogPrimaryActionState.Update;
ActionSymbol = Symbol.ArrowSync; ActionSymbol = Symbol.ArrowSync;
ActionTooltip = L("market.button.update", "Update"); ActionTooltip = L("market.button.update", "Update");
IsActionEnabled = true; IsActionEnabled = true;
@@ -203,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsInstalled) if (IsInstalled)
{ {
ActionState = PluginMarketPrimaryActionState.Installed; ActionState = PluginCatalogPrimaryActionState.Installed;
ActionSymbol = Symbol.CheckmarkCircle; ActionSymbol = Symbol.CheckmarkCircle;
ActionTooltip = L("market.button.installed", "Installed"); ActionTooltip = L("market.button.installed", "Installed");
IsActionEnabled = false; IsActionEnabled = false;
return; return;
} }
ActionState = PluginMarketPrimaryActionState.Install; ActionState = PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowDownload; ActionSymbol = Symbol.ArrowDownload;
ActionTooltip = L("market.button.install", "Install"); ActionTooltip = L("market.button.install", "Install");
IsActionEnabled = true; IsActionEnabled = true;
@@ -242,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback); => _localizationService.GetString(_languageCode, key, fallback);
} }
public sealed partial class PluginMarketDetailViewModel : ViewModelBase public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
{ {
private readonly LocalizationService _localizationService; private readonly LocalizationService _localizationService;
private readonly string _languageCode; private readonly string _languageCode;
private readonly AirAppMarketReadmeService _readmeService; private readonly AirAppMarketReadmeService _readmeService;
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync; private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
private bool _isInitialized; private bool _isInitialized;
public PluginMarketDetailViewModel( public PluginCatalogDetailViewModel(
PluginMarketItemViewModel item, PluginCatalogItemViewModel item,
LocalizationService localizationService, LocalizationService localizationService,
string languageCode, string languageCode,
AirAppMarketReadmeService readmeService, AirAppMarketReadmeService readmeService,
Func<PluginMarketItemViewModel, Task> primaryActionAsync) Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
{ {
Item = item; Item = item;
_localizationService = localizationService; _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."); 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; } public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
@@ -375,7 +375,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback); => _localizationService.GetString(_languageCode, key, fallback);
} }
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
{ {
private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog; 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 Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly Version? _hostVersion; private readonly Version? _hostVersion;
private bool _isInitialized; private bool _isInitialized;
private bool _hasLoadedMarket; private bool _hasLoadedCatalog;
public PluginMarketSettingsPageViewModel( public PluginCatalogSettingsPageViewModel(
ISettingsFacadeService settingsFacade, ISettingsFacadeService settingsFacade,
LocalizationService localizationService, LocalizationService localizationService,
AirAppMarketIconService iconService, AirAppMarketIconService iconService,
@@ -402,16 +402,16 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion); Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
RefreshLocalizedText(); 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<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] [ObservableProperty]
private string _statusMessage = string.Empty; private string _statusMessage = string.Empty;
@@ -454,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
await RefreshAsync(); await RefreshAsync();
} }
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item) public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
{ {
return new PluginMarketDetailViewModel( return new PluginCatalogDetailViewModel(
item, item,
_localizationService, _localizationService,
_languageCode, _languageCode,
@@ -475,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
try try
{ {
IsBusy = true; IsBusy = true;
StatusMessage = L("market.status.loading", "Loading the official plugin market..."); StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();
var result = await _pluginCatalog.LoadCatalogAsync(); var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success) if (!result.Success)
{ {
_hasLoadedMarket = false; _hasLoadedCatalog = false;
MarketPlugins.Clear(); CatalogPlugins.Clear();
FilteredPlugins.Clear(); FilteredPlugins.Clear();
ShowEmptyState = true; ShowEmptyState = true;
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage) 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; : result.ErrorMessage;
StatusMessage = string.IsNullOrWhiteSpace(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( : string.Format(
CultureInfo.CurrentCulture, 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); result.ErrorMessage);
return; return;
} }
_hasLoadedMarket = true; _hasLoadedCatalog = true;
MarketPlugins.Clear(); CatalogPlugins.Clear();
foreach (var plugin in result.Plugins) 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); item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
MarketPlugins.Add(item); CatalogPlugins.Add(item);
_ = item.EnsureIconLoadedAsync(_iconService); _ = item.EnsureIconLoadedAsync(_iconService);
} }
@@ -513,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
? string.Format( ? string.Format(
CultureInfo.CurrentCulture, CultureInfo.CurrentCulture,
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"), 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")) result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
: string.Format( : string.Format(
CultureInfo.CurrentCulture, CultureInfo.CurrentCulture,
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."), L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
MarketPlugins.Count); CatalogPlugins.Count);
} }
finally finally
{ {
@@ -527,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
private void OpenDetails(PluginMarketItemViewModel? item) private void OpenDetails(PluginCatalogItemViewModel? item)
{ {
if (item is null) if (item is null)
{ {
@@ -538,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item) private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item)
{ {
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item); return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
} }
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item) private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item)
{ {
if (item.IsInstalling) if (item.IsInstalling)
{ {
return; return;
} }
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired) if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired)
{ {
RestartRequested?.Invoke(RestartRequiredMessage); RestartRequested?.Invoke(RestartRequiredMessage);
return; return;
@@ -614,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshItemStates() private void RefreshItemStates()
{ {
foreach (var item in MarketPlugins) foreach (var item in CatalogPlugins)
{ {
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion); item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
} }
@@ -642,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{ {
FilteredPlugins.Clear(); FilteredPlugins.Clear();
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins; IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
var query = SearchText?.Trim(); var query = SearchText?.Trim();
if (!string.IsNullOrWhiteSpace(query)) if (!string.IsNullOrWhiteSpace(query))
{ {
@@ -660,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
} }
ShowEmptyState = FilteredPlugins.Count == 0; ShowEmptyState = FilteredPlugins.Count == 0;
EmptyStateText = !_hasLoadedMarket EmptyStateText = !_hasLoadedCatalog
? L("market.list.empty", "The plugin market has not been loaded yet.") ? L("market.list.empty", "The plugin catalog has not been loaded yet.")
: string.IsNullOrWhiteSpace(query) : string.IsNullOrWhiteSpace(query)
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.") ? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
: L("market.list.no_results", "No plugins match the current search."); : L("market.list.no_results", "No plugins match the current search.");
@@ -669,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshLocalizedText() private void RefreshLocalizedText()
{ {
PageTitle = L("settings.plugin_market.title", "Plugin Market"); PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs."); PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins"); SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
RefreshButtonText = L("market.toolbar.refresh", "Refresh"); RefreshButtonText = L("market.toolbar.refresh", "Refresh");
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart."); 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) private string L(string key, string fallback)

View File

@@ -1517,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _installNowButtonText = string.Empty; private string _installNowButtonText = string.Empty;
[ObservableProperty]
private string _redownloadButtonText = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _latestVersionText = string.Empty; private string _latestVersionText = string.Empty;
@@ -1556,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _downloadThreadsDescription = string.Empty; private string _downloadThreadsDescription = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateLabel = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateDescription = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _stableChannelText = string.Empty; private string _stableChannelText = string.Empty;
@@ -1619,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsInstallButtonVisible => HasPendingInstaller; public bool IsInstallButtonVisible => HasPendingInstaller;
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
public string DownloadThreadsValueText => public string DownloadThreadsValueText =>
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
@@ -1838,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))] [RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync() 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 try
{ {
@@ -1845,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = false; IsDownloadProgressVisible = false;
DownloadProgressValue = 0; DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); 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; _lastCheckResult = result.Success ? result : null;
RefreshLastCheckedFromSettings(); RefreshLastCheckedFromSettings();
@@ -1863,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
} }
ApplyCheckResultDisplay(result); ApplyCheckResultDisplay(result);
if (!result.IsUpdateAvailable) if (!result.IsUpdateAvailable && !isForce)
{ {
return; return;
} }
if (result.PreferredAsset is null) if (result.PreferredAsset is null)
{ {
UpdateStatus = L( UpdateStatus = isForce
"settings.update.status_asset_missing", ? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
"A new release is available, but no compatible installer was found."); : L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
return; return;
} }
@@ -1884,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateStatus = string.Format( UpdateStatus = string.Format(
CultureInfo.CurrentCulture, 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); result.LatestVersionText);
} }
finally 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.")); 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() private void RefreshLocalizedText()
{ {
PageTitle = L("settings.update.title", "Update"); PageTitle = L("settings.update.title", "Update");
@@ -1939,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateModeLabel = L("settings.update.mode_label", "Update Mode"); UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); 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."); 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"); CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install"); DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now"); 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"); CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release"); LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
PublishedAtLabel = L("settings.update.published_at_label", "Published At"); PublishedAtLabel = L("settings.update.published_at_label", "Published At");
@@ -2147,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(IsDownloadButtonVisible)); OnPropertyChanged(nameof(IsDownloadButtonVisible));
OnPropertyChanged(nameof(IsInstallButtonVisible)); OnPropertyChanged(nameof(IsInstallButtonVisible));
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
OnPropertyChanged(nameof(DownloadThreadsValueText)); OnPropertyChanged(nameof(DownloadThreadsValueText));
RedownloadUpdateCommand.NotifyCanExecuteChanged();
} }
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions() private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()

View File

@@ -473,6 +473,11 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
_dialogSessionId = null; _dialogSessionId = null;
_dialogSessionLabel = string.Empty; _dialogSessionLabel = string.Empty;
DialogRenameTextBox.Text = string.Empty; DialogRenameTextBox.Text = string.Empty;
DialogOverlayBorder.IsVisible = false;
if (_currentSnapshot is not null)
{
RenderSnapshot(_currentSnapshot);
}
} }
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e) private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)

View File

@@ -4,8 +4,8 @@
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
xmlns:helpers="using:LanMountainDesktop.Helpers" xmlns:helpers="using:LanMountainDesktop.Helpers"
xmlns:fi="using:FluentIcons.Avalonia.Fluent" xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer" x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer"
x:DataType="vm:PluginMarketDetailViewModel"> x:DataType="vm:PluginCatalogDetailViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container" <StackPanel Classes="settings-page-container"
Margin="0,0,0,8"> Margin="0,0,0,8">
@@ -41,7 +41,7 @@
</StackPanel> </StackPanel>
<Button Grid.Column="2" <Button Grid.Column="2"
Classes="plugin-market-icon-button" Classes="plugin-catalog-icon-button"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding PerformPrimaryActionCommand}" Command="{Binding PerformPrimaryActionCommand}"
IsEnabled="{Binding Item.IsActionEnabled}" IsEnabled="{Binding Item.IsActionEnabled}"
@@ -103,7 +103,7 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}" <mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
Markdown="{Binding ReadmeMarkdown}" Markdown="{Binding ReadmeMarkdown}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" /> Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
</StackPanel> </StackPanel>
</Border> </Border>

View File

@@ -3,14 +3,14 @@ using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages; namespace LanMountainDesktop.Views.SettingsPages;
public partial class PluginMarketDetailDrawer : UserControl public partial class PluginCatalogDetailDrawer : UserControl
{ {
public PluginMarketDetailDrawer() public PluginCatalogDetailDrawer()
{ {
InitializeComponent(); InitializeComponent();
} }
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel) public PluginCatalogDetailDrawer(PluginCatalogDetailViewModel viewModel)
{ {
DataContext = viewModel; DataContext = viewModel;
InitializeComponent(); InitializeComponent();

View File

@@ -3,9 +3,9 @@
xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent" xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage" x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogSettingsPage"
x:Name="Root" x:Name="Root"
x:DataType="vm:PluginMarketSettingsPageViewModel"> x:DataType="vm:PluginCatalogSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated"> <StackPanel Classes="settings-page-container settings-page-animated">
<ui:SettingsExpander Header="{Binding RefreshButtonText}" <ui:SettingsExpander Header="{Binding RefreshButtonText}"
@@ -47,7 +47,7 @@
</Style> </Style>
</ListBox.Styles> </ListBox.Styles>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PluginMarketItemViewModel"> <DataTemplate x:DataType="vm:PluginCatalogItemViewModel">
<Border Classes="settings-list-item"> <Border Classes="settings-list-item">
<Grid ColumnDefinitions="Auto,*,Auto" <Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="14"> ColumnSpacing="14">
@@ -70,7 +70,7 @@
</Border> </Border>
<Button Grid.Column="1" <Button Grid.Column="1"
Classes="plugin-market-row-button" Classes="plugin-catalog-row-button"
Command="{Binding #Root.DataContext.OpenDetailsCommand}" Command="{Binding #Root.DataContext.OpenDetailsCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}">
<StackPanel Spacing="4" <StackPanel Spacing="4"
@@ -83,7 +83,7 @@
</Button> </Button>
<Button Grid.Column="2" <Button Grid.Column="2"
Classes="plugin-market-icon-button" Classes="plugin-catalog-icon-button"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}" Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"

View File

@@ -9,21 +9,21 @@ using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages; namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo( [SettingsPageInfo(
"plugin-market", "plugin-catalog",
"Plugin Market", "Plugin Catalog",
SettingsPageCategory.PluginMarket, SettingsPageCategory.PluginCatalog,
IconKey = "ShoppingBag", IconKey = "ShoppingBag",
SortOrder = 35, SortOrder = 35,
TitleLocalizationKey = "settings.plugin_market.title", TitleLocalizationKey = "settings.plugin_catalog.title",
DescriptionLocalizationKey = "settings.plugin_market.subtitle")] DescriptionLocalizationKey = "settings.plugin_catalog.subtitle")]
public partial class PluginMarketSettingsPage : SettingsPageBase public partial class PluginCatalogSettingsPage : SettingsPageBase
{ {
public PluginMarketSettingsPage() public PluginCatalogSettingsPage()
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel()) : this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
{ {
} }
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel) public PluginCatalogSettingsPage(PluginCatalogSettingsPageViewModel viewModel)
{ {
ViewModel = viewModel; ViewModel = viewModel;
ViewModel.RestartRequested += OnRestartRequested; ViewModel.RestartRequested += OnRestartRequested;
@@ -32,7 +32,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
InitializeComponent(); InitializeComponent();
} }
public PluginMarketSettingsPageViewModel ViewModel { get; } public PluginCatalogSettingsPageViewModel ViewModel { get; }
public override async void OnNavigatedTo(object? parameter) public override async void OnNavigatedTo(object? parameter)
{ {
@@ -44,22 +44,22 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
await ViewModel.InitializeAsync(); await ViewModel.InitializeAsync();
} }
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel() private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
{ {
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService(); var localizationService = new LocalizationService();
return new PluginMarketSettingsPageViewModel( return new PluginCatalogSettingsPageViewModel(
settingsFacade, settingsFacade,
localizationService, localizationService,
new AirAppMarketIconService(), new AirAppMarketIconService(),
new AirAppMarketReadmeService()); new AirAppMarketReadmeService());
} }
private static PluginMarketSettingsPageViewModel CreateDesignTimeViewModel() private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
{ {
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService(); var localizationService = new LocalizationService();
var viewModel = new PluginMarketSettingsPageViewModel( var viewModel = new PluginCatalogSettingsPageViewModel(
settingsFacade, settingsFacade,
localizationService, localizationService,
new AirAppMarketIconService(), new AirAppMarketIconService(),
@@ -68,8 +68,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
var previewHostVersion = new Version(1, 2, 0); var previewHostVersion = new Version(1, 2, 0);
var items = new[] var items = new[]
{ {
CreateMarketItem( CreateCatalogItemViewModel(
new PluginMarketPluginInfo( CreateCatalogItem(
"news-tiles", "news-tiles",
"News Tiles", "News Tiles",
"Brings editorial news cards and ticker rows to the desktop.", "Brings editorial news cards and ticker rows to the desktop.",
@@ -91,8 +91,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
localizationService, localizationService,
installedPlugin: null, installedPlugin: null,
previewHostVersion), previewHostVersion),
CreateMarketItem( CreateCatalogItemViewModel(
new PluginMarketPluginInfo( CreateCatalogItem(
"workspace-pulse", "workspace-pulse",
"Workspace Pulse", "Workspace Pulse",
"Tracks active projects and shows a compact productivity summary.", "Tracks active projects and shows a compact productivity summary.",
@@ -125,8 +125,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
true, true,
null), null),
previewHostVersion), previewHostVersion),
CreateMarketItem( CreateCatalogItemViewModel(
new PluginMarketPluginInfo( CreateCatalogItem(
"glass-panels", "glass-panels",
"Glass Panels", "Glass Panels",
"Adds experimental acrylic surfaces for plugin-powered widgets.", "Adds experimental acrylic surfaces for plugin-powered widgets.",
@@ -152,7 +152,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
foreach (var item in items) foreach (var item in items)
{ {
viewModel.MarketPlugins.Add(item); viewModel.CatalogPlugins.Add(item);
viewModel.FilteredPlugins.Add(item); viewModel.FilteredPlugins.Add(item);
} }
@@ -167,24 +167,87 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
RequestRestart(reason ?? ViewModel.RestartRequiredMessage); RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
} }
private async void OnDetailsRequested(PluginMarketItemViewModel item) private async void OnDetailsRequested(PluginCatalogItemViewModel item)
{ {
var detailViewModel = ViewModel.CreateDetailViewModel(item); var detailViewModel = ViewModel.CreateDetailViewModel(item);
var drawer = new PluginMarketDetailDrawer(detailViewModel); var drawer = new PluginCatalogDetailDrawer(detailViewModel);
OpenDrawer(drawer, detailViewModel.DrawerTitle); OpenDrawer(drawer, detailViewModel.DrawerTitle);
await detailViewModel.InitializeAsync(); await detailViewModel.InitializeAsync();
} }
private static PluginMarketItemViewModel CreateMarketItem( private static PluginCatalogItemViewModel CreateCatalogItemViewModel(
PluginMarketPluginInfo plugin, PluginCatalogItemInfo plugin,
LocalizationService localizationService, LocalizationService localizationService,
InstalledPluginInfo? installedPlugin, InstalledPluginInfo? installedPlugin,
Version hostVersion) Version hostVersion)
{ {
var languageCode = localizationService.NormalizeLanguageCode( var languageCode = localizationService.NormalizeLanguageCode(
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode); HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
var item = new PluginMarketItemViewModel(plugin, localizationService, languageCode); var item = new PluginCatalogItemViewModel(plugin, localizationService, languageCode);
item.ApplyInstallState(installedPlugin, hostVersion); item.ApplyInstallState(installedPlugin, hostVersion);
return item; 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)
],
[]);
}
} }

View File

@@ -47,7 +47,7 @@
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}" <mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
Markdown="{Binding MarkdownContent}" Markdown="{Binding MarkdownContent}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" /> Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>

View File

@@ -141,6 +141,9 @@
<Button Command="{Binding DownloadLatestReleaseCommand}" <Button Command="{Binding DownloadLatestReleaseCommand}"
Content="{Binding DownloadButtonText}" Content="{Binding DownloadButtonText}"
IsVisible="{Binding IsDownloadButtonVisible}" /> IsVisible="{Binding IsDownloadButtonVisible}" />
<Button Command="{Binding RedownloadUpdateCommand}"
Content="{Binding RedownloadButtonText}"
IsVisible="{Binding IsRedownloadButtonVisible}" />
<Button Classes="settings-accent-button" <Button Classes="settings-accent-button"
Command="{Binding InstallPendingUpdateCommand}" Command="{Binding InstallPendingUpdateCommand}"
Content="{Binding InstallNowButtonText}" Content="{Binding InstallNowButtonText}"
@@ -172,6 +175,14 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
</ui:SettingsExpander.Footer> </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>
<ui:SettingsExpander Classes="settings-expander-card" <ui:SettingsExpander Classes="settings-expander-card"

View File

@@ -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);
}

View File

@@ -41,7 +41,7 @@ public sealed class AirAppMarketIconService : IDisposable
} }
public async Task<Bitmap> LoadAsync( public async Task<Bitmap> LoadAsync(
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin, LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);

View File

@@ -10,6 +10,7 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketIndexService : IDisposable internal sealed class AirAppMarketIndexService : IDisposable
{ {
private readonly AirAppMarketCacheService _cacheService; private readonly AirAppMarketCacheService _cacheService;
private readonly AirAppMarketMetadataResolverService _metadataResolver;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService) public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
@@ -22,6 +23,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add( _httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json")); new MediaTypeWithQualityHeaderValue("application/json"));
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
} }
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default) 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 json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
var document = AirAppMarketIndexDocument.Load(json, localIndexPath); var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json); _cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult( return new AirAppMarketLoadResult(
true, true,
@@ -66,6 +69,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl); var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json); _cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult( return new AirAppMarketLoadResult(
true, true,
@@ -93,6 +97,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
try try
{ {
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath); var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
return new AirAppMarketLoadResult( return new AirAppMarketLoadResult(
true, true,
cachedDocument, cachedDocument,
@@ -124,6 +129,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
public void Dispose() public void Dispose()
{ {
_metadataResolver.Dispose();
_httpClient.Dispose(); _httpClient.Dispose();
} }
} }

View File

@@ -188,7 +188,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
var localCopyResult = await _downloadService.DownloadAsync( var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath, localPackagePath,
attemptPath, attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
if (!localCopyResult.Success) if (!localCopyResult.Success)
{ {
@@ -208,7 +208,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
var downloadResult = await _downloadService.DownloadAsync( var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl, resolvedDownloadUrl,
attemptPath, attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
if (!downloadResult.Success) if (!downloadResult.Success)
{ {
@@ -231,14 +231,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); 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( AppLogger.Error(
"PluginMarket", "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( return new AirAppMarketVerificationResult(
false, 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); return new AirAppMarketVerificationResult(true, null);

View File

@@ -678,30 +678,39 @@ internal sealed class AirAppMarketPluginRepositoryEntry
public AirAppMarketPluginRepositoryEntry ValidateAndNormalize(string sourceName) 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( var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl) AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."), ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."),
nameof(RepositoryUrl), nameof(RepositoryUrl),
sourceName); 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 ?? []) var normalizedTags = (Tags ?? [])
.Select(AirAppMarketIndexDocument.NormalizeValue) .Select(AirAppMarketIndexDocument.NormalizeValue)
.Where(tag => !string.IsNullOrWhiteSpace(tag)) .Where(tag => !string.IsNullOrWhiteSpace(tag))
@@ -718,8 +727,7 @@ internal sealed class AirAppMarketPluginRepositoryEntry
HomepageUrl = normalizedHomepageUrl, HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl, RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags, Tags = normalizedTags,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? string.Empty
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.releaseNotes.")
}; };
} }
} }
@@ -808,30 +816,20 @@ internal sealed class AirAppMarketPluginPublicationEntry
public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId) public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId)
{ {
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag( var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
ReleaseTag, var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag) ?? string.Empty;
nameof(ReleaseTag), if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
sourceName);
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing publication.releaseAssetName for plugin '{pluginId}'.");
if (PublishedAt == default || UpdatedAt == default)
{ {
throw new InvalidOperationException( normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
$"Market index '{sourceName}' is missing valid publication timestamps for plugin '{pluginId}'."); normalizedReleaseTag,
nameof(ReleaseTag),
sourceName);
} }
if (PackageSizeBytes <= 0) var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName) ?? string.Empty;
{ var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty;
throw new InvalidOperationException( if (!string.IsNullOrWhiteSpace(normalizedSha256) &&
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{pluginId}'."); (normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch))))
}
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)))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'."); $"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}'."); $"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'.");
} }
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
return new AirAppMarketPluginPublicationEntry return new AirAppMarketPluginPublicationEntry
{ {
ReleaseTag = normalizedReleaseTag, ReleaseTag = normalizedReleaseTag,
@@ -1039,120 +1035,10 @@ internal sealed class AirAppMarketPluginEntry
? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId) ? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId)
: null; : 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( var resolvedPackageSources = NormalizePackageSources(
normalizedPublication?.PackageSources, normalizedPublication?.PackageSources ?? PackageSources,
sourceName, sourceName,
resolvedPluginId, resolvedPluginId,
resolvedReleaseTag,
resolvedReleaseAssetName,
resolvedRepositoryUrl,
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)); AirAppMarketIndexDocument.NormalizeValue(DownloadUrl));
if (resolvedPackageSources.Count == 0) if (resolvedPackageSources.Count == 0)
{ {
@@ -1160,19 +1046,84 @@ internal sealed class AirAppMarketPluginEntry
$"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'."); $"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'.");
} }
var resolvedDownloadUrl = resolvedPackageSources[0].Url; var resolvedRepositoryUrl = FirstNonEmpty(
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt; normalizedRepository?.RepositoryUrl,
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt; AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl));
if (resolvedPublishedAt == default || resolvedUpdatedAt == default) if (string.IsNullOrWhiteSpace(resolvedRepositoryUrl))
{ {
throw new InvalidOperationException( throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl.");
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{resolvedPluginId}'.");
} }
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( var resolvedDependencies = NormalizeDependencies(
normalizedManifest?.SharedContracts, normalizedManifest?.SharedContracts ?? SharedContracts,
normalizedCapabilities?.SharedContracts,
SharedContracts,
sourceName, sourceName,
resolvedPluginId); resolvedPluginId);
var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? []) var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? [])
@@ -1185,8 +1136,7 @@ internal sealed class AirAppMarketPluginEntry
var resolvedReleaseNotes = FirstNonEmpty( var resolvedReleaseNotes = FirstNonEmpty(
normalizedRepository?.ReleaseNotes, normalizedRepository?.ReleaseNotes,
AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)) AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes))
?? throw new InvalidOperationException( ?? string.Empty;
$"Market index '{sourceName}' is missing release notes for plugin '{resolvedPluginId}'.");
return new AirAppMarketPluginEntry return new AirAppMarketPluginEntry
{ {
@@ -1225,6 +1175,13 @@ internal sealed class AirAppMarketPluginEntry
public string GetVersionSummary() public string GetVersionSummary()
{ {
if (string.IsNullOrWhiteSpace(Version) &&
string.IsNullOrWhiteSpace(ApiVersion) &&
string.IsNullOrWhiteSpace(MinHostVersion))
{
return "Unknown";
}
return string.Format( return string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}", "v{0} | API {1} | Host >= {2}",
@@ -1309,21 +1266,13 @@ internal sealed class AirAppMarketPluginEntry
} }
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies( private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
IReadOnlyList<AirAppMarketPluginDependencyEntry>? manifestDependencies, IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies,
IReadOnlyList<AirAppMarketPluginDependencyEntry>? capabilityDependencies,
IReadOnlyList<AirAppMarketPluginDependencyEntry>? legacyDependencies,
string sourceName, string sourceName,
string pluginId) string pluginId)
{ {
IReadOnlyList<AirAppMarketPluginDependencyEntry> dependencies = manifestDependencies is { Count: > 0 } var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
? manifestDependencies
: capabilityDependencies is { Count: > 0 }
? capabilityDependencies
: legacyDependencies ?? [];
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>(dependencies.Count);
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var dependency in dependencies) foreach (var dependency in dependencies ?? [])
{ {
var normalizedDependency = dependency.ValidateAndNormalize(sourceName); var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
@@ -1343,9 +1292,6 @@ internal sealed class AirAppMarketPluginEntry
IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources, IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources,
string sourceName, string sourceName,
string pluginId, string pluginId,
string? releaseTag,
string? releaseAssetName,
string repositoryUrl,
string? legacyDownloadUrl) string? legacyDownloadUrl)
{ {
var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count + 1); var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count + 1);
@@ -1364,42 +1310,16 @@ internal sealed class AirAppMarketPluginEntry
var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl); var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl);
if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl)) if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl))
{ {
var legacyKind = !string.IsNullOrWhiteSpace(releaseTag) && !string.IsNullOrWhiteSpace(releaseAssetName)
? PluginPackageSourceKind.ReleaseAsset
: PluginPackageSourceKind.RawFallback;
var legacySource = new AirAppMarketPluginPackageSourceEntry var legacySource = new AirAppMarketPluginPackageSourceEntry
{ {
Kind = legacyKind switch Kind = "rawFallback",
{
PluginPackageSourceKind.ReleaseAsset => "releaseAsset",
PluginPackageSourceKind.RawFallback => "rawFallback",
PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal",
_ => "rawFallback"
},
Url = normalizedLegacyDownloadUrl, Url = normalizedLegacyDownloadUrl,
SourceKind = legacyKind SourceKind = PluginPackageSourceKind.RawFallback
}; };
normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId)); normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId));
return normalizedSources; 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; return normalizedSources;
} }

View File

@@ -36,7 +36,7 @@ public sealed class AirAppMarketReadmeService : IDisposable
} }
public async Task<string> LoadAsync( public async Task<string> LoadAsync(
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin, LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);