From 372b5b7adce4942e4c470c00482acdc8b31a0d05 Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 25 Mar 2026 11:27:30 +0800 Subject: [PATCH] 0.7.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新功能优化、插件市场优化,反正就是优化了很多东西 --- .../SettingsCategories.cs | 4 +- .../SettingsPageCategory.cs | 2 + ...lper.cs => PluginCatalogMarkdownHelper.cs} | 2 +- LanMountainDesktop/Localization/en-US.json | 13 +- LanMountainDesktop/Localization/ja-JP.json | 21 +- LanMountainDesktop/Localization/ko-KR.json | 23 +- LanMountainDesktop/Localization/zh-CN.json | 23 +- .../Models/AppSettingsSnapshot.cs | 2 + .../Services/GitHubReleaseUpdateService.cs | 302 +++++++++++++- .../Services/Settings/SettingsContracts.cs | 185 +-------- .../Settings/SettingsDomainServices.cs | 89 ++-- .../Services/UpdateWorkflowService.cs | 147 ++++++- .../Styles/SettingsCardStyles.axaml | 10 +- ...=> PluginCatalogSettingsPageViewModels.cs} | 98 ++--- .../ViewModels/SettingsViewModels.cs | 100 ++++- .../StudySessionHistoryWidget.axaml.cs | 5 + ....axaml => PluginCatalogDetailDrawer.axaml} | 8 +- ....cs => PluginCatalogDetailDrawer.axaml.cs} | 6 +- ....axaml => PluginCatalogSettingsPage.axaml} | 10 +- ....cs => PluginCatalogSettingsPage.axaml.cs} | 113 +++-- .../SettingsPages/PrivacyPolicyDrawer.axaml | 2 +- .../SettingsPages/UpdateSettingsPage.axaml | 11 + .../AirAppMarketMetadataResolverService.cs | 391 ++++++++++++++++++ .../plugins/PluginMarketIconService.cs | 2 +- .../plugins/PluginMarketIndexService.cs | 6 + .../plugins/PluginMarketInstallService.cs | 21 +- .../plugins/PluginMarketModels.cs | 334 ++++++--------- .../plugins/PluginMarketReadmeService.cs | 2 +- 28 files changed, 1360 insertions(+), 572 deletions(-) rename LanMountainDesktop/Helpers/{PluginMarketMarkdownHelper.cs => PluginCatalogMarkdownHelper.cs} (95%) rename LanMountainDesktop/ViewModels/{PluginMarketSettingsPageViewModels.cs => PluginCatalogSettingsPageViewModels.cs} (86%) rename LanMountainDesktop/Views/SettingsPages/{PluginMarketDetailDrawer.axaml => PluginCatalogDetailDrawer.axaml} (97%) rename LanMountainDesktop/Views/SettingsPages/{PluginMarketDetailDrawer.axaml.cs => PluginCatalogDetailDrawer.axaml.cs} (57%) rename LanMountainDesktop/Views/SettingsPages/{PluginMarketSettingsPage.axaml => PluginCatalogSettingsPage.axaml} (94%) rename LanMountainDesktop/Views/SettingsPages/{PluginMarketSettingsPage.axaml.cs => PluginCatalogSettingsPage.axaml.cs} (63%) create mode 100644 LanMountainDesktop/plugins/AirAppMarketMetadataResolverService.cs diff --git a/LanMountainDesktop.PluginSdk/SettingsCategories.cs b/LanMountainDesktop.PluginSdk/SettingsCategories.cs index 5d253ce..72c64a2 100644 --- a/LanMountainDesktop.PluginSdk/SettingsCategories.cs +++ b/LanMountainDesktop.PluginSdk/SettingsCategories.cs @@ -6,7 +6,9 @@ public static class SettingsCategories public const string Appearance = "Appearance"; public const string Components = "Components"; public const string Plugins = "Plugins"; - public const string PluginMarket = "PluginMarket"; + public const string PluginCatalog = "PluginCatalog"; + [Obsolete("Use PluginCatalog instead.")] + public const string PluginMarket = PluginCatalog; public const string Update = "Update"; public const string About = "About"; public const string Advanced = "Advanced"; diff --git a/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs b/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs index 0f1d125..22348d6 100644 --- a/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs +++ b/LanMountainDesktop.PluginSdk/SettingsPageCategory.cs @@ -6,6 +6,8 @@ public enum SettingsPageCategory Appearance = 10, Components = 20, Plugins = 30, + PluginCatalog = 35, + [Obsolete("Use PluginCatalog instead.")] PluginMarket = 35, About = 40 } diff --git a/LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs b/LanMountainDesktop/Helpers/PluginCatalogMarkdownHelper.cs similarity index 95% rename from LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs rename to LanMountainDesktop/Helpers/PluginCatalogMarkdownHelper.cs index b5015f0..6c26f8f 100644 --- a/LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs +++ b/LanMountainDesktop/Helpers/PluginCatalogMarkdownHelper.cs @@ -6,7 +6,7 @@ using Markdown.Avalonia; namespace LanMountainDesktop.Helpers; -public static class PluginMarketMarkdownHelper +public static class PluginCatalogMarkdownHelper { private static Markdown.Avalonia.Markdown? _engine; diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index bef6c3b..0514e74 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -418,6 +418,11 @@ "settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.", "settings.update.download_threads_label": "Download Threads", "settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.", + "settings.update.force_check_label": "Force Check Update", + "settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.", + "settings.update.status_force_checking": "Force checking GitHub releases...", + "settings.update.status_force_no_asset": "Release found but no compatible installer available.", + "settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.", "settings.update.install_now_button": "Install Now", "settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.", "settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.", @@ -525,10 +530,10 @@ "settings.plugins.source_manifest": "Loose manifest", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}", - "settings.nav.plugin_market": "Plugin Market", - "settings.plugin_market.title": "Plugin Market", - "settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.", - "settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.", + "settings.nav.plugin_catalog": "Plugin Catalog", + "settings.plugin_catalog.title": "Plugin Catalog", + "settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.", + "settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.", "settings.update.status_idle": "No update check has been performed yet.", "settings.update.status_preferences_saved": "Update preferences saved.", "settings.update.status_check_failed": "Failed to check for updates.", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index 783bea0..d6ab53e 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -418,6 +418,11 @@ "settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。", "settings.update.download_threads_label": "ダウンロードスレッド", "settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。", + "settings.update.force_check_label": "強制アップデート確認", + "settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。", + "settings.update.status_force_checking": "GitHubリリースを強制確認中...", + "settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。", + "settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。", "settings.update.install_now_button": "今すぐインストール", "settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。", "settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。", @@ -477,7 +482,7 @@ "settings.plugins.refresh_button": "プラグインを更新", "settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。", "settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。", - "settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。", + "settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。", "settings.plugins.marketplace_header": "マーケットプレイス", "settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。", "settings.plugins.delete_button_short": "削除", @@ -525,10 +530,10 @@ "settings.plugins.source_manifest": "ルーズマニフェスト", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}", - "settings.nav.plugin_market": "プラグインマーケット", - "settings.plugin_market.title": "プラグインマーケット", - "settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。", - "settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。", + "settings.nav.plugin_catalog": "プラグインカタログ", + "settings.plugin_catalog.title": "プラグインカタログ", + "settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。", + "settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。", "settings.update.status_idle": "アップデートの確認はまだ実行されていません。", "settings.update.status_preferences_saved": "アップデート設定が保存されました。", "settings.update.status_check_failed": "アップデートの確認に失敗しました。", @@ -537,15 +542,15 @@ "settings.window.drawer_default": "詳細", "market.toolbar.search_placeholder": "プラグインを検索", "market.toolbar.refresh": "更新", - "market.status.loading": "公式プラグインマーケットをロード中...", + "market.status.loading": "公式プラグインカタログをロード中...", "market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。", "market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}", - "market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}", + "market.status.load_failed_format": "プラグインカタログのロードに失敗しました: {0}", "market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...", "market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。", "market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}", "market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。", - "market.list.empty": "プラグインマーケットはまだロードされていません。", + "market.list.empty": "プラグインカタログはまだロードされていません。", "market.list.no_results": "現在の検索に一致するプラグインはありません。", "market.card.subtitle_format": "{0} | v{1}", "market.card.loaded": "ロード済み", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index e520245..f38539d 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -418,6 +418,11 @@ "settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.", "settings.update.download_threads_label": "다운로드 스레드 수", "settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.", + "settings.update.force_check_label": "강제 업데이트 확인", + "settings.update.force_check_desc": "버전 비교를 무시하고 GitHub에서 강제로 최신 버전을 가져옵니다.", + "settings.update.status_force_checking": "GitHub 릴리스 강제 확인 중...", + "settings.update.status_force_no_asset": "릴리스를 찾았지만 호환되는 설치 프로그램이 없습니다.", + "settings.update.status_force_available_format": "릴리스 {0}을(를) 사용할 수 있습니다. '다운로드 및 설치'를 클릭하세요.", "settings.update.install_now_button": "지금 설치", "settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.", "settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.", @@ -476,8 +481,8 @@ "settings.plugins.refresh_button": "플러그인 새로고침", "settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.", "settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.", - "settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.", - "settings.plugins.marketplace_header": "플러그인 마켓", + "settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.", + "settings.plugins.marketplace_header": "플러그인 카탈로그", "settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.", "settings.plugins.delete_button_short": "삭제", "settings.plugins.install_button_short": "설치", @@ -524,10 +529,10 @@ "settings.plugins.source_manifest": "매니페스트 파일", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}", - "settings.nav.plugin_market": "플러그인 마켓", - "settings.plugin_market.title": "플러그인 마켓", - "settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.", - "settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.", + "settings.nav.plugin_catalog": "플러그인 카탈로그", + "settings.plugin_catalog.title": "플러그인 카탈로그", + "settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.", + "settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.", "settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.", "settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.", "settings.update.status_check_failed": "업데이트 확인 실패.", @@ -536,15 +541,15 @@ "settings.window.drawer_default": "상세 정보", "market.toolbar.search_placeholder": "플러그인 검색", "market.toolbar.refresh": "새로고침", - "market.status.loading": "공식 플러그인 마켓 로딩 중...", + "market.status.loading": "공식 플러그인 카탈로그 로딩 중...", "market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.", "market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}", - "market.status.load_failed_format": "플러그인 마켓 로드 실패: {0}", + "market.status.load_failed_format": "플러그인 카탈로그 로드 실패: {0}", "market.status.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...", "market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.", "market.status.install_failed_format": "플러그인 설치 실패: {0}", "market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.", - "market.list.empty": "플러그인 마켓이 아직 로드되지 않았습니다.", + "market.list.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.", "market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.", "market.card.subtitle_format": "{0} | v{1}", "market.card.loaded": "로드됨", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 8843dfa..9246a2b 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -413,6 +413,11 @@ "settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。", "settings.update.download_threads_label": "下载线程数", "settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。", + "settings.update.force_check_label": "强制检查更新", + "settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。", + "settings.update.status_force_checking": "正在强制检查 GitHub Release...", + "settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。", + "settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。", "settings.update.install_now_button": "立即安装", "settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。", "settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。", @@ -471,8 +476,8 @@ "settings.plugins.refresh_button": "刷新插件", "settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。", "settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。", - "settings.plugins.refresh_failed": "加载插件市场索引失败。", - "settings.plugins.marketplace_header": "插件市场", + "settings.plugins.refresh_failed": "加载插件目录索引失败。", + "settings.plugins.marketplace_header": "插件目录", "settings.plugins.marketplace_empty": "当前没有可用的市场插件。", "settings.plugins.delete_button_short": "删除", "settings.plugins.install_button_short": "安装", @@ -519,10 +524,10 @@ "settings.plugins.source_manifest": "散装清单", "settings.plugins.subtitle_format": "{0} | {1} | {2}", "settings.plugins.detail_format": "设置页:{0} | 组件:{1}", - "settings.nav.plugin_market": "插件市场", - "settings.plugin_market.title": "插件市场", - "settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。", - "settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。", + "settings.nav.plugin_catalog": "插件目录", + "settings.plugin_catalog.title": "插件目录", + "settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。", + "settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。", "settings.update.status_idle": "尚未执行更新检查。", "settings.update.status_preferences_saved": "更新偏好已保存。", "settings.update.status_check_failed": "检查更新失败。", @@ -531,15 +536,15 @@ "settings.window.drawer_default": "详情", "market.toolbar.search_placeholder": "搜索插件", "market.toolbar.refresh": "刷新", - "market.status.loading": "正在加载官方插件市场...", + "market.status.loading": "正在加载官方插件目录...", "market.status.loaded_network_format": "已从官方源加载 {0} 个插件。", "market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}", - "market.status.load_failed_format": "加载插件市场失败:{0}", + "market.status.load_failed_format": "加载插件目录失败:{0}", "market.status.installing_format": "正在下载并暂存插件“{0}”...", "market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。", "market.status.install_failed_format": "安装插件失败:{0}", "market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。", - "market.list.empty": "插件市场尚未加载。", + "market.list.empty": "插件目录尚未加载。", "market.list.no_results": "没有匹配当前搜索的插件。", "market.card.subtitle_format": "{0} | v{1}", "market.card.loaded": "已加载", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index cb9455e..44e70ba 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot public long? LastUpdateCheckUtcMs { get; set; } + public string? PendingUpdateSha256 { get; set; } + public List TopStatusComponentIds { get; set; } = []; public List PinnedTaskbarActions { get; set; } = diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index b2a6f0a..3706ebf 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services; public sealed record GitHubReleaseAsset( string Name, string BrowserDownloadUrl, - long SizeBytes); + long SizeBytes, + string? Sha256 = null); public sealed record GitHubReleaseInfo( string TagName, @@ -31,12 +33,16 @@ public sealed record UpdateCheckResult( string LatestVersionText, GitHubReleaseInfo? Release, GitHubReleaseAsset? PreferredAsset, - string? ErrorMessage); + string? ErrorMessage, + bool ForceMode = false); public sealed record UpdateDownloadResult( bool Success, string? FilePath, - string? ErrorMessage); + string? ErrorMessage, + bool HashVerified = false, + string? ExpectedHash = null, + string? ActualHash = null); public sealed class GitHubReleaseUpdateService : IDisposable { @@ -169,6 +175,80 @@ public sealed class GitHubReleaseUpdateService : IDisposable } } + public async Task 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 DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, @@ -206,9 +286,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable progressAdapter, cancellationToken); - return result.Success - ? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null) - : new UpdateDownloadResult(false, null, result.ErrorMessage); + if (!result.Success) + { + return new UpdateDownloadResult(false, null, result.ErrorMessage); + } + + var filePath = result.FilePath ?? destinationFilePath; + var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken); + + if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified) + { + return new UpdateDownloadResult( + false, + filePath, + $"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}", + false, + asset.Sha256, + actualHash); + } + + return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash); + } + + public async Task RedownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationFilePath, + string downloadSource, + int maxParallelSegments, + IProgress? 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 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 GetReleaseByTagAsync( @@ -343,13 +542,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable continue; } - assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes)); + assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null)); } } + var sha256Map = BuildSha256MapFromAssets(assets, element); + + if (sha256Map.Count > 0) + { + assets = assets.Select(a => + sha256Map.TryGetValue(a.Name, out var hash) + ? a with { Sha256 = hash } + : a).ToList(); + } + return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets); } + private static Dictionary BuildSha256MapFromAssets(List assets, JsonElement releaseElement) + { + var map = new Dictionary(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 assets, Dictionary 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 assets) { if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows()) diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 32dc224..bf488c8 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -67,7 +67,8 @@ public sealed record UpdateSettingsState( string? PendingUpdateInstallerPath, string? PendingUpdateVersion, long? PendingUpdatePublishedAtUtcMs, - long? LastUpdateCheckUtcMs); + long? LastUpdateCheckUtcMs, + string? PendingUpdateSha256); public sealed record PluginManagementSettingsState(IReadOnlyList DisabledPluginIds); public enum PluginPackageSourceKind { @@ -175,14 +176,6 @@ public sealed record PluginCatalogItemInfo( public IReadOnlyList SharedContracts => Manifest.SharedContracts; - public IReadOnlyList Dependencies => - Manifest.SharedContracts - .Select(contract => new PluginCatalogDependencyInfo( - contract.Id, - contract.Version, - contract.AssemblyName)) - .ToArray(); - public DateTimeOffset PublishedAt => Publication.PublishedAt; public DateTimeOffset UpdatedAt => Publication.UpdatedAt; @@ -192,82 +185,6 @@ public sealed record PluginCatalogItemInfo( public string ReleaseAssetName => Publication.ReleaseAssetName; public string ReleaseNotes => Repository.ReleaseNotes; - - public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item) - { - return new PluginMarketPluginInfo( - item.Id, - item.Name, - item.Description, - item.Author, - item.Version, - item.ApiVersion, - item.MinHostVersion, - item.DownloadUrl, - item.ReleaseTag, - item.ReleaseAssetName, - item.IconUrl, - item.ReadmeUrl, - item.HomepageUrl, - item.RepositoryUrl, - item.Tags.ToArray(), - item.Dependencies.Select(dependency => new PluginMarketDependencyInfo( - dependency.Id, - dependency.Version, - dependency.AssemblyName)).ToArray(), - item.PublishedAt, - item.UpdatedAt); - } - - public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin) - { - return new PluginCatalogItemInfo( - new PluginCatalogManifestInfo( - plugin.Id, - plugin.Name, - plugin.Description, - plugin.Author, - plugin.Version, - plugin.ApiVersion, - string.Empty, - plugin.Dependencies - .Select(dependency => new PluginCatalogSharedContractInfo( - dependency.Id, - dependency.Version, - dependency.AssemblyName)) - .ToArray()), - new PluginCatalogCompatibilityInfo( - plugin.MinHostVersion, - plugin.ApiVersion), - new PluginCatalogRepositoryInfo( - plugin.IconUrl, - plugin.RepositoryUrl, - plugin.ReadmeUrl, - plugin.HomepageUrl, - plugin.RepositoryUrl, - plugin.Tags, - string.Empty), - new PluginCatalogPublicationInfo( - plugin.ReleaseTag, - plugin.ReleaseAssetName, - plugin.PublishedAt, - plugin.UpdatedAt, - 0, - string.Empty, - null), - string.IsNullOrWhiteSpace(plugin.DownloadUrl) - ? [] - : [ - new PluginPackageSourceInfo( - string.IsNullOrWhiteSpace(plugin.ReleaseTag) - ? PluginPackageSourceKind.RawFallback - : PluginPackageSourceKind.ReleaseAsset, - plugin.DownloadUrl, - string.Empty, - 0) - ], - []); - } } public sealed record PluginCatalogIndexResult( @@ -277,19 +194,7 @@ public sealed record PluginCatalogIndexResult( string? Source, string? SourceLocation, string? WarningMessage, - string? ErrorMessage) -{ - public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result) - { - return new PluginMarketIndexResult( - result.Success, - result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(), - result.Source, - result.SourceLocation, - result.WarningMessage, - result.ErrorMessage); - } -} + string? ErrorMessage); public sealed record PluginInstallDiagnostic( string Code, @@ -302,73 +207,6 @@ public sealed record PluginCatalogInstallResult( string? PluginName, PluginManifest? InstalledManifest, IReadOnlyList 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 Tags, - IReadOnlyList Dependencies, - DateTimeOffset PublishedAt, - DateTimeOffset UpdatedAt); - -[Obsolete("Use PluginCatalogIndexResult instead.")] -public sealed record PluginMarketIndexResult( - bool Success, - IReadOnlyList Plugins, - string? Source, - string? SourceLocation, - string? WarningMessage, - string? ErrorMessage); - -[Obsolete("Use PluginCatalogInstallResult instead.")] -public sealed record PluginMarketInstallResult( - bool Success, - string? PluginId, - string? PluginName, string? ErrorMessage); public interface IPluginCatalogSourceProvider @@ -488,6 +326,7 @@ public interface IUpdateSettingsService UpdateSettingsState Get(); void Save(UpdateSettingsState state); Task CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); + Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, @@ -495,6 +334,13 @@ public interface IUpdateSettingsService int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default); + Task RedownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationFilePath, + string downloadSource, + int maxParallelSegments, + IProgress? progress = null, + CancellationToken cancellationToken = default); } public interface ILauncherCatalogService @@ -523,13 +369,6 @@ public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); } -[Obsolete("Use IPluginCatalogSettingsService instead.")] -public interface IPluginMarketSettingsService : IPluginCatalogSettingsService -{ - Task LoadIndexAsync(CancellationToken cancellationToken = default); - new Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); -} - public interface IApplicationInfoService { string GetAppVersionText(); @@ -554,8 +393,6 @@ public interface ISettingsFacadeService ILauncherPolicyService LauncherPolicy { get; } IPluginManagementSettingsService PluginManagement { get; } IPluginCatalogSettingsService PluginCatalog { get; } - [Obsolete("Use PluginCatalog instead.")] - IPluginMarketSettingsService PluginMarket { get; } IApplicationInfoService ApplicationInfo { get; } } diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 2df8c4b..12a7704 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl snapshot.PendingUpdateInstallerPath, snapshot.PendingUpdateVersion, snapshot.PendingUpdatePublishedAtUtcMs, - snapshot.LastUpdateCheckUtcMs); + snapshot.LastUpdateCheckUtcMs, + snapshot.PendingUpdateSha256); } public void Save(UpdateSettingsState state) @@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0 ? state.LastUpdateCheckUtcMs : null; + snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256) + ? null + : state.PendingUpdateSha256.Trim().ToLowerInvariant(); _settingsService.SaveSnapshot( SettingsScope.App, snapshot, @@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), nameof(AppSettingsSnapshot.PendingUpdateVersion), nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs), - nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs) + nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), + nameof(AppSettingsSnapshot.PendingUpdateSha256) ]); } @@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); } + public Task ForceCheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + } + public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, @@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl cancellationToken); } + public Task RedownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationFilePath, + string downloadSource, + int maxParallelSegments, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + return _releaseUpdateService.RedownloadAssetAsync( + asset, + destinationFilePath, + downloadSource, + maxParallelSegments, + progress, + cancellationToken); + } + public void Dispose() { _releaseUpdateService.Dispose(); @@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting } } -internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable +internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable { private PluginRuntimeService? _pluginRuntimeService; private AirAppMarketIndexService _indexService; private AirAppMarketInstallService? _installService; private readonly Dictionary _cachedPlugins = new(StringComparer.OrdinalIgnoreCase); - public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService) + public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService) { _pluginRuntimeService = pluginRuntimeService; @@ -875,11 +905,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService return LoadCatalogCoreAsync(cancellationToken); } - async Task IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken) - { - return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false); - } - public Task InstallAsync( string pluginId, CancellationToken cancellationToken = default) @@ -887,13 +912,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService return InstallCatalogCoreAsync(pluginId, cancellationToken); } - async Task IPluginMarketSettingsService.InstallAsync( - string pluginId, - CancellationToken cancellationToken) - { - return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false); - } - private async Task LoadCatalogCoreAsync(CancellationToken cancellationToken = default) { var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false); @@ -1055,23 +1073,25 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService private static IReadOnlyList BuildPackageSources(AirAppMarketPluginEntry entry) { - if (string.IsNullOrWhiteSpace(entry.DownloadUrl)) + var sources = entry.GetPackageSourcesInInstallOrder(); + if (sources.Count == 0) { return []; } - var sourceKind = entry.HasReleaseDownloadMetadata - ? PluginPackageSourceKind.ReleaseAsset - : PluginPackageSourceKind.RawFallback; - - return - [ - new PluginPackageSourceInfo( - sourceKind, - entry.DownloadUrl, + return sources + .Select(source => new PluginPackageSourceInfo( + source.SourceKind switch + { + LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset, + LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback, + LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal, + _ => PluginPackageSourceKind.RawFallback + }, + source.Url, entry.Sha256, - entry.PackageSizeBytes) - ]; + entry.PackageSizeBytes)) + .ToArray(); } private static IReadOnlyList BuildCatalogSources( @@ -1165,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable { private readonly UpdateSettingsService _updateSettingsService; - private readonly PluginMarketSettingsService _pluginMarketSettingsService; + private readonly PluginCatalogSettingsService _pluginCatalogSettingsService; private readonly PluginManagementSettingsService _pluginManagementSettingsService; private readonly WeatherSettingsService _weatherSettingsService; @@ -1188,9 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl LauncherPolicy = new LauncherPolicyService(); _pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService); PluginManagement = _pluginManagementSettingsService; - _pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService); - PluginCatalog = _pluginMarketSettingsService; - PluginMarket = _pluginMarketSettingsService; + _pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService); + PluginCatalog = _pluginCatalogSettingsService; ApplicationInfo = new ApplicationInfoService(); } @@ -1224,20 +1243,18 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl public IPluginCatalogSettingsService PluginCatalog { get; } - public IPluginMarketSettingsService PluginMarket { get; } - public IApplicationInfoService ApplicationInfo { get; } public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService) { _pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService); - _pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService); + _pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService); } public void Dispose() { _weatherSettingsService.Dispose(); _updateSettingsService.Dispose(); - _pluginMarketSettingsService.Dispose(); + _pluginCatalogSettingsService.Dispose(); } } diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 3babbeb..c1498e5 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services; public sealed record UpdatePendingInfo( string InstallerPath, string VersionText, - DateTimeOffset? PublishedAt); + DateTimeOffset? PublishedAt, + string? Sha256 = null); + +public sealed record UpdateVerifyResult( + bool Success, + bool HashMatched, + string? ExpectedHash, + string? ActualHash, + string? ErrorMessage); public sealed record UpdateInstallerLaunchResult( bool Success, @@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService public async Task CheckForUpdatesAsync( Version currentVersion, + bool isForce = false, CancellationToken cancellationToken = default) { var state = _settingsFacade.Update.Get(); @@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase); - var result = await _settingsFacade.Update.CheckForUpdatesAsync( - currentVersion, - includePrerelease, - cancellationToken); + var result = isForce + ? await _settingsFacade.Update.ForceCheckForUpdatesAsync( + currentVersion, + includePrerelease, + cancellationToken) + : await _settingsFacade.Update.CheckForUpdatesAsync( + currentVersion, + includePrerelease, + cancellationToken); SaveState(state with { @@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService return result; } + public async Task ForceCheckForUpdatesAsync( + Version currentVersion, + CancellationToken cancellationToken = default) + { + return await CheckForUpdatesAsync(currentVersion, true, cancellationToken); + } + public async Task DownloadReleaseAsync( UpdateCheckResult checkResult, IProgress? progress = null, @@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) && File.Exists(existingPending.InstallerPath)) { - return new UpdateDownloadResult(true, existingPending.InstallerPath, null); + var verifyResult = await VerifyPendingUpdateAsync(); + if (verifyResult.Success) + { + return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash); + } + + AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}"); } Directory.CreateDirectory(_updatesDirectory); @@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue ? null : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), - LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + PendingUpdateSha256 = result.ActualHash }); } return result; } + public async Task RedownloadReleaseAsync( + UpdateCheckResult checkResult, + IProgress? 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 VerifyPendingUpdateAsync() + { + var state = _settingsFacade.Update.Get(); + var pending = GetPendingUpdate(state); + + if (pending is null) + { + return new UpdateVerifyResult(false, false, null, null, "No pending update available."); + } + + if (!File.Exists(pending.InstallerPath)) + { + return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist."); + } + + var expectedHash = pending.Sha256; + var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath); + + if (string.IsNullOrEmpty(expectedHash)) + { + return new UpdateVerifyResult(true, true, null, actualHash, null); + } + + var hashMatched = string.Equals( + expectedHash?.Trim().ToLowerInvariant(), + actualHash?.Trim().ToLowerInvariant(), + StringComparison.OrdinalIgnoreCase); + + return new UpdateVerifyResult( + hashMatched, + hashMatched, + expectedHash, + actualHash, + hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); + } + public async Task AutoCheckIfEnabledAsync( Version currentVersion, CancellationToken cancellationToken = default) @@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService try { // Always check for updates on startup (removed AutoCheckUpdates check) - var result = await CheckForUpdatesAsync(currentVersion, cancellationToken); + var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken); if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null) { return; @@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService { PendingUpdateInstallerPath = null, PendingUpdateVersion = null, - PendingUpdatePublishedAtUtcMs = null + PendingUpdatePublishedAtUtcMs = null, + PendingUpdateSha256 = null }); } @@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService return new UpdatePendingInfo( installerPath, string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion, - publishedAt); + publishedAt, + state.PendingUpdateSha256); } private void SaveState(UpdateSettingsState state) diff --git a/LanMountainDesktop/Styles/SettingsCardStyles.axaml b/LanMountainDesktop/Styles/SettingsCardStyles.axaml index 400ef87..69e8fcc 100644 --- a/LanMountainDesktop/Styles/SettingsCardStyles.axaml +++ b/LanMountainDesktop/Styles/SettingsCardStyles.axaml @@ -267,7 +267,7 @@ - - - - - diff --git a/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs b/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs similarity index 86% rename from LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs rename to LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs index 6dd99ad..866f06b 100644 --- a/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs +++ b/LanMountainDesktop/ViewModels/PluginCatalogSettingsPageViewModels.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings; namespace LanMountainDesktop.ViewModels; -public enum PluginMarketPrimaryActionState +public enum PluginCatalogPrimaryActionState { Install, Update, @@ -24,13 +24,13 @@ public enum PluginMarketPrimaryActionState Incompatible } -public sealed partial class PluginMarketItemViewModel : ViewModelBase +public sealed partial class PluginCatalogItemViewModel : ViewModelBase { private readonly LocalizationService _localizationService; private readonly string _languageCode; private bool _isLoadingIcon; - public PluginMarketItemViewModel( + public PluginCatalogItemViewModel( PluginCatalogItemInfo plugin, LocalizationService localizationService, string languageCode) @@ -104,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase public bool HasIcon => IconBitmap is not null; - public PluginMarketPrimaryActionState ActionState { get; private set; } + public PluginCatalogPrimaryActionState ActionState { get; private set; } partial void OnIconBitmapChanged(Bitmap? value) { @@ -164,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase { if (IsInstalling) { - ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install; + ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install; ActionSymbol = Symbol.ArrowClockwise; ActionTooltip = L("market.button.installing", "Installing..."); IsActionEnabled = false; @@ -173,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase if (!IsCompatibleWithHost) { - ActionState = PluginMarketPrimaryActionState.Incompatible; + ActionState = PluginCatalogPrimaryActionState.Incompatible; ActionSymbol = Symbol.Warning; ActionTooltip = string.Format( CultureInfo.CurrentCulture, @@ -185,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase if (RequiresRestart) { - ActionState = PluginMarketPrimaryActionState.RestartRequired; + ActionState = PluginCatalogPrimaryActionState.RestartRequired; ActionSymbol = Symbol.ArrowClockwise; ActionTooltip = L("market.button.restart", "Restart to apply"); IsActionEnabled = true; @@ -194,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase if (IsUpdateAvailable) { - ActionState = PluginMarketPrimaryActionState.Update; + ActionState = PluginCatalogPrimaryActionState.Update; ActionSymbol = Symbol.ArrowSync; ActionTooltip = L("market.button.update", "Update"); IsActionEnabled = true; @@ -203,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase if (IsInstalled) { - ActionState = PluginMarketPrimaryActionState.Installed; + ActionState = PluginCatalogPrimaryActionState.Installed; ActionSymbol = Symbol.CheckmarkCircle; ActionTooltip = L("market.button.installed", "Installed"); IsActionEnabled = false; return; } - ActionState = PluginMarketPrimaryActionState.Install; + ActionState = PluginCatalogPrimaryActionState.Install; ActionSymbol = Symbol.ArrowDownload; ActionTooltip = L("market.button.install", "Install"); IsActionEnabled = true; @@ -242,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase => _localizationService.GetString(_languageCode, key, fallback); } -public sealed partial class PluginMarketDetailViewModel : ViewModelBase +public sealed partial class PluginCatalogDetailViewModel : ViewModelBase { private readonly LocalizationService _localizationService; private readonly string _languageCode; private readonly AirAppMarketReadmeService _readmeService; - private readonly Func _primaryActionAsync; + private readonly Func _primaryActionAsync; private bool _isInitialized; - public PluginMarketDetailViewModel( - PluginMarketItemViewModel item, + public PluginCatalogDetailViewModel( + PluginCatalogItemViewModel item, LocalizationService localizationService, string languageCode, AirAppMarketReadmeService readmeService, - Func primaryActionAsync) + Func primaryActionAsync) { Item = item; _localizationService = localizationService; @@ -273,7 +273,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin."); } - public PluginMarketItemViewModel Item { get; } + public PluginCatalogItemViewModel Item { get; } public ObservableCollection Dependencies { get; } @@ -375,7 +375,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase => _localizationService.GetString(_languageCode, key, fallback); } -public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase +public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase { private readonly ISettingsFacadeService _settingsFacade; private readonly IPluginCatalogSettingsService _pluginCatalog; @@ -386,9 +386,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase private readonly Dictionary _installedPlugins = new(StringComparer.OrdinalIgnoreCase); private readonly Version? _hostVersion; private bool _isInitialized; - private bool _hasLoadedMarket; + private bool _hasLoadedCatalog; - public PluginMarketSettingsPageViewModel( + public PluginCatalogSettingsPageViewModel( ISettingsFacadeService settingsFacade, LocalizationService localizationService, AirAppMarketIconService iconService, @@ -402,16 +402,16 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion); RefreshLocalizedText(); - StatusMessage = L("market.status.loading", "Loading the official plugin market..."); + StatusMessage = L("market.status.loading", "Loading the official plugin catalog..."); } public event Action? RestartRequested; - public event Action? DetailsRequested; + public event Action? DetailsRequested; - public ObservableCollection MarketPlugins { get; } = []; + public ObservableCollection CatalogPlugins { get; } = []; - public ObservableCollection FilteredPlugins { get; } = []; + public ObservableCollection FilteredPlugins { get; } = []; [ObservableProperty] private string _statusMessage = string.Empty; @@ -454,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase await RefreshAsync(); } - public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item) + public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item) { - return new PluginMarketDetailViewModel( + return new PluginCatalogDetailViewModel( item, _localizationService, _languageCode, @@ -475,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase try { IsBusy = true; - StatusMessage = L("market.status.loading", "Loading the official plugin market..."); + StatusMessage = L("market.status.loading", "Loading the official plugin catalog..."); RefreshInstalledSnapshot(); var result = await _pluginCatalog.LoadCatalogAsync(); if (!result.Success) { - _hasLoadedMarket = false; - MarketPlugins.Clear(); + _hasLoadedCatalog = false; + CatalogPlugins.Clear(); FilteredPlugins.Clear(); ShowEmptyState = true; EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage) - ? L("market.list.empty", "The plugin market has not been loaded yet.") + ? L("market.list.empty", "The plugin catalog has not been loaded yet.") : result.ErrorMessage; StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage) - ? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown") + ? L("market.status.load_failed_format", "Failed to load the plugin catalog: Unknown") : string.Format( CultureInfo.CurrentCulture, - L("market.status.load_failed_format", "Failed to load the plugin market: {0}"), + L("market.status.load_failed_format", "Failed to load the plugin catalog: {0}"), result.ErrorMessage); return; } - _hasLoadedMarket = true; - MarketPlugins.Clear(); + _hasLoadedCatalog = true; + CatalogPlugins.Clear(); foreach (var plugin in result.Plugins) { - var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode); + var item = new PluginCatalogItemViewModel(plugin, _localizationService, _languageCode); item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion); - MarketPlugins.Add(item); + CatalogPlugins.Add(item); _ = item.EnsureIconLoadedAsync(_iconService); } @@ -513,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase ? string.Format( CultureInfo.CurrentCulture, L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"), - MarketPlugins.Count, + CatalogPlugins.Count, result.WarningMessage ?? L("market.detail.unknown", "Unknown")) : string.Format( CultureInfo.CurrentCulture, L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."), - MarketPlugins.Count); + CatalogPlugins.Count); } finally { @@ -527,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase } [RelayCommand] - private void OpenDetails(PluginMarketItemViewModel? item) + private void OpenDetails(PluginCatalogItemViewModel? item) { if (item is null) { @@ -538,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase } [RelayCommand] - private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item) + private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item) { return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item); } - private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item) + private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item) { if (item.IsInstalling) { return; } - if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired) + if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired) { RestartRequested?.Invoke(RestartRequiredMessage); return; @@ -614,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase private void RefreshItemStates() { - foreach (var item in MarketPlugins) + foreach (var item in CatalogPlugins) { item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion); } @@ -642,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase { FilteredPlugins.Clear(); - IEnumerable filtered = MarketPlugins; + IEnumerable filtered = CatalogPlugins; var query = SearchText?.Trim(); if (!string.IsNullOrWhiteSpace(query)) { @@ -660,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase } ShowEmptyState = FilteredPlugins.Count == 0; - EmptyStateText = !_hasLoadedMarket - ? L("market.list.empty", "The plugin market has not been loaded yet.") + EmptyStateText = !_hasLoadedCatalog + ? L("market.list.empty", "The plugin catalog has not been loaded yet.") : string.IsNullOrWhiteSpace(query) ? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.") : L("market.list.no_results", "No plugins match the current search."); @@ -669,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase private void RefreshLocalizedText() { - PageTitle = L("settings.plugin_market.title", "Plugin Market"); - PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs."); + PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog"); + PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs."); SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins"); RefreshButtonText = L("market.toolbar.refresh", "Refresh"); RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart."); - EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet."); + EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet."); } private string L(string key, string fallback) diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 58b5892..649d5a4 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1517,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _installNowButtonText = string.Empty; + [ObservableProperty] + private string _redownloadButtonText = string.Empty; + [ObservableProperty] private string _latestVersionText = string.Empty; @@ -1556,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _downloadThreadsDescription = string.Empty; + [ObservableProperty] + private string _forceCheckUpdateLabel = string.Empty; + + [ObservableProperty] + private string _forceCheckUpdateDescription = string.Empty; + [ObservableProperty] private string _stableChannelText = string.Empty; @@ -1619,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase public bool IsInstallButtonVisible => HasPendingInstaller; + public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading; + public string DownloadThreadsValueText => UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture); @@ -1838,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] private async Task CheckForUpdatesAsync() + { + await CheckForUpdatesCoreAsync(isForce: false); + } + + private bool CanForceCheckUpdate() => !IsBusy; + + [RelayCommand(CanExecute = nameof(CanForceCheckUpdate))] + private async Task ForceCheckUpdateAsync() + { + await CheckForUpdatesCoreAsync(isForce: true); + } + + private async Task CheckForUpdatesCoreAsync(bool isForce) { try { @@ -1845,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase IsDownloadProgressVisible = false; DownloadProgressValue = 0; DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); - UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases..."); + UpdateStatus = isForce + ? L("settings.update.status_force_checking", "Force checking GitHub releases...") + : L("settings.update.status_checking", "Checking GitHub releases..."); - var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion); + var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce); _lastCheckResult = result.Success ? result : null; RefreshLastCheckedFromSettings(); @@ -1863,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase } ApplyCheckResultDisplay(result); - if (!result.IsUpdateAvailable) + if (!result.IsUpdateAvailable && !isForce) { return; } if (result.PreferredAsset is null) { - UpdateStatus = L( - "settings.update.status_asset_missing", - "A new release is available, but no compatible installer was found."); + UpdateStatus = isForce + ? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.") + : L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found."); return; } @@ -1884,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase UpdateStatus = string.Format( CultureInfo.CurrentCulture, - L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."), + isForce + ? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.") + : L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."), result.LatestVersionText); } finally @@ -1926,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download.")); } + private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null; + + [RelayCommand(CanExecute = nameof(CanRedownloadUpdate))] + private async Task RedownloadUpdateAsync() + { + if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null) + { + UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading."); + return; + } + + try + { + IsDownloading = true; + IsDownloadProgressVisible = true; + DownloadProgressValue = 0; + DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer..."); + + var progress = new Progress(value => + { + DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d); + DownloadProgressText = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.download_progress_format", "Download progress: {0:F0}%"), + DownloadProgressValue); + }); + + var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress); + if (!downloadResult.Success) + { + UpdateStatus = string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"), + downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates.")); + return; + } + + ApplyPendingState(_settingsFacade.Update.Get()); + UpdateStatus = downloadResult.HashVerified + ? BuildPendingReadyStatus() + : string.Format( + CultureInfo.CurrentCulture, + L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"), + downloadResult.ActualHash ?? "N/A"); + } + finally + { + IsDownloading = false; + IsDownloadProgressVisible = false; + } + } + private void RefreshLocalizedText() { PageTitle = L("settings.update.title", "Update"); @@ -1939,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase UpdateModeLabel = L("settings.update.mode_label", "Update Mode"); DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates."); + ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update"); + ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison."); CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates"); DownloadButtonText = L("settings.update.download_install_button", "Download & Install"); InstallNowButtonText = L("settings.update.install_now_button", "Install Now"); + RedownloadButtonText = L("settings.update.redownload_button", "Redownload"); CurrentVersionLabel = L("settings.update.current_version_label", "Current Version"); LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release"); PublishedAtLabel = L("settings.update.published_at_label", "Published At"); @@ -2147,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase { OnPropertyChanged(nameof(IsDownloadButtonVisible)); OnPropertyChanged(nameof(IsInstallButtonVisible)); + OnPropertyChanged(nameof(IsRedownloadButtonVisible)); OnPropertyChanged(nameof(DownloadThreadsValueText)); + RedownloadUpdateCommand.NotifyCanExecuteChanged(); } private IReadOnlyList CreateUpdateChannelOptions() diff --git a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs index 244a6db..d93f450 100644 --- a/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionHistoryWidget.axaml.cs @@ -473,6 +473,11 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW _dialogSessionId = null; _dialogSessionLabel = string.Empty; DialogRenameTextBox.Text = string.Empty; + DialogOverlayBorder.IsVisible = false; + if (_currentSnapshot is not null) + { + RenderSnapshot(_currentSnapshot); + } } private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e) diff --git a/LanMountainDesktop/Views/SettingsPages/PluginMarketDetailDrawer.axaml b/LanMountainDesktop/Views/SettingsPages/PluginCatalogDetailDrawer.axaml similarity index 97% rename from LanMountainDesktop/Views/SettingsPages/PluginMarketDetailDrawer.axaml rename to LanMountainDesktop/Views/SettingsPages/PluginCatalogDetailDrawer.axaml index bd2704a..aad25d8 100644 --- a/LanMountainDesktop/Views/SettingsPages/PluginMarketDetailDrawer.axaml +++ b/LanMountainDesktop/Views/SettingsPages/PluginCatalogDetailDrawer.axaml @@ -4,8 +4,8 @@ xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia" xmlns:helpers="using:LanMountainDesktop.Helpers" xmlns:fi="using:FluentIcons.Avalonia.Fluent" - x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer" - x:DataType="vm:PluginMarketDetailViewModel"> + x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer" + x:DataType="vm:PluginCatalogDetailViewModel"> @@ -41,7 +41,7 @@