mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
This commit is contained in:
@@ -6,7 +6,9 @@ public static class SettingsCategories
|
|||||||
public const string Appearance = "Appearance";
|
public const string 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";
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "ロード済み",
|
||||||
|
|||||||
@@ -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": "로드됨",
|
||||||
|
|||||||
@@ -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": "已加载",
|
||||||
|
|||||||
@@ -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; } =
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
|
internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly bool _ownsHttpClient;
|
||||||
|
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
|
||||||
|
{
|
||||||
|
if (httpClient is null)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
|
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||||
|
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
_ownsHttpClient = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_ownsHttpClient = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AirAppMarketIndexDocument> EnrichAsync(
|
||||||
|
AirAppMarketIndexDocument document,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(document);
|
||||||
|
|
||||||
|
if (document.Plugins.Count == 0)
|
||||||
|
{
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
|
||||||
|
foreach (var plugin in document.Plugins)
|
||||||
|
{
|
||||||
|
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketIndexDocument
|
||||||
|
{
|
||||||
|
SchemaVersion = document.SchemaVersion,
|
||||||
|
SourceId = document.SourceId,
|
||||||
|
SourceName = document.SourceName,
|
||||||
|
GeneratedAt = document.GeneratedAt,
|
||||||
|
Contracts = document.Contracts,
|
||||||
|
Plugins = enrichedPlugins
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_ownsHttpClient)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
|
||||||
|
AirAppMarketPluginEntry entry,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
|
||||||
|
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||||
|
PluginManifest? manifest = null;
|
||||||
|
AirAppMarketRepositoryTemplate? template = null;
|
||||||
|
|
||||||
|
foreach (var branch in branchCandidates)
|
||||||
|
{
|
||||||
|
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||||
|
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (manifest is not null && template is not null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
|
||||||
|
var resolvedManifest = manifest;
|
||||||
|
var resolvedPackageSources = entry.PackageSources.Count > 0
|
||||||
|
? entry.PackageSources
|
||||||
|
: entry.Publication?.PackageSources ?? [];
|
||||||
|
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
||||||
|
|
||||||
|
return new AirAppMarketPluginEntry
|
||||||
|
{
|
||||||
|
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
|
||||||
|
Manifest = resolvedManifest is null
|
||||||
|
? entry.Manifest
|
||||||
|
: new AirAppMarketPluginManifestEntry
|
||||||
|
{
|
||||||
|
Id = resolvedManifest.Id,
|
||||||
|
Name = resolvedManifest.Name,
|
||||||
|
Description = resolvedManifest.Description ?? string.Empty,
|
||||||
|
Author = resolvedManifest.Author ?? string.Empty,
|
||||||
|
Version = resolvedManifest.Version ?? string.Empty,
|
||||||
|
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
|
||||||
|
EntranceAssembly = resolvedManifest.EntranceAssembly,
|
||||||
|
SharedContracts = resolvedManifest.SharedContracts?
|
||||||
|
.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||||
|
{
|
||||||
|
Id = contract.Id,
|
||||||
|
Version = contract.Version,
|
||||||
|
AssemblyName = contract.AssemblyName
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
?? []
|
||||||
|
},
|
||||||
|
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
|
||||||
|
? new AirAppMarketPluginCompatibilityEntry
|
||||||
|
{
|
||||||
|
MinHostVersion = FirstNonEmpty(
|
||||||
|
template?.MinHostVersion,
|
||||||
|
entry.MinHostVersion),
|
||||||
|
PluginApiVersion = FirstNonEmpty(
|
||||||
|
resolvedManifest?.ApiVersion,
|
||||||
|
entry.ApiVersion)
|
||||||
|
?? string.Empty
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Repository = new AirAppMarketPluginRepositoryEntry
|
||||||
|
{
|
||||||
|
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||||
|
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||||
|
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||||
|
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||||
|
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||||
|
?? string.Empty,
|
||||||
|
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||||
|
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||||
|
},
|
||||||
|
Publication = entry.Publication,
|
||||||
|
Capabilities = entry.Capabilities,
|
||||||
|
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||||
|
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
|
||||||
|
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
|
||||||
|
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
|
||||||
|
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
|
||||||
|
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
|
||||||
|
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||||
|
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
||||||
|
Sha256 = entry.Sha256,
|
||||||
|
PackageSizeBytes = entry.PackageSizeBytes,
|
||||||
|
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||||
|
ReleaseTag = entry.ReleaseTag,
|
||||||
|
ReleaseAssetName = entry.ReleaseAssetName,
|
||||||
|
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||||
|
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||||
|
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||||
|
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||||
|
?? string.Empty,
|
||||||
|
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||||
|
SharedContracts = resolvedManifest?.SharedContracts
|
||||||
|
?.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||||
|
{
|
||||||
|
Id = contract.Id,
|
||||||
|
Version = contract.Version,
|
||||||
|
AssemblyName = contract.AssemblyName
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
?? entry.SharedContracts,
|
||||||
|
PackageSources = resolvedPackageSources,
|
||||||
|
Md5 = entry.Md5,
|
||||||
|
PublishedAt = entry.PublishedAt,
|
||||||
|
UpdatedAt = entry.UpdatedAt,
|
||||||
|
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
|
||||||
|
string owner,
|
||||||
|
string repositoryName,
|
||||||
|
string branch,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
|
||||||
|
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (text is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||||
|
return PluginManifest.Load(stream, candidateUrl);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
|
||||||
|
string owner,
|
||||||
|
string repositoryName,
|
||||||
|
string branch,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
|
||||||
|
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (text is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
|
||||||
|
string owner,
|
||||||
|
string repositoryName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = new List<string>(4);
|
||||||
|
|
||||||
|
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
|
||||||
|
!string.IsNullOrWhiteSpace(cachedBranch))
|
||||||
|
{
|
||||||
|
candidates.Add(cachedBranch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!string.IsNullOrWhiteSpace(defaultBranch))
|
||||||
|
{
|
||||||
|
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
|
||||||
|
candidates.Add(defaultBranch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.Add("main");
|
||||||
|
candidates.Add("master");
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Where(branch => !string.IsNullOrWhiteSpace(branch))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryGetDefaultBranchAsync(
|
||||||
|
string owner,
|
||||||
|
string repositoryName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
|
||||||
|
{
|
||||||
|
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback to conventional branches.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatRepositoryKey(string owner, string repositoryName)
|
||||||
|
{
|
||||||
|
return $"{owner.Trim()}/{repositoryName.Trim()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
|
||||||
|
{
|
||||||
|
foreach (var list in lists)
|
||||||
|
{
|
||||||
|
if (list is null || list.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = list
|
||||||
|
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value!)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (normalized.Count > 0)
|
||||||
|
{
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AirAppMarketRepositoryTemplate(
|
||||||
|
string? MinHostVersion,
|
||||||
|
string? IconUrl,
|
||||||
|
string? ProjectUrl,
|
||||||
|
string? ReadmeUrl,
|
||||||
|
string? HomepageUrl,
|
||||||
|
string? RepositoryUrl,
|
||||||
|
List<string>? Tags,
|
||||||
|
string? ReleaseNotes);
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ public sealed class AirAppMarketIconService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Bitmap> LoadAsync(
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user