From 9d8d853ed4e7668da86eb01f9a5e86e66743f185 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=BA=E5=95=86=E7=84=97=E8=92=9F=E9=95=BF?=
<104508795+ztzpro@users.noreply.github.com>
Date: Wed, 30 Sep 2026 08:01:55 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(plugin):=20=E4=BF=AE=E5=A4=8D=E5=BC=80?=
=?UTF-8?q?=E6=9C=BA=E9=A2=84=E5=8A=A0=E8=BD=BD=E5=8B=BE=E9=80=89=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E4=B8=8D=E5=90=8C=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 通过 PluginHost 统一更新预加载持久化状态与运行时插件实例状态
- 修复刷新插件列表后“开机预加载”复选框被重置为未勾选的问题
- 确保后续启动预加载扫描能够读取最新的用户设置
---
WinPieGestures/Plugin/PluginHost.cs | 23 +++++++++++++++++++++++
WinPieGestures/SettingsWindow.xaml.cs | 2 +-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/WinPieGestures/Plugin/PluginHost.cs b/WinPieGestures/Plugin/PluginHost.cs
index 70db529d..72d750b1 100644
--- a/WinPieGestures/Plugin/PluginHost.cs
+++ b/WinPieGestures/Plugin/PluginHost.cs
@@ -401,6 +401,29 @@ public static bool Enable(string pluginId, out string error)
return true;
}
+ ///
+ /// 设置插件的启动预加载偏好,并同步当前运行时实例。
+ ///
+ /// PluginRegistryStore 负责持久化,而运行中的
+ /// 还持有一份登记快照。两者必须同时更新,否则设置页刷新时会从旧快照读回
+ /// IsPreload = false,并且后续的预加载扫描也会漏掉刚勾选的插件。
+ ///
+ ///
+ public static void SetPreload(string pluginId, bool preload)
+ {
+ if (string.IsNullOrWhiteSpace(pluginId)) return;
+
+ PluginRegistryStore.SetPreload(pluginId, preload);
+
+ lock (Gate)
+ {
+ if (Instances.TryGetValue(pluginId, out PluginInstance? instance))
+ {
+ instance.Entry.Preload = preload;
+ }
+ }
+ }
+
/// 是否需要重启宿主才能释放该插件的旧运行时。
public static bool IsRestartRequired(string pluginId)
{
diff --git a/WinPieGestures/SettingsWindow.xaml.cs b/WinPieGestures/SettingsWindow.xaml.cs
index 92c3f058..052fbe58 100644
--- a/WinPieGestures/SettingsWindow.xaml.cs
+++ b/WinPieGestures/SettingsWindow.xaml.cs
@@ -8083,7 +8083,7 @@ private void PluginRowPreloadCheckBox_Click(object sender, RoutedEventArgs e)
}
bool desired = box.IsChecked == true;
- PluginRegistryStore.SetPreload(pluginId, desired);
+ PluginHost.SetPreload(pluginId, desired);
if (desired && !PluginHost.PreloadNow(pluginId, out string preloadError))
{
System.Windows.MessageBox.Show(this,
From 5b91f1850feb3a90a9b889b23318dcb739cafca9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=BA=E5=95=86=E7=84=97=E8=92=9F=E9=95=BF?=
<104508795+ztzpro@users.noreply.github.com>
Date: Wed, 30 Sep 2026 08:03:53 +0800
Subject: [PATCH 2/2] =?UTF-8?q?feat(plugin):=20=E5=A2=9E=E5=8A=A0=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=8E=E5=AE=98=E6=96=B9?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 为官方插件和本地已安装插件增加统一的详情入口,展示功能描述、功能列表与插件信息
- 从本地 plugin.json 或官方 catalog 读取静态功能元数据,未加载插件时不触发程序集加载
- 在用户主动刷新或引导安装后缓存官方目录,重新打开设置页时可离线读取
- 显示官方目录的缓存更新时间,并兼容缺少新增详情字段的旧 catalog
---
WinPieGestures/I18n.cs | 18 ++
WinPieGestures/Plugin/OfficialPluginClient.cs | 128 +++++++++++++
.../Plugin/OfficialPluginListItem.cs | 10 +-
WinPieGestures/Plugin/PluginDetailModel.cs | 176 ++++++++++++++++++
WinPieGestures/Plugin/PluginListItem.cs | 4 +
WinPieGestures/Plugin/PluginPaths.cs | 3 +
WinPieGestures/PluginDetailWindow.xaml | 60 ++++++
WinPieGestures/PluginDetailWindow.xaml.cs | 52 ++++++
WinPieGestures/SettingsWindow.xaml | 26 +--
WinPieGestures/SettingsWindow.xaml.cs | 61 ++++++
10 files changed, 524 insertions(+), 14 deletions(-)
create mode 100644 WinPieGestures/Plugin/PluginDetailModel.cs
create mode 100644 WinPieGestures/PluginDetailWindow.xaml
create mode 100644 WinPieGestures/PluginDetailWindow.xaml.cs
diff --git a/WinPieGestures/I18n.cs b/WinPieGestures/I18n.cs
index 40b4534e..2c9039dd 100644
--- a/WinPieGestures/I18n.cs
+++ b/WinPieGestures/I18n.cs
@@ -2064,6 +2064,23 @@ void Add(string key, string zhCn, string? zhTw = null, string? en = null, string
Add("PluginsRestartTitle", "重启 StarPie", "重新啟動 StarPie", "Restart StarPie", "StarPie を再起動");
Add("PluginsRestartFailed", "重启 StarPie 失败:{0}", "重新啟動 StarPie 失敗:{0}", "Failed to restart StarPie: {0}", "StarPie の再起動に失敗しました:{0}");
Add("PluginsCardSettingsButton", "⚙ 设置", "⚙ 設定", "⚙ Settings", "⚙ 設定");
+ Add("PluginsDetailButton", "详情", "詳細資料", "Details", "詳細");
+ Add("PluginsDetailTitle", "插件详情", "外掛詳細資料", "Plugin details", "プラグイン詳細");
+ Add("PluginsDetailDescription", "功能描述", "功能描述", "Description", "機能説明");
+ Add("PluginsDetailFeatures", "包含功能", "包含功能", "Included features", "含まれる機能");
+ Add("PluginsDetailCapabilities", "所需权限", "所需權限", "Required capabilities", "必要な権限");
+ Add("PluginsDetailMetadata", "插件信息", "外掛資訊", "Plugin information", "プラグイン情報");
+ Add("PluginsDetailAuthor", "作者", "作者", "Author", "作者");
+ Add("PluginsDetailLicense", "许可证", "授權條款", "License", "ライセンス");
+ Add("PluginsDetailVersion", "版本", "版本", "Version", "バージョン");
+ Add("PluginsDetailSource", "来源", "來源", "Source", "提供元");
+ Add("PluginsDetailPluginId", "插件 ID", "外掛 ID", "Plugin ID", "プラグイン ID");
+ Add("PluginsDetailTargetFramework", "运行框架", "執行框架", "Target framework", "ターゲット フレームワーク");
+ Add("PluginsDetailInstallPath", "安装路径", "安裝路徑", "Install path", "インストール パス");
+ Add("PluginsDetailDependencies", "插件依赖", "外掛相依", "Dependencies", "依存関係");
+ Add("PluginsDetailNoFeatures", "该插件暂未声明具体功能列表。", "此插件尚未宣告具體功能列表。", "This plugin has not declared a detailed feature list.", "このプラグインは詳細な機能一覧を宣言していません。");
+ Add("PluginsDetailSourceOfficial", "官方插件", "官方外掛", "Official plugin", "公式プラグイン");
+ Add("PluginsDetailSourceLocal", "本地安装", "本機安裝", "Local installation", "ローカルインストール");
Add("PluginsCardSigned", "已签名", "已簽章", "signed", "署名済み");
Add("PluginsCardUninstallButton", "🗑 卸载", "🗑 解除安裝", "🗑 Uninstall", "🗑 アンインストール");
Add("PluginsCardUnsigned", "未签名", "未簽章", "unsigned", "未署名");
@@ -2120,6 +2137,7 @@ void Add(string key, string zhCn, string? zhTw = null, string? en = null, string
Add("PluginsOfficialActionInstalled", "已安装", "已安裝", "Installed", "インストール済み");
Add("PluginsOfficialActionUpdate", "⬆️ 更新", "⬆️ 更新", "⬆️ Update", "⬆️ 更新");
Add("PluginsOfficialCatalogInfo", "目录 {0} · {1} 个模块 · 来源 StarPie-Official-Plugins", "目錄 {0} · {1} 個模組 · 來源 StarPie-Official-Plugins", "Catalog {0} · {1} modules · from StarPie-Official-Plugins", "カタログ {0} · {1} モジュール · 提供元 StarPie-Official-Plugins");
+ Add("PluginsOfficialCatalogCached", "官方插件目录:已缓存 · 更新于 {0} · {1} 个模块", "官方外掛目錄:已快取 · 更新於 {0} · {1} 個模組", "Official plugin catalog: cached · updated {0} · {1} modules", "公式プラグインカタログ:キャッシュ済み · 更新 {0} · {1} モジュール");
Add("PluginsOfficialClaimSeparator", "、", "、", ", ", "、");
Add("PluginsOfficialHeader", "官方插件", "官方外掛", "Official plugins", "公式プラグイン");
Add("PluginsOfficialInstallFailed", "官方插件 {0} 安装失败:\n\n{1}", "官方外掛 {0} 安裝失敗:\n\n{1}", "Failed to install official plugin {0}:\n\n{1}", "公式プラグイン {0} のインストールに失敗しました:\n\n{1}");
diff --git a/WinPieGestures/Plugin/OfficialPluginClient.cs b/WinPieGestures/Plugin/OfficialPluginClient.cs
index 8a9de722..c8865dce 100644
--- a/WinPieGestures/Plugin/OfficialPluginClient.cs
+++ b/WinPieGestures/Plugin/OfficialPluginClient.cs
@@ -5,6 +5,7 @@
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -37,12 +38,35 @@ internal sealed class OfficialPluginModule
public string Sha256 { get; set; } = "";
public long Size { get; set; }
public string ApiVersion { get; set; } = "";
+ public string TargetFramework { get; set; } = "";
public string MinHostVersion { get; set; } = "";
public string? MaxHostVersion { get; set; }
public List TypeClaims { get; set; } = new();
public List Capabilities { get; set; } = new();
+ public string Description { get; set; } = "";
+ public string Author { get; set; } = "";
+ public string? Homepage { get; set; }
+ public string License { get; set; } = "";
+ public string? Icon { get; set; }
+ public List Tags { get; set; } = new();
+ public List Features { get; set; } = new();
}
+internal sealed class OfficialPluginFeature
+{
+ public string Id { get; set; } = "";
+ public string Name { get; set; } = "";
+ public string Description { get; set; } = "";
+ public string Category { get; set; } = "";
+ public string? IconKey { get; set; }
+}
+
+internal sealed class OfficialPluginCatalogCache
+{
+ public int CacheVersion { get; set; } = 1;
+ public DateTimeOffset CachedAt { get; set; }
+ public OfficialPluginCatalog Catalog { get; set; } = new();
+}
internal sealed class OfficialPluginInstallResult
{
@@ -92,9 +116,104 @@ public static async Task FetchCatalogAsync(CancellationTo
ValidateCatalog(catalog);
if (!string.Equals(catalog!.ReleaseTag, releaseTag, StringComparison.Ordinal))
throw new InvalidDataException($"官方 catalog 的 releaseTag 与发布标签不一致:{catalog.ReleaseTag} / {releaseTag}。");
+
+ // 下载成功后写入本地缓存。缓存失败不应把成功的目录刷新误报成失败。
+ try
+ {
+ SaveCachedCatalog(catalog);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogWarn($"[plugin] 保存官方插件目录缓存失败:{ex.Message}");
+ }
+
return catalog;
}
+ public static bool TryLoadCachedCatalog(
+ out OfficialPluginCatalog catalog,
+ out DateTimeOffset cachedAt,
+ out string error)
+ {
+ catalog = null!;
+ cachedAt = default;
+ error = "";
+
+ string path = PluginPaths.OfficialCatalogCacheFile;
+ if (!File.Exists(path)) return false;
+
+ try
+ {
+ OfficialPluginCatalogCache? cache = JsonSerializer.Deserialize(
+ File.ReadAllText(path), JsonOptions);
+ if (cache?.Catalog == null)
+ {
+ error = "官方插件目录缓存为空。";
+ return false;
+ }
+ if (cache.CacheVersion != 1)
+ {
+ error = "官方插件目录缓存版本不受支持。";
+ return false;
+ }
+
+ ValidateCatalog(cache.Catalog);
+ catalog = cache.Catalog;
+ cachedAt = cache.CachedAt == default
+ ? new DateTimeOffset(File.GetLastWriteTimeUtc(path))
+ : cache.CachedAt;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+
+ public static DateTimeOffset? GetCachedCatalogTimestamp()
+ {
+ try
+ {
+ string path = PluginPaths.OfficialCatalogCacheFile;
+ return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path)) : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static void SaveCachedCatalog(OfficialPluginCatalog catalog)
+ {
+ Directory.CreateDirectory(PluginPaths.Root);
+ string target = PluginPaths.OfficialCatalogCacheFile;
+ string temporary = target + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ var cache = new OfficialPluginCatalogCache
+ {
+ CacheVersion = 1,
+ CachedAt = DateTimeOffset.Now,
+ Catalog = catalog,
+ };
+
+ try
+ {
+ string json = JsonSerializer.Serialize(cache, JsonOptions);
+ File.WriteAllText(temporary, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ File.Move(temporary, target, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ catch
+ {
+ }
+ }
+ }
+
private static async Task FetchLatestReleaseTagAsync(CancellationToken cancellationToken)
{
string feed = await Http.GetStringAsync(ReleasesFeedUrl, cancellationToken).ConfigureAwait(false);
@@ -374,6 +493,15 @@ private static void ValidateCatalog(OfficialPluginCatalog? catalog)
throw new InvalidDataException($"插件 {module.Id} 的包大小不合法。");
if (module.Sha256.Length != 64 || !module.Sha256.All(Uri.IsHexDigit))
throw new InvalidDataException($"插件 {module.Id} 的 SHA-256 不合法。");
+
+ var featureIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (OfficialPluginFeature feature in module.Features ?? new List())
+ {
+ if (string.IsNullOrWhiteSpace(feature.Id) || string.IsNullOrWhiteSpace(feature.Name))
+ throw new InvalidDataException($"插件 {module.Id} 的功能条目缺少 id 或 name。");
+ if (!featureIds.Add(feature.Id))
+ throw new InvalidDataException($"插件 {module.Id} 含有重复功能:{feature.Id}。");
+ }
}
}
}
diff --git a/WinPieGestures/Plugin/OfficialPluginListItem.cs b/WinPieGestures/Plugin/OfficialPluginListItem.cs
index c6f0d945..566a1825 100644
--- a/WinPieGestures/Plugin/OfficialPluginListItem.cs
+++ b/WinPieGestures/Plugin/OfficialPluginListItem.cs
@@ -12,17 +12,21 @@ internal sealed class OfficialPluginListItem
public OfficialPluginModule Module { get; }
public string DisplayName => Module.Name;
public string VersionText => string.IsNullOrWhiteSpace(Module.Version) ? "" : $"v{Module.Version}";
- public string SummaryText => $"{Module.Id} | {(Module.TypeClaims.Count == 0
- ? I18n.T("PluginsOfficialSummaryFallback")
- : string.Join(I18n.T("PluginsOfficialClaimSeparator"), Module.TypeClaims))}";
+ public string SummaryText => !string.IsNullOrWhiteSpace(Module.Description)
+ ? Module.Description
+ : $"{Module.Id} | {(Module.TypeClaims.Count == 0
+ ? I18n.T("PluginsOfficialSummaryFallback")
+ : string.Join(I18n.T("PluginsOfficialClaimSeparator"), Module.TypeClaims))}";
public string StateText { get; }
public string InstallButtonText { get; }
+ public string DetailsText { get; }
public bool CanInstall { get; }
public bool IsInstalled { get; }
public OfficialPluginListItem(OfficialPluginModule module, string? installedVersion)
{
Module = module;
+ DetailsText = I18n.T("PluginsDetailButton");
IsInstalled = !string.IsNullOrWhiteSpace(installedVersion);
if (!IsInstalled)
{
diff --git a/WinPieGestures/Plugin/PluginDetailModel.cs b/WinPieGestures/Plugin/PluginDetailModel.cs
new file mode 100644
index 00000000..cee2b134
--- /dev/null
+++ b/WinPieGestures/Plugin/PluginDetailModel.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using StarPie.Plugin;
+
+namespace WinPieGestures.Plugins;
+
+internal sealed class PluginFeatureItem
+{
+ public string Id { get; init; } = "";
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+ public string Category { get; init; } = "";
+ public string? IconKey { get; init; }
+}
+
+internal sealed class PluginDetailModel
+{
+ public string DisplayName { get; init; } = "";
+ public string VersionText { get; init; } = "";
+ public string Description { get; init; } = "";
+ public string Author { get; init; } = "";
+ public string License { get; init; } = "";
+ public string Homepage { get; init; } = "";
+ public string PluginId { get; init; } = "";
+ public string SourceText { get; init; } = "";
+ public string StateText { get; init; } = "";
+ public string TargetFramework { get; init; } = "";
+ public string CapabilitiesText { get; init; } = "";
+ public string DependenciesText { get; init; } = "";
+ public string InstallPath { get; init; } = "";
+ public string Sha256Text { get; init; } = "";
+ public IReadOnlyList Features { get; init; } = Array.Empty();
+
+ public bool HasFeatures => Features.Count > 0;
+ public bool HasCapabilities => !string.IsNullOrWhiteSpace(CapabilitiesText);
+ public bool HasDependencies => !string.IsNullOrWhiteSpace(DependenciesText);
+}
+
+internal static class PluginDetailModelBuilder
+{
+ public static PluginDetailModel FromLocal(PluginInstance instance)
+ {
+ PluginManifest? manifest = instance.Scan.Manifest;
+ List features = PluginFeatureReader.Read(instance);
+
+ if (features.Count == 0)
+ {
+ features = PluginHost.Catalog.SnapshotActions()
+ .Where(action => string.Equals(action.PluginId, instance.PluginId, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(action => action.Category, StringComparer.CurrentCultureIgnoreCase)
+ .ThenBy(action => action.DisplayName, StringComparer.CurrentCultureIgnoreCase)
+ .Select(action => new PluginFeatureItem
+ {
+ Id = action.ShortId,
+ Name = action.DisplayName,
+ Description = action.Description,
+ Category = action.Category,
+ IconKey = action.IconKey,
+ })
+ .ToList();
+ }
+
+ return new PluginDetailModel
+ {
+ DisplayName = !string.IsNullOrWhiteSpace(instance.Entry.Name)
+ ? instance.Entry.Name
+ : (manifest?.Name ?? instance.PluginId),
+ VersionText = string.IsNullOrWhiteSpace(instance.Entry.Version) ? "" : $"v{instance.Entry.Version}",
+ Description = !string.IsNullOrWhiteSpace(manifest?.Description) ? manifest!.Description : instance.Entry.Description,
+ Author = !string.IsNullOrWhiteSpace(manifest?.Author) ? manifest!.Author : instance.Entry.Author,
+ License = !string.IsNullOrWhiteSpace(manifest?.License) ? manifest!.License : instance.Entry.License,
+ Homepage = manifest?.Homepage ?? "",
+ PluginId = instance.PluginId,
+ SourceText = instance.Entry.Official ? I18n.T("PluginsDetailSourceOfficial") : I18n.T("PluginsDetailSourceLocal"),
+ StateText = PluginListItem.DescribeState(instance).Text,
+ TargetFramework = instance.Scan.TargetFramework,
+ CapabilitiesText = string.Join(I18n.T("PluginsEnumSeparator"), manifest?.Capabilities ?? new List()),
+ DependenciesText = manifest?.Dependencies == null
+ ? ""
+ : string.Join(I18n.T("PluginsEnumSeparator"), manifest.Dependencies.Select(dependency =>
+ string.IsNullOrWhiteSpace(dependency.VersionRange) ? dependency.Id : $"{dependency.Id} {dependency.VersionRange}")),
+ InstallPath = instance.Directory,
+ Sha256Text = instance.Scan.Sha256,
+ Features = features,
+ };
+ }
+
+ public static PluginDetailModel FromOfficial(OfficialPluginModule module, PluginInstance? installed)
+ {
+ PluginDetailModel? local = installed == null ? null : FromLocal(installed);
+ IReadOnlyList features = local?.Features.Count > 0
+ ? local.Features
+ : (module.Features ?? new List()).Select(feature => new PluginFeatureItem
+ {
+ Id = feature.Id,
+ Name = feature.Name,
+ Description = feature.Description,
+ Category = feature.Category,
+ IconKey = feature.IconKey,
+ }).ToList();
+
+ return new PluginDetailModel
+ {
+ DisplayName = !string.IsNullOrWhiteSpace(module.Name) ? module.Name : (local?.DisplayName ?? module.Id),
+ VersionText = string.IsNullOrWhiteSpace(module.Version) ? (local?.VersionText ?? "") : $"v{module.Version}",
+ Description = !string.IsNullOrWhiteSpace(module.Description) ? module.Description : (local?.Description ?? ""),
+ Author = !string.IsNullOrWhiteSpace(module.Author) ? module.Author : (local?.Author ?? ""),
+ License = !string.IsNullOrWhiteSpace(module.License) ? module.License : (local?.License ?? ""),
+ Homepage = module.Homepage ?? local?.Homepage ?? "",
+ PluginId = module.Id,
+ SourceText = I18n.T("PluginsDetailSourceOfficial"),
+ StateText = local?.StateText ?? I18n.T("PluginsOfficialStateNotInstalled"),
+ TargetFramework = local?.TargetFramework ?? module.TargetFramework,
+ CapabilitiesText = string.Join(I18n.T("PluginsEnumSeparator"), module.Capabilities),
+ DependenciesText = local?.DependenciesText ?? "",
+ InstallPath = local?.InstallPath ?? "",
+ Sha256Text = string.IsNullOrWhiteSpace(module.Sha256) ? (local?.Sha256Text ?? "") : module.Sha256,
+ Features = features,
+ };
+ }
+}
+
+internal static class PluginFeatureReader
+{
+ public static List Read(PluginInstance instance)
+ {
+ try
+ {
+ string manifestPath = Path.Combine(instance.Directory, PluginPaths.ManifestFileName);
+ if (!File.Exists(manifestPath)) return new List();
+
+ using JsonDocument document = JsonDocument.Parse(File.ReadAllText(manifestPath), new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+ if (!document.RootElement.TryGetProperty("features", out JsonElement features) || features.ValueKind != JsonValueKind.Array)
+ return new List();
+
+ var result = new List();
+ foreach (JsonElement item in features.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object) continue;
+ string id = ReadString(item, "id");
+ string name = ReadString(item, "name");
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) continue;
+ result.Add(new PluginFeatureItem
+ {
+ Id = id,
+ Name = name,
+ Description = ReadString(item, "description"),
+ Category = ReadString(item, "category"),
+ IconKey = ReadNullableString(item, "iconKey"),
+ });
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogWarn($"[plugin] 读取插件功能清单失败 {instance.PluginId}:{ex.Message}");
+ return new List();
+ }
+ }
+
+ private static string ReadString(JsonElement element, string name) =>
+ element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
+
+ private static string? ReadNullableString(JsonElement element, string name)
+ {
+ string value = ReadString(element, name);
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+}
diff --git a/WinPieGestures/Plugin/PluginListItem.cs b/WinPieGestures/Plugin/PluginListItem.cs
index 52f80ba4..90ef9198 100644
--- a/WinPieGestures/Plugin/PluginListItem.cs
+++ b/WinPieGestures/Plugin/PluginListItem.cs
@@ -56,6 +56,9 @@ internal sealed class PluginListItem
/// 卡片上「设置」按钮的文字。同 的绑定理由。
public string SettingsText { get; init; } = "";
+ /// 卡片上「详情」按钮的文字。
+ public string DetailsText { get; init; } = "";
+
///
/// 该插件是否声明了插件级参数页(SDK 1.6)。为假时卡片上不出现「设置」按钮。
///
@@ -146,6 +149,7 @@ public static PluginListItem Build(PluginInstance instance)
UninstallText = I18n.T("PluginsCardUninstallButton"),
PreloadText = I18n.T("PluginsCardPreloadCheckBox"),
SettingsText = I18n.T("PluginsCardSettingsButton"),
+ DetailsText = I18n.T("PluginsDetailButton"),
HasSettingsPage = PluginSettingsPageService.HasPage(instance.PluginId),
ErrorText = errorText,
IsEnabled = entry.Enabled,
diff --git a/WinPieGestures/Plugin/PluginPaths.cs b/WinPieGestures/Plugin/PluginPaths.cs
index 8a0f866b..d0fa1412 100644
--- a/WinPieGestures/Plugin/PluginPaths.cs
+++ b/WinPieGestures/Plugin/PluginPaths.cs
@@ -78,6 +78,9 @@ public static bool ScanRootExists
/// 宿主运维数据:失败计数 / 安全模式标记。
public static string HealthFile => Path.Combine(Root, "health.json");
+ /// 官方插件 catalog 的本地缓存。网络只在用户主动刷新或安装引导时访问。
+ public static string OfficialCatalogCacheFile => Path.Combine(Root, "module-catalog.json");
+
/// 插件日志目录(与主日志同根,按插件分文件)。
public static string LogDirectory => Path.Combine(AppLogger.GetLogFolderPath(), "plugins");
diff --git a/WinPieGestures/PluginDetailWindow.xaml b/WinPieGestures/PluginDetailWindow.xaml
new file mode 100644
index 00000000..7e2ed8f2
--- /dev/null
+++ b/WinPieGestures/PluginDetailWindow.xaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WinPieGestures/PluginDetailWindow.xaml.cs b/WinPieGestures/PluginDetailWindow.xaml.cs
new file mode 100644
index 00000000..8c875638
--- /dev/null
+++ b/WinPieGestures/PluginDetailWindow.xaml.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Windows;
+using WinPieGestures.Plugins;
+
+namespace WinPieGestures;
+
+public partial class PluginDetailWindow : Window
+{
+ private readonly PluginDetailModel _model;
+
+ internal PluginDetailWindow(PluginDetailModel model)
+ {
+ _model = model ?? throw new ArgumentNullException(nameof(model));
+ InitializeComponent();
+ AppThemeManager.ApplyTheme(this, ConfigManager.CurrentConfig?.AppTheme ?? "System");
+ RenderModel();
+ }
+
+ private void RenderModel()
+ {
+ Title = $"{I18n.T("PluginsDetailTitle")} - {_model.DisplayName}";
+ NameText.Text = $"{_model.DisplayName} {_model.VersionText}".Trim();
+ StatusText.Text = $"{_model.StateText} · {_model.SourceText}";
+ DescriptionHeaderText.Text = I18n.T("PluginsDetailDescription");
+ DescriptionText.Text = string.IsNullOrWhiteSpace(_model.Description) ? I18n.T("PluginsOfficialSummaryFallback") : _model.Description;
+ FeaturesHeaderText.Text = I18n.T("PluginsDetailFeatures");
+ FeaturesItemsControl.ItemsSource = _model.Features;
+ NoFeaturesText.Text = I18n.T("PluginsDetailNoFeatures");
+ NoFeaturesText.Visibility = _model.HasFeatures ? Visibility.Collapsed : Visibility.Visible;
+ CapabilitiesHeaderText.Text = I18n.T("PluginsDetailCapabilities");
+ CapabilitiesText.Text = _model.HasCapabilities ? _model.CapabilitiesText : I18n.T("PluginsDetailNoFeatures");
+ MetadataHeaderText.Text = I18n.T("PluginsDetailMetadata");
+ AuthorLabelText.Text = I18n.T("PluginsDetailAuthor");
+ LicenseLabelText.Text = I18n.T("PluginsDetailLicense");
+ VersionLabelText.Text = I18n.T("PluginsDetailVersion");
+ SourceLabelText.Text = I18n.T("PluginsDetailSource");
+ PluginIdLabelText.Text = I18n.T("PluginsDetailPluginId");
+ TargetFrameworkLabelText.Text = I18n.T("PluginsDetailTargetFramework");
+ DependenciesLabelText.Text = I18n.T("PluginsDetailDependencies");
+ InstallPathLabelText.Text = I18n.T("PluginsDetailInstallPath");
+ AuthorValueText.Text = _model.Author;
+ LicenseValueText.Text = _model.License;
+ VersionValueText.Text = _model.VersionText;
+ SourceValueText.Text = _model.SourceText;
+ PluginIdValueText.Text = _model.PluginId;
+ TargetFrameworkValueText.Text = _model.TargetFramework;
+ DependenciesValueText.Text = _model.HasDependencies ? _model.DependenciesText : "—";
+ InstallPathValueText.Text = string.IsNullOrWhiteSpace(_model.InstallPath) ? "—" : _model.InstallPath;
+ }
+
+ private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/WinPieGestures/SettingsWindow.xaml b/WinPieGestures/SettingsWindow.xaml
index 08e45107..03840510 100644
--- a/WinPieGestures/SettingsWindow.xaml
+++ b/WinPieGestures/SettingsWindow.xaml
@@ -3875,17 +3875,20 @@
-
+
+
+
+
@@ -4009,6 +4012,7 @@
+