Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions WinPieGestures/I18n.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "未署名");
Expand Down Expand Up @@ -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}");
Expand Down
128 changes: 128 additions & 0 deletions WinPieGestures/Plugin/OfficialPluginClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> TypeClaims { get; set; } = new();
public List<string> 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<string> Tags { get; set; } = new();
public List<OfficialPluginFeature> 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
{
Expand Down Expand Up @@ -92,9 +116,104 @@ public static async Task<OfficialPluginCatalog> 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<OfficialPluginCatalogCache>(
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<string> FetchLatestReleaseTagAsync(CancellationToken cancellationToken)
{
string feed = await Http.GetStringAsync(ReleasesFeedUrl, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -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<string>(StringComparer.OrdinalIgnoreCase);
foreach (OfficialPluginFeature feature in module.Features ?? new List<OfficialPluginFeature>())
{
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}。");
}
}
}
}
Expand Down
10 changes: 7 additions & 3 deletions WinPieGestures/Plugin/OfficialPluginListItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
176 changes: 176 additions & 0 deletions WinPieGestures/Plugin/PluginDetailModel.cs
Original file line number Diff line number Diff line change
@@ -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<PluginFeatureItem> Features { get; init; } = Array.Empty<PluginFeatureItem>();

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<PluginFeatureItem> 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<string>()),
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<PluginFeatureItem> features = local?.Features.Count > 0
? local.Features
: (module.Features ?? new List<OfficialPluginFeature>()).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<PluginFeatureItem> Read(PluginInstance instance)
{
try
{
string manifestPath = Path.Combine(instance.Directory, PluginPaths.ManifestFileName);
if (!File.Exists(manifestPath)) return new List<PluginFeatureItem>();

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

var result = new List<PluginFeatureItem>();
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<PluginFeatureItem>();
}
}

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