mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
372b5b7adc | ||
|
|
74703582e7 | ||
|
|
26ff11b16b | ||
|
|
b83cfb47b0 | ||
|
|
a0bb83c743 |
@@ -6,7 +6,9 @@ public static class SettingsCategories
|
||||
public const string Appearance = "Appearance";
|
||||
public const string Components = "Components";
|
||||
public const string Plugins = "Plugins";
|
||||
public const string PluginMarket = "PluginMarket";
|
||||
public const string PluginCatalog = "PluginCatalog";
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
public const string PluginMarket = PluginCatalog;
|
||||
public const string Update = "Update";
|
||||
public const string About = "About";
|
||||
public const string Advanced = "Advanced";
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum SettingsPageCategory
|
||||
Appearance = 10,
|
||||
Components = 20,
|
||||
Plugins = 30,
|
||||
PluginCatalog = 35,
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Helpers;
|
||||
|
||||
public static class PluginMarketMarkdownHelper
|
||||
public static class PluginCatalogMarkdownHelper
|
||||
{
|
||||
private static Markdown.Avalonia.Markdown? _engine;
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||
"settings.update.download_threads_label": "Download Threads",
|
||||
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||
"settings.update.force_check_label": "Force Check Update",
|
||||
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
|
||||
"settings.update.status_force_checking": "Force checking GitHub releases...",
|
||||
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
|
||||
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
|
||||
"settings.update.install_now_button": "Install Now",
|
||||
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
||||
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
||||
@@ -525,10 +530,10 @@
|
||||
"settings.plugins.source_manifest": "Loose manifest",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||
"settings.nav.plugin_market": "Plugin Market",
|
||||
"settings.plugin_market.title": "Plugin Market",
|
||||
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||
"settings.nav.plugin_catalog": "Plugin Catalog",
|
||||
"settings.plugin_catalog.title": "Plugin Catalog",
|
||||
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
|
||||
"settings.update.status_idle": "No update check has been performed yet.",
|
||||
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
|
||||
"settings.update.download_threads_label": "ダウンロードスレッド",
|
||||
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
|
||||
"settings.update.force_check_label": "強制アップデート確認",
|
||||
"settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。",
|
||||
"settings.update.status_force_checking": "GitHubリリースを強制確認中...",
|
||||
"settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。",
|
||||
"settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。",
|
||||
"settings.update.install_now_button": "今すぐインストール",
|
||||
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
|
||||
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
|
||||
@@ -477,7 +482,7 @@
|
||||
"settings.plugins.refresh_button": "プラグインを更新",
|
||||
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
|
||||
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
|
||||
"settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。",
|
||||
"settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
|
||||
"settings.plugins.marketplace_header": "マーケットプレイス",
|
||||
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
|
||||
"settings.plugins.delete_button_short": "削除",
|
||||
@@ -525,10 +530,10 @@
|
||||
"settings.plugins.source_manifest": "ルーズマニフェスト",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
|
||||
"settings.nav.plugin_market": "プラグインマーケット",
|
||||
"settings.plugin_market.title": "プラグインマーケット",
|
||||
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||
"settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。",
|
||||
"settings.nav.plugin_catalog": "プラグインカタログ",
|
||||
"settings.plugin_catalog.title": "プラグインカタログ",
|
||||
"settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||
"settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
|
||||
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
|
||||
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
|
||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||
@@ -537,15 +542,15 @@
|
||||
"settings.window.drawer_default": "詳細",
|
||||
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||
"market.toolbar.refresh": "更新",
|
||||
"market.status.loading": "公式プラグインマーケットをロード中...",
|
||||
"market.status.loading": "公式プラグインカタログをロード中...",
|
||||
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
|
||||
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
|
||||
"market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}",
|
||||
"market.status.load_failed_format": "プラグインカタログのロードに失敗しました: {0}",
|
||||
"market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
|
||||
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
|
||||
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
|
||||
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
|
||||
"market.list.empty": "プラグインマーケットはまだロードされていません。",
|
||||
"market.list.empty": "プラグインカタログはまだロードされていません。",
|
||||
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "ロード済み",
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
|
||||
"settings.update.download_threads_label": "다운로드 스레드 수",
|
||||
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
|
||||
"settings.update.force_check_label": "강제 업데이트 확인",
|
||||
"settings.update.force_check_desc": "버전 비교를 무시하고 GitHub에서 강제로 최신 버전을 가져옵니다.",
|
||||
"settings.update.status_force_checking": "GitHub 릴리스 강제 확인 중...",
|
||||
"settings.update.status_force_no_asset": "릴리스를 찾았지만 호환되는 설치 프로그램이 없습니다.",
|
||||
"settings.update.status_force_available_format": "릴리스 {0}을(를) 사용할 수 있습니다. '다운로드 및 설치'를 클릭하세요.",
|
||||
"settings.update.install_now_button": "지금 설치",
|
||||
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
|
||||
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
|
||||
@@ -476,8 +481,8 @@
|
||||
"settings.plugins.refresh_button": "플러그인 새로고침",
|
||||
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
|
||||
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
|
||||
"settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.",
|
||||
"settings.plugins.marketplace_header": "플러그인 마켓",
|
||||
"settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.",
|
||||
"settings.plugins.marketplace_header": "플러그인 카탈로그",
|
||||
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
|
||||
"settings.plugins.delete_button_short": "삭제",
|
||||
"settings.plugins.install_button_short": "설치",
|
||||
@@ -524,10 +529,10 @@
|
||||
"settings.plugins.source_manifest": "매니페스트 파일",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
|
||||
"settings.nav.plugin_market": "플러그인 마켓",
|
||||
"settings.plugin_market.title": "플러그인 마켓",
|
||||
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||
"settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.",
|
||||
"settings.nav.plugin_catalog": "플러그인 카탈로그",
|
||||
"settings.plugin_catalog.title": "플러그인 카탈로그",
|
||||
"settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||
"settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.",
|
||||
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
|
||||
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
|
||||
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||
@@ -536,15 +541,15 @@
|
||||
"settings.window.drawer_default": "상세 정보",
|
||||
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||
"market.toolbar.refresh": "새로고침",
|
||||
"market.status.loading": "공식 플러그인 마켓 로딩 중...",
|
||||
"market.status.loading": "공식 플러그인 카탈로그 로딩 중...",
|
||||
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
|
||||
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}",
|
||||
"market.status.load_failed_format": "플러그인 마켓 로드 실패: {0}",
|
||||
"market.status.load_failed_format": "플러그인 카탈로그 로드 실패: {0}",
|
||||
"market.status.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...",
|
||||
"market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
|
||||
"market.status.install_failed_format": "플러그인 설치 실패: {0}",
|
||||
"market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.",
|
||||
"market.list.empty": "플러그인 마켓이 아직 로드되지 않았습니다.",
|
||||
"market.list.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.",
|
||||
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "로드됨",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
||||
"settings.update.download_threads_label": "下载线程数",
|
||||
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
||||
"settings.update.force_check_label": "强制检查更新",
|
||||
"settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。",
|
||||
"settings.update.status_force_checking": "正在强制检查 GitHub Release...",
|
||||
"settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。",
|
||||
"settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。",
|
||||
"settings.update.install_now_button": "立即安装",
|
||||
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
||||
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
||||
@@ -471,8 +476,8 @@
|
||||
"settings.plugins.refresh_button": "刷新插件",
|
||||
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
||||
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
||||
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
||||
"settings.plugins.marketplace_header": "插件市场",
|
||||
"settings.plugins.refresh_failed": "加载插件目录索引失败。",
|
||||
"settings.plugins.marketplace_header": "插件目录",
|
||||
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
|
||||
"settings.plugins.delete_button_short": "删除",
|
||||
"settings.plugins.install_button_short": "安装",
|
||||
@@ -519,10 +524,10 @@
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"settings.nav.plugin_market": "插件市场",
|
||||
"settings.plugin_market.title": "插件市场",
|
||||
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"settings.nav.plugin_catalog": "插件目录",
|
||||
"settings.plugin_catalog.title": "插件目录",
|
||||
"settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
|
||||
"settings.update.status_idle": "尚未执行更新检查。",
|
||||
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
||||
"settings.update.status_check_failed": "检查更新失败。",
|
||||
@@ -531,15 +536,15 @@
|
||||
"settings.window.drawer_default": "详情",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
"market.status.loading": "正在加载官方插件市场...",
|
||||
"market.status.loading": "正在加载官方插件目录...",
|
||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||
"market.status.load_failed_format": "加载插件目录失败:{0}",
|
||||
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||
"market.list.empty": "插件市场尚未加载。",
|
||||
"market.list.empty": "插件目录尚未加载。",
|
||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "已加载",
|
||||
|
||||
@@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public long? LastUpdateCheckUtcMs { get; set; }
|
||||
|
||||
public string? PendingUpdateSha256 { get; set; }
|
||||
|
||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||
|
||||
public List<string> PinnedTaskbarActions { get; set; } =
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
|
||||
public sealed record GitHubReleaseAsset(
|
||||
string Name,
|
||||
string BrowserDownloadUrl,
|
||||
long SizeBytes);
|
||||
long SizeBytes,
|
||||
string? Sha256 = null);
|
||||
|
||||
public sealed record GitHubReleaseInfo(
|
||||
string TagName,
|
||||
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
|
||||
string LatestVersionText,
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
string? FilePath,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
bool HashVerified = false,
|
||||
string? ExpectedHash = null,
|
||||
string? ActualHash = null);
|
||||
|
||||
public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
{
|
||||
@@ -169,6 +175,80 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "Repository information is not configured.",
|
||||
ForceMode: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var release = includePrerelease
|
||||
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
|
||||
: await GetLatestStableReleaseAsync(cancellationToken);
|
||||
|
||||
if (release is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "No release data was returned from GitHub.",
|
||||
ForceMode: true);
|
||||
}
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: ex.Message,
|
||||
ForceMode: true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -206,9 +286,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
progressAdapter,
|
||||
cancellationToken);
|
||||
|
||||
return result.Success
|
||||
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
||||
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
if (!result.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
}
|
||||
|
||||
var filePath = result.FilePath ?? destinationFilePath;
|
||||
var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified)
|
||||
{
|
||||
return new UpdateDownloadResult(
|
||||
false,
|
||||
filePath,
|
||||
$"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}",
|
||||
false,
|
||||
asset.Sha256,
|
||||
actualHash);
|
||||
}
|
||||
|
||||
return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash);
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (File.Exists(destinationFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(destinationFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete existing file for redownload: {destinationFilePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var partFile = destinationFilePath + ".part";
|
||||
if (File.Exists(partFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(partFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete part file for redownload: {partFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var packageFile = destinationFilePath + ".download";
|
||||
if (File.Exists(packageFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(packageFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to delete package file for redownload: {packageFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return await DownloadAssetAsync(asset, destinationFilePath, downloadSource, maxParallelSegments, progress, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<(bool Success, string? Hash)> VerifyFileHashAsync(
|
||||
string filePath,
|
||||
string? expectedHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||
{
|
||||
var computedHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||
return (true, computedHash);
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||
var verified = string.Equals(
|
||||
expectedHash?.Trim().ToLowerInvariant(),
|
||||
actualHash?.Trim().ToLowerInvariant(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return (verified, actualHash);
|
||||
}
|
||||
|
||||
public static async Task<string?> ComputeFileSha256Async(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
filePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Update", $"Failed to compute SHA256 for file: {filePath}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||
@@ -343,13 +542,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
|
||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null));
|
||||
}
|
||||
}
|
||||
|
||||
var sha256Map = BuildSha256MapFromAssets(assets, element);
|
||||
|
||||
if (sha256Map.Count > 0)
|
||||
{
|
||||
assets = assets.Select(a =>
|
||||
sha256Map.TryGetValue(a.Name, out var hash)
|
||||
? a with { Sha256 = hash }
|
||||
: a).ToList();
|
||||
}
|
||||
|
||||
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSha256MapFromAssets(List<GitHubReleaseAsset> assets, JsonElement releaseElement)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (asset.Name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) ||
|
||||
asset.Name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var baseName = asset.Name[..asset.Name.LastIndexOf('.')];
|
||||
var targetAsset = assets.FirstOrDefault(a =>
|
||||
a.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (targetAsset is not null && !map.ContainsKey(targetAsset.Name))
|
||||
{
|
||||
map[targetAsset.Name] = asset.BrowserDownloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseElement.TryGetProperty("body", out var bodyNode) &&
|
||||
bodyNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var body = bodyNode.GetString() ?? string.Empty;
|
||||
ParseSha256FromBody(body, assets, map);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void ParseSha256FromBody(string body, List<GitHubReleaseAsset> assets, Dictionary<string, string> map)
|
||||
{
|
||||
var lines = body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmedLine = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith("#"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var hash = parts[0];
|
||||
var fileName = parts[1];
|
||||
|
||||
if (hash.Length == 64 && IsHexString(hash))
|
||||
{
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
if (asset.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Equals("*" + asset.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!map.ContainsKey(asset.Name))
|
||||
{
|
||||
map[asset.Name] = hash.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Services.Settings;
|
||||
namespace LanMountainDesktop.Services.Settings
|
||||
{
|
||||
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
@@ -64,44 +67,173 @@ public sealed record UpdateSettingsState(
|
||||
string? PendingUpdateInstallerPath,
|
||||
string? PendingUpdateVersion,
|
||||
long? PendingUpdatePublishedAtUtcMs,
|
||||
long? LastUpdateCheckUtcMs);
|
||||
long? LastUpdateCheckUtcMs,
|
||||
string? PendingUpdateSha256);
|
||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||
public sealed record PluginMarketDependencyInfo(
|
||||
public enum PluginPackageSourceKind
|
||||
{
|
||||
ReleaseAsset = 0,
|
||||
RawFallback = 1,
|
||||
WorkspaceLocal = 2
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogSourceInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? SourceUrl,
|
||||
string? CachePath,
|
||||
bool IsOfficial,
|
||||
int Priority);
|
||||
|
||||
public sealed record PluginCatalogSharedContractInfo(
|
||||
string Id,
|
||||
string Version,
|
||||
string AssemblyName);
|
||||
public sealed record PluginMarketPluginInfo(
|
||||
|
||||
public sealed record PluginCapabilityInfo(
|
||||
string Id,
|
||||
string? Version,
|
||||
string? AssemblyName);
|
||||
|
||||
public sealed record PluginPackageSourceInfo(
|
||||
PluginPackageSourceKind Kind,
|
||||
string Url,
|
||||
string Sha256,
|
||||
long PackageSizeBytes);
|
||||
|
||||
public sealed record PluginCatalogManifestInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string Author,
|
||||
string Version,
|
||||
string ApiVersion,
|
||||
string EntranceAssembly,
|
||||
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
|
||||
|
||||
public sealed record PluginCatalogCompatibilityInfo(
|
||||
string MinHostVersion,
|
||||
string DownloadUrl,
|
||||
string ReleaseTag,
|
||||
string ReleaseAssetName,
|
||||
string ApiVersion);
|
||||
|
||||
public sealed record PluginCatalogRepositoryInfo(
|
||||
string IconUrl,
|
||||
string ProjectUrl,
|
||||
string ReadmeUrl,
|
||||
string HomepageUrl,
|
||||
string RepositoryUrl,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
||||
string ReleaseNotes);
|
||||
|
||||
public sealed record PluginCatalogPublicationInfo(
|
||||
string ReleaseTag,
|
||||
string ReleaseAssetName,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
public sealed record PluginMarketIndexResult(
|
||||
DateTimeOffset UpdatedAt,
|
||||
long PackageSizeBytes,
|
||||
string Sha256,
|
||||
string? Md5);
|
||||
|
||||
public sealed record PluginCatalogItemInfo(
|
||||
PluginCatalogManifestInfo Manifest,
|
||||
PluginCatalogCompatibilityInfo Compatibility,
|
||||
PluginCatalogRepositoryInfo Repository,
|
||||
PluginCatalogPublicationInfo Publication,
|
||||
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
|
||||
IReadOnlyList<PluginCapabilityInfo> Capabilities)
|
||||
{
|
||||
public string Id => Manifest.Id;
|
||||
|
||||
public string Name => Manifest.Name;
|
||||
|
||||
public string Description => Manifest.Description;
|
||||
|
||||
public string Author => Manifest.Author;
|
||||
|
||||
public string Version => Manifest.Version;
|
||||
|
||||
public string ApiVersion => Manifest.ApiVersion;
|
||||
|
||||
public string MinHostVersion => Compatibility.MinHostVersion;
|
||||
|
||||
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
|
||||
|
||||
public string Sha256 => Publication.Sha256;
|
||||
|
||||
public long PackageSizeBytes => Publication.PackageSizeBytes;
|
||||
|
||||
public string IconUrl => Repository.IconUrl;
|
||||
|
||||
public string ProjectUrl => Repository.ProjectUrl;
|
||||
|
||||
public string ReadmeUrl => Repository.ReadmeUrl;
|
||||
|
||||
public string HomepageUrl => Repository.HomepageUrl;
|
||||
|
||||
public string RepositoryUrl => Repository.RepositoryUrl;
|
||||
|
||||
public IReadOnlyList<string> Tags => Repository.Tags;
|
||||
|
||||
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
|
||||
|
||||
public DateTimeOffset PublishedAt => Publication.PublishedAt;
|
||||
|
||||
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
|
||||
|
||||
public string ReleaseTag => Publication.ReleaseTag;
|
||||
|
||||
public string ReleaseAssetName => Publication.ReleaseAssetName;
|
||||
|
||||
public string ReleaseNotes => Repository.ReleaseNotes;
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogIndexResult(
|
||||
bool Success,
|
||||
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
||||
IReadOnlyList<PluginCatalogItemInfo> Plugins,
|
||||
IReadOnlyList<PluginCatalogSourceInfo> Sources,
|
||||
string? Source,
|
||||
string? SourceLocation,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage);
|
||||
public sealed record PluginMarketInstallResult(
|
||||
|
||||
public sealed record PluginInstallDiagnostic(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Details = null);
|
||||
|
||||
public sealed record PluginCatalogInstallResult(
|
||||
bool Success,
|
||||
string? PluginId,
|
||||
string? PluginName,
|
||||
PluginManifest? InstalledManifest,
|
||||
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
|
||||
string? ErrorMessage);
|
||||
|
||||
public interface IPluginCatalogSourceProvider
|
||||
{
|
||||
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPluginCatalogService : IPluginCatalogSourceProvider
|
||||
{
|
||||
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPackageSourceResolver
|
||||
{
|
||||
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
|
||||
}
|
||||
|
||||
public interface IPluginCompatibilityEvaluator
|
||||
{
|
||||
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
|
||||
}
|
||||
|
||||
public interface IPluginInstallOrchestrator
|
||||
{
|
||||
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IGridSettingsService
|
||||
{
|
||||
GridSettingsState Get();
|
||||
@@ -194,6 +326,7 @@ public interface IUpdateSettingsService
|
||||
UpdateSettingsState Get();
|
||||
void Save(UpdateSettingsState state);
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -201,6 +334,13 @@ public interface IUpdateSettingsService
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ILauncherCatalogService
|
||||
@@ -223,10 +363,10 @@ public interface IPluginManagementSettingsService
|
||||
bool DeleteInstalledPlugin(string pluginId);
|
||||
}
|
||||
|
||||
public interface IPluginMarketSettingsService
|
||||
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
|
||||
{
|
||||
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
||||
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IApplicationInfoService
|
||||
@@ -252,6 +392,18 @@ public interface ISettingsFacadeService
|
||||
ILauncherCatalogService LauncherCatalog { get; }
|
||||
ILauncherPolicyService LauncherPolicy { get; }
|
||||
IPluginManagementSettingsService PluginManagement { get; }
|
||||
IPluginMarketSettingsService PluginMarket { get; }
|
||||
IPluginCatalogSettingsService PluginCatalog { get; }
|
||||
IApplicationInfoService ApplicationInfo { get; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace LanMountainDesktop.Services.PluginMarket
|
||||
{
|
||||
internal enum PluginPackageSourceKind
|
||||
{
|
||||
ReleaseAsset = 0,
|
||||
RawFallback = 1,
|
||||
WorkspaceLocal = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot.PendingUpdateInstallerPath,
|
||||
snapshot.PendingUpdateVersion,
|
||||
snapshot.PendingUpdatePublishedAtUtcMs,
|
||||
snapshot.LastUpdateCheckUtcMs);
|
||||
snapshot.LastUpdateCheckUtcMs,
|
||||
snapshot.PendingUpdateSha256);
|
||||
}
|
||||
|
||||
public void Save(UpdateSettingsState state)
|
||||
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
|
||||
? state.LastUpdateCheckUtcMs
|
||||
: null;
|
||||
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
|
||||
? null
|
||||
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
||||
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
||||
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
|
||||
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
|
||||
nameof(AppSettingsSnapshot.PendingUpdateSha256)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
string downloadSource,
|
||||
int maxParallelSegments,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.RedownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
maxParallelSegments,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_releaseUpdateService.Dispose();
|
||||
@@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
|
||||
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
|
||||
{
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private AirAppMarketIndexService _indexService;
|
||||
private AirAppMarketInstallService? _installService;
|
||||
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
|
||||
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
_pluginRuntimeService = pluginRuntimeService;
|
||||
|
||||
@@ -870,14 +900,29 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||
}
|
||||
|
||||
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
|
||||
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _indexService.LoadAsync(cancellationToken);
|
||||
return LoadCatalogCoreAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PluginCatalogInstallResult> InstallAsync(
|
||||
string pluginId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InstallCatalogCoreAsync(pluginId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
return new PluginMarketIndexResult(
|
||||
_cachedPlugins.Clear();
|
||||
return new PluginCatalogIndexResult(
|
||||
false,
|
||||
[],
|
||||
sources,
|
||||
result.Source?.ToString(),
|
||||
result.SourceLocation,
|
||||
result.WarningMessage,
|
||||
@@ -889,81 +934,191 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
||||
.Select(entry =>
|
||||
{
|
||||
_cachedPlugins[entry.Id] = entry;
|
||||
return new PluginMarketPluginInfo(
|
||||
entry.Id,
|
||||
entry.Name,
|
||||
entry.Description,
|
||||
entry.Author,
|
||||
entry.Version,
|
||||
entry.ApiVersion,
|
||||
entry.MinHostVersion,
|
||||
entry.DownloadUrl,
|
||||
entry.ReleaseTag,
|
||||
entry.ReleaseAssetName,
|
||||
entry.IconUrl,
|
||||
entry.ReadmeUrl,
|
||||
entry.HomepageUrl,
|
||||
entry.RepositoryUrl,
|
||||
entry.Tags,
|
||||
entry.SharedContracts
|
||||
.Select(contract => new PluginMarketDependencyInfo(
|
||||
contract.Id,
|
||||
contract.Version,
|
||||
contract.AssemblyName))
|
||||
.ToArray(),
|
||||
entry.PublishedAt,
|
||||
entry.UpdatedAt);
|
||||
return MapCatalogItem(entry);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return new PluginMarketIndexResult(
|
||||
return new PluginCatalogIndexResult(
|
||||
true,
|
||||
plugins,
|
||||
sources,
|
||||
result.Source?.ToString(),
|
||||
result.SourceLocation,
|
||||
result.WarningMessage,
|
||||
null);
|
||||
}
|
||||
|
||||
public async Task<PluginMarketInstallResult> InstallAsync(
|
||||
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
|
||||
string pluginId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginId))
|
||||
{
|
||||
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
|
||||
return new PluginCatalogInstallResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
|
||||
"Plugin id is required.");
|
||||
}
|
||||
|
||||
if (_installService is null || _pluginRuntimeService is null)
|
||||
{
|
||||
return new PluginMarketInstallResult(
|
||||
return new PluginCatalogInstallResult(
|
||||
false,
|
||||
pluginId,
|
||||
null,
|
||||
null,
|
||||
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
|
||||
"Plugin runtime is unavailable.");
|
||||
}
|
||||
|
||||
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
||||
{
|
||||
var load = await LoadIndexAsync(cancellationToken);
|
||||
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!load.Success)
|
||||
{
|
||||
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
|
||||
return new PluginCatalogInstallResult(
|
||||
false,
|
||||
pluginId,
|
||||
null,
|
||||
null,
|
||||
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
|
||||
load.ErrorMessage);
|
||||
}
|
||||
|
||||
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
||||
{
|
||||
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
|
||||
return new PluginCatalogInstallResult(
|
||||
false,
|
||||
pluginId,
|
||||
null,
|
||||
null,
|
||||
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
|
||||
"Plugin was not found in the official catalog.");
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _installService.InstallAsync(entry, cancellationToken);
|
||||
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
{
|
||||
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
|
||||
return new PluginCatalogInstallResult(
|
||||
false,
|
||||
entry.Id,
|
||||
entry.Name,
|
||||
null,
|
||||
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
|
||||
result.ErrorMessage);
|
||||
}
|
||||
|
||||
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
|
||||
return new PluginCatalogInstallResult(
|
||||
true,
|
||||
result.Manifest?.Id ?? entry.Id,
|
||||
result.Manifest?.Name ?? entry.Name,
|
||||
result.Manifest,
|
||||
[],
|
||||
null);
|
||||
}
|
||||
|
||||
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
var manifest = new PluginCatalogManifestInfo(
|
||||
entry.Id,
|
||||
entry.Name,
|
||||
entry.Description,
|
||||
entry.Author,
|
||||
entry.Version,
|
||||
entry.ApiVersion,
|
||||
string.Empty,
|
||||
entry.SharedContracts
|
||||
.Select(contract => new PluginCatalogSharedContractInfo(
|
||||
contract.Id,
|
||||
contract.Version,
|
||||
contract.AssemblyName))
|
||||
.ToArray());
|
||||
|
||||
var compatibility = new PluginCatalogCompatibilityInfo(
|
||||
entry.MinHostVersion,
|
||||
entry.ApiVersion);
|
||||
|
||||
var repository = new PluginCatalogRepositoryInfo(
|
||||
entry.IconUrl,
|
||||
entry.ProjectUrl,
|
||||
entry.ReadmeUrl,
|
||||
entry.HomepageUrl,
|
||||
entry.RepositoryUrl,
|
||||
entry.Tags.ToArray(),
|
||||
entry.ReleaseNotes);
|
||||
|
||||
var publication = new PluginCatalogPublicationInfo(
|
||||
entry.ReleaseTag,
|
||||
entry.ReleaseAssetName,
|
||||
entry.PublishedAt,
|
||||
entry.UpdatedAt,
|
||||
entry.PackageSizeBytes,
|
||||
entry.Sha256,
|
||||
null);
|
||||
|
||||
var sources = BuildPackageSources(entry);
|
||||
|
||||
return new PluginCatalogItemInfo(
|
||||
manifest,
|
||||
compatibility,
|
||||
repository,
|
||||
publication,
|
||||
sources,
|
||||
[]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
var sources = entry.GetPackageSourcesInInstallOrder();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
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))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
|
||||
string? sourceId,
|
||||
string? sourceLocation,
|
||||
string? warningMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
|
||||
? "plugin-catalog"
|
||||
: sourceId.Trim();
|
||||
|
||||
return
|
||||
[
|
||||
new PluginCatalogSourceInfo(
|
||||
normalizedSourceId,
|
||||
normalizedSourceId,
|
||||
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
|
||||
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
|
||||
null,
|
||||
true,
|
||||
0)
|
||||
];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -1030,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;
|
||||
|
||||
@@ -1053,8 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
LauncherPolicy = new LauncherPolicyService();
|
||||
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
||||
PluginManagement = _pluginManagementSettingsService;
|
||||
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
||||
PluginMarket = _pluginMarketSettingsService;
|
||||
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
|
||||
PluginCatalog = _pluginCatalogSettingsService;
|
||||
ApplicationInfo = new ApplicationInfoService();
|
||||
}
|
||||
|
||||
@@ -1086,20 +1241,20 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
|
||||
public IPluginManagementSettingsService PluginManagement { get; }
|
||||
|
||||
public IPluginMarketSettingsService PluginMarket { get; }
|
||||
public IPluginCatalogSettingsService PluginCatalog { get; }
|
||||
|
||||
public IApplicationInfoService ApplicationInfo { get; }
|
||||
|
||||
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_weatherSettingsService.Dispose();
|
||||
_updateSettingsService.Dispose();
|
||||
_pluginMarketSettingsService.Dispose();
|
||||
_pluginCatalogSettingsService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
|
||||
public sealed record UpdatePendingInfo(
|
||||
string InstallerPath,
|
||||
string VersionText,
|
||||
DateTimeOffset? PublishedAt);
|
||||
DateTimeOffset? PublishedAt,
|
||||
string? Sha256 = null);
|
||||
|
||||
public sealed record UpdateVerifyResult(
|
||||
bool Success,
|
||||
bool HashMatched,
|
||||
string? ExpectedHash,
|
||||
string? ActualHash,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record UpdateInstallerLaunchResult(
|
||||
bool Success,
|
||||
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
|
||||
UpdateSettingsValues.ChannelPreview,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var result = await _settingsFacade.Update.CheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken);
|
||||
var result = isForce
|
||||
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken)
|
||||
: await _settingsFacade.Update.CheckForUpdatesAsync(
|
||||
currentVersion,
|
||||
includePrerelease,
|
||||
cancellationToken);
|
||||
|
||||
SaveState(state with
|
||||
{
|
||||
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
Version currentVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
|
||||
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
||||
File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null);
|
||||
var verifyResult = await VerifyPendingUpdateAsync();
|
||||
if (verifyResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
|
||||
}
|
||||
|
||||
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var existingPending = GetPendingUpdate(state);
|
||||
|
||||
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(existingPending.InstallerPath);
|
||||
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
ClearPendingUpdate();
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||
|
||||
state = _settingsFacade.Update.Get();
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UpdateDownloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var pending = GetPendingUpdate(state);
|
||||
|
||||
if (pending is null)
|
||||
{
|
||||
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
|
||||
}
|
||||
|
||||
if (!File.Exists(pending.InstallerPath))
|
||||
{
|
||||
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
||||
}
|
||||
|
||||
var expectedHash = pending.Sha256;
|
||||
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
||||
|
||||
if (string.IsNullOrEmpty(expectedHash))
|
||||
{
|
||||
return new UpdateVerifyResult(true, true, null, actualHash, null);
|
||||
}
|
||||
|
||||
var hashMatched = string.Equals(
|
||||
expectedHash?.Trim().ToLowerInvariant(),
|
||||
actualHash?.Trim().ToLowerInvariant(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new UpdateVerifyResult(
|
||||
hashMatched,
|
||||
hashMatched,
|
||||
expectedHash,
|
||||
actualHash,
|
||||
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
public async Task AutoCheckIfEnabledAsync(
|
||||
Version currentVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
|
||||
try
|
||||
{
|
||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
|
||||
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
||||
{
|
||||
return;
|
||||
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = null,
|
||||
PendingUpdateVersion = null,
|
||||
PendingUpdatePublishedAtUtcMs = null
|
||||
PendingUpdatePublishedAtUtcMs = null,
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
|
||||
return new UpdatePendingInfo(
|
||||
installerPath,
|
||||
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
||||
publishedAt);
|
||||
publishedAt,
|
||||
state.PendingUpdateSha256);
|
||||
}
|
||||
|
||||
private void SaveState(UpdateSettingsState state)
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-row-button">
|
||||
<Style Selector="Button.plugin-catalog-row-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -275,11 +275,11 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-row-button:pointerover">
|
||||
<Style Selector="Button.plugin-catalog-row-button:pointerover">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button">
|
||||
<Style Selector="Button.plugin-catalog-icon-button">
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -290,11 +290,11 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button:pointerover">
|
||||
<Style Selector="Button.plugin-catalog-icon-button:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
|
||||
<Style Selector="Button.plugin-catalog-icon-button fi|SymbolIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
</Style>
|
||||
|
||||
@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public enum PluginMarketPrimaryActionState
|
||||
public enum PluginCatalogPrimaryActionState
|
||||
{
|
||||
Install,
|
||||
Update,
|
||||
@@ -24,14 +24,14 @@ 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(
|
||||
PluginMarketPluginInfo plugin,
|
||||
public PluginCatalogItemViewModel(
|
||||
PluginCatalogItemInfo plugin,
|
||||
LocalizationService localizationService,
|
||||
string languageCode)
|
||||
{
|
||||
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
ActionTooltip = L("market.button.install", "Install");
|
||||
}
|
||||
|
||||
public PluginMarketPluginInfo Info { get; }
|
||||
public PluginCatalogItemInfo Info { get; }
|
||||
|
||||
public string PluginId => Info.Id;
|
||||
|
||||
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
|
||||
public string ReadmeUrl => Info.ReadmeUrl;
|
||||
|
||||
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
|
||||
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
|
||||
|
||||
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
|
||||
|
||||
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
|
||||
|
||||
public string IconFallbackText { get; }
|
||||
|
||||
@@ -100,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)
|
||||
{
|
||||
@@ -160,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;
|
||||
@@ -169,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,
|
||||
@@ -181,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;
|
||||
@@ -190,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;
|
||||
@@ -199,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;
|
||||
@@ -238,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
|
||||
{
|
||||
private readonly LocalizationService _localizationService;
|
||||
private readonly string _languageCode;
|
||||
private readonly AirAppMarketReadmeService _readmeService;
|
||||
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
|
||||
private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
|
||||
private bool _isInitialized;
|
||||
|
||||
public PluginMarketDetailViewModel(
|
||||
PluginMarketItemViewModel item,
|
||||
public PluginCatalogDetailViewModel(
|
||||
PluginCatalogItemViewModel item,
|
||||
LocalizationService localizationService,
|
||||
string languageCode,
|
||||
AirAppMarketReadmeService readmeService,
|
||||
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
|
||||
Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
|
||||
{
|
||||
Item = item;
|
||||
_localizationService = localizationService;
|
||||
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
_readmeService = readmeService;
|
||||
_primaryActionAsync = primaryActionAsync;
|
||||
|
||||
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
|
||||
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
|
||||
VersionLabel = L("market.detail.version", "Version");
|
||||
PublisherLabel = L("market.detail.author", "Author");
|
||||
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
||||
@@ -269,9 +273,9 @@ 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<PluginMarketDependencyInfo> Dependencies { get; }
|
||||
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
|
||||
|
||||
public string DrawerTitle => Item.Name;
|
||||
|
||||
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||
|
||||
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
||||
|
||||
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
|
||||
|
||||
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_isInitialized)
|
||||
@@ -367,9 +375,10 @@ 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;
|
||||
private readonly LocalizationService _localizationService;
|
||||
private readonly AirAppMarketIconService _iconService;
|
||||
private readonly AirAppMarketReadmeService _readmeService;
|
||||
@@ -377,31 +386,32 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Version? _hostVersion;
|
||||
private bool _isInitialized;
|
||||
private bool _hasLoadedMarket;
|
||||
private bool _hasLoadedCatalog;
|
||||
|
||||
public PluginMarketSettingsPageViewModel(
|
||||
public PluginCatalogSettingsPageViewModel(
|
||||
ISettingsFacadeService settingsFacade,
|
||||
LocalizationService localizationService,
|
||||
AirAppMarketIconService iconService,
|
||||
AirAppMarketReadmeService readmeService)
|
||||
{
|
||||
_settingsFacade = settingsFacade;
|
||||
_pluginCatalog = _settingsFacade.PluginCatalog;
|
||||
_localizationService = localizationService;
|
||||
_iconService = iconService;
|
||||
_readmeService = readmeService;
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
|
||||
RefreshLocalizedText();
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
|
||||
}
|
||||
|
||||
public event Action<string?>? RestartRequested;
|
||||
|
||||
public event Action<PluginMarketItemViewModel>? DetailsRequested;
|
||||
public event Action<PluginCatalogItemViewModel>? DetailsRequested;
|
||||
|
||||
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
|
||||
public ObservableCollection<PluginCatalogItemViewModel> CatalogPlugins { get; } = [];
|
||||
|
||||
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
|
||||
public ObservableCollection<PluginCatalogItemViewModel> FilteredPlugins { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
@@ -444,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,
|
||||
@@ -465,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 _settingsFacade.PluginMarket.LoadIndexAsync();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -503,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
|
||||
{
|
||||
@@ -517,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDetails(PluginMarketItemViewModel? item)
|
||||
private void OpenDetails(PluginCatalogItemViewModel? item)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
@@ -528,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;
|
||||
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
||||
item.Name);
|
||||
|
||||
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
||||
var result = await _pluginCatalog.InstallAsync(item.PluginId);
|
||||
if (result.Success)
|
||||
{
|
||||
RefreshInstalledSnapshot();
|
||||
@@ -604,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);
|
||||
}
|
||||
@@ -632,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
FilteredPlugins.Clear();
|
||||
|
||||
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
|
||||
IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
|
||||
var query = SearchText?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
@@ -650,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.");
|
||||
@@ -659,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.plugin_market.title", "Plugin Market");
|
||||
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
|
||||
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
|
||||
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
|
||||
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
||||
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
|
||||
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
@@ -1517,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _installNowButtonText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _redownloadButtonText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _latestVersionText = string.Empty;
|
||||
|
||||
@@ -1556,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _downloadThreadsDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceCheckUpdateLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _forceCheckUpdateDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _stableChannelText = string.Empty;
|
||||
|
||||
@@ -1619,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public bool IsInstallButtonVisible => HasPendingInstaller;
|
||||
|
||||
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
||||
|
||||
public string DownloadThreadsValueText =>
|
||||
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -1838,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
await CheckForUpdatesCoreAsync(isForce: false);
|
||||
}
|
||||
|
||||
private bool CanForceCheckUpdate() => !IsBusy;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanForceCheckUpdate))]
|
||||
private async Task ForceCheckUpdateAsync()
|
||||
{
|
||||
await CheckForUpdatesCoreAsync(isForce: true);
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1845,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
IsDownloadProgressVisible = false;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion);
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
RefreshLastCheckedFromSettings();
|
||||
|
||||
@@ -1863,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
ApplyCheckResultDisplay(result);
|
||||
if (!result.IsUpdateAvailable)
|
||||
if (!result.IsUpdateAvailable && !isForce)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.PreferredAsset is null)
|
||||
{
|
||||
UpdateStatus = L(
|
||||
"settings.update.status_asset_missing",
|
||||
"A new release is available, but no compatible installer was found.");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
||||
: L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1884,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
|
||||
UpdateStatus = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
||||
isForce
|
||||
? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.")
|
||||
: L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
||||
result.LatestVersionText);
|
||||
}
|
||||
finally
|
||||
@@ -1926,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
|
||||
}
|
||||
|
||||
private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||
private async Task RedownloadUpdateAsync()
|
||||
{
|
||||
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
|
||||
{
|
||||
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsDownloading = true;
|
||||
IsDownloadProgressVisible = true;
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer...");
|
||||
|
||||
var progress = new Progress<double>(value =>
|
||||
{
|
||||
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||
DownloadProgressText = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||
DownloadProgressValue);
|
||||
});
|
||||
|
||||
var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
UpdateStatus = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"),
|
||||
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyPendingState(_settingsFacade.Update.Get());
|
||||
UpdateStatus = downloadResult.HashVerified
|
||||
? BuildPendingReadyStatus()
|
||||
: string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
|
||||
downloadResult.ActualHash ?? "N/A");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsDownloading = false;
|
||||
IsDownloadProgressVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshLocalizedText()
|
||||
{
|
||||
PageTitle = L("settings.update.title", "Update");
|
||||
@@ -1939,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
RedownloadButtonText = L("settings.update.redownload_button", "Redownload");
|
||||
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
|
||||
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
|
||||
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
|
||||
@@ -2147,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDownloadButtonVisible));
|
||||
OnPropertyChanged(nameof(IsInstallButtonVisible));
|
||||
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
||||
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
||||
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
|
||||
@@ -625,17 +625,18 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
||||
RootBorder.CornerRadius = unifiedMainRectangle;
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * scale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * scale, 7, 22);
|
||||
CardBorder.CornerRadius = unifiedMainRectangle;
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22),
|
||||
Math.Clamp(16 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 7, 22));
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(100, totalWidth - horizontalPadding * 2);
|
||||
|
||||
var headlineFont = Math.Clamp(24 * scale, 12, 34);
|
||||
BrandPrimaryTextBlock.FontSize = headlineFont;
|
||||
@@ -649,7 +650,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
||||
|
||||
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
|
||||
var imageWidth = Math.Clamp(innerWidth * 0.22, 60, 170);
|
||||
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
|
||||
News1ImageHost.Width = imageWidth;
|
||||
News1ImageHost.Height = imageHeight;
|
||||
@@ -657,6 +658,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
News2ImageHost.Height = imageHeight;
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
var columnGap = Math.Clamp(12 * scale, 6, 18);
|
||||
NewsItem1Grid.ColumnSpacing = columnGap;
|
||||
@@ -664,25 +667,29 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
|
||||
var availableTextWidth = Math.Max(
|
||||
84,
|
||||
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
|
||||
var availableTextWidth = Math.Max(80, innerWidth - imageWidth - columnGap);
|
||||
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
|
||||
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
||||
News1TitleTextBlock.FontSize = newsFont;
|
||||
News2TitleTextBlock.FontSize = newsFont;
|
||||
var mainNewsLineHeight = newsFont * 1.14;
|
||||
var mainNewsLineHeight = newsFont * 1.2;
|
||||
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
var mainNewsMinHeight = mainNewsLineHeight * 2;
|
||||
var mainNewsMinHeight = mainNewsLineHeight * 2.2;
|
||||
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||
News1TitleTextBlock.MaxLines = 2;
|
||||
News2TitleTextBlock.MaxLines = 2;
|
||||
|
||||
var rowSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||
if (ContentGrid is Grid contentGrid && contentGrid.RowDefinitions.Count >= 4)
|
||||
{
|
||||
contentGrid.RowSpacing = rowSpacing;
|
||||
}
|
||||
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
row.RootGrid.ColumnSpacing = columnGap;
|
||||
@@ -694,11 +701,12 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
||||
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
|
||||
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
|
||||
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.2;
|
||||
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2.2;
|
||||
row.TitleTextBlock.MaxLines = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,36 @@
|
||||
BorderThickness="0"
|
||||
Padding="14,14,14,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
RowDefinitions="Auto,*">
|
||||
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="凤凰网新闻"
|
||||
Foreground="#E24B2D"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="BrandTextBlock"
|
||||
Text="鳳凰網"
|
||||
Foreground="#E24B2D"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center" />
|
||||
<Border x:Name="NewsBadge"
|
||||
Background="#E24B2D"
|
||||
CornerRadius="4"
|
||||
Padding="6,2"
|
||||
Margin="4,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="NewsBadgeText"
|
||||
Text="新聞"
|
||||
Foreground="White"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
@@ -58,129 +75,18 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="NewsItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem1Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem1TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem1ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem1Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem2Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem2TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem2ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem2Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem3Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem3TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem3ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem3Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="NewsItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnNewsItemPointerPressed">
|
||||
<Grid x:Name="NewsItem4Grid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="NewsItem4TextBlock"
|
||||
Text="新闻标题"
|
||||
Foreground="#202327"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top" />
|
||||
<Border x:Name="NewsItem4ImageHost"
|
||||
Grid.Column="1"
|
||||
Width="148"
|
||||
Height="84"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
Background="#E6E8EC">
|
||||
<Image x:Name="NewsItem4Image"
|
||||
Stretch="UniformToFill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ScrollViewer x:Name="NewsScrollViewer"
|
||||
Grid.Row="1"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NewsStackPanel" Spacing="6">
|
||||
<TextBlock x:Name="LoadingTextBlock"
|
||||
Text="正在加载..."
|
||||
Foreground="#6A6F77"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 4;
|
||||
private const int MaxDisplayItemCount = 4;
|
||||
private const int MaxDisplayItemCount = 12;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
@@ -47,9 +47,9 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<DailyNewsItemSnapshot> _activeItems = [];
|
||||
private readonly List<NewsItemVisual> _itemVisuals = [];
|
||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[MaxDisplayItemCount];
|
||||
private readonly Dictionary<string, DailyNewsItemSnapshot> _newsByUrl = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<NewsItemControl> _itemControls = [];
|
||||
private readonly Dictionary<string, Bitmap> _imageCache = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
@@ -61,28 +61,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private bool _isNightVisual = true;
|
||||
|
||||
private sealed record NewsItemVisual(
|
||||
Border Host,
|
||||
Grid RowGrid,
|
||||
TextBlock TitleTextBlock,
|
||||
Border ImageHost,
|
||||
Image ImageControl);
|
||||
|
||||
public IfengNewsWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||
NewsItem1TextBlock.FontFamily = MiSansFontFamily;
|
||||
NewsItem2TextBlock.FontFamily = MiSansFontFamily;
|
||||
NewsItem3TextBlock.FontFamily = MiSansFontFamily;
|
||||
NewsItem4TextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_itemVisuals.Add(new NewsItemVisual(NewsItem1Host, NewsItem1Grid, NewsItem1TextBlock, NewsItem1ImageHost, NewsItem1Image));
|
||||
_itemVisuals.Add(new NewsItemVisual(NewsItem2Host, NewsItem2Grid, NewsItem2TextBlock, NewsItem2ImageHost, NewsItem2Image));
|
||||
_itemVisuals.Add(new NewsItemVisual(NewsItem3Host, NewsItem3Grid, NewsItem3TextBlock, NewsItem3ImageHost, NewsItem3Image));
|
||||
_itemVisuals.Add(new NewsItemVisual(NewsItem4Host, NewsItem4Grid, NewsItem4TextBlock, NewsItem4ImageHost, NewsItem4Image));
|
||||
LoadingTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
@@ -135,7 +120,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
DisposeNewsBitmaps();
|
||||
DisposeImageCache();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
@@ -191,18 +176,19 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
|
||||
NewsBadge.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
foreach (var visual in _itemVisuals)
|
||||
{
|
||||
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
|
||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
}
|
||||
|
||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||
|
||||
foreach (var control in _itemControls)
|
||||
{
|
||||
control.ApplyNightMode(_isNightVisual);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
@@ -217,22 +203,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Border host ||
|
||||
host.Tag is null ||
|
||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
||||
index < 0 ||
|
||||
index >= _activeItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_activeItems[index].Url);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshNewsAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
@@ -272,7 +242,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -296,100 +265,90 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
|
||||
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||
|
||||
_activeItems.Clear();
|
||||
foreach (var item in snapshot.Items)
|
||||
var newItems = snapshot.Items
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Url) && !_newsByUrl.ContainsKey(item.Url))
|
||||
.ToList();
|
||||
|
||||
if (newItems.Count == 0 && _itemControls.Count == 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_activeItems.Add(item);
|
||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.TitleTextBlock.Text = i < _activeItems.Count
|
||||
? NormalizeCompactText(_activeItems[i].Title)
|
||||
: fallbackText;
|
||||
SetNewsBitmap(i, null);
|
||||
}
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
|
||||
var tasks = Enumerable.Range(0, MaxDisplayItemCount)
|
||||
.Select(index => TryDownloadBitmapAsync(
|
||||
index < _activeItems.Count ? _activeItems[index].ImageUrl : null,
|
||||
cancellationToken))
|
||||
.ToArray();
|
||||
var bitmaps = await Task.WhenAll(tasks);
|
||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
foreach (var bitmap in bitmaps)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
}
|
||||
|
||||
ApplyEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < bitmaps.Length; i++)
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
SetNewsBitmap(i, bitmaps[i]);
|
||||
_newsByUrl[item.Url] = item;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
var control = new NewsItemControl(item, _isNightVisual);
|
||||
control.Clicked += (s, url) => TryOpenUrl(url);
|
||||
NewsStackPanel.Children.Insert(NewsStackPanel.Children.Count - 1, control);
|
||||
_itemControls.Add(control);
|
||||
}
|
||||
|
||||
UpdateAdaptiveLayout();
|
||||
});
|
||||
|
||||
var imageTasks = newItems.Select(async item =>
|
||||
{
|
||||
var bitmap = await TryDownloadBitmapAsync(item.ImageUrl, cancellationToken);
|
||||
if (bitmap != null && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (_imageCache.TryGetValue(item.Url, out var oldBitmap))
|
||||
{
|
||||
oldBitmap.Dispose();
|
||||
}
|
||||
_imageCache[item.Url] = bitmap;
|
||||
|
||||
var control = _itemControls.FirstOrDefault(c => c.NewsUrl == item.Url);
|
||||
control?.SetImage(bitmap);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(imageTasks);
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||
|
||||
_activeItems.Clear();
|
||||
var loadingText = L("ifeng.widget.loading_item", "加载中...");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.TitleTextBlock.Text = loadingText;
|
||||
SetNewsBitmap(i, null);
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("ifeng.widget.loading", "加载中...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
LoadingTextBlock.Text = L("ifeng.widget.loading", "加载中...");
|
||||
LoadingTextBlock.IsVisible = true;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||
|
||||
_activeItems.Clear();
|
||||
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.TitleTextBlock.Text = fallbackText;
|
||||
SetNewsBitmap(i, null);
|
||||
}
|
||||
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyEmptyState()
|
||||
{
|
||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||
|
||||
LoadingTextBlock.IsVisible = false;
|
||||
StatusTextBlock.Text = L("ifeng.widget.fallback_item", "暂无新闻");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
@@ -408,26 +367,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var rowSpacing = Math.Clamp(8 * softScale, 4, 12);
|
||||
ContentGrid.RowSpacing = rowSpacing;
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
|
||||
var headerHeight = Math.Clamp(totalHeight * 0.10, 28, 54);
|
||||
HeaderGrid.Height = headerHeight;
|
||||
HeaderGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(8 * softScale, 4, 12));
|
||||
|
||||
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
|
||||
var innerHeight = Math.Max(160, totalHeight - verticalPadding * 2d);
|
||||
var availableRowsHeight = Math.Max(120, innerHeight - rowSpacing * 4d);
|
||||
var headerHeight = Math.Clamp(availableRowsHeight * 0.16, 24, 54);
|
||||
var itemHeight = Math.Max(32, (availableRowsHeight - headerHeight) / 4d);
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 5)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(headerHeight);
|
||||
for (var i = 1; i <= 4; i++)
|
||||
{
|
||||
ContentGrid.RowDefinitions[i].Height = new GridLength(itemHeight);
|
||||
}
|
||||
}
|
||||
|
||||
BrandTextBlock.FontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
|
||||
var brandFontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
|
||||
BrandTextBlock.FontSize = brandFontSize;
|
||||
NewsBadgeText.FontSize = brandFontSize;
|
||||
|
||||
var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44);
|
||||
RefreshButton.Width = refreshSize;
|
||||
@@ -435,51 +381,25 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20);
|
||||
|
||||
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
|
||||
var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176);
|
||||
var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98);
|
||||
var columnGap = Math.Clamp(itemHeight * 0.20, 6, 14);
|
||||
var rowPadding = Math.Clamp(itemHeight * 0.08, 1, 5);
|
||||
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
|
||||
var titleFont = Math.Clamp(itemHeight * 0.32, 12, 24);
|
||||
|
||||
var baseTitleFont = 14;
|
||||
var areaFactor = (totalWidth * totalHeight) / (BaseWidthCells * BaseCellSize * BaseHeightCells * BaseCellSize);
|
||||
var adaptiveTitleFont = baseTitleFont * Math.Sqrt(Math.Clamp(areaFactor, 0.6, 2.5));
|
||||
var titleFont = Math.Clamp(adaptiveTitleFont, 11, 26);
|
||||
|
||||
foreach (var visual in _itemVisuals)
|
||||
foreach (var control in _itemControls)
|
||||
{
|
||||
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
|
||||
visual.RowGrid.ColumnSpacing = columnGap;
|
||||
if (visual.RowGrid.ColumnDefinitions.Count > 1)
|
||||
{
|
||||
visual.RowGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
}
|
||||
|
||||
visual.ImageHost.Width = imageWidth;
|
||||
visual.ImageHost.Height = imageHeight;
|
||||
visual.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
||||
|
||||
visual.TitleTextBlock.MaxWidth = textWidth;
|
||||
visual.TitleTextBlock.FontSize = titleFont;
|
||||
visual.TitleTextBlock.LineHeight = titleFont * 1.12;
|
||||
visual.TitleTextBlock.MinHeight = visual.TitleTextBlock.LineHeight * 2;
|
||||
visual.TitleTextBlock.MaxLines = 2;
|
||||
control.UpdateLayout(softScale, innerWidth, imageWidth, imageHeight, titleFont);
|
||||
}
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 20);
|
||||
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
|
||||
LoadingTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
|
||||
ApplyNightModeVisual();
|
||||
}
|
||||
|
||||
private void UpdateInteractionState()
|
||||
{
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
||||
visual.Host.IsHitTestVisible = enabled;
|
||||
visual.Host.Opacity = enabled ? 1.0 : 0.68;
|
||||
visual.Host.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
var enabled = _isAttached && !_isRefreshing;
|
||||
@@ -515,7 +435,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
@@ -614,7 +533,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed URLs or shell launch failures.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,32 +558,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
private void SetNewsBitmap(int index, Bitmap? bitmap)
|
||||
private void DisposeImageCache()
|
||||
{
|
||||
if (index < 0 || index >= _newsBitmaps.Length)
|
||||
foreach (var bitmap in _imageCache.Values)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var visual = _itemVisuals[index];
|
||||
var oldBitmap = _newsBitmaps[index];
|
||||
if (ReferenceEquals(visual.ImageControl.Source, oldBitmap))
|
||||
{
|
||||
visual.ImageControl.Source = null;
|
||||
}
|
||||
|
||||
oldBitmap?.Dispose();
|
||||
_newsBitmaps[index] = bitmap;
|
||||
visual.ImageControl.Source = bitmap;
|
||||
}
|
||||
|
||||
private void DisposeNewsBitmaps()
|
||||
{
|
||||
for (var i = 0; i < _newsBitmaps.Length; i++)
|
||||
{
|
||||
SetNewsBitmap(i, null);
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Clear();
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
@@ -715,4 +614,142 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed class NewsItemControl : Border
|
||||
{
|
||||
private readonly DailyNewsItemSnapshot _item;
|
||||
private readonly Grid _grid;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly Border _imageHost;
|
||||
private readonly Image _imageControl;
|
||||
private bool _isNightVisual;
|
||||
private Point _pointerPressedPosition;
|
||||
private bool _isPointerPressed;
|
||||
|
||||
public string NewsUrl => _item.Url;
|
||||
|
||||
public NewsItemControl(DailyNewsItemSnapshot item, bool isNightVisual)
|
||||
{
|
||||
_item = item;
|
||||
_isNightVisual = isNightVisual;
|
||||
|
||||
Padding = new Thickness(0, 4);
|
||||
Background = Brushes.Transparent;
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerReleased += OnPointerReleased;
|
||||
PointerCaptureLost += OnPointerCaptureLost;
|
||||
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Text = NormalizeCompactText(item.Title),
|
||||
Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top
|
||||
};
|
||||
|
||||
_imageControl = new Image
|
||||
{
|
||||
Stretch = Stretch.UniformToFill
|
||||
};
|
||||
|
||||
_imageHost = new Border
|
||||
{
|
||||
Width = 148,
|
||||
Height = 84,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC")),
|
||||
Child = _imageControl
|
||||
};
|
||||
|
||||
_grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = ColumnDefinitions.Parse("*,Auto"),
|
||||
ColumnSpacing = 10
|
||||
};
|
||||
|
||||
Grid.SetColumn(_imageHost, 1);
|
||||
_grid.Children.Add(_titleTextBlock);
|
||||
_grid.Children.Add(_imageHost);
|
||||
|
||||
Child = _grid;
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
_isPointerPressed = true;
|
||||
_pointerPressedPosition = e.GetPosition(this);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isPointerPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPointerPressed = false;
|
||||
var releasePosition = e.GetPosition(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
|
||||
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
|
||||
|
||||
if (distance < 5)
|
||||
{
|
||||
Clicked?.Invoke(this, _item.Url);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||
{
|
||||
_isPointerPressed = false;
|
||||
}
|
||||
|
||||
public void ApplyNightMode(bool isNightVisual)
|
||||
{
|
||||
_isNightVisual = isNightVisual;
|
||||
_titleTextBlock.Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
_imageHost.Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC"));
|
||||
}
|
||||
|
||||
public void UpdateLayout(double scale, double innerWidth, double imageWidth, double imageHeight, double titleFont)
|
||||
{
|
||||
var columnGap = Math.Clamp(imageHeight * 0.20, 6, 14);
|
||||
_grid.ColumnSpacing = columnGap;
|
||||
|
||||
if (_grid.ColumnDefinitions.Count > 1)
|
||||
{
|
||||
_grid.ColumnDefinitions[1] = new ColumnDefinition(new GridLength(imageWidth));
|
||||
}
|
||||
|
||||
_imageHost.Width = imageWidth;
|
||||
_imageHost.Height = imageHeight;
|
||||
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
||||
|
||||
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
|
||||
_titleTextBlock.MaxWidth = textWidth;
|
||||
_titleTextBlock.FontSize = titleFont;
|
||||
_titleTextBlock.LineHeight = titleFont * 1.12;
|
||||
_titleTextBlock.MinHeight = _titleTextBlock.LineHeight * 2;
|
||||
}
|
||||
|
||||
public void SetImage(Bitmap bitmap)
|
||||
{
|
||||
_imageControl.Source = bitmap;
|
||||
}
|
||||
|
||||
public event EventHandler<string>? Clicked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,16 @@
|
||||
BorderThickness="1"
|
||||
Foreground="#bb5649"
|
||||
Focusable="False"
|
||||
ToolTip.Tip="刷新新闻"
|
||||
ToolTip.Tip="刷新今日新闻"
|
||||
Click="OnRefreshButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||
Symbol="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="14"
|
||||
Foreground="#bb5649" />
|
||||
<TextBlock Text="刷新"
|
||||
<TextBlock x:Name="RefreshButtonText"
|
||||
Text="刷新"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -625,13 +625,84 @@ public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedNews.Clear();
|
||||
_loadedDates.Clear();
|
||||
_dailyViews.Clear();
|
||||
NewsStackPanel.Children.Clear();
|
||||
_earliestLoadedDate = DateTime.Today;
|
||||
_isLoading = true;
|
||||
RefreshButtonText.Text = "刷新中...";
|
||||
RefreshIcon.IsEnabled = false;
|
||||
|
||||
await LoadInitialNewsAsync();
|
||||
try
|
||||
{
|
||||
var allNews = await FetchJuyaNewsAsync();
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var today = DateTime.Today;
|
||||
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
|
||||
|
||||
if (todayNews != null)
|
||||
{
|
||||
_cachedNews[today] = todayNews;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
var existingIndex = _loadedDates.IndexOf(today);
|
||||
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
|
||||
{
|
||||
var oldView = _dailyViews[existingIndex];
|
||||
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
|
||||
|
||||
if (insertIndex >= 0)
|
||||
{
|
||||
NewsStackPanel.Children.RemoveAt(insertIndex);
|
||||
_dailyViews.RemoveAt(existingIndex);
|
||||
|
||||
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||
|
||||
NewsStackPanel.Children.Insert(insertIndex, newView);
|
||||
_dailyViews.Insert(existingIndex, newView);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||
|
||||
NewsStackPanel.Children.Insert(0, newView);
|
||||
_dailyViews.Insert(0, newView);
|
||||
_loadedDates.Insert(0, today);
|
||||
}
|
||||
|
||||
RefreshButtonText.Text = "刷新";
|
||||
RefreshIcon.IsEnabled = true;
|
||||
UpdateAdaptiveLayout();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
RefreshButtonText.Text = "刷新";
|
||||
RefreshIcon.IsEnabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
RefreshButtonText.Text = "刷新";
|
||||
RefreshIcon.IsEnabled = true;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryOpenUrl(string? url)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
@@ -9,86 +9,124 @@
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#F1F4F9"
|
||||
CornerRadius="20"
|
||||
ClipToBounds="True"
|
||||
Padding="8">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
RowSpacing="8">
|
||||
<Border x:Name="CanvasBorder"
|
||||
Grid.Row="0"
|
||||
Background="#FFFFFF"
|
||||
BorderBrush="#24000000"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
ClipToBounds="True">
|
||||
<inking:InkCanvas x:Name="InkCanvas" />
|
||||
</Border>
|
||||
<Grid>
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#F1F4F9"
|
||||
CornerRadius="20"
|
||||
ClipToBounds="True"
|
||||
Padding="8">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
RowSpacing="8">
|
||||
<Border x:Name="CanvasBorder"
|
||||
Grid.Row="0"
|
||||
Background="#FFFFFF"
|
||||
BorderBrush="#24000000"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
ClipToBounds="True">
|
||||
<inking:InkCanvas x:Name="InkCanvas" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ToolbarBorder"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
Background="#E6FFFFFF"
|
||||
BorderBrush="#16000000"
|
||||
<Border x:Name="ToolbarBorder"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
Background="#E6FFFFFF"
|
||||
BorderBrush="#16000000"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,6">
|
||||
<StackPanel x:Name="ToolbarButtonsPanel"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<Button x:Name="PenButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Pen"
|
||||
Click="OnPenButtonClick">
|
||||
<fi:SymbolIcon x:Name="PenIcon"
|
||||
Symbol="Pen"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="EraserButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Eraser"
|
||||
Click="OnEraserButtonClick">
|
||||
<fi:SymbolIcon x:Name="EraserIcon"
|
||||
Symbol="EraserTool"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="ClearButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Clear"
|
||||
Click="OnClearButtonClick">
|
||||
<fi:SymbolIcon x:Name="ClearIcon"
|
||||
Symbol="Delete"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="ExportButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Export SVG"
|
||||
Click="OnExportButtonClick">
|
||||
<fi:SymbolIcon x:Name="ExportIcon"
|
||||
Symbol="ArrowExport"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Popup x:Name="ColorPickerPopup"
|
||||
Placement="Top"
|
||||
PlacementTarget="{Binding #PenButton}"
|
||||
IsLightDismissEnabled="True"
|
||||
WindowManagerAddShadowHint="False">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,6">
|
||||
<StackPanel x:Name="ToolbarButtonsPanel"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<Button x:Name="PenButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Pen"
|
||||
Click="OnPenButtonClick">
|
||||
<fi:SymbolIcon x:Name="PenIcon"
|
||||
Symbol="Pen"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="EraserButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Eraser"
|
||||
Click="OnEraserButtonClick">
|
||||
<fi:SymbolIcon x:Name="EraserIcon"
|
||||
Symbol="EraserTool"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="ClearButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Clear"
|
||||
Click="OnClearButtonClick">
|
||||
<fi:SymbolIcon x:Name="ClearIcon"
|
||||
Symbol="Delete"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button x:Name="ExportButton"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Padding="0"
|
||||
CornerRadius="15"
|
||||
ToolTip.Tip="Export SVG"
|
||||
Click="OnExportButtonClick">
|
||||
<fi:SymbolIcon x:Name="ExportIcon"
|
||||
Symbol="ArrowExport"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
CornerRadius="8"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="12">
|
||||
<ColorView x:Name="InkColorPicker"
|
||||
IsAlphaEnabled="False"
|
||||
IsColorSpectrumVisible="True"
|
||||
IsColorPaletteVisible="True"
|
||||
IsHexInputVisible="True"
|
||||
ColorChanged="OnColorPickerColorChanged" />
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="粗细"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12" />
|
||||
<Slider x:Name="InkThicknessSlider"
|
||||
Grid.Column="1"
|
||||
Minimum="1"
|
||||
Maximum="8"
|
||||
Value="2.5"
|
||||
SmallChange="0.5"
|
||||
LargeChange="1"
|
||||
ValueChanged="OnInkThicknessSliderValueChanged" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private double _currentCellSize = 48;
|
||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||
private bool? _isNightModeApplied;
|
||||
private SKColor _currentInkColor = SKColors.Black;
|
||||
private SKColor _selectedInkColor = SKColors.Black;
|
||||
private float _selectedInkThickness = 2.5f;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||
private string _placementId = string.Empty;
|
||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshFromSettings();
|
||||
ApplyThemeVisual(force: true);
|
||||
InitializeColorPicker();
|
||||
SetToolMode(WhiteboardToolMode.Pen);
|
||||
}
|
||||
|
||||
private void InitializeColorPicker()
|
||||
{
|
||||
if (InkColorPicker is not null)
|
||||
{
|
||||
InkColorPicker.Color = new Color(
|
||||
_selectedInkColor.Alpha,
|
||||
_selectedInkColor.Red,
|
||||
_selectedInkColor.Green,
|
||||
_selectedInkColor.Blue);
|
||||
}
|
||||
|
||||
if (InkThicknessSlider is not null)
|
||||
{
|
||||
InkThicknessSlider.Value = _selectedInkThickness;
|
||||
}
|
||||
}
|
||||
|
||||
public int NoteRetentionDays => _noteRetentionDays;
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
settings.IgnorePressure = true;
|
||||
settings.InkThickness = 2.5f;
|
||||
settings.InkThickness = _selectedInkThickness;
|
||||
settings.EraserSize = new Size(20, 20);
|
||||
settings.IsBitmapCacheEnabled = true;
|
||||
settings.MaxBitmapCacheSize = 2048;
|
||||
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
|
||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||
}
|
||||
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
|
||||
_isNightModeApplied = isNightMode;
|
||||
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
|
||||
|
||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
||||
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
||||
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
||||
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
||||
RecolorAllStrokes(_currentInkColor);
|
||||
RefreshToolButtonVisuals();
|
||||
}
|
||||
|
||||
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
}
|
||||
|
||||
public void ForceSaveNote()
|
||||
{
|
||||
if (_disposed || !HasValidPersistenceContext())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_noteDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_noteDirty = false;
|
||||
_noteSaveTimer.Stop();
|
||||
var noteSnapshot = BuildNoteSnapshot();
|
||||
try
|
||||
{
|
||||
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
if (mode == WhiteboardToolMode.Pen)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||
}
|
||||
|
||||
RefreshToolButtonVisuals();
|
||||
}
|
||||
|
||||
private void SetInkColor(SKColor color)
|
||||
{
|
||||
_selectedInkColor = color;
|
||||
if (_toolMode == WhiteboardToolMode.Pen)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||
}
|
||||
RefreshToolButtonVisuals();
|
||||
}
|
||||
|
||||
private void SetInkThickness(float thickness)
|
||||
{
|
||||
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
|
||||
if (_toolMode == WhiteboardToolMode.Pen)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshToolButtonVisuals()
|
||||
{
|
||||
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
||||
@@ -350,7 +409,32 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SetToolMode(WhiteboardToolMode.Pen);
|
||||
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
|
||||
{
|
||||
if (ColorPickerPopup.IsOpen)
|
||||
{
|
||||
ColorPickerPopup.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
ColorPickerPopup.Open();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SetToolMode(WhiteboardToolMode.Pen);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||
{
|
||||
var color = e.NewColor;
|
||||
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
||||
}
|
||||
|
||||
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||
{
|
||||
SetInkThickness((float)e.NewValue);
|
||||
}
|
||||
|
||||
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
||||
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
_noteDirty = false;
|
||||
_noteSaveTimer.Stop();
|
||||
var noteSnapshot = BuildNoteSnapshot();
|
||||
var componentId = _componentId;
|
||||
var placementId = _placementId;
|
||||
var retentionDays = _noteRetentionDays;
|
||||
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
||||
componentId,
|
||||
placementId,
|
||||
noteSnapshot,
|
||||
retentionDays));
|
||||
try
|
||||
{
|
||||
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async void SchedulePersistedNoteLoad()
|
||||
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
{
|
||||
ClearAllStrokes();
|
||||
ApplyNoteSnapshot(noteSnapshot);
|
||||
RecolorAllStrokes(_currentInkColor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -3276,4 +3276,19 @@ public partial class MainWindow
|
||||
_isComponentLibraryComponentGestureActive = false;
|
||||
ApplyComponentLibraryComponentOffset();
|
||||
}
|
||||
|
||||
internal void SaveAllWhiteboardNotes()
|
||||
{
|
||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||
{
|
||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||
{
|
||||
var contentHost = TryGetContentHost(host);
|
||||
if (contentHost?.Child is WhiteboardWidget whiteboard)
|
||||
{
|
||||
whiteboard.ForceSaveNote();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
var wasVisible = IsVisible;
|
||||
var windowState = WindowState.ToString();
|
||||
|
||||
SaveAllWhiteboardNotes();
|
||||
PersistSettings();
|
||||
_componentEditorWindowService.Close();
|
||||
if (_detachedComponentLibraryWindow is not null)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
||||
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer"
|
||||
x:DataType="vm:PluginMarketDetailViewModel">
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer"
|
||||
x:DataType="vm:PluginCatalogDetailViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container"
|
||||
Margin="0,0,0,8">
|
||||
@@ -41,7 +41,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Classes="plugin-market-icon-button"
|
||||
Classes="plugin-catalog-icon-button"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding PerformPrimaryActionCommand}"
|
||||
IsEnabled="{Binding Item.IsActionEnabled}"
|
||||
@@ -103,7 +103,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
|
||||
Markdown="{Binding ReadmeMarkdown}"
|
||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -3,14 +3,14 @@ using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PluginMarketDetailDrawer : UserControl
|
||||
public partial class PluginCatalogDetailDrawer : UserControl
|
||||
{
|
||||
public PluginMarketDetailDrawer()
|
||||
public PluginCatalogDetailDrawer()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel)
|
||||
public PluginCatalogDetailDrawer(PluginCatalogDetailViewModel viewModel)
|
||||
{
|
||||
DataContext = viewModel;
|
||||
InitializeComponent();
|
||||
@@ -3,9 +3,9 @@
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogSettingsPage"
|
||||
x:Name="Root"
|
||||
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
||||
x:DataType="vm:PluginCatalogSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||
@@ -47,7 +47,7 @@
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
|
||||
<DataTemplate x:DataType="vm:PluginCatalogItemViewModel">
|
||||
<Border Classes="settings-list-item">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
@@ -70,7 +70,7 @@
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="plugin-market-row-button"
|
||||
Classes="plugin-catalog-row-button"
|
||||
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Spacing="4"
|
||||
@@ -83,7 +83,7 @@
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Classes="plugin-market-icon-button"
|
||||
Classes="plugin-catalog-icon-button"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
@@ -9,21 +9,21 @@ using LanMountainDesktop.ViewModels;
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
[SettingsPageInfo(
|
||||
"plugin-market",
|
||||
"Plugin Market",
|
||||
SettingsPageCategory.PluginMarket,
|
||||
"plugin-catalog",
|
||||
"Plugin Catalog",
|
||||
SettingsPageCategory.PluginCatalog,
|
||||
IconKey = "ShoppingBag",
|
||||
SortOrder = 35,
|
||||
TitleLocalizationKey = "settings.plugin_market.title",
|
||||
DescriptionLocalizationKey = "settings.plugin_market.subtitle")]
|
||||
public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
TitleLocalizationKey = "settings.plugin_catalog.title",
|
||||
DescriptionLocalizationKey = "settings.plugin_catalog.subtitle")]
|
||||
public partial class PluginCatalogSettingsPage : SettingsPageBase
|
||||
{
|
||||
public PluginMarketSettingsPage()
|
||||
public PluginCatalogSettingsPage()
|
||||
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
|
||||
{
|
||||
}
|
||||
|
||||
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel)
|
||||
public PluginCatalogSettingsPage(PluginCatalogSettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
ViewModel.RestartRequested += OnRestartRequested;
|
||||
@@ -32,7 +32,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PluginMarketSettingsPageViewModel ViewModel { get; }
|
||||
public PluginCatalogSettingsPageViewModel ViewModel { get; }
|
||||
|
||||
public override async void OnNavigatedTo(object? parameter)
|
||||
{
|
||||
@@ -44,22 +44,22 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
await ViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel()
|
||||
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var localizationService = new LocalizationService();
|
||||
return new PluginMarketSettingsPageViewModel(
|
||||
return new PluginCatalogSettingsPageViewModel(
|
||||
settingsFacade,
|
||||
localizationService,
|
||||
new AirAppMarketIconService(),
|
||||
new AirAppMarketReadmeService());
|
||||
}
|
||||
|
||||
private static PluginMarketSettingsPageViewModel CreateDesignTimeViewModel()
|
||||
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var localizationService = new LocalizationService();
|
||||
var viewModel = new PluginMarketSettingsPageViewModel(
|
||||
var viewModel = new PluginCatalogSettingsPageViewModel(
|
||||
settingsFacade,
|
||||
localizationService,
|
||||
new AirAppMarketIconService(),
|
||||
@@ -68,8 +68,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
var previewHostVersion = new Version(1, 2, 0);
|
||||
var items = new[]
|
||||
{
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"news-tiles",
|
||||
"News Tiles",
|
||||
"Brings editorial news cards and ticker rows to the desktop.",
|
||||
@@ -91,8 +91,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
localizationService,
|
||||
installedPlugin: null,
|
||||
previewHostVersion),
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"workspace-pulse",
|
||||
"Workspace Pulse",
|
||||
"Tracks active projects and shows a compact productivity summary.",
|
||||
@@ -125,8 +125,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
true,
|
||||
null),
|
||||
previewHostVersion),
|
||||
CreateMarketItem(
|
||||
new PluginMarketPluginInfo(
|
||||
CreateCatalogItemViewModel(
|
||||
CreateCatalogItem(
|
||||
"glass-panels",
|
||||
"Glass Panels",
|
||||
"Adds experimental acrylic surfaces for plugin-powered widgets.",
|
||||
@@ -152,7 +152,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
viewModel.MarketPlugins.Add(item);
|
||||
viewModel.CatalogPlugins.Add(item);
|
||||
viewModel.FilteredPlugins.Add(item);
|
||||
}
|
||||
|
||||
@@ -167,24 +167,87 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
|
||||
}
|
||||
|
||||
private async void OnDetailsRequested(PluginMarketItemViewModel item)
|
||||
private async void OnDetailsRequested(PluginCatalogItemViewModel item)
|
||||
{
|
||||
var detailViewModel = ViewModel.CreateDetailViewModel(item);
|
||||
var drawer = new PluginMarketDetailDrawer(detailViewModel);
|
||||
var drawer = new PluginCatalogDetailDrawer(detailViewModel);
|
||||
OpenDrawer(drawer, detailViewModel.DrawerTitle);
|
||||
await detailViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private static PluginMarketItemViewModel CreateMarketItem(
|
||||
PluginMarketPluginInfo plugin,
|
||||
private static PluginCatalogItemViewModel CreateCatalogItemViewModel(
|
||||
PluginCatalogItemInfo plugin,
|
||||
LocalizationService localizationService,
|
||||
InstalledPluginInfo? installedPlugin,
|
||||
Version hostVersion)
|
||||
{
|
||||
var languageCode = localizationService.NormalizeLanguageCode(
|
||||
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
|
||||
var item = new PluginMarketItemViewModel(plugin, localizationService, languageCode);
|
||||
var item = new PluginCatalogItemViewModel(plugin, localizationService, languageCode);
|
||||
item.ApplyInstallState(installedPlugin, hostVersion);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static PluginCatalogItemInfo CreateCatalogItem(
|
||||
string id,
|
||||
string name,
|
||||
string description,
|
||||
string author,
|
||||
string version,
|
||||
string apiVersion,
|
||||
string minHostVersion,
|
||||
string downloadUrl,
|
||||
string releaseTag,
|
||||
string releaseAssetName,
|
||||
string iconUrl,
|
||||
string readmeUrl,
|
||||
string homepageUrl,
|
||||
string repositoryUrl,
|
||||
string[] tags,
|
||||
PluginCatalogSharedContractInfo[] sharedContracts,
|
||||
DateTimeOffset publishedAt,
|
||||
DateTimeOffset updatedAt)
|
||||
{
|
||||
return new PluginCatalogItemInfo(
|
||||
new PluginCatalogManifestInfo(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
author,
|
||||
version,
|
||||
apiVersion,
|
||||
string.Empty,
|
||||
sharedContracts),
|
||||
new PluginCatalogCompatibilityInfo(
|
||||
minHostVersion,
|
||||
apiVersion),
|
||||
new PluginCatalogRepositoryInfo(
|
||||
iconUrl,
|
||||
homepageUrl,
|
||||
readmeUrl,
|
||||
homepageUrl,
|
||||
repositoryUrl,
|
||||
tags,
|
||||
string.Empty),
|
||||
new PluginCatalogPublicationInfo(
|
||||
releaseTag,
|
||||
releaseAssetName,
|
||||
publishedAt,
|
||||
updatedAt,
|
||||
0,
|
||||
string.Empty,
|
||||
null),
|
||||
string.IsNullOrWhiteSpace(downloadUrl)
|
||||
? []
|
||||
: [
|
||||
new PluginPackageSourceInfo(
|
||||
string.IsNullOrWhiteSpace(releaseTag)
|
||||
? LanMountainDesktop.Services.Settings.PluginPackageSourceKind.RawFallback
|
||||
: LanMountainDesktop.Services.Settings.PluginPackageSourceKind.ReleaseAsset,
|
||||
downloadUrl,
|
||||
string.Empty,
|
||||
0)
|
||||
],
|
||||
[]);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
|
||||
Markdown="{Binding MarkdownContent}"
|
||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -141,6 +141,9 @@
|
||||
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
||||
Content="{Binding DownloadButtonText}"
|
||||
IsVisible="{Binding IsDownloadButtonVisible}" />
|
||||
<Button Command="{Binding RedownloadUpdateCommand}"
|
||||
Content="{Binding RedownloadButtonText}"
|
||||
IsVisible="{Binding IsRedownloadButtonVisible}" />
|
||||
<Button Classes="settings-accent-button"
|
||||
Command="{Binding InstallPendingUpdateCommand}"
|
||||
Content="{Binding InstallNowButtonText}"
|
||||
@@ -172,6 +175,14 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem Content="{Binding ForceCheckUpdateLabel}"
|
||||
Description="{Binding ForceCheckUpdateDescription}"
|
||||
IsClickEnabled="True"
|
||||
Command="{Binding ForceCheckUpdateCommand}">
|
||||
<ui:SettingsExpanderItem.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ArrowSync" />
|
||||
</ui:SettingsExpanderItem.IconSource>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Classes="settings-expander-card"
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services.PluginMarket;
|
||||
|
||||
internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketIndexDocument> EnrichAsync(
|
||||
AirAppMarketIndexDocument document,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (document.Plugins.Count == 0)
|
||||
{
|
||||
return document;
|
||||
}
|
||||
|
||||
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
|
||||
foreach (var plugin in document.Plugins)
|
||||
{
|
||||
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return new AirAppMarketIndexDocument
|
||||
{
|
||||
SchemaVersion = document.SchemaVersion,
|
||||
SourceId = document.SourceId,
|
||||
SourceName = document.SourceName,
|
||||
GeneratedAt = document.GeneratedAt,
|
||||
Contracts = document.Contracts,
|
||||
Plugins = enrichedPlugins
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
|
||||
AirAppMarketPluginEntry entry,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
|
||||
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||
PluginManifest? manifest = null;
|
||||
AirAppMarketRepositoryTemplate? template = null;
|
||||
|
||||
foreach (var branch in branchCandidates)
|
||||
{
|
||||
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is not null && template is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
|
||||
var resolvedManifest = manifest;
|
||||
var resolvedPackageSources = entry.PackageSources.Count > 0
|
||||
? entry.PackageSources
|
||||
: entry.Publication?.PackageSources ?? [];
|
||||
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
|
||||
Manifest = resolvedManifest is null
|
||||
? entry.Manifest
|
||||
: new AirAppMarketPluginManifestEntry
|
||||
{
|
||||
Id = resolvedManifest.Id,
|
||||
Name = resolvedManifest.Name,
|
||||
Description = resolvedManifest.Description ?? string.Empty,
|
||||
Author = resolvedManifest.Author ?? string.Empty,
|
||||
Version = resolvedManifest.Version ?? string.Empty,
|
||||
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
|
||||
EntranceAssembly = resolvedManifest.EntranceAssembly,
|
||||
SharedContracts = resolvedManifest.SharedContracts?
|
||||
.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||
{
|
||||
Id = contract.Id,
|
||||
Version = contract.Version,
|
||||
AssemblyName = contract.AssemblyName
|
||||
})
|
||||
.ToList()
|
||||
?? []
|
||||
},
|
||||
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
|
||||
? new AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
MinHostVersion = FirstNonEmpty(
|
||||
template?.MinHostVersion,
|
||||
entry.MinHostVersion),
|
||||
PluginApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
entry.ApiVersion)
|
||||
?? string.Empty
|
||||
}
|
||||
: null,
|
||||
Repository = new AirAppMarketPluginRepositoryEntry
|
||||
{
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||
?? string.Empty,
|
||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
},
|
||||
Publication = entry.Publication,
|
||||
Capabilities = entry.Capabilities,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
||||
Sha256 = entry.Sha256,
|
||||
PackageSizeBytes = entry.PackageSizeBytes,
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ReleaseTag = entry.ReleaseTag,
|
||||
ReleaseAssetName = entry.ReleaseAssetName,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
|
||||
?? string.Empty,
|
||||
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
|
||||
SharedContracts = resolvedManifest?.SharedContracts
|
||||
?.Select(contract => new AirAppMarketPluginDependencyEntry
|
||||
{
|
||||
Id = contract.Id,
|
||||
Version = contract.Version,
|
||||
AssemblyName = contract.AssemblyName
|
||||
})
|
||||
.ToList()
|
||||
?? entry.SharedContracts,
|
||||
PackageSources = resolvedPackageSources,
|
||||
Md5 = entry.Md5,
|
||||
PublishedAt = entry.PublishedAt,
|
||||
UpdatedAt = entry.UpdatedAt,
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string branch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
|
||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
return PluginManifest.Load(stream, candidateUrl);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string branch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
|
||||
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new List<string>(4);
|
||||
|
||||
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
|
||||
!string.IsNullOrWhiteSpace(cachedBranch))
|
||||
{
|
||||
candidates.Add(cachedBranch);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(defaultBranch))
|
||||
{
|
||||
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
|
||||
candidates.Add(defaultBranch);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.Add("main");
|
||||
candidates.Add("master");
|
||||
|
||||
return candidates
|
||||
.Where(branch => !string.IsNullOrWhiteSpace(branch))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<string?> TryGetDefaultBranchAsync(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
|
||||
{
|
||||
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to conventional branches.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatRepositoryKey(string owner, string repositoryName)
|
||||
{
|
||||
return $"{owner.Trim()}/{repositoryName.Trim()}";
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
|
||||
{
|
||||
foreach (var list in lists)
|
||||
{
|
||||
if (list is null || list.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = list
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private sealed record AirAppMarketRepositoryTemplate(
|
||||
string? MinHostVersion,
|
||||
string? IconUrl,
|
||||
string? ProjectUrl,
|
||||
string? ReadmeUrl,
|
||||
string? HomepageUrl,
|
||||
string? RepositoryUrl,
|
||||
List<string>? Tags,
|
||||
string? ReleaseNotes);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ public sealed class AirAppMarketIconService : IDisposable
|
||||
}
|
||||
|
||||
public async Task<Bitmap> LoadAsync(
|
||||
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
|
||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
||||
internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
private readonly AirAppMarketCacheService _cacheService;
|
||||
private readonly AirAppMarketMetadataResolverService _metadataResolver;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
||||
@@ -22,6 +23,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
||||
@@ -34,6 +36,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
|
||||
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
@@ -66,6 +69,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
||||
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
@@ -93,6 +97,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
try
|
||||
{
|
||||
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
||||
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
cachedDocument,
|
||||
@@ -124,6 +129,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_metadataResolver.Dispose();
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
||||
|
||||
internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||
|
||||
private readonly PluginRuntimeService _runtime;
|
||||
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
@@ -38,107 +41,237 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
Directory.CreateDirectory(_downloadsDirectory);
|
||||
var downloadPath = Path.Combine(
|
||||
_downloadsDirectory,
|
||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
|
||||
|
||||
try
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
|
||||
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||
var helperPath = ResolveHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
var localCopyResult = await _downloadService.DownloadAsync(
|
||||
localPackagePath,
|
||||
downloadPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||
cancellationToken: cancellationToken);
|
||||
if (!localCopyResult.Success)
|
||||
{
|
||||
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var downloadResult = await _downloadService.DownloadAsync(
|
||||
resolvedDownloadUrl,
|
||||
downloadPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||
cancellationToken: cancellationToken);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
var actualSize = new FileInfo(downloadPath).Length;
|
||||
string actualHash;
|
||||
await using (var hashStream = File.OpenRead(downloadPath))
|
||||
{
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||
File.Delete(downloadPath);
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
|
||||
$"Plugins install helper was not found at '{helperPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_downloadsDirectory);
|
||||
var sources = plugin.GetPackageSourcesInInstallOrder();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
"Plugin does not declare any package sources.");
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
|
||||
|
||||
var sourceErrors = new List<string>();
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||
if (attemptResult.Success)
|
||||
{
|
||||
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null);
|
||||
}
|
||||
|
||||
if (attemptResult.Fatal)
|
||||
{
|
||||
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage))
|
||||
{
|
||||
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
var combinedMessage = sourceErrors.Count == 0
|
||||
? $"Failed to install plugin '{plugin.Id}' from all available package sources."
|
||||
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
|
||||
return new AirAppMarketInstallResult(false, null, combinedMessage);
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attemptPath = Path.Combine(
|
||||
_downloadsDirectory,
|
||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||
AppLogger.Warn(
|
||||
"PluginMarket",
|
||||
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
|
||||
|
||||
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!acquireResult.Success)
|
||||
{
|
||||
TryDeleteFile(attemptPath);
|
||||
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
|
||||
}
|
||||
|
||||
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!verificationResult.Success)
|
||||
{
|
||||
TryDeleteFile(attemptPath);
|
||||
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
|
||||
}
|
||||
|
||||
PluginManifest manifest;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var helperResult = await _helperClient.InstallPackageAsync(
|
||||
downloadPath,
|
||||
attemptPath,
|
||||
_runtime.PluginsDirectory,
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||
{
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
helperResult.ErrorMessage ?? "Plugins install helper failed.");
|
||||
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
|
||||
}
|
||||
|
||||
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = _runtime.InstallPluginPackage(downloadPath);
|
||||
manifest = _runtime.InstallPluginPackage(attemptPath);
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
||||
return new AirAppMarketInstallResult(true, manifest, null);
|
||||
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
|
||||
return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PluginMarket",
|
||||
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
||||
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
|
||||
$"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
|
||||
ex);
|
||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
||||
TryDeleteFile(attemptPath);
|
||||
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
string resolvedDownloadUrl,
|
||||
string attemptPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||
{
|
||||
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'.");
|
||||
}
|
||||
|
||||
var localCopyResult = await _downloadService.DownloadAsync(
|
||||
localPackagePath,
|
||||
attemptPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!localCopyResult.Success)
|
||||
{
|
||||
return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage);
|
||||
}
|
||||
|
||||
return new AirAppMarketAcquisitionResult(true, null);
|
||||
}
|
||||
|
||||
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||
{
|
||||
return new AirAppMarketAcquisitionResult(
|
||||
false,
|
||||
$"Workspace package source '{source.Url}' could not be resolved to a local file.");
|
||||
}
|
||||
|
||||
var downloadResult = await _downloadService.DownloadAsync(
|
||||
resolvedDownloadUrl,
|
||||
attemptPath,
|
||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage);
|
||||
}
|
||||
|
||||
return new AirAppMarketAcquisitionResult(true, null);
|
||||
}
|
||||
|
||||
private async Task<AirAppMarketVerificationResult> VerifyPackageAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
string attemptPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actualSize = new FileInfo(attemptPath).Length;
|
||||
string actualHash;
|
||||
await using (var hashStream = File.OpenRead(attemptPath))
|
||||
{
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false);
|
||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
|
||||
{
|
||||
AppLogger.Error(
|
||||
"PluginMarket",
|
||||
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||
return new AirAppMarketVerificationResult(
|
||||
false,
|
||||
$"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);
|
||||
}
|
||||
|
||||
private static string ResolveHelperPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures for temporary install artifacts.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,4 +285,18 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
|
||||
private sealed record AirAppMarketInstallAttemptResult(
|
||||
bool Success,
|
||||
bool Fatal,
|
||||
PluginManifest? Manifest,
|
||||
string? ErrorMessage);
|
||||
|
||||
private sealed record AirAppMarketAcquisitionResult(
|
||||
bool Success,
|
||||
string? ErrorMessage);
|
||||
|
||||
private sealed record AirAppMarketVerificationResult(
|
||||
bool Success,
|
||||
string? ErrorMessage);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ internal static class AirAppMarketDefaults
|
||||
public const string DefaultIndexUrl =
|
||||
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
|
||||
|
||||
public static string BuildGitHubRawUrl(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string branch,
|
||||
string relativePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(branch);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"https://raw.githubusercontent.com/{owner.Trim()}/{repositoryName.Trim()}/{branch.Trim().TrimStart('/')}/{relativePath.Trim().TrimStart('/').Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/')}");
|
||||
}
|
||||
|
||||
public static string BuildGitHubReleaseDownloadUrl(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
@@ -39,10 +55,31 @@ internal static class AirAppMarketDefaults
|
||||
{
|
||||
localPath = string.Empty;
|
||||
|
||||
if (File.Exists(url))
|
||||
{
|
||||
localPath = Path.GetFullPath(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var fileUri) &&
|
||||
fileUri.IsFile)
|
||||
{
|
||||
var filePath = fileUri.LocalPath;
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
localPath = Path.GetFullPath(filePath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
string repositoryName;
|
||||
string relativePath;
|
||||
|
||||
if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName))
|
||||
if (TryParseWorkspaceUrl(url, out repositoryName, out relativePath))
|
||||
{
|
||||
// Already parsed from workspace://{repository}/{relativePath}.
|
||||
}
|
||||
else if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName))
|
||||
{
|
||||
relativePath = releaseAssetName;
|
||||
}
|
||||
@@ -148,6 +185,72 @@ internal static class AirAppMarketDefaults
|
||||
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath);
|
||||
}
|
||||
|
||||
private static bool TryParseWorkspaceUrl(
|
||||
string url,
|
||||
out string repositoryName,
|
||||
out string relativePath)
|
||||
{
|
||||
repositoryName = string.Empty;
|
||||
relativePath = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Scheme, "workspace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
repositoryName = uri.Host;
|
||||
var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/');
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
relativePath = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath);
|
||||
}
|
||||
|
||||
public static bool TryParsePackageSourceKind(string? value, out PluginPackageSourceKind kind)
|
||||
{
|
||||
kind = PluginPackageSourceKind.ReleaseAsset;
|
||||
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(normalized, ignoreCase: true, out kind))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (normalized)
|
||||
{
|
||||
case "releaseAsset":
|
||||
kind = PluginPackageSourceKind.ReleaseAsset;
|
||||
return true;
|
||||
case "rawFallback":
|
||||
kind = PluginPackageSourceKind.RawFallback;
|
||||
return true;
|
||||
case "workspaceLocal":
|
||||
kind = PluginPackageSourceKind.WorkspaceLocal;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetPackageSourceOrder(PluginPackageSourceKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
PluginPackageSourceKind.ReleaseAsset => 0,
|
||||
PluginPackageSourceKind.RawFallback => 1,
|
||||
PluginPackageSourceKind.WorkspaceLocal => 2,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseGitHubReleaseDownloadUrl(
|
||||
string url,
|
||||
out string repositoryName,
|
||||
@@ -475,8 +578,388 @@ internal sealed class AirAppMarketPluginDependencyEntry
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginManifestEntry
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
public string Author { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
public string ApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public string EntranceAssembly { get; init; } = string.Empty;
|
||||
|
||||
public List<AirAppMarketPluginDependencyEntry> SharedContracts { get; init; } = [];
|
||||
|
||||
public AirAppMarketPluginManifestEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
return new AirAppMarketPluginManifestEntry
|
||||
{
|
||||
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.id."),
|
||||
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.name."),
|
||||
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.description."),
|
||||
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.author."),
|
||||
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
|
||||
EntranceAssembly = AirAppMarketIndexDocument.NormalizeValue(EntranceAssembly) ?? string.Empty,
|
||||
SharedContracts = NormalizeDependencies(sourceName, SharedContracts)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
|
||||
string sourceName,
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies)
|
||||
{
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
|
||||
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dependency in dependencies ?? [])
|
||||
{
|
||||
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||
if (!seenDependencies.Add(dependencyKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' in plugin manifest.");
|
||||
}
|
||||
|
||||
normalizedDependencies.Add(normalizedDependency);
|
||||
}
|
||||
|
||||
return normalizedDependencies;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
public string MinHostVersion { get; init; } = string.Empty;
|
||||
|
||||
public string PluginApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
return new AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
MinHostVersion,
|
||||
nameof(MinHostVersion),
|
||||
sourceName),
|
||||
PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
PluginApiVersion,
|
||||
nameof(PluginApiVersion),
|
||||
sourceName)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginRepositoryEntry
|
||||
{
|
||||
public string IconUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ProjectUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ReadmeUrl { get; init; } = string.Empty;
|
||||
|
||||
public string HomepageUrl { get; init; } = string.Empty;
|
||||
|
||||
public string RepositoryUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public string ReleaseNotes { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginRepositoryEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."),
|
||||
nameof(RepositoryUrl),
|
||||
sourceName);
|
||||
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedIconUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedProjectUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedProjectUrl,
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReadmeUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedHomepageUrl))
|
||||
{
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
}
|
||||
|
||||
var normalizedTags = (Tags ?? [])
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new AirAppMarketPluginRepositoryEntry
|
||||
{
|
||||
IconUrl = normalizedIconUrl,
|
||||
ProjectUrl = normalizedProjectUrl,
|
||||
ReadmeUrl = normalizedReadmeUrl,
|
||||
HomepageUrl = normalizedHomepageUrl,
|
||||
RepositoryUrl = normalizedRepositoryUrl,
|
||||
Tags = normalizedTags,
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset;
|
||||
|
||||
public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId)
|
||||
{
|
||||
var normalizedKind = AirAppMarketIndexDocument.NormalizeValue(Kind)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing package source kind for plugin '{pluginId}'.");
|
||||
if (!AirAppMarketDefaults.TryParsePackageSourceKind(normalizedKind, out var sourceKind))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'.");
|
||||
EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId);
|
||||
|
||||
return new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = sourceKind switch
|
||||
{
|
||||
PluginPackageSourceKind.ReleaseAsset => "releaseAsset",
|
||||
PluginPackageSourceKind.RawFallback => "rawFallback",
|
||||
PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal",
|
||||
_ => normalizedKind
|
||||
},
|
||||
Url = normalizedUrl,
|
||||
SourceKind = sourceKind
|
||||
};
|
||||
}
|
||||
|
||||
internal static void EnsurePackageSourceUrl(string url, string sourceName, string pluginId)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (File.Exists(url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid package source url '{url}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
if (uri.IsFile ||
|
||||
uri.Scheme == Uri.UriSchemeHttp ||
|
||||
uri.Scheme == Uri.UriSchemeHttps ||
|
||||
string.Equals(uri.Scheme, "workspace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares unsupported package source url scheme '{uri.Scheme}' for plugin '{pluginId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginPublicationEntry
|
||||
{
|
||||
public string ReleaseTag { get; init; } = string.Empty;
|
||||
|
||||
public string ReleaseAssetName { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset PublishedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
public long PackageSizeBytes { get; init; }
|
||||
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public string Md5 { get; init; } = string.Empty;
|
||||
|
||||
public List<AirAppMarketPluginPackageSourceEntry> PackageSources { get; init; } = [];
|
||||
|
||||
public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId)
|
||||
{
|
||||
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
|
||||
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
|
||||
{
|
||||
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
normalizedReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName) ?? string.Empty;
|
||||
var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedSha256) &&
|
||||
(normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedMd5 = AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedMd5) &&
|
||||
(normalizedMd5.Length != 32 || normalizedMd5.Any(ch => !Uri.IsHexDigit(ch))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
return new AirAppMarketPluginPublicationEntry
|
||||
{
|
||||
ReleaseTag = normalizedReleaseTag,
|
||||
ReleaseAssetName = normalizedReleaseAssetName,
|
||||
PublishedAt = PublishedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
PackageSizeBytes = PackageSizeBytes,
|
||||
Sha256 = normalizedSha256,
|
||||
Md5 = normalizedMd5,
|
||||
PackageSources = normalizedPackageSources
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginPackageSourceEntry> NormalizePackageSources(
|
||||
IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources,
|
||||
string sourceName,
|
||||
string pluginId)
|
||||
{
|
||||
var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count);
|
||||
var seenKinds = new HashSet<PluginPackageSourceKind>();
|
||||
var previousOrder = -1;
|
||||
foreach (var source in packageSources ?? [])
|
||||
{
|
||||
var normalizedSource = source.ValidateAndNormalize(sourceName, pluginId);
|
||||
var order = AirAppMarketDefaults.GetPackageSourceOrder(normalizedSource.SourceKind);
|
||||
if (order < previousOrder)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares packageSources out of order for plugin '{pluginId}'. Expected releaseAsset -> rawFallback -> workspaceLocal.");
|
||||
}
|
||||
|
||||
previousOrder = order;
|
||||
if (!seenKinds.Add(normalizedSource.SourceKind))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares duplicate package source kind '{normalizedSource.Kind}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
normalizedSources.Add(normalizedSource);
|
||||
}
|
||||
|
||||
return normalizedSources;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginCapabilitiesEntry
|
||||
{
|
||||
public List<AirAppMarketPluginDependencyEntry> SharedContracts { get; init; } = [];
|
||||
|
||||
public List<string> DesktopComponents { get; init; } = [];
|
||||
|
||||
public List<string> SettingsSections { get; init; } = [];
|
||||
|
||||
public List<string> Exports { get; init; } = [];
|
||||
|
||||
public List<string> MessageTypes { get; init; } = [];
|
||||
|
||||
public AirAppMarketPluginCapabilitiesEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
return new AirAppMarketPluginCapabilitiesEntry
|
||||
{
|
||||
SharedContracts = NormalizeDependencies(sourceName, SharedContracts),
|
||||
DesktopComponents = NormalizeValues(DesktopComponents),
|
||||
SettingsSections = NormalizeValues(SettingsSections),
|
||||
Exports = NormalizeValues(Exports),
|
||||
MessageTypes = NormalizeValues(MessageTypes)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
|
||||
string sourceName,
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies)
|
||||
{
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
|
||||
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dependency in dependencies ?? [])
|
||||
{
|
||||
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||
var key = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||
if (!seenDependencies.Add(key))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares duplicate capability dependency '{key}'.");
|
||||
}
|
||||
|
||||
normalizedDependencies.Add(normalizedDependency);
|
||||
}
|
||||
|
||||
return normalizedDependencies;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeValues(IReadOnlyList<string>? values)
|
||||
{
|
||||
return (values ?? [])
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
public string PluginId { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginManifestEntry? Manifest { get; init; }
|
||||
|
||||
public AirAppMarketPluginCompatibilityEntry? Compatibility { get; init; }
|
||||
|
||||
public AirAppMarketPluginRepositoryEntry? Repository { get; init; }
|
||||
|
||||
public AirAppMarketPluginPublicationEntry? Publication { get; init; }
|
||||
|
||||
public AirAppMarketPluginCapabilitiesEntry? Capabilities { get; init; }
|
||||
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
@@ -515,6 +998,10 @@ internal sealed class AirAppMarketPluginEntry
|
||||
|
||||
public List<AirAppMarketPluginDependencyEntry> SharedContracts { get; init; } = [];
|
||||
|
||||
public List<AirAppMarketPluginPackageSourceEntry> PackageSources { get; init; } = [];
|
||||
|
||||
public string Md5 { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset PublishedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
@@ -527,133 +1014,174 @@ internal sealed class AirAppMarketPluginEntry
|
||||
|
||||
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedTags = (Tags ?? [])
|
||||
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
|
||||
var normalizedManifest = HasManifestData(Manifest)
|
||||
? Manifest!.ValidateAndNormalize(sourceName)
|
||||
: null;
|
||||
var normalizedCompatibility = HasCompatibilityData(Compatibility)
|
||||
? Compatibility!.ValidateAndNormalize(sourceName)
|
||||
: null;
|
||||
var normalizedRepository = HasRepositoryData(Repository)
|
||||
? Repository!.ValidateAndNormalize(sourceName)
|
||||
: null;
|
||||
var normalizedCapabilities = HasCapabilitiesData(Capabilities)
|
||||
? Capabilities!.ValidateAndNormalize(sourceName)
|
||||
: null;
|
||||
var resolvedPluginId = FirstNonEmpty(
|
||||
normalizedManifest?.Id,
|
||||
AirAppMarketIndexDocument.NormalizeValue(PluginId),
|
||||
AirAppMarketIndexDocument.NormalizeValue(Id))
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id.");
|
||||
var normalizedPublication = HasPublicationData(Publication)
|
||||
? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId)
|
||||
: null;
|
||||
|
||||
var resolvedPackageSources = NormalizePackageSources(
|
||||
normalizedPublication?.PackageSources ?? PackageSources,
|
||||
sourceName,
|
||||
resolvedPluginId,
|
||||
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl));
|
||||
if (resolvedPackageSources.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'.");
|
||||
}
|
||||
|
||||
var resolvedRepositoryUrl = FirstNonEmpty(
|
||||
normalizedRepository?.RepositoryUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl));
|
||||
if (string.IsNullOrWhiteSpace(resolvedRepositoryUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl.");
|
||||
}
|
||||
|
||||
var resolvedDownloadUrl = FirstNonEmpty(
|
||||
resolvedPackageSources.FirstOrDefault()?.Url,
|
||||
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedName = FirstNonEmpty(
|
||||
normalizedManifest?.Name,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Name))
|
||||
?? string.Empty;
|
||||
var resolvedDescription = FirstNonEmpty(
|
||||
normalizedManifest?.Description,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Description))
|
||||
?? string.Empty;
|
||||
var resolvedAuthor = FirstNonEmpty(
|
||||
normalizedManifest?.Author,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Author))
|
||||
?? string.Empty;
|
||||
var resolvedVersion = FirstNonEmpty(
|
||||
normalizedManifest?.Version,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Version))
|
||||
?? string.Empty;
|
||||
var resolvedApiVersion = FirstNonEmpty(
|
||||
normalizedCompatibility?.PluginApiVersion,
|
||||
normalizedManifest?.ApiVersion,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ApiVersion))
|
||||
?? string.Empty;
|
||||
var resolvedMinHostVersion = FirstNonEmpty(
|
||||
normalizedCompatibility?.MinHostVersion,
|
||||
AirAppMarketIndexDocument.NormalizeValue(MinHostVersion))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedIconUrl = FirstNonEmpty(
|
||||
normalizedRepository?.IconUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(IconUrl))
|
||||
?? string.Empty;
|
||||
var resolvedProjectUrl = FirstNonEmpty(
|
||||
normalizedRepository?.ProjectUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl))
|
||||
?? string.Empty;
|
||||
var resolvedReadmeUrl = FirstNonEmpty(
|
||||
normalizedRepository?.ReadmeUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl))
|
||||
?? string.Empty;
|
||||
var resolvedHomepageUrl = FirstNonEmpty(
|
||||
normalizedRepository?.HomepageUrl,
|
||||
AirAppMarketIndexDocument.NormalizeValue(HomepageUrl))
|
||||
?? string.Empty;
|
||||
|
||||
var resolvedReleaseTag = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseTag,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseTag))
|
||||
?? string.Empty;
|
||||
var resolvedReleaseAssetName = FirstNonEmpty(
|
||||
normalizedPublication?.ReleaseAssetName,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName))
|
||||
?? string.Empty;
|
||||
var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes;
|
||||
var resolvedSha256 = FirstNonEmpty(
|
||||
normalizedPublication?.Sha256,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant())
|
||||
?? string.Empty;
|
||||
var resolvedMd5 = FirstNonEmpty(
|
||||
normalizedPublication?.Md5,
|
||||
AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant())
|
||||
?? string.Empty;
|
||||
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt;
|
||||
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt;
|
||||
|
||||
var resolvedDependencies = NormalizeDependencies(
|
||||
normalizedManifest?.SharedContracts ?? SharedContracts,
|
||||
sourceName,
|
||||
resolvedPluginId);
|
||||
var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? [])
|
||||
.Select(AirAppMarketIndexDocument.NormalizeValue)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((SharedContracts ?? []).Count);
|
||||
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var dependency in SharedContracts ?? [])
|
||||
{
|
||||
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||
if (!seenDependencies.Add(dependencyKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
normalizedDependencies.Add(normalizedDependency);
|
||||
}
|
||||
|
||||
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
|
||||
|
||||
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
|
||||
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag);
|
||||
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName);
|
||||
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(ProjectUrl)}'.");
|
||||
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(ReadmeUrl)}'.");
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
|
||||
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
|
||||
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedProjectUrl,
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedRepositoryUrl,
|
||||
nameof(RepositoryUrl),
|
||||
sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedReleaseTag) != string.IsNullOrWhiteSpace(normalizedReleaseAssetName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
|
||||
{
|
||||
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
normalizedReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
sourceName);
|
||||
}
|
||||
|
||||
if (PackageSizeBytes <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
if (PublishedAt == default || UpdatedAt == default)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
|
||||
}
|
||||
var resolvedReleaseNotes = FirstNonEmpty(
|
||||
normalizedRepository?.ReleaseNotes,
|
||||
AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes))
|
||||
?? string.Empty;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
|
||||
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
|
||||
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
|
||||
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
|
||||
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
|
||||
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
|
||||
DownloadUrl = normalizedDownloadUrl,
|
||||
Sha256 = normalizedSha,
|
||||
PackageSizeBytes = PackageSizeBytes,
|
||||
IconUrl = normalizedIconUrl,
|
||||
ReleaseTag = normalizedReleaseTag ?? string.Empty,
|
||||
ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty,
|
||||
ProjectUrl = normalizedProjectUrl,
|
||||
ReadmeUrl = normalizedReadmeUrl,
|
||||
HomepageUrl = normalizedHomepageUrl,
|
||||
RepositoryUrl = normalizedRepositoryUrl,
|
||||
Tags = normalizedTags,
|
||||
SharedContracts = normalizedDependencies,
|
||||
PublishedAt = PublishedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
|
||||
PluginId = resolvedPluginId,
|
||||
Manifest = normalizedManifest,
|
||||
Compatibility = normalizedCompatibility,
|
||||
Repository = normalizedRepository,
|
||||
Publication = normalizedPublication,
|
||||
Capabilities = normalizedCapabilities,
|
||||
Id = resolvedPluginId,
|
||||
Name = resolvedName,
|
||||
Description = resolvedDescription,
|
||||
Author = resolvedAuthor,
|
||||
Version = resolvedVersion,
|
||||
ApiVersion = resolvedApiVersion,
|
||||
MinHostVersion = resolvedMinHostVersion,
|
||||
DownloadUrl = resolvedDownloadUrl,
|
||||
Sha256 = resolvedSha256,
|
||||
Md5 = resolvedMd5,
|
||||
PackageSizeBytes = resolvedPackageSize,
|
||||
IconUrl = resolvedIconUrl,
|
||||
ReleaseTag = resolvedReleaseTag ?? string.Empty,
|
||||
ReleaseAssetName = resolvedReleaseAssetName ?? string.Empty,
|
||||
ProjectUrl = resolvedProjectUrl,
|
||||
ReadmeUrl = resolvedReadmeUrl,
|
||||
HomepageUrl = resolvedHomepageUrl,
|
||||
RepositoryUrl = resolvedRepositoryUrl,
|
||||
Tags = resolvedTags,
|
||||
SharedContracts = resolvedDependencies,
|
||||
PackageSources = resolvedPackageSources,
|
||||
PublishedAt = resolvedPublishedAt,
|
||||
UpdatedAt = resolvedUpdatedAt,
|
||||
ReleaseNotes = resolvedReleaseNotes
|
||||
};
|
||||
}
|
||||
|
||||
public string GetVersionSummary()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Version) &&
|
||||
string.IsNullOrWhiteSpace(ApiVersion) &&
|
||||
string.IsNullOrWhiteSpace(MinHostVersion))
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"v{0} | API {1} | Host >= {2}",
|
||||
@@ -661,4 +1189,151 @@ internal sealed class AirAppMarketPluginEntry
|
||||
ApiVersion,
|
||||
MinHostVersion);
|
||||
}
|
||||
|
||||
public IReadOnlyList<AirAppMarketPluginPackageSourceEntry> GetPackageSourcesInInstallOrder()
|
||||
{
|
||||
if (PackageSources.Count > 0)
|
||||
{
|
||||
return PackageSources
|
||||
.OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DownloadUrl))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sourceKind = HasReleaseDownloadMetadata
|
||||
? PluginPackageSourceKind.ReleaseAsset
|
||||
: PluginPackageSourceKind.RawFallback;
|
||||
return
|
||||
[
|
||||
new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = sourceKind switch
|
||||
{
|
||||
PluginPackageSourceKind.ReleaseAsset => "releaseAsset",
|
||||
PluginPackageSourceKind.RawFallback => "rawFallback",
|
||||
PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal",
|
||||
_ => "rawFallback"
|
||||
},
|
||||
Url = DownloadUrl,
|
||||
SourceKind = sourceKind
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static bool HasManifestData(AirAppMarketPluginManifestEntry? manifest)
|
||||
{
|
||||
return manifest is not null &&
|
||||
(!string.IsNullOrWhiteSpace(manifest.Id) ||
|
||||
!string.IsNullOrWhiteSpace(manifest.Name) ||
|
||||
!string.IsNullOrWhiteSpace(manifest.Version));
|
||||
}
|
||||
|
||||
private static bool HasCompatibilityData(AirAppMarketPluginCompatibilityEntry? compatibility)
|
||||
{
|
||||
return compatibility is not null &&
|
||||
(!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) ||
|
||||
!string.IsNullOrWhiteSpace(compatibility.PluginApiVersion));
|
||||
}
|
||||
|
||||
private static bool HasRepositoryData(AirAppMarketPluginRepositoryEntry? repository)
|
||||
{
|
||||
return repository is not null &&
|
||||
(!string.IsNullOrWhiteSpace(repository.IconUrl) ||
|
||||
!string.IsNullOrWhiteSpace(repository.ProjectUrl) ||
|
||||
!string.IsNullOrWhiteSpace(repository.RepositoryUrl));
|
||||
}
|
||||
|
||||
private static bool HasPublicationData(AirAppMarketPluginPublicationEntry? publication)
|
||||
{
|
||||
return publication is not null &&
|
||||
(!string.IsNullOrWhiteSpace(publication.ReleaseTag) ||
|
||||
!string.IsNullOrWhiteSpace(publication.ReleaseAssetName) ||
|
||||
publication.PackageSources.Count > 0);
|
||||
}
|
||||
|
||||
private static bool HasCapabilitiesData(AirAppMarketPluginCapabilitiesEntry? capabilities)
|
||||
{
|
||||
return capabilities is not null &&
|
||||
(capabilities.SharedContracts.Count > 0 ||
|
||||
capabilities.DesktopComponents.Count > 0 ||
|
||||
capabilities.SettingsSections.Count > 0 ||
|
||||
capabilities.Exports.Count > 0 ||
|
||||
capabilities.MessageTypes.Count > 0);
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
|
||||
IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies,
|
||||
string sourceName,
|
||||
string pluginId)
|
||||
{
|
||||
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
|
||||
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dependency in dependencies ?? [])
|
||||
{
|
||||
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||
if (!seenDependencies.Add(dependencyKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
normalizedDependencies.Add(normalizedDependency);
|
||||
}
|
||||
|
||||
return normalizedDependencies;
|
||||
}
|
||||
|
||||
private static List<AirAppMarketPluginPackageSourceEntry> NormalizePackageSources(
|
||||
IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources,
|
||||
string sourceName,
|
||||
string pluginId,
|
||||
string? legacyDownloadUrl)
|
||||
{
|
||||
var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count + 1);
|
||||
foreach (var source in packageSources ?? [])
|
||||
{
|
||||
normalizedSources.Add(source.ValidateAndNormalize(sourceName, pluginId));
|
||||
}
|
||||
|
||||
if (normalizedSources.Count > 0)
|
||||
{
|
||||
return normalizedSources
|
||||
.OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl))
|
||||
{
|
||||
var legacySource = new AirAppMarketPluginPackageSourceEntry
|
||||
{
|
||||
Kind = "rawFallback",
|
||||
Url = normalizedLegacyDownloadUrl,
|
||||
SourceKind = PluginPackageSourceKind.RawFallback
|
||||
};
|
||||
normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId));
|
||||
return normalizedSources;
|
||||
}
|
||||
|
||||
return normalizedSources;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class AirAppMarketReadmeService : IDisposable
|
||||
}
|
||||
|
||||
public async Task<string> LoadAsync(
|
||||
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
|
||||
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
if (!plugin.HasReleaseDownloadMetadata)
|
||||
var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
|
||||
if (firstSource is null)
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
}
|
||||
|
||||
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string> ResolveDownloadUrlAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.SourceKind switch
|
||||
{
|
||||
PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false),
|
||||
PluginPackageSourceKind.RawFallback => source.Url,
|
||||
PluginPackageSourceKind.WorkspaceLocal => source.Url,
|
||||
_ => source.Url
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> ResolveReleaseAssetDownloadUrlAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceUrl = source.Url;
|
||||
if (!plugin.HasReleaseDownloadMetadata)
|
||||
{
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
||||
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
|
||||
try
|
||||
{
|
||||
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
||||
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
|
||||
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false);
|
||||
var asset = release?.Assets.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
|
||||
return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
return releaseDownloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"widget.display_name": "Radio Station Schedule",
|
||||
"widget.category": "Info",
|
||||
"widget.loading": "Loading schedule...",
|
||||
"widget.retry": "Retry",
|
||||
"widget.no_schedule": "No schedule data",
|
||||
"widget.network_error": "Network error",
|
||||
"settings.title": "VoiceHub Settings",
|
||||
"settings.description": "Configure radio station schedule data source and display options",
|
||||
"settings.apiUrl.title": "API URL",
|
||||
"settings.apiUrl.description": "VoiceHub backend API URL for fetching schedule data",
|
||||
"settings.showRequester.title": "Show Requester",
|
||||
"settings.showRequester.description": "Display requester information in the schedule list",
|
||||
"settings.showVoteCount.title": "Show Vote Count",
|
||||
"settings.showVoteCount.description": "Display song vote count in the schedule list",
|
||||
"settings.refreshInterval.title": "Refresh Interval",
|
||||
"settings.refreshInterval.description": "Time interval for automatic schedule data refresh"
|
||||
}
|
||||
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"widget.display_name": "广播站排期",
|
||||
"widget.category": "信息",
|
||||
"widget.loading": "正在加载排期...",
|
||||
"widget.retry": "重试",
|
||||
"widget.no_schedule": "暂无排期数据",
|
||||
"widget.network_error": "网络错误",
|
||||
"settings.title": "VoiceHub 设置",
|
||||
"settings.description": "配置广播站排期数据源和显示选项",
|
||||
"settings.apiUrl.title": "API 地址",
|
||||
"settings.apiUrl.description": "VoiceHub 后端 API 地址,用于获取排期数据",
|
||||
"settings.showRequester.title": "显示点歌人",
|
||||
"settings.showRequester.description": "在排期列表中显示点歌人信息",
|
||||
"settings.showVoteCount.title": "显示投票数",
|
||||
"settings.showVoteCount.description": "在排期列表中显示歌曲投票数",
|
||||
"settings.refreshInterval.title": "刷新间隔",
|
||||
"settings.refreshInterval.description": "自动刷新排期数据的时间间隔"
|
||||
}
|
||||
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace VoiceHubLanDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 插件设置
|
||||
/// </summary>
|
||||
public sealed class PluginSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// API 地址
|
||||
/// </summary>
|
||||
public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示点歌人
|
||||
/// </summary>
|
||||
public bool ShowRequester { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示投票数
|
||||
/// </summary>
|
||||
public bool ShowVoteCount { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新间隔(分钟)
|
||||
/// </summary>
|
||||
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||
}
|
||||
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace VoiceHubLanDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 歌曲信息
|
||||
/// </summary>
|
||||
public sealed class Song
|
||||
{
|
||||
/// <summary>
|
||||
/// 歌曲标题
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 艺术家/歌手
|
||||
/// </summary>
|
||||
[JsonPropertyName("artist")]
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 点歌人
|
||||
/// </summary>
|
||||
[JsonPropertyName("requester")]
|
||||
public string Requester { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 投票数/热度
|
||||
/// </summary>
|
||||
[JsonPropertyName("voteCount")]
|
||||
public int VoteCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 排期歌曲项目
|
||||
/// </summary>
|
||||
public sealed class SongItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 播放日期 (yyyy-MM-dd)
|
||||
/// </summary>
|
||||
[JsonPropertyName("playDate")]
|
||||
public string PlayDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 播放序号
|
||||
/// </summary>
|
||||
[JsonPropertyName("sequence")]
|
||||
public int Sequence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 歌曲信息
|
||||
/// </summary>
|
||||
[JsonPropertyName("song")]
|
||||
public Song Song { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 获取播放日期
|
||||
/// </summary>
|
||||
public DateTime GetPlayDate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||
System.Globalization.DateTimeStyles.None, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组件状态
|
||||
/// </summary>
|
||||
public enum ComponentState
|
||||
{
|
||||
/// <summary>
|
||||
/// 加载中
|
||||
/// </summary>
|
||||
Loading,
|
||||
|
||||
/// <summary>
|
||||
/// 正常显示
|
||||
/// </summary>
|
||||
Normal,
|
||||
|
||||
/// <summary>
|
||||
/// 网络错误
|
||||
/// </summary>
|
||||
NetworkError,
|
||||
|
||||
/// <summary>
|
||||
/// 暂无排期
|
||||
/// </summary>
|
||||
NoSchedule
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示数据
|
||||
/// </summary>
|
||||
public sealed class DisplayData
|
||||
{
|
||||
public ComponentState State { get; set; }
|
||||
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||
public DateTime? DisplayDate { get; set; }
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
}
|
||||
62
VoiceHubLanDesktop/README.md
Normal file
62
VoiceHubLanDesktop/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# VoiceHubLanDesktop
|
||||
|
||||
VoiceHub 广播站排期插件,用于 LanMountainDesktop 桌面应用。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📻 **排期显示**:展示 VoiceHub 广播站当日排期歌曲
|
||||
- 🔄 **自动刷新**:支持自定义刷新间隔(5分钟 ~ 2小时)
|
||||
- ⚙️ **灵活配置**:可自定义 API 地址、显示选项
|
||||
- 🌐 **多语言支持**:支持中文和英文
|
||||
|
||||
## 安装
|
||||
|
||||
将 `.laapp` 包放入 LanMountainDesktop 的插件目录:
|
||||
```
|
||||
%LocalAppData%\LanMountainDesktop\Extensions\Plugins\
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
在 LanMountainDesktop 设置中找到 "VoiceHub 设置":
|
||||
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|-----|------|--------|
|
||||
| API 地址 | VoiceHub 后端 API 地址 | `https://voicehub.lao-shui.top/api/songs/public` |
|
||||
| 显示点歌人 | 是否显示点歌人信息 | 是 |
|
||||
| 显示投票数 | 是否显示歌曲投票数 | 否 |
|
||||
| 刷新间隔 | 自动刷新时间间隔 | 1小时 |
|
||||
|
||||
## 组件规格
|
||||
|
||||
- **最小尺寸**:3 × 4 网格
|
||||
- **缩放模式**:等比例缩放
|
||||
- **放置位置**:桌面
|
||||
|
||||
## 开发
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
cd VoiceHubLanDesktop
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### 打包
|
||||
|
||||
```bash
|
||||
dotnet pack
|
||||
# 或使用脚本
|
||||
../scripts/Pack-PluginPackages.ps1
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- .NET 10
|
||||
- Avalonia UI 11.3.12
|
||||
- LanMountainDesktop.PluginSdk 4.0.0
|
||||
- CommunityToolkit.Mvvm 8.2.1
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using VoiceHubLanDesktop.Models;
|
||||
|
||||
namespace VoiceHubLanDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// VoiceHub API 服务
|
||||
/// </summary>
|
||||
public sealed class VoiceHubApiService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||
private const int MaxRetryCount = 3;
|
||||
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
public VoiceHubApiService()
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取公开排期数据
|
||||
/// </summary>
|
||||
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||
string? apiUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||
|
||||
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_requestTimeout);
|
||||
|
||||
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||
|
||||
if (items is null)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||
}
|
||||
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (attempt == MaxRetryCount - 1)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
if (attempt == MaxRetryCount - 1)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||
}
|
||||
|
||||
// 指数退避
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||
}
|
||||
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API 结果
|
||||
/// </summary>
|
||||
public sealed class ApiResult<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Data { get; }
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Data = data;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||
}
|
||||
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using VoiceHubLanDesktop.Models;
|
||||
|
||||
namespace VoiceHubLanDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 排期管理服务
|
||||
/// </summary>
|
||||
public sealed class VoiceHubScheduleService
|
||||
{
|
||||
private readonly VoiceHubApiService _apiService;
|
||||
private readonly VoiceHubSettingsService _settingsService;
|
||||
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||
private DateTime _cacheTime = DateTime.MinValue;
|
||||
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||
|
||||
public event EventHandler<ScheduleUpdatedEventArgs>? ScheduleUpdated;
|
||||
|
||||
public VoiceHubScheduleService(VoiceHubApiService apiService, VoiceHubSettingsService settingsService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取今日排期
|
||||
/// </summary>
|
||||
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = _settingsService.GetSettings();
|
||||
|
||||
// 检查缓存
|
||||
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||
{
|
||||
return BuildDisplayData(_cachedSchedule);
|
||||
}
|
||||
|
||||
// 从 API 获取
|
||||
var result = await _apiService.GetPublicScheduleAsync(settings.ApiUrl, cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NetworkError,
|
||||
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||
};
|
||||
}
|
||||
|
||||
var items = result.Data ?? [];
|
||||
|
||||
// 更新缓存
|
||||
_cachedSchedule = items;
|
||||
_cacheTime = DateTime.Now;
|
||||
|
||||
return BuildDisplayData(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强制刷新
|
||||
/// </summary>
|
||||
public async Task<DisplayData> RefreshAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_cachedSchedule = [];
|
||||
_cacheTime = DateTime.MinValue;
|
||||
return await GetTodayScheduleAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedSchedule = [];
|
||||
_cacheTime = DateTime.MinValue;
|
||||
}
|
||||
|
||||
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无排期数据"
|
||||
};
|
||||
}
|
||||
|
||||
// 过滤有效日期
|
||||
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||
|
||||
if (validItems.Count == 0)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无有效排期数据"
|
||||
};
|
||||
}
|
||||
|
||||
// 找到今天或最近未来的排期
|
||||
var today = DateTime.Today;
|
||||
var todaySchedule = validItems
|
||||
.Where(s => s.GetPlayDate() == today)
|
||||
.OrderBy(s => s.Sequence)
|
||||
.ToList();
|
||||
|
||||
List<SongItem> displayItems;
|
||||
DateTime actualDate;
|
||||
|
||||
if (todaySchedule.Count > 0)
|
||||
{
|
||||
displayItems = todaySchedule;
|
||||
actualDate = today;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 找最近的未来排期
|
||||
var futureSchedule = validItems
|
||||
.Where(s => s.GetPlayDate() > today)
|
||||
.GroupBy(s => s.GetPlayDate())
|
||||
.OrderBy(g => g.Key)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (futureSchedule != null)
|
||||
{
|
||||
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||
actualDate = futureSchedule.Key;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无排期数据"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 触发更新事件
|
||||
ScheduleUpdated?.Invoke(this, new ScheduleUpdatedEventArgs(displayItems, actualDate));
|
||||
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.Normal,
|
||||
Songs = displayItems,
|
||||
DisplayDate = actualDate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 排期更新事件参数
|
||||
/// </summary>
|
||||
public sealed class ScheduleUpdatedEventArgs : EventArgs
|
||||
{
|
||||
public IReadOnlyList<SongItem> Songs { get; }
|
||||
public DateTime DisplayDate { get; }
|
||||
|
||||
public ScheduleUpdatedEventArgs(IReadOnlyList<SongItem> songs, DateTime displayDate)
|
||||
{
|
||||
Songs = songs;
|
||||
DisplayDate = displayDate;
|
||||
}
|
||||
}
|
||||
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using VoiceHubLanDesktop.Models;
|
||||
|
||||
namespace VoiceHubLanDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 插件设置服务
|
||||
/// </summary>
|
||||
public sealed class VoiceHubSettingsService
|
||||
{
|
||||
private readonly IPluginSettingsService _settingsService;
|
||||
private const string SettingsSectionId = "voicehub-settings";
|
||||
private PluginSettings? _cachedSettings;
|
||||
|
||||
public event EventHandler<PluginSettings>? SettingsChanged;
|
||||
|
||||
public VoiceHubSettingsService(IPluginSettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取设置
|
||||
/// </summary>
|
||||
public PluginSettings GetSettings()
|
||||
{
|
||||
if (_cachedSettings != null)
|
||||
{
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
var settings = new PluginSettings();
|
||||
|
||||
try
|
||||
{
|
||||
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", SettingsSectionId);
|
||||
if (!string.IsNullOrWhiteSpace(apiUrl))
|
||||
{
|
||||
settings.ApiUrl = apiUrl;
|
||||
}
|
||||
|
||||
var showRequester = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showRequester", SettingsSectionId);
|
||||
if (showRequester.HasValue)
|
||||
{
|
||||
settings.ShowRequester = showRequester.Value;
|
||||
}
|
||||
|
||||
var showVoteCount = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showVoteCount", SettingsSectionId);
|
||||
if (showVoteCount.HasValue)
|
||||
{
|
||||
settings.ShowVoteCount = showVoteCount.Value;
|
||||
}
|
||||
|
||||
var refreshInterval = _settingsService.GetValue<string>(SettingsScope.Plugin, "refreshInterval", SettingsSectionId);
|
||||
if (!string.IsNullOrWhiteSpace(refreshInterval) && int.TryParse(refreshInterval, out var minutes))
|
||||
{
|
||||
settings.RefreshIntervalMinutes = minutes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 使用默认值
|
||||
}
|
||||
|
||||
_cachedSettings = settings;
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存设置
|
||||
/// </summary>
|
||||
public void SaveSettings(PluginSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
_settingsService.SetValue(SettingsScope.Plugin, "apiUrl", settings.ApiUrl, sectionId: SettingsSectionId);
|
||||
_settingsService.SetValue(SettingsScope.Plugin, "showRequester", settings.ShowRequester, sectionId: SettingsSectionId);
|
||||
_settingsService.SetValue(SettingsScope.Plugin, "showVoteCount", settings.ShowVoteCount, sectionId: SettingsSectionId);
|
||||
_settingsService.SetValue(SettingsScope.Plugin, "refreshInterval", settings.RefreshIntervalMinutes.ToString(), sectionId: SettingsSectionId);
|
||||
|
||||
_cachedSettings = settings;
|
||||
SettingsChanged?.Invoke(this, settings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略保存错误
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedSettings = null;
|
||||
}
|
||||
}
|
||||
52
VoiceHubLanDesktop/SongModels.cs
Normal file
52
VoiceHubLanDesktop/SongModels.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace VoiceHubLanDesktop;
|
||||
|
||||
/// <summary>
|
||||
/// 歌曲信息
|
||||
/// </summary>
|
||||
public sealed class Song
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artist")]
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("requester")]
|
||||
public string Requester { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("voteCount")]
|
||||
public int VoteCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 排期歌曲项目
|
||||
/// </summary>
|
||||
public sealed class SongItem
|
||||
{
|
||||
[JsonPropertyName("playDate")]
|
||||
public string PlayDate { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public int Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("song")]
|
||||
public Song Song { get; set; } = new();
|
||||
|
||||
public DateTime GetPlayDate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||
System.Globalization.DateTimeStyles.None, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
@@ -0,0 +1,144 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="400"
|
||||
x:Class="VoiceHubLanDesktop.Views.VoiceHubScheduleControl"
|
||||
x:DataType="VoiceHubLanDesktop.Views.VoiceHubScheduleControl">
|
||||
|
||||
<Design.DataContext>
|
||||
<VoiceHubLanDesktop.Views.VoiceHubScheduleControl/>
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 标题栏 -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
Padding="12,8"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
|
||||
<TextBlock Text="{Binding TitleText}"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding DateText}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<Grid Grid.Row="1">
|
||||
<!-- 加载状态 -->
|
||||
<StackPanel x:Name="LoadingPanel"
|
||||
Orientation="Vertical"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12"
|
||||
IsVisible="{Binding IsLoading}">
|
||||
<ProgressBar IsIndeterminate="True"
|
||||
Width="100"
|
||||
Height="4"/>
|
||||
<TextBlock Text="正在加载排期..."
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 排期列表 -->
|
||||
<ScrollViewer x:Name="SchedulePanel"
|
||||
IsVisible="{Binding IsNormal}"
|
||||
Padding="8,8,8,8">
|
||||
<ItemsControl ItemsSource="{Binding Songs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="12,10"
|
||||
Margin="0,0,0,8">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<!-- 序号 -->
|
||||
<Border Grid.Column="0"
|
||||
Background="{DynamicResource SystemAccentColor}"
|
||||
CornerRadius="12"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Sequence}"
|
||||
FontSize="11"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="{Binding Song.Title}"
|
||||
FontSize="14"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"/>
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1">
|
||||
<Run Text="{Binding Song.Artist}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StackPanel x:Name="EmptyPanel"
|
||||
Orientation="Vertical"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding IsEmpty}">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="48"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"/>
|
||||
<TextBlock Text="{Binding EmptyMessage}"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<StackPanel x:Name="ErrorPanel"
|
||||
Orientation="Vertical"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding IsError}">
|
||||
<TextBlock Text=""
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="48"
|
||||
Foreground="#FFB00020"/>
|
||||
<TextBlock Text="{Binding ErrorMessage}"
|
||||
FontSize="14"
|
||||
Foreground="#FFB00020"
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="200"
|
||||
TextAlignment="Center"/>
|
||||
<Button Content="重试"
|
||||
Command="{Binding RetryCommand}"
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using VoiceHubLanDesktop.Models;
|
||||
using VoiceHubLanDesktop.Services;
|
||||
|
||||
namespace VoiceHubLanDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 广播站排期显示组件
|
||||
/// </summary>
|
||||
public sealed partial class VoiceHubScheduleControl : UserControl
|
||||
{
|
||||
private readonly VoiceHubScheduleService _scheduleService;
|
||||
private readonly VoiceHubSettingsService _settingsService;
|
||||
private readonly DispatcherTimer? _refreshTimer;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
public ObservableCollection<SongItem> Songs { get; } = [];
|
||||
|
||||
[ObservableProperty] private string _titleText = "广播站排期";
|
||||
[ObservableProperty] private string _dateText = "";
|
||||
[ObservableProperty] private string _emptyMessage = "暂无排期数据";
|
||||
[ObservableProperty] private string _errorMessage = "";
|
||||
[ObservableProperty] private bool _isLoading = true;
|
||||
[ObservableProperty] private bool _isNormal = false;
|
||||
[ObservableProperty] private bool _isEmpty = false;
|
||||
[ObservableProperty] private bool _isError = false;
|
||||
|
||||
public VoiceHubScheduleControl(
|
||||
VoiceHubScheduleService scheduleService,
|
||||
VoiceHubSettingsService settingsService,
|
||||
IPluginRuntimeContext runtimeContext)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = this;
|
||||
|
||||
_scheduleService = scheduleService;
|
||||
_settingsService = settingsService;
|
||||
|
||||
// 设置刷新定时器
|
||||
var settings = _settingsService.GetSettings();
|
||||
_refreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes)
|
||||
};
|
||||
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||
_refreshTimer.Start();
|
||||
|
||||
// 监听设置变化
|
||||
_settingsService.SettingsChanged += OnSettingsChanged;
|
||||
|
||||
// 初始加载
|
||||
_ = LoadAsync();
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PluginSettings settings)
|
||||
{
|
||||
if (_refreshTimer != null)
|
||||
{
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes);
|
||||
}
|
||||
_scheduleService.ClearCache();
|
||||
_ = RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
SetState(ComponentState.Loading);
|
||||
|
||||
try
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts = new CancellationTokenSource();
|
||||
|
||||
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyDisplayData(displayData);
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
SetState(ComponentState.NetworkError, $"加载失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDisplayData(DisplayData data)
|
||||
{
|
||||
switch (data.State)
|
||||
{
|
||||
case ComponentState.Normal:
|
||||
Songs.Clear();
|
||||
foreach (var song in data.Songs)
|
||||
{
|
||||
Songs.Add(song);
|
||||
}
|
||||
DateText = data.DisplayDate?.ToString("MM月dd日") ?? "";
|
||||
SetState(ComponentState.Normal);
|
||||
break;
|
||||
|
||||
case ComponentState.NoSchedule:
|
||||
EmptyMessage = data.ErrorMessage ?? "暂无排期数据";
|
||||
SetState(ComponentState.NoSchedule);
|
||||
break;
|
||||
|
||||
case ComponentState.NetworkError:
|
||||
SetState(ComponentState.NetworkError, data.ErrorMessage ?? "网络错误");
|
||||
break;
|
||||
|
||||
default:
|
||||
SetState(ComponentState.Loading);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetState(ComponentState state, string? message = null)
|
||||
{
|
||||
IsLoading = state == ComponentState.Loading;
|
||||
IsNormal = state == ComponentState.Normal;
|
||||
IsEmpty = state == ComponentState.NoSchedule;
|
||||
IsError = state == ComponentState.NetworkError;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
if (state == ComponentState.NetworkError)
|
||||
{
|
||||
ErrorMessage = message;
|
||||
}
|
||||
else if (state == ComponentState.NoSchedule)
|
||||
{
|
||||
EmptyMessage = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RetryAsync()
|
||||
{
|
||||
_scheduleService.ClearCache();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
|
||||
_refreshTimer?.Stop();
|
||||
_loadCts?.Cancel();
|
||||
_settingsService.SettingsChanged -= OnSettingsChanged;
|
||||
}
|
||||
}
|
||||
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VoiceHubLanDesktop;
|
||||
|
||||
/// <summary>
|
||||
/// VoiceHub API 服务
|
||||
/// </summary>
|
||||
public sealed class VoiceHubApiService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||
private const int MaxRetryCount = 3;
|
||||
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
public VoiceHubApiService()
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||
string? apiUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||
|
||||
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_requestTimeout);
|
||||
|
||||
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||
|
||||
if (items is null)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||
}
|
||||
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (attempt == MaxRetryCount - 1)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
if (attempt == MaxRetryCount - 1)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||
}
|
||||
|
||||
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||
}
|
||||
|
||||
public void Dispose() => _httpClient.Dispose();
|
||||
}
|
||||
|
||||
public sealed class ApiResult<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Data { get; }
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Data = data;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||
}
|
||||
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<LanMountainPluginBuildOutputDirectory>$(OutputPath)</LanMountainPluginBuildOutputDirectory>
|
||||
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||
<LanMountainPluginPackageOutputDirectory>$(MSBuildThisFileDirectory)</LanMountainPluginPackageOutputDirectory>
|
||||
<LanMountainPluginPackageExtension>.laapp</LanMountainPluginPackageExtension>
|
||||
<LanMountainPluginPackageFileName>$(AssemblyName).$(LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</LanMountainPluginPackageFileName>
|
||||
<LanMountainPluginPackagePath>$(LanMountainPluginPackageOutputDirectory)$(LanMountainPluginPackageFileName)</LanMountainPluginPackagePath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace VoiceHubLanDesktop;
|
||||
|
||||
/// <summary>
|
||||
/// VoiceHub 广播站排期插件入口
|
||||
/// </summary>
|
||||
[PluginEntrance]
|
||||
public sealed class VoiceHubPlugin : PluginBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var localizer = CreateLocalizer(context);
|
||||
|
||||
// 注册服务
|
||||
services.AddSingleton<VoiceHubApiService>();
|
||||
services.AddSingleton<VoiceHubScheduleService>();
|
||||
|
||||
// 注册桌面组件 - 最小 3x4 网格,允许等比例缩放
|
||||
services.AddPluginDesktopComponent<VoiceHubScheduleWidget>(
|
||||
CreateScheduleComponentOptions(localizer));
|
||||
|
||||
// 注册设置页面
|
||||
services.AddPluginSettingsSection(
|
||||
id: "voicehub-settings",
|
||||
titleLocalizationKey: "settings.title",
|
||||
configure: builder =>
|
||||
{
|
||||
builder.AddText(
|
||||
key: "apiUrl",
|
||||
titleLocalizationKey: "settings.apiUrl.title",
|
||||
descriptionLocalizationKey: "settings.apiUrl.description",
|
||||
defaultValue: "https://voicehub.lao-shui.top/api/songs/public");
|
||||
|
||||
builder.AddBoolean(
|
||||
key: "showRequester",
|
||||
titleLocalizationKey: "settings.showRequester.title",
|
||||
descriptionLocalizationKey: "settings.showRequester.description",
|
||||
defaultValue: true);
|
||||
|
||||
builder.AddBoolean(
|
||||
key: "showVoteCount",
|
||||
titleLocalizationKey: "settings.showVoteCount.title",
|
||||
descriptionLocalizationKey: "settings.showVoteCount.description",
|
||||
defaultValue: false);
|
||||
|
||||
builder.AddSelection(
|
||||
key: "refreshInterval",
|
||||
titleLocalizationKey: "settings.refreshInterval.title",
|
||||
descriptionLocalizationKey: "settings.refreshInterval.description",
|
||||
defaultValue: "60",
|
||||
choices:
|
||||
[
|
||||
new SettingsOptionChoice("5分钟", "5"),
|
||||
new SettingsOptionChoice("15分钟", "15"),
|
||||
new SettingsOptionChoice("30分钟", "30"),
|
||||
new SettingsOptionChoice("1小时", "60"),
|
||||
new SettingsOptionChoice("2小时", "120")
|
||||
]);
|
||||
},
|
||||
descriptionLocalizationKey: "settings.description",
|
||||
iconKey: "Settings",
|
||||
sortOrder: 0);
|
||||
}
|
||||
|
||||
private static PluginLocalizer CreateLocalizer(HostBuilderContext context)
|
||||
{
|
||||
var pluginDirectory = context.Properties.TryGetValue("LanMountainDesktop.PluginDirectory", out var directoryValue) &&
|
||||
directoryValue is string resolvedPluginDirectory &&
|
||||
!string.IsNullOrWhiteSpace(resolvedPluginDirectory)
|
||||
? resolvedPluginDirectory
|
||||
: AppContext.BaseDirectory;
|
||||
|
||||
var properties = context.Properties
|
||||
.Where(pair => pair.Key is string)
|
||||
.ToDictionary(pair => (string)pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new PluginLocalizer(pluginDirectory, PluginLocalizer.ResolveLanguageCode(properties));
|
||||
}
|
||||
|
||||
private static PluginDesktopComponentOptions CreateScheduleComponentOptions(PluginLocalizer localizer)
|
||||
{
|
||||
return new PluginDesktopComponentOptions
|
||||
{
|
||||
ComponentId = "com.voicehub.schedule",
|
||||
DisplayName = localizer.GetString("widget.display_name", "广播站排期"),
|
||||
DisplayNameLocalizationKey = "widget.display_name",
|
||||
IconKey = "Radio",
|
||||
Category = localizer.GetString("widget.category", "信息"),
|
||||
MinWidthCells = 3,
|
||||
MinHeightCells = 4,
|
||||
AllowDesktopPlacement = true,
|
||||
AllowStatusBarPlacement = false,
|
||||
ResizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
CornerRadiusPreset = PluginCornerRadiusPreset.Default
|
||||
};
|
||||
}
|
||||
}
|
||||
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace VoiceHubLanDesktop;
|
||||
|
||||
/// <summary>
|
||||
/// 排期管理服务
|
||||
/// </summary>
|
||||
public sealed class VoiceHubScheduleService
|
||||
{
|
||||
private readonly VoiceHubApiService _apiService;
|
||||
private readonly IPluginSettingsService _settingsService;
|
||||
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||
private DateTime _cacheTime = DateTime.MinValue;
|
||||
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||
|
||||
private const string SettingsSectionId = "voicehub-settings";
|
||||
|
||||
public VoiceHubScheduleService(VoiceHubApiService apiService, IPluginSettingsService settingsService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiUrl = GetApiUrl();
|
||||
|
||||
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||
{
|
||||
return BuildDisplayData(_cachedSchedule);
|
||||
}
|
||||
|
||||
var result = await _apiService.GetPublicScheduleAsync(apiUrl, cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NetworkError,
|
||||
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||
};
|
||||
}
|
||||
|
||||
var items = result.Data ?? [];
|
||||
_cachedSchedule = items;
|
||||
_cacheTime = DateTime.Now;
|
||||
|
||||
return BuildDisplayData(items);
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedSchedule = [];
|
||||
_cacheTime = DateTime.MinValue;
|
||||
}
|
||||
|
||||
private string GetApiUrl()
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", sectionId: SettingsSectionId);
|
||||
return string.IsNullOrWhiteSpace(apiUrl)
|
||||
? "https://voicehub.lao-shui.top/api/songs/public"
|
||||
: apiUrl;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "https://voicehub.lao-shui.top/api/songs/public";
|
||||
}
|
||||
}
|
||||
|
||||
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无排期数据"
|
||||
};
|
||||
}
|
||||
|
||||
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||
|
||||
if (validItems.Count == 0)
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无有效排期数据"
|
||||
};
|
||||
}
|
||||
|
||||
var today = DateTime.Today;
|
||||
var todaySchedule = validItems
|
||||
.Where(s => s.GetPlayDate() == today)
|
||||
.OrderBy(s => s.Sequence)
|
||||
.ToList();
|
||||
|
||||
List<SongItem> displayItems;
|
||||
DateTime actualDate;
|
||||
|
||||
if (todaySchedule.Count > 0)
|
||||
{
|
||||
displayItems = todaySchedule;
|
||||
actualDate = today;
|
||||
}
|
||||
else
|
||||
{
|
||||
var futureSchedule = validItems
|
||||
.Where(s => s.GetPlayDate() > today)
|
||||
.GroupBy(s => s.GetPlayDate())
|
||||
.OrderBy(g => g.Key)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (futureSchedule != null)
|
||||
{
|
||||
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||
actualDate = futureSchedule.Key;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.NoSchedule,
|
||||
ErrorMessage = "暂无排期数据"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new DisplayData
|
||||
{
|
||||
State = ComponentState.Normal,
|
||||
Songs = displayItems,
|
||||
DisplayDate = actualDate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum ComponentState
|
||||
{
|
||||
Loading,
|
||||
Normal,
|
||||
NetworkError,
|
||||
NoSchedule
|
||||
}
|
||||
|
||||
public sealed class DisplayData
|
||||
{
|
||||
public ComponentState State { get; set; }
|
||||
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||
public DateTime? DisplayDate { get; set; }
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
}
|
||||
393
VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
Normal file
393
VoiceHubLanDesktop/VoiceHubScheduleWidget.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace VoiceHubLanDesktop;
|
||||
|
||||
/// <summary>
|
||||
/// 广播站排期显示组件
|
||||
/// </summary>
|
||||
internal sealed class VoiceHubScheduleWidget : Border
|
||||
{
|
||||
private readonly PluginDesktopComponentContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly VoiceHubScheduleService _scheduleService;
|
||||
private readonly PluginAppearanceSnapshot? _appearanceSnapshot;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _dateTextBlock;
|
||||
private readonly StackPanel _contentPanel;
|
||||
private readonly StackPanel _loadingPanel;
|
||||
private readonly StackPanel _errorPanel;
|
||||
private readonly DispatcherTimer? _refreshTimer;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
public VoiceHubScheduleWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_scheduleService = context.GetService<VoiceHubScheduleService>()
|
||||
?? throw new InvalidOperationException("VoiceHubScheduleService is not available.");
|
||||
_appearanceSnapshot = context.GetAppearanceSnapshot();
|
||||
|
||||
// 创建 UI 元素
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.Bold,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_dateTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_contentPanel = new StackPanel
|
||||
{
|
||||
Spacing = 8
|
||||
};
|
||||
|
||||
_loadingPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new ProgressBar
|
||||
{
|
||||
IsIndeterminate = true,
|
||||
Width = 100,
|
||||
Height = 4
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = T("widget.loading", "正在加载排期..."),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_errorPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Spacing = 8
|
||||
};
|
||||
|
||||
// 设置背景和边框
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||
BorderThickness = new Thickness(1);
|
||||
|
||||
// 构建主布局
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 12,
|
||||
Children =
|
||||
{
|
||||
// 标题栏
|
||||
new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(12, 8),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "📻",
|
||||
FontSize = 16,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
_titleTextBlock,
|
||||
_dateTextBlock
|
||||
}
|
||||
}
|
||||
},
|
||||
// 内容区域
|
||||
new ScrollViewer
|
||||
{
|
||||
Padding = new Thickness(8),
|
||||
Content = _contentPanel
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||
|
||||
// 设置刷新定时器
|
||||
var refreshInterval = GetRefreshInterval();
|
||||
_refreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(refreshInterval)
|
||||
};
|
||||
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||
|
||||
// 事件处理
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
// 初始化显示
|
||||
SetTitle();
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_refreshTimer?.Start();
|
||||
_ = LoadAsync();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_refreshTimer?.Stop();
|
||||
_loadCts?.Cancel();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
ShowLoading();
|
||||
|
||||
try
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts = new CancellationTokenSource();
|
||||
|
||||
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyDisplayData(displayData);
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ShowError($"加载失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDisplayData(DisplayData data)
|
||||
{
|
||||
switch (data.State)
|
||||
{
|
||||
case ComponentState.Normal:
|
||||
ShowContent(data);
|
||||
break;
|
||||
case ComponentState.NoSchedule:
|
||||
ShowError(data.ErrorMessage ?? "暂无排期数据");
|
||||
break;
|
||||
case ComponentState.NetworkError:
|
||||
ShowError(data.ErrorMessage ?? "网络错误");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowLoading()
|
||||
{
|
||||
if (Child is not Grid mainGrid) return;
|
||||
mainGrid.Children[1] = _loadingPanel;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
_errorPanel.Children.Clear();
|
||||
_errorPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "⚠️",
|
||||
FontSize = 48,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFF87171"))
|
||||
});
|
||||
_errorPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFF87171")),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxWidth = 200,
|
||||
TextAlignment = TextAlignment.Center
|
||||
});
|
||||
_errorPanel.Children.Add(new Button
|
||||
{
|
||||
Content = T("widget.retry", "重试"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(0, 8, 0, 0)
|
||||
});
|
||||
|
||||
var retryButton = (Button)_errorPanel.Children[2];
|
||||
retryButton.Click += async (_, _) => await RefreshAsync();
|
||||
|
||||
if (Child is not Grid mainGrid) return;
|
||||
mainGrid.Children[1] = _errorPanel;
|
||||
}
|
||||
|
||||
private void ShowContent(DisplayData data)
|
||||
{
|
||||
_contentPanel.Children.Clear();
|
||||
|
||||
var basis = GetLayoutBasis();
|
||||
var titleSize = Math.Clamp(basis * 0.055, 12, 16);
|
||||
var detailSize = Math.Clamp(basis * 0.045, 10, 13);
|
||||
|
||||
foreach (var item in data.Songs)
|
||||
{
|
||||
var card = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
|
||||
PluginCornerRadiusPreset.Md,
|
||||
new CornerRadius(8)),
|
||||
Padding = new Thickness(12, 10),
|
||||
Child = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 12,
|
||||
Children =
|
||||
{
|
||||
// 序号
|
||||
new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = item.Sequence.ToString(),
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = Brushes.White,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
},
|
||||
// 歌曲信息
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.Song.Title,
|
||||
FontSize = titleSize,
|
||||
FontWeight = FontWeight.Medium,
|
||||
Foreground = Brushes.White,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"{item.Song.Artist}",
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetColumn(((Grid)card.Child!).Children[1], 1);
|
||||
_contentPanel.Children.Add(card);
|
||||
}
|
||||
|
||||
// 更新日期显示
|
||||
_dateTextBlock.Text = data.DisplayDate?.ToString("MM月dd日") ?? "";
|
||||
|
||||
if (Child is not Grid mainGrid) return;
|
||||
mainGrid.Children[1] = new ScrollViewer
|
||||
{
|
||||
Padding = new Thickness(8),
|
||||
Content = _contentPanel
|
||||
};
|
||||
}
|
||||
|
||||
private void SetTitle()
|
||||
{
|
||||
_titleTextBlock.Text = T("widget.display_name", "广播站排期");
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = GetLayoutBasis();
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.06, 10, 18));
|
||||
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
|
||||
PluginCornerRadiusPreset.Island,
|
||||
new CornerRadius(Math.Clamp(basis * 0.12, 16, 28)));
|
||||
_titleTextBlock.FontSize = Math.Clamp(basis * 0.065, 12, 16);
|
||||
_dateTextBlock.FontSize = Math.Clamp(basis * 0.05, 10, 13);
|
||||
}
|
||||
|
||||
private double GetLayoutBasis()
|
||||
{
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 3;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||
return Math.Max(_context.CellSize * 3, Math.Min(width, height));
|
||||
}
|
||||
|
||||
private int GetRefreshInterval()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interval = _context.GetService<IPluginSettingsService>()
|
||||
?.GetValue<string>(SettingsScope.Plugin, "refreshInterval", sectionId: "voicehub-settings");
|
||||
if (!string.IsNullOrWhiteSpace(interval) && int.TryParse(interval, out var minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return 60;
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
_scheduleService.ClearCache();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
}
|
||||
10
VoiceHubLanDesktop/plugin.json
Normal file
10
VoiceHubLanDesktop/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "com.voicehub.landesktop",
|
||||
"name": "VoiceHub 广播站排期",
|
||||
"description": "展示 VoiceHub 广播站当日排期歌曲,按播放顺序显示歌曲信息",
|
||||
"author": "VoiceHub",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.0",
|
||||
"entranceAssembly": "VoiceHubLanDesktop.dll",
|
||||
"sharedContracts": []
|
||||
}
|
||||
Reference in New Issue
Block a user