Compare commits

...

5 Commits

Author SHA1 Message Date
lincube
372b5b7adc 0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
2026-03-25 11:27:30 +08:00
lincube
74703582e7 0.7.8.1
凤凰网新闻组件优化、央广网新闻组件优化。
2026-03-25 07:44:55 +08:00
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
56 changed files with 4952 additions and 853 deletions

View File

@@ -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";

View File

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

View File

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

View File

@@ -418,6 +418,11 @@
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
"settings.update.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.",

View File

@@ -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": "ロード済み",

View File

@@ -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": "로드됨",

View File

@@ -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": "已加载",

View File

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

View File

@@ -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())

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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()

View File

@@ -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"

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

@@ -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
{

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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}"

View File

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

View File

@@ -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>

View File

@@ -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"

View File

@@ -0,0 +1,391 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketMetadataResolverService : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public async Task<AirAppMarketIndexDocument> EnrichAsync(
AirAppMarketIndexDocument document,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
if (document.Plugins.Count == 0)
{
return document;
}
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
foreach (var plugin in document.Plugins)
{
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
}
return new AirAppMarketIndexDocument
{
SchemaVersion = document.SchemaVersion,
SourceId = document.SourceId,
SourceName = document.SourceName,
GeneratedAt = document.GeneratedAt,
Contracts = document.Contracts,
Plugins = enrichedPlugins
};
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
AirAppMarketPluginEntry entry,
CancellationToken cancellationToken)
{
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
{
return entry;
}
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
PluginManifest? manifest = null;
AirAppMarketRepositoryTemplate? template = null;
foreach (var branch in branchCandidates)
{
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
if (manifest is not null && template is not null)
{
break;
}
}
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
var resolvedManifest = manifest;
var resolvedPackageSources = entry.PackageSources.Count > 0
? entry.PackageSources
: entry.Publication?.PackageSources ?? [];
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
return new AirAppMarketPluginEntry
{
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
Manifest = resolvedManifest is null
? entry.Manifest
: new AirAppMarketPluginManifestEntry
{
Id = resolvedManifest.Id,
Name = resolvedManifest.Name,
Description = resolvedManifest.Description ?? string.Empty,
Author = resolvedManifest.Author ?? string.Empty,
Version = resolvedManifest.Version ?? string.Empty,
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
EntranceAssembly = resolvedManifest.EntranceAssembly,
SharedContracts = resolvedManifest.SharedContracts?
.Select(contract => new AirAppMarketPluginDependencyEntry
{
Id = contract.Id,
Version = contract.Version,
AssemblyName = contract.AssemblyName
})
.ToList()
?? []
},
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
? new AirAppMarketPluginCompatibilityEntry
{
MinHostVersion = FirstNonEmpty(
template?.MinHostVersion,
entry.MinHostVersion),
PluginApiVersion = FirstNonEmpty(
resolvedManifest?.ApiVersion,
entry.ApiVersion)
?? string.Empty
}
: null,
Repository = new AirAppMarketPluginRepositoryEntry
{
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
?? string.Empty,
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
},
Publication = entry.Publication,
Capabilities = entry.Capabilities,
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
Sha256 = entry.Sha256,
PackageSizeBytes = entry.PackageSizeBytes,
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
ReleaseTag = entry.ReleaseTag,
ReleaseAssetName = entry.ReleaseAssetName,
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
?? string.Empty,
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
SharedContracts = resolvedManifest?.SharedContracts
?.Select(contract => new AirAppMarketPluginDependencyEntry
{
Id = contract.Id,
Version = contract.Version,
AssemblyName = contract.AssemblyName
})
.ToList()
?? entry.SharedContracts,
PackageSources = resolvedPackageSources,
Md5 = entry.Md5,
PublishedAt = entry.PublishedAt,
UpdatedAt = entry.UpdatedAt,
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
};
}
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
string owner,
string repositoryName,
string branch,
CancellationToken cancellationToken)
{
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
if (text is null)
{
return null;
}
try
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
return PluginManifest.Load(stream, candidateUrl);
}
catch
{
return null;
}
}
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
string owner,
string repositoryName,
string branch,
CancellationToken cancellationToken)
{
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
if (text is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
}
catch
{
return null;
}
}
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
string owner,
string repositoryName,
CancellationToken cancellationToken)
{
var candidates = new List<string>(4);
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
!string.IsNullOrWhiteSpace(cachedBranch))
{
candidates.Add(cachedBranch);
}
else
{
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(defaultBranch))
{
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
candidates.Add(defaultBranch);
}
}
candidates.Add("main");
candidates.Add("master");
return candidates
.Where(branch => !string.IsNullOrWhiteSpace(branch))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<string?> TryGetDefaultBranchAsync(
string owner,
string repositoryName,
CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var document = JsonDocument.Parse(responseText);
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
{
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
}
}
catch
{
// Fallback to conventional branches.
}
return null;
}
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
{
try
{
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
private static string FormatRepositoryKey(string owner, string repositoryName)
{
return $"{owner.Trim()}/{repositoryName.Trim()}";
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
return null;
}
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
{
foreach (var list in lists)
{
if (list is null || list.Count == 0)
{
continue;
}
var normalized = list
.Select(AirAppMarketIndexDocument.NormalizeValue)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count > 0)
{
return normalized;
}
}
return [];
}
private sealed record AirAppMarketRepositoryTemplate(
string? MinHostVersion,
string? IconUrl,
string? ProjectUrl,
string? ReadmeUrl,
string? HomepageUrl,
string? RepositoryUrl,
List<string>? Tags,
string? ReleaseNotes);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
}

View 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": "自动刷新排期数据的时间间隔"
}

View 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;
}

View 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;
}

View 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

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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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="&#xE7D1;"
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="&#xE7E5;"
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="&#xE783;"
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>

View 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;
}
}

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

View 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>

View 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
};
}
}

View 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;
}

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

View 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": []
}