优化天气胶囊拖动性能与插件示例迁移
完成天气胶囊在高 DPI 和窄窗口下的逐级降级布局,减少窗口拖动期间的主题与材质重建。新增三个停用的 manifest v3 插件示例、Tauri 伴随程序和构建哈希校验;旧版插件自动回收到可恢复目录。安装器升级成功后递归清理旧静态资源空目录,并补充相关文档和测试。
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
/Release/
|
||||
**/dist/
|
||||
**/node_modules/
|
||||
src/YMhut.Box.PluginEcho/plugin-echo/
|
||||
src/box-winUI/AppPackages/
|
||||
server/update/build/
|
||||
server/update/public/downloads/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 从旧清单迁移到 manifest v3
|
||||
|
||||
客户端扫描到旧清单后会立即停用插件,并将目录移动到 `data/PluginRecycle/legacy-<id>-<时间>-<随机值>`。请从该可恢复副本升级清单后,再作为新包导入;旧授权、KV 与 WebView 缓存不会继承。
|
||||
|
||||
1. 添加 `manifestVersion: 3`、`apiVersion: "2"` 和 `runtime: "WebView"`。
|
||||
2. 为 `permissions` 中每项权限添加非空 `permissionReasons`。
|
||||
3. 将启动必需权限放入 `security.requiredPermissions`,其余保持可选。
|
||||
|
||||
@@ -88,4 +88,4 @@ await window.ymhut.output.set(response.content);
|
||||
|
||||
`WebView` 是默认和正式运行时。Tauri 独立窗口仅使用应用自带宿主,默认关闭;它要求插件开发者模式、`ExternalRuntime` 授权和按插件版本保存的二次确认。Tauri 插件内容运行在独立的 `https://p-<hash>.localhost` 原点和插件私有 WebView2 数据目录中,应用宿主页只通过一次性、当前用户命名管道把 Bridge 请求转发给同一个权限宿主。会话令牌仅存在于原生宿主内,不进入插件 JavaScript。插件包不能携带或构建原生可执行文件,也不能访问 Tauri 全局 API。
|
||||
|
||||
旧清单自动降级为 `LegacyWebOnly`:本地 Web 内容可运行,Bridge、远程连接和外接运行时全部关闭。迁移步骤见 [MIGRATION-v3.md](MIGRATION-v3.md),完整边界见 [SECURITY.md](SECURITY.md),TypeScript 声明见 [ymhut.d.ts](ymhut.d.ts)。
|
||||
`manifestVersion < 3` 的旧清单不再运行。扫描时插件会立即停用、清除授权和 WebView 缓存,并移动到 `data/PluginRecycle/legacy-<id>-<时间>-<随机值>` 以便恢复;迁移失败时保持隐藏并在下次扫描重试。缺失或损坏的清单不会被自动删除。升级步骤见 [MIGRATION-v3.md](MIGRATION-v3.md),完整边界见 [SECURITY.md](SECURITY.md),TypeScript 声明见 [ymhut.d.ts](ymhut.d.ts)。
|
||||
|
||||
@@ -1130,6 +1130,82 @@ begin
|
||||
#endif
|
||||
end;
|
||||
|
||||
function IsPreservedDirectoryTree(const RelativePath: string): Boolean;
|
||||
var
|
||||
Normalized: string;
|
||||
begin
|
||||
Normalized := LowerCase(Trim(RelativePath));
|
||||
Result :=
|
||||
(Normalized = 'data') or (Pos('data\', Normalized) = 1) or
|
||||
(Normalized = 'feedbackpackages') or (Pos('feedbackpackages\', Normalized) = 1) or
|
||||
(Normalized = 'config') or (Pos('config\', Normalized) = 1) or
|
||||
(Normalized = 'runtime\uninstall-engine') or
|
||||
(Pos('runtime\uninstall-engine\', Normalized) = 1);
|
||||
end;
|
||||
|
||||
function ManifestRequiresDirectory(const RelativePath: string): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
DirectoryPrefix: string;
|
||||
Item: string;
|
||||
begin
|
||||
Result := False;
|
||||
DirectoryPrefix := LowerCase(RemoveBackslashUnlessRoot(RelativePath)) + '\';
|
||||
for I := 0 to NewPayloadFiles.Count - 1 do
|
||||
begin
|
||||
Item := LowerCase(NewPayloadFiles[I]);
|
||||
if Pos(DirectoryPrefix, Item) = 1 then
|
||||
begin
|
||||
Result := True;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
function IsInstallDirectoryPath(const AppDir, Candidate: string): Boolean;
|
||||
var
|
||||
RootPath: string;
|
||||
CandidatePath: string;
|
||||
begin
|
||||
RootPath := LowerCase(AddBackslash(RemoveBackslashUnlessRoot(AppDir)));
|
||||
CandidatePath := LowerCase(AddBackslash(RemoveBackslashUnlessRoot(Candidate)));
|
||||
Result := Pos(RootPath, CandidatePath) = 1;
|
||||
end;
|
||||
|
||||
procedure RemoveEmptyInstallDirectories(const AppDir, CurrentDir: string);
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
FullPath: string;
|
||||
RelativePath: string;
|
||||
begin
|
||||
if (not DirExists(CurrentDir)) or (not IsInstallDirectoryPath(AppDir, CurrentDir)) then
|
||||
Exit;
|
||||
|
||||
if FindFirst(AddBackslash(CurrentDir) + '*', FindRec) then
|
||||
begin
|
||||
try
|
||||
repeat
|
||||
if (FindRec.Name <> '.') and (FindRec.Name <> '..') and
|
||||
((FindRec.Attributes and DirectoryAttribute) <> 0) then
|
||||
begin
|
||||
FullPath := AddBackslash(CurrentDir) + FindRec.Name;
|
||||
RelativePath := RelativeInstallPath(AppDir, FullPath);
|
||||
if (RelativePath <> '') and
|
||||
((FindRec.Attributes and $400) = 0) and
|
||||
(not IsPreservedDirectoryTree(RelativePath)) then
|
||||
begin
|
||||
RemoveEmptyInstallDirectories(AppDir, FullPath);
|
||||
if (not ManifestRequiresDirectory(RelativePath)) and RemoveDir(FullPath) then
|
||||
Log('Removed empty obsolete install directory: ' + RelativePath);
|
||||
end;
|
||||
end;
|
||||
until not FindNext(FindRec);
|
||||
finally
|
||||
FindClose(FindRec);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure FinalizeLegacyInstallLayout();
|
||||
var
|
||||
AppDir: string;
|
||||
@@ -1140,6 +1216,7 @@ begin
|
||||
DeleteKnownObsoletePayload(AppDir)
|
||||
else
|
||||
QuarantineUnknownLegacyFiles(AppDir, AppDir);
|
||||
RemoveEmptyInstallDirectories(AppDir, AppDir);
|
||||
CleanLegacyRuntimeAndCache();
|
||||
end;
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# IPCheck manifest v3 安全示例
|
||||
# Bridge 能力 manifest v3 示例
|
||||
|
||||
该内置插件演示 manifest v3、必需/可选权限、精确公网 HTTPS 来源和 `PluginHostProtocol v2` Bridge。页面和脚本全部随插件本地发布,不依赖远程代码。
|
||||
|
||||
+10
-2
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IPCheck 安全网络概览</title>
|
||||
<title>Bridge 能力示例</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div><p class="eyebrow">MANIFEST V3 SAMPLE</p><h1>IPCheck 安全网络概览</h1><p id="summary">等待检测</p></div>
|
||||
<div><p class="eyebrow">MANIFEST V3 SAMPLE</p><h1>Bridge 能力示例</h1><p id="summary">等待检测</p></div>
|
||||
<button id="refresh">刷新</button>
|
||||
</header>
|
||||
<section class="hero"><span>公网 IP</span><strong id="ip">--</strong><small id="location">未读取</small></section>
|
||||
@@ -20,6 +20,14 @@
|
||||
<article><span>活动接口</span><strong id="interfaces">未授权</strong></article>
|
||||
</section>
|
||||
<pre id="details">尚无结果</pre>
|
||||
<section class="bridge-panel">
|
||||
<label for="input">输入备注</label>
|
||||
<input id="input" type="text" maxlength="120" placeholder="仅在你主动输入时保存">
|
||||
<button id="load">读取输入</button>
|
||||
<button id="pick">选择文件</button>
|
||||
<button id="tool">离线格式化</button>
|
||||
<button id="browser">系统浏览器</button>
|
||||
</section>
|
||||
<footer>
|
||||
<button id="copy">复制报告</button>
|
||||
<button id="save">保存快照</button>
|
||||
+21
-1
@@ -30,7 +30,7 @@ async function refresh() {
|
||||
lastReport = JSON.stringify({ publicIp: ip.ip, location: { city: who.city, region: who.region, country: who.country }, trace: { colo: trace.colo, tls: trace.tls, warp: trace.warp }, fixedPing: ping, localSummary: diagnostics.summary }, null, 2);
|
||||
$("details").textContent = lastReport;
|
||||
$("summary").textContent = "检测完成;未授权的可选能力以结构化错误显示。";
|
||||
await hostCall(() => window.ymhut.log.info("IPCheck fixed-scope refresh completed"), null);
|
||||
await hostCall(() => window.ymhut.log.info("Bridge capability sample refresh completed"), null);
|
||||
}
|
||||
|
||||
$("refresh").addEventListener("click", () => refresh().catch(error => { $("summary").textContent = `检测失败:${error.message}`; }));
|
||||
@@ -39,4 +39,24 @@ $("save").addEventListener("click", async () => { await window.ymhut.storage.set
|
||||
$("output").addEventListener("click", async () => { await window.ymhut.output.set(lastReport); });
|
||||
$("docs").addEventListener("click", async () => { await window.ymhut.openExternal("https://github.com/YMhut/box-winUI3#plugins"); });
|
||||
|
||||
// Keep every Bridge capability explicit and user initiated in this sample.
|
||||
$("input").addEventListener("change", async event => {
|
||||
await window.ymhut.input.set({ note: event.target.value || "" });
|
||||
});
|
||||
$("load").addEventListener("click", async () => {
|
||||
const value = await window.ymhut.input.get();
|
||||
$("input").value = value?.note || "";
|
||||
});
|
||||
$("pick").addEventListener("click", async () => {
|
||||
const result = await window.ymhut.file.openPicker();
|
||||
$("summary").textContent = result?.name ? \`已选择:\${result.name}\` : "已取消文件选择";
|
||||
});
|
||||
$("tool").addEventListener("click", async () => {
|
||||
const result = await window.ymhut.tool.run("json_formatter", $("details").textContent || "{}");
|
||||
$("details").textContent = result?.output || JSON.stringify(result, null, 2);
|
||||
});
|
||||
$("browser").addEventListener("click", async () => {
|
||||
await window.ymhut.openExternal("https://github.com/YMhut/box-winUI3#plugins", { target: "system" });
|
||||
});
|
||||
|
||||
refresh().catch(error => { $("summary").textContent = `检测失败:${error.message}`; });
|
||||
+3
@@ -15,6 +15,9 @@ article:nth-child(even) { padding-left: 16px; }
|
||||
article span, small { color: color-mix(in srgb, CanvasText 62%, transparent); }
|
||||
pre { min-height: 150px; margin: 20px 0 0; padding: 14px; overflow: auto; border: 1px solid color-mix(in srgb, CanvasText 16%, transparent); border-radius: 6px; white-space: pre-wrap; }
|
||||
footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.bridge-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; align-items: center; margin-top: 14px; }
|
||||
.bridge-panel label { grid-column: 1 / -1; font-weight: 600; }
|
||||
.bridge-panel input { min-width: 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, CanvasText 26%, transparent); border-radius: 6px; background: Canvas; color: CanvasText; }
|
||||
button { min-height: 34px; padding: 6px 12px; border-radius: 5px; border: 1px solid color-mix(in srgb, CanvasText 22%, transparent); background: Canvas; color: CanvasText; }
|
||||
button:hover { background: color-mix(in srgb, CanvasText 7%, Canvas); }
|
||||
@media (max-width: 620px) { main { padding: 18px; } .metrics { grid-template-columns: 1fr; } article { padding: 14px 0 !important; } .hero strong { font-size: 28px; } }
|
||||
+20
-6
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "ipcheck-demo",
|
||||
"name": "IPCheck 安全网络概览",
|
||||
"id": "bridge-capabilities-demo",
|
||||
"name": "Bridge 能力示例",
|
||||
"version": "2.0.0",
|
||||
"author": "YMhut Box",
|
||||
"builtIn": true,
|
||||
@@ -13,14 +13,18 @@
|
||||
"minimumWindowsBuild": 17763,
|
||||
"architectures": [ "X64", "Arm64" ]
|
||||
},
|
||||
"permissions": [ "Http", "Log", "Output", "Storage", "Clipboard", "OpenExternal", "NetworkDiagnostics" ],
|
||||
"permissions": [ "Input", "Output", "Log", "Storage", "Http", "Clipboard", "FilePicker", "RunTool", "OpenExternal", "OpenSystemBrowser", "NetworkDiagnostics" ],
|
||||
"permissionReasons": {
|
||||
"Input": "读取用户在插件面板中主动填写的可选备注。",
|
||||
"Http": "仅访问清单中列出的公网 HTTPS 来源以读取公网 IP 和 Cloudflare Trace。",
|
||||
"Log": "记录用户主动运行检测或 Bridge 调用失败的审计信息。",
|
||||
"Output": "将检测报告发送到客户端插件输出面板。",
|
||||
"Storage": "在客户端插件 KV 中保存用户主动创建的最近一次快照。",
|
||||
"Clipboard": "仅在用户点击复制按钮后写入生成的检测报告。",
|
||||
"FilePicker": "仅在用户点击导入或导出按钮后打开系统文件选择器。",
|
||||
"RunTool": "仅调用声明范围内的离线 JSON 格式化工具。",
|
||||
"OpenExternal": "仅在用户点击文档按钮后打开声明的 GitHub 文档来源。",
|
||||
"OpenSystemBrowser": "仅在用户点击系统浏览器按钮后打开声明的公开文档。",
|
||||
"NetworkDiagnostics": "读取本机接口摘要并对固定的 1.1.1.1 目标执行有限 Ping。"
|
||||
},
|
||||
"security": {
|
||||
@@ -34,18 +38,28 @@
|
||||
"https://1.1.1.1"
|
||||
],
|
||||
"openExternalOrigins": [ "https://github.com" ],
|
||||
"runToolIds": []
|
||||
"runToolIds": [ "json_formatter" ]
|
||||
},
|
||||
"surfaces": [
|
||||
{
|
||||
"kind": "ToolboxTool",
|
||||
"id": "ipcheck",
|
||||
"name": "IPCheck 网络概览",
|
||||
"id": "bridge-lab",
|
||||
"name": "Bridge 能力实验室",
|
||||
"description": "固定公共来源与本机网络摘要,不接受任意目标探测。",
|
||||
"entry": "index.html",
|
||||
"category": "plugin",
|
||||
"keywords": [ "ip", "network", "dns", "latency", "privacy", "plugin" ],
|
||||
"iconGlyph": "\uE968"
|
||||
},
|
||||
{
|
||||
"kind": "NavPage",
|
||||
"id": "network-help",
|
||||
"name": "网络能力说明",
|
||||
"description": "Bridge 权限与固定网络范围说明。",
|
||||
"entry": "index.html",
|
||||
"category": "plugin",
|
||||
"keywords": [ "bridge", "permissions", "help" ],
|
||||
"iconGlyph": "\uE897"
|
||||
}
|
||||
],
|
||||
"resources": [ "index.html", "style.css", "main.js", "README.md" ]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri 工具绑定示例
|
||||
|
||||
这是默认停用的 manifest v3 内置示例。页面是预构建静态产物,运行时只使用 YMhut Box 随包 Tauri 宿主,不依赖系统 Node、npm 或 Rust。
|
||||
|
||||
`echo-tool.exe` 由仓库中的 `YMhut.Box.PluginEcho` NativeAOT 项目在构建时生成,SHA-256 在安装示例时写入清单。它不访问网络、不请求提权,只通过系统对话框回显 Text、Integer、Boolean、Choice 和 FilePath 参数。
|
||||
|
||||
示例只有在用户开启开发者模式、逐项授权并确认当前插件版本后才能运行。
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "tauri-toolbinding-demo",
|
||||
"name": "Tauri 工具绑定示例",
|
||||
"version": "1.0.0",
|
||||
"author": "YMhut Box",
|
||||
"builtIn": true,
|
||||
"description": "预构建 Tauri 页面与受控单文件伴随程序示例,覆盖全部类型化参数。",
|
||||
"entry": "dist/index.html",
|
||||
"runtime": "Tauri",
|
||||
"permissions": [ "ExternalRuntime", "ExternalTool" ],
|
||||
"permissionReasons": {
|
||||
"ExternalRuntime": "使用随包固定 Tauri 宿主显示预构建页面。",
|
||||
"ExternalTool": "仅在用户点击后启动清单内、哈希匹配的本地参数回显程序。"
|
||||
},
|
||||
"security": {
|
||||
"requiredPermissions": [ "ExternalRuntime", "ExternalTool" ],
|
||||
"allowNetwork": false,
|
||||
"allowProcessSpawn": false
|
||||
},
|
||||
"tauri": {
|
||||
"sourceDirectory": "dist",
|
||||
"executable": "",
|
||||
"templateVersion": "1",
|
||||
"shellPanel": true,
|
||||
"buildOnCreate": false,
|
||||
"rebuildOnMissingOrChanged": false,
|
||||
"distDirectory": "dist",
|
||||
"hostApiVersion": "2"
|
||||
},
|
||||
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
|
||||
"surfaces": [
|
||||
{
|
||||
"kind": "ToolboxTool",
|
||||
"id": "binding-lab",
|
||||
"name": "受控工具绑定",
|
||||
"description": "查看 Tauri 运行状态并以类型化参数启动本地回显工具。",
|
||||
"entry": "dist/index.html",
|
||||
"category": "plugin",
|
||||
"keywords": [ "tauri", "external-tool", "binding", "manifest-v3" ],
|
||||
"iconGlyph": "\uE756"
|
||||
}
|
||||
],
|
||||
"toolBindings": [
|
||||
{
|
||||
"id": "echo-arguments",
|
||||
"name": "参数回显",
|
||||
"executable": "tools/echo-tool.exe",
|
||||
"workingDirectory": "tools",
|
||||
"architecture": "x64",
|
||||
"timeoutMs": 30000,
|
||||
"files": [
|
||||
{ "path": "tools/echo-tool.exe", "sha256": "{GENERATED_SHA256:tools/echo-tool.exe}" }
|
||||
],
|
||||
"parameters": [
|
||||
{ "name": "text", "kind": "Text", "required": true, "maxLength": 160 },
|
||||
{ "name": "count", "kind": "Integer", "required": true, "maxLength": 8 },
|
||||
{ "name": "enabled", "kind": "Boolean", "required": true, "maxLength": 5 },
|
||||
{ "name": "mode", "kind": "Choice", "required": true, "maxLength": 16, "choices": [ "safe", "preview", "audit" ] },
|
||||
{ "name": "file", "kind": "FilePath", "required": false, "maxLength": 1024 }
|
||||
],
|
||||
"arguments": [ "text={text}", "count={count}", "enabled={enabled}", "mode={mode}", "file={file}" ]
|
||||
}
|
||||
],
|
||||
"resources": [ "dist/index.html", "dist/style.css", "dist/main.js", "tools/echo-tool.exe", "README.md" ]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Settings;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace YMhut.Box.Core.Plugins;
|
||||
|
||||
@@ -20,14 +21,6 @@ public sealed class BuiltInPluginInstallerService(
|
||||
{
|
||||
private const string ResourcePrefix = "YMhut.Box.Core.Plugins.BuiltIn.";
|
||||
private const string FingerprintFileName = ".ymhut-built-in.sha256";
|
||||
private static readonly string[] KnownRootFiles =
|
||||
[
|
||||
"README.md",
|
||||
"ymhut.plugin.json",
|
||||
"index.html",
|
||||
"style.css",
|
||||
"main.js"
|
||||
];
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public string PluginsRoot => PluginRegistryService.ResolvePluginsRoot(paths, settingsService?.Current.PluginRootPath);
|
||||
@@ -144,6 +137,8 @@ public sealed class BuiltInPluginInstallerService(
|
||||
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await ResolveGeneratedHashesAsync(targetRoot, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fingerprint = await ComputeDirectoryFingerprintAsync(targetRoot, cancellationToken).ConfigureAwait(false);
|
||||
await File.WriteAllTextAsync(Path.Combine(targetRoot, FingerprintFileName), fingerprint, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -179,42 +174,82 @@ public sealed class BuiltInPluginInstallerService(
|
||||
private static IReadOnlyList<EmbeddedBuiltInPlugin> DiscoverEmbeddedPlugins()
|
||||
{
|
||||
var assembly = typeof(BuiltInPluginInstallerService).Assembly;
|
||||
var resources = assembly.GetManifestResourceNames()
|
||||
var names = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
|
||||
.Select(ParseResource)
|
||||
.OfType<EmbeddedBuiltInResource>()
|
||||
.GroupBy(resource => resource.PluginToken, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new EmbeddedBuiltInPlugin(
|
||||
DecodePluginFolder(group.Key),
|
||||
group.OrderBy(resource => resource.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray()))
|
||||
.Where(plugin => plugin.Resources.Any(resource => string.Equals(resource.RelativePath, PluginManifest.FileName, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(plugin => plugin.FolderName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var manifestNames = names
|
||||
.Where(name => name.EndsWith("." + PluginManifest.FileName, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(name => name[ResourcePrefix.Length..^(PluginManifest.FileName.Length + 1)])
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToArray();
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private static EmbeddedBuiltInResource? ParseResource(string resourceName)
|
||||
{
|
||||
var relativeName = resourceName[ResourcePrefix.Length..];
|
||||
foreach (var fileName in KnownRootFiles)
|
||||
var plugins = new List<EmbeddedBuiltInPlugin>();
|
||||
foreach (var token in manifestNames)
|
||||
{
|
||||
var suffix = "." + fileName;
|
||||
if (!relativeName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var manifestResourceName = ResourcePrefix + token + "." + PluginManifest.FileName;
|
||||
var tokenPrefix = ResourcePrefix + token + ".";
|
||||
var resources = names
|
||||
.Where(name => name.StartsWith(tokenPrefix, StringComparison.Ordinal) &&
|
||||
!name.Equals(manifestResourceName, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(name => new EmbeddedBuiltInResource(token, DecodeEmbeddedRelativePath(name[tokenPrefix.Length..]), name))
|
||||
.Where(resource => !string.IsNullOrWhiteSpace(resource.RelativePath))
|
||||
.ToList();
|
||||
resources.Add(new EmbeddedBuiltInResource(token, PluginManifest.FileName, manifestResourceName));
|
||||
|
||||
var token = relativeName[..^suffix.Length];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EmbeddedBuiltInResource(token, fileName, resourceName);
|
||||
plugins.Add(new EmbeddedBuiltInPlugin(
|
||||
DecodePluginFolder(token),
|
||||
resources.OrderBy(resource => resource.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray()));
|
||||
}
|
||||
|
||||
return null;
|
||||
return plugins.OrderBy(plugin => plugin.FolderName, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static async Task ResolveGeneratedHashesAsync(string targetRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestPath = Path.Combine(targetRoot, PluginManifest.FileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var manifestText = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var matches = Regex.Matches(manifestText, "\\{GENERATED_SHA256:(?<path>[^{}]+)\\}", RegexOptions.CultureInvariant);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var relative = match.Groups["path"].Value.Replace('/', Path.DirectorySeparatorChar);
|
||||
var filePath = Path.GetFullPath(Path.Combine(targetRoot, relative));
|
||||
if (!PluginRegistryService.IsInside(targetRoot, filePath) || !File.Exists(filePath))
|
||||
{
|
||||
throw new InvalidDataException($"Built-in plugin hash source is missing: {relative}");
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
|
||||
manifestText = manifestText.Replace(match.Value, hash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(manifestPath, manifestText, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string DecodeEmbeddedRelativePath(string suffix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(suffix))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var parts = suffix.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 1)
|
||||
{
|
||||
return suffix;
|
||||
}
|
||||
|
||||
var fileName = parts.Length == 2
|
||||
? parts[0] + "." + parts[1]
|
||||
: parts[^2] + "." + parts[^1];
|
||||
return parts.Length == 2
|
||||
? fileName
|
||||
: string.Join(Path.DirectorySeparatorChar, parts[..^2].Append(fileName));
|
||||
}
|
||||
|
||||
private static string DecodePluginFolder(string token)
|
||||
|
||||
@@ -21,6 +21,14 @@ public interface IPluginRegistryService
|
||||
Task<IReadOnlyList<PluginToolModule>> LoadEnabledToolModulesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LegacyPluginMigrationResult(
|
||||
string PluginId,
|
||||
string SourcePath,
|
||||
string? RecyclePath,
|
||||
bool Recycled,
|
||||
bool Skipped,
|
||||
string? Error = null);
|
||||
|
||||
public sealed class PluginRegistryService(
|
||||
AppPaths paths,
|
||||
IPluginStateStore stateStore,
|
||||
@@ -36,7 +44,8 @@ public sealed class PluginRegistryService(
|
||||
{
|
||||
new JsonStringEnumConverter<PluginPermission>(),
|
||||
new JsonStringEnumConverter<PluginSurfaceKind>(),
|
||||
new JsonStringEnumConverter<PluginRuntimeKind>()
|
||||
new JsonStringEnumConverter<PluginRuntimeKind>(),
|
||||
new JsonStringEnumConverter<PluginToolArgumentKind>()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,17 +70,18 @@ public sealed class PluginRegistryService(
|
||||
|
||||
public async Task<IReadOnlyList<LoadedPlugin>> LoadPluginsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (builtInInstaller is not null)
|
||||
{
|
||||
await builtInInstaller.EnsureInstalledAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(PluginsRoot);
|
||||
await MigrateLegacyPluginsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var plugins = new List<LoadedPlugin>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var builtInIds = ToolCatalog.DefaultModules().Select(module => module.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -102,6 +112,15 @@ public sealed class PluginRegistryService(
|
||||
errors.Add(exception.Message);
|
||||
}
|
||||
|
||||
// Legacy Web-only packages are never loaded into the runtime. They
|
||||
// are moved to a recoverable location after the manifest has been
|
||||
// parsed successfully; malformed or missing manifests stay visible
|
||||
// as invalid so user data is never deleted by a failed scan.
|
||||
if (manifest is not null && manifest.IsLegacy)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
manifest ??= new PluginManifest(Path.GetFileName(directory), Path.GetFileName(directory), "0.0.0", string.Empty, string.Empty, string.Empty, [], [], []);
|
||||
ValidateManifest(manifest, directory, seen, builtInIds, errors, currentClientVersion);
|
||||
seen.Add(manifest.Id);
|
||||
@@ -120,6 +139,154 @@ public sealed class PluginRegistryService(
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private async Task MigrateLegacyPluginsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var directory in Directory.EnumerateDirectories(PluginsRoot).Order(StringComparer.OrdinalIgnoreCase).ToArray())
|
||||
{
|
||||
var manifestPath = Path.Combine(directory, PluginManifest.FileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PluginManifest? manifest;
|
||||
await using (var stream = File.OpenRead(manifestPath))
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<PluginManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (manifest is null || !manifest.IsLegacy)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var migration = await RecycleLegacyPluginAsync(manifest, directory, cancellationToken).ConfigureAwait(false);
|
||||
await WriteMigrationLogAsync(migration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Missing or malformed manifests stay on disk and are handled as
|
||||
// invalid plugins when the plugin subsystem is enabled.
|
||||
await (logService?.WriteAsync("Warning", "plugin", "Legacy plugin scan was skipped", exception.Message, cancellationToken)
|
||||
?? Task.CompletedTask).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyPluginMigrationResult> RecycleLegacyPluginAsync(
|
||||
PluginManifest manifest,
|
||||
string sourcePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pluginId = PluginIds.Normalize(manifest.Id);
|
||||
try
|
||||
{
|
||||
await stateStore.ClearPluginDataAsync(manifest.Id, includeState: true, cancellationToken).ConfigureAwait(false);
|
||||
TryDeleteDirectory(Path.Combine(paths.Cache, "WebView2", "Plugins", manifest.Id));
|
||||
|
||||
var recycleRoot = Path.Combine(paths.Data, "PluginRecycle");
|
||||
Directory.CreateDirectory(recycleRoot);
|
||||
var recyclePath = Path.Combine(recycleRoot,
|
||||
$"legacy-{pluginId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}");
|
||||
if (!IsInside(PluginsRoot, sourcePath) || Directory.Exists(recyclePath))
|
||||
{
|
||||
return new LegacyPluginMigrationResult(manifest.Id, sourcePath, null, false, true,
|
||||
"The legacy plugin path is outside the configured plugin root.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Move(sourcePath, recyclePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
await CopyDirectoryAndVerifyAsync(sourcePath, recyclePath, cancellationToken).ConfigureAwait(false);
|
||||
Directory.Delete(sourcePath, recursive: true);
|
||||
}
|
||||
|
||||
return new LegacyPluginMigrationResult(manifest.Id, sourcePath, recyclePath, true, false);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
return new LegacyPluginMigrationResult(manifest.Id, sourcePath, null, false, false, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteMigrationLogAsync(LegacyPluginMigrationResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
if (logService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var detail = result.Recycled
|
||||
? $"recycled={result.RecyclePath}"
|
||||
: $"source={result.SourcePath}; error={result.Error ?? "unknown"}";
|
||||
await logService.WriteAsync(
|
||||
result.Recycled ? "Information" : "Warning",
|
||||
$"plugin:{result.PluginId}",
|
||||
result.Recycled ? "Legacy plugin moved to recoverable recycle storage." : "Legacy plugin was not loaded.",
|
||||
detail,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task CopyDirectoryAndVerifyAsync(string source, string destination, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ContainsPackageReparsePoint(source))
|
||||
{
|
||||
throw new InvalidDataException("Legacy plugin contains a symbolic link or directory junction.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destination);
|
||||
var sourceRoot = Path.GetFullPath(source).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(sourceRoot, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destination, relative));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(sourceRoot, file);
|
||||
var target = Path.Combine(destination, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
await using (var input = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
await using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var sourceHash = await HashFileAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
var targetHash = await HashFileAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(sourceHash, targetHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException($"Legacy plugin file verification failed: {relative}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> HashFileAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PluginToolModule>> LoadEnabledToolModulesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
|
||||
|
||||
@@ -18,5 +18,26 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Plugins\BuiltIn\**\*.*" />
|
||||
<ProjectReference Include="..\YMhut.Box.PluginEcho\YMhut.Box.PluginEcho.csproj"
|
||||
ReferenceOutputAssembly="false"
|
||||
PrivateAssets="all"
|
||||
SkipGetTargetFrameworkProperties="true" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<PluginEchoProject>$(MSBuildThisFileDirectory)..\YMhut.Box.PluginEcho\YMhut.Box.PluginEcho.csproj</PluginEchoProject>
|
||||
<PluginEchoOutput>$(MSBuildProjectDirectory)\obj\$(Configuration)\$(TargetFramework)\plugin-echo\ymhut-plugin-echo.exe</PluginEchoOutput>
|
||||
</PropertyGroup>
|
||||
<Target Name="BuildPluginEchoCompanion" BeforeTargets="AssignTargetPaths">
|
||||
<MSBuild Projects="$(PluginEchoProject)"
|
||||
Targets="Publish"
|
||||
Properties="Configuration=$(Configuration);RuntimeIdentifier=win-x64;PublishDir=$([System.IO.Path]::GetDirectoryName('$(PluginEchoOutput)'))\" />
|
||||
<Error Condition="!Exists('$(PluginEchoOutput)')" Text="The built-in plugin echo companion was not generated." />
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(PluginEchoOutput)">
|
||||
<LogicalName>YMhut.Box.Core.Plugins.BuiltIn.tauri_toolbinding_demo.tools.echo-tool.exe</LogicalName>
|
||||
<WithCulture>false</WithCulture>
|
||||
<Type>Non-Resx</Type>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace YMhut.Box.PluginEcho;
|
||||
|
||||
internal static partial class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
var output = new StringBuilder("YMhut Box 插件伴随程序参数回显\r\n\r\n");
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
output.Append(index + 1).Append(". ").AppendLine(args[index]);
|
||||
}
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
output.AppendLine("未提供参数。");
|
||||
}
|
||||
|
||||
return MessageBox(nint.Zero, output.ToString(), "受控工具绑定示例", 0x40);
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial int MessageBox(nint window, string text, string caption, uint type);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PublishAot>true</PublishAot>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<AssemblyName>ymhut-plugin-echo</AssemblyName>
|
||||
<RootNamespace>YMhut.Box.PluginEcho</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -159,6 +159,14 @@ public sealed class InstallLayoutPathsTests
|
||||
"else\n QuarantineUnknownLegacyFiles(AppDir, AppDir)",
|
||||
script.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("procedure RemoveEmptyInstallDirectories", script, StringComparison.Ordinal);
|
||||
Assert.Contains("RemoveEmptyInstallDirectories(AppDir, AppDir);", script, StringComparison.Ordinal);
|
||||
Assert.Contains("(FindRec.Attributes and $400) = 0", script, StringComparison.Ordinal);
|
||||
Assert.Contains("runtime\\uninstall-engine", script, StringComparison.OrdinalIgnoreCase);
|
||||
var finalize = script[script.LastIndexOf("procedure FinalizeLegacyInstallLayout();", StringComparison.Ordinal)..];
|
||||
Assert.IsLessThan(
|
||||
finalize.IndexOf("RemoveEmptyInstallDirectories(AppDir, AppDir);", StringComparison.Ordinal),
|
||||
finalize.IndexOf("VerifyNewApplicationPayload(AppDir);", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class PluginRuntimeTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ManifestV2AcceptsShellCommandInsidePluginRoot()
|
||||
public async Task LegacyShellManifestIsRecycledWithoutExecution()
|
||||
{
|
||||
using var workspace = new TempDirectory(Path.Combine(Path.GetTempPath(), "ymhut-runtime-tests", Guid.NewGuid().ToString("N")));
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "shell-demo", """
|
||||
@@ -57,18 +57,16 @@ public sealed class PluginRuntimeTests
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single();
|
||||
|
||||
Assert.IsTrue(plugin.IsValid, string.Join("; ", plugin.Errors));
|
||||
Assert.AreEqual(PluginRuntimeKind.Shell, plugin.Manifest.Runtime);
|
||||
Assert.HasCount(1, plugin.Manifest.Commands!);
|
||||
Assert.HasCount(0, await registry.LoadPluginsAsync());
|
||||
Assert.IsFalse(Directory.Exists(pluginRoot));
|
||||
Assert.HasCount(1, Directory.EnumerateDirectories(Path.Combine(paths.Data, "PluginRecycle"), "legacy-shell-demo-*").ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ManifestV2RejectsCommandPathEscapingPluginRoot()
|
||||
public async Task LegacyEscapingCommandManifestIsRecycledBeforeValidation()
|
||||
{
|
||||
using var workspace = new TempDirectory(Path.Combine(Path.GetTempPath(), "ymhut-runtime-tests", Guid.NewGuid().ToString("N")));
|
||||
_ = CreatePlugin(workspace.Path, "bad-shell", """
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "bad-shell", """
|
||||
{
|
||||
"id": "bad-shell",
|
||||
"name": "Bad Shell",
|
||||
@@ -90,10 +88,9 @@ public sealed class PluginRuntimeTests
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single();
|
||||
|
||||
Assert.IsFalse(plugin.IsValid);
|
||||
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("Command entry", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.HasCount(0, await registry.LoadPluginsAsync());
|
||||
Assert.IsFalse(Directory.Exists(pluginRoot));
|
||||
Assert.HasCount(1, Directory.EnumerateDirectories(Path.Combine(paths.Data, "PluginRecycle"), "legacy-bad-shell-*").ToArray());
|
||||
}
|
||||
|
||||
private static string CreatePlugin(string root, string folder, string manifest)
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class PluginTests
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "hello-tools", """
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "hello-tools",
|
||||
"name": "Hello Tools",
|
||||
"version": "1.0.0",
|
||||
@@ -23,6 +25,14 @@ public sealed class PluginTests
|
||||
"description": "Test plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": ["Input", "Output", "Log", "Storage"],
|
||||
"permissionReasons": {
|
||||
"Input": "Read test input.",
|
||||
"Output": "Show test output.",
|
||||
"Log": "Write the test audit record.",
|
||||
"Storage": "Store the test value."
|
||||
},
|
||||
"security": { "requiredPermissions": [] },
|
||||
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "hello", "name": "Hello", "description": "Hello tool", "entry": "index.html", "category": "dev" },
|
||||
{ "kind": "NavPage", "id": "home", "name": "Home", "description": "Home page", "entry": "index.html" }
|
||||
@@ -51,6 +61,8 @@ public sealed class PluginTests
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "bad", """
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "plugin:bad",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
@@ -58,6 +70,9 @@ public sealed class PluginTests
|
||||
"description": "Bad plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"permissionReasons": {},
|
||||
"security": { "requiredPermissions": [] },
|
||||
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "json_formatter", "name": "Override", "description": "conflict", "entry": "index.html" }
|
||||
],
|
||||
@@ -104,6 +119,8 @@ public sealed class PluginTests
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "merge-demo", """
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "merge-demo",
|
||||
"name": "Merge Demo",
|
||||
"version": "1.0.0",
|
||||
@@ -111,6 +128,9 @@ public sealed class PluginTests
|
||||
"description": "Merge plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"permissionReasons": {},
|
||||
"security": { "requiredPermissions": [] },
|
||||
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "tool", "name": "Merged Tool", "description": "Merged", "entry": "index.html" }
|
||||
],
|
||||
@@ -211,13 +231,13 @@ public sealed class PluginTests
|
||||
var installer = new BuiltInPluginInstallerService(paths);
|
||||
await installer.EnsureInstalledAsync();
|
||||
|
||||
var installed = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "index.html");
|
||||
var installedReadme = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "README.md");
|
||||
var installedManifest = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", PluginManifest.FileName);
|
||||
var installed = Path.Combine(paths.Root, "Plugins", "bridge-capabilities-demo", "index.html");
|
||||
var installedReadme = Path.Combine(paths.Root, "Plugins", "bridge-capabilities-demo", "README.md");
|
||||
var installedManifest = Path.Combine(paths.Root, "Plugins", "bridge-capabilities-demo", PluginManifest.FileName);
|
||||
Assert.IsTrue(File.Exists(installed));
|
||||
Assert.IsTrue(File.Exists(installedReadme));
|
||||
Assert.IsTrue(File.Exists(installedManifest));
|
||||
Assert.IsTrue(File.ReadAllText(installed).Contains("IPCheck 安全网络概览", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(File.ReadAllText(installed).Contains("Bridge 能力示例", StringComparison.OrdinalIgnoreCase));
|
||||
var manifestText = File.ReadAllText(installedManifest);
|
||||
Assert.IsTrue(manifestText.Contains("\"Http\"", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(manifestText.Contains("\"OpenExternal\"", StringComparison.OrdinalIgnoreCase));
|
||||
@@ -239,7 +259,7 @@ public sealed class PluginTests
|
||||
|
||||
var stateStore = new PluginStateStore(paths);
|
||||
var registry = new PluginRegistryService(paths, stateStore);
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single(item => item.Manifest.Id == "ipcheck-demo");
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single(item => item.Manifest.Id == "bridge-capabilities-demo");
|
||||
|
||||
Assert.IsTrue(plugin.IsValid, string.Join("; ", plugin.Errors));
|
||||
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.Storage);
|
||||
@@ -253,6 +273,76 @@ public sealed class PluginTests
|
||||
StringAssert.Contains(readme, "AI 实现提示");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ThreeBuiltInSamplesAreDisabledValidAndCoverSupportedCapabilities()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "App"));
|
||||
var installer = new BuiltInPluginInstallerService(paths);
|
||||
await installer.EnsureInstalledAsync();
|
||||
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
|
||||
var plugins = await registry.LoadPluginsAsync();
|
||||
|
||||
CollectionAssert.AreEquivalent(
|
||||
new[] { "web-capabilities-demo", "bridge-capabilities-demo", "tauri-toolbinding-demo" },
|
||||
plugins.Select(plugin => plugin.Manifest.Id).ToArray());
|
||||
Assert.IsTrue(plugins.All(plugin => plugin.IsValid),
|
||||
string.Join(Environment.NewLine, plugins.SelectMany(plugin => plugin.Errors)));
|
||||
Assert.IsTrue(plugins.All(plugin => !plugin.State.Enabled));
|
||||
|
||||
var permissionUnion = plugins.SelectMany(plugin => plugin.Manifest.Permissions).ToHashSet();
|
||||
foreach (var permission in new[]
|
||||
{
|
||||
PluginPermission.Input, PluginPermission.Output, PluginPermission.Log, PluginPermission.Storage,
|
||||
PluginPermission.Http, PluginPermission.NetworkDiagnostics, PluginPermission.Clipboard,
|
||||
PluginPermission.FilePicker, PluginPermission.RunTool, PluginPermission.OpenExternal,
|
||||
PluginPermission.OpenSystemBrowser, PluginPermission.ExternalRuntime, PluginPermission.ExternalTool
|
||||
})
|
||||
{
|
||||
CollectionAssert.Contains(permissionUnion.ToList(), permission);
|
||||
}
|
||||
|
||||
var tauri = plugins.Single(plugin => plugin.Manifest.Id == "tauri-toolbinding-demo");
|
||||
var binding = tauri.Manifest.ToolBindings!.Single();
|
||||
var executable = Path.Combine(tauri.RootPath, binding.Executable);
|
||||
Assert.IsTrue(File.Exists(executable));
|
||||
var actualHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(executable)));
|
||||
Assert.AreEqual(binding.Files.Single(file => file.Path == binding.Executable).Sha256, actualHash);
|
||||
CollectionAssert.AreEquivalent(
|
||||
Enum.GetValues<PluginToolArgumentKind>(),
|
||||
binding.Parameters!.Select(parameter => parameter.Kind).Distinct().ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LegacyPluginIsDisabledAndMovedToRecoverableRecycleStorage()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "legacy-demo", """
|
||||
{
|
||||
"id": "legacy-demo",
|
||||
"name": "Legacy Demo",
|
||||
"version": "1.0.0",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"surfaces": [],
|
||||
"resources": ["index.html"]
|
||||
}
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>Legacy</h1>");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var stateStore = new PluginStateStore(paths);
|
||||
await stateStore.SetEnabledAsync("legacy-demo", true);
|
||||
await stateStore.SetValueAsync("legacy-demo", "key", "value");
|
||||
|
||||
var registry = new PluginRegistryService(paths, stateStore);
|
||||
Assert.HasCount(0, await registry.LoadPluginsAsync());
|
||||
Assert.IsFalse(Directory.Exists(pluginRoot));
|
||||
var recycled = Directory.EnumerateDirectories(Path.Combine(paths.Data, "PluginRecycle"), "legacy-legacy-demo-*").Single();
|
||||
Assert.IsTrue(File.Exists(Path.Combine(recycled, PluginManifest.FileName)));
|
||||
Assert.IsFalse((await stateStore.GetStateAsync("legacy-demo")).Enabled);
|
||||
Assert.HasCount(0, await stateStore.ListValuesAsync("legacy-demo"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PluginDocsAndToolResultAssetsExposeRequiredSections()
|
||||
{
|
||||
@@ -300,6 +390,8 @@ public sealed class PluginTests
|
||||
|
||||
private static string BasicManifest(string id) => $$"""
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"apiVersion": "2",
|
||||
"id": "{{id}}",
|
||||
"name": "{{id}}",
|
||||
"version": "1.0.0",
|
||||
@@ -307,6 +399,9 @@ public sealed class PluginTests
|
||||
"description": "Test plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"permissionReasons": {},
|
||||
"security": { "requiredPermissions": [] },
|
||||
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "tool", "name": "Tool", "description": "Tool", "entry": "index.html" }
|
||||
],
|
||||
|
||||
@@ -11,8 +11,8 @@ public sealed class WeatherCapsuleLayoutTests
|
||||
{
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Full, WeatherCapsuleLayout.SelectMode(168));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Compact, WeatherCapsuleLayout.SelectMode(120));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.IconOnly, WeatherCapsuleLayout.SelectMode(40));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Hidden, WeatherCapsuleLayout.SelectMode(39));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.IconOnly, WeatherCapsuleLayout.SelectMode(32));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Hidden, WeatherCapsuleLayout.SelectMode(31));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -22,4 +22,22 @@ public sealed class WeatherCapsuleLayoutTests
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Compact, WeatherCapsuleLayout.SelectMode(210, 1.5));
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.IconOnly, WeatherCapsuleLayout.SelectMode(90, 1.5));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CaptionInsetAndLogicalDipsAreDeductedExactlyOnce()
|
||||
{
|
||||
var result = WeatherCapsuleLayout.Resolve(new WeatherCapsuleLayoutInput(
|
||||
WindowWidth: 768,
|
||||
BrandWidth: 152,
|
||||
CaptionButtonInset: 146,
|
||||
FixedActionsWidth: 104,
|
||||
MinimumDragWidth: 64,
|
||||
HorizontalPadding: 28,
|
||||
FontScale: 1.18));
|
||||
|
||||
Assert.AreEqual(274, result.AvailableWidth, 0.001);
|
||||
Assert.AreEqual(WeatherCapsuleDisplayMode.Full, result.Mode);
|
||||
Assert.AreEqual(216, result.TargetWidth, 0.001);
|
||||
Assert.AreEqual(36, result.TargetHeight, 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace YMhut.Box.WinUI.Controls;
|
||||
internal sealed class AppTitleBar : Grid
|
||||
{
|
||||
private const double DefaultCaptionButtonReserve = 146;
|
||||
private const double ExpandedBrandWidth = 152;
|
||||
private const double CompactBrandWidth = 36;
|
||||
private readonly StackPanel _brandPanel;
|
||||
private readonly StackPanel _brandTextPanel;
|
||||
|
||||
public AppTitleBar(string version, ITitleWeatherService weatherService)
|
||||
{
|
||||
@@ -29,7 +33,8 @@ internal sealed class AppTitleBar : Grid
|
||||
Grid.SetColumnSpan(TitleDragArea, 3);
|
||||
Children.Add(TitleDragArea);
|
||||
|
||||
Children.Add(BuildBrand(version));
|
||||
(_brandPanel, _brandTextPanel) = BuildBrand(version);
|
||||
Children.Add(_brandPanel);
|
||||
|
||||
GlobalSearchBox = new AutoSuggestBox
|
||||
{
|
||||
@@ -54,10 +59,6 @@ internal sealed class AppTitleBar : Grid
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children = { WeatherCapsule, CloudButton, QuickSettingsButton, ThemeToggleButton }
|
||||
};
|
||||
ActionsPanel.SizeChanged += (_, args) => ActionsPanel.Clip = new RectangleGeometry
|
||||
{
|
||||
Rect = new Windows.Foundation.Rect(0, 0, Math.Max(0, args.NewSize.Width), Math.Max(0, args.NewSize.Height))
|
||||
};
|
||||
Grid.SetColumn(ActionsPanel, 2);
|
||||
Children.Add(ActionsPanel);
|
||||
}
|
||||
@@ -91,7 +92,47 @@ internal sealed class AppTitleBar : Grid
|
||||
ActionsPanel.Spacing = compact ? 4 : 6;
|
||||
}
|
||||
|
||||
private UIElement BuildBrand(string version)
|
||||
public WeatherCapsuleLayoutResult ApplyResponsiveLayout(
|
||||
double windowWidth,
|
||||
double captionButtonInset,
|
||||
double fontScale,
|
||||
bool compactDensity)
|
||||
{
|
||||
SetCaptionButtonReserve(captionButtonInset);
|
||||
var spacing = compactDensity ? 4d : 6d;
|
||||
ActionsPanel.Spacing = spacing;
|
||||
var fixedActionsWidth = (32d * 3) + (spacing * 2);
|
||||
var minimumDragWidth = windowWidth < 520 ? 24d : 64d;
|
||||
var expandedInput = new WeatherCapsuleLayoutInput(
|
||||
windowWidth,
|
||||
Math.Max(ExpandedBrandWidth, _brandPanel.ActualWidth),
|
||||
CaptionButtonInset: Math.Max(0, captionButtonInset),
|
||||
fixedActionsWidth,
|
||||
minimumDragWidth,
|
||||
// Padding.Right already contains the caption reserve. Keep only the
|
||||
// stable left/title padding here so the reserve is deducted once.
|
||||
Padding.Left + 12,
|
||||
fontScale);
|
||||
var layout = WeatherCapsuleLayout.Resolve(expandedInput);
|
||||
var compactBrand = windowWidth < 520 || layout.Mode == WeatherCapsuleDisplayMode.Hidden;
|
||||
_brandTextPanel.Visibility = compactBrand ? Visibility.Collapsed : Visibility.Visible;
|
||||
_brandPanel.Spacing = compactBrand ? 0 : 10;
|
||||
_brandPanel.Margin = new Thickness(0, 0, compactBrand ? 8 : 16, 0);
|
||||
|
||||
if (compactBrand)
|
||||
{
|
||||
layout = WeatherCapsuleLayout.Resolve(expandedInput with { BrandWidth = CompactBrandWidth });
|
||||
}
|
||||
|
||||
WeatherCapsule.SetLayout(layout);
|
||||
var weatherAndSpacing = layout.Mode == WeatherCapsuleDisplayMode.Hidden
|
||||
? 0
|
||||
: layout.TargetWidth + spacing;
|
||||
ActionsPanel.MaxWidth = fixedActionsWidth + weatherAndSpacing;
|
||||
return layout;
|
||||
}
|
||||
|
||||
private (StackPanel Root, StackPanel Text) BuildBrand(string version)
|
||||
{
|
||||
var brandPanel = new StackPanel
|
||||
{
|
||||
@@ -123,7 +164,7 @@ internal sealed class AppTitleBar : Grid
|
||||
Foreground = ModernUi.TextSecondary,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
brandPanel.Children.Add(new StackPanel
|
||||
var textPanel = new StackPanel
|
||||
{
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Spacing = 0,
|
||||
@@ -138,9 +179,10 @@ internal sealed class AppTitleBar : Grid
|
||||
},
|
||||
VersionText
|
||||
}
|
||||
});
|
||||
};
|
||||
brandPanel.Children.Add(textPanel);
|
||||
|
||||
return brandPanel;
|
||||
return (brandPanel, textPanel);
|
||||
}
|
||||
|
||||
private static Button CreateTitleButton(string glyph, string tooltip)
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
private readonly TextBlock _conditionText;
|
||||
private readonly Grid _visual;
|
||||
private readonly Grid _detailsPanel;
|
||||
private readonly Grid _iconHost;
|
||||
private readonly Flyout _flyout;
|
||||
|
||||
private TitleWeatherSnapshot _snapshot = TitleWeatherSnapshot.Loading;
|
||||
@@ -70,12 +71,12 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
new ColumnDefinition { Width = GridLength.Auto }
|
||||
}
|
||||
};
|
||||
var iconHost = new Grid { Width = 22, Height = 22, Children = { _weatherIcon, _loadingRing } };
|
||||
_visual.Children.Add(iconHost);
|
||||
_iconHost = new Grid { Width = 22, Height = 22, Children = { _weatherIcon, _loadingRing } };
|
||||
_visual.Children.Add(_iconHost);
|
||||
_detailsPanel = new Grid
|
||||
{
|
||||
MinWidth = 76,
|
||||
MaxWidth = 130,
|
||||
MinWidth = 0,
|
||||
MaxWidth = 120,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
RowDefinitions =
|
||||
{
|
||||
@@ -106,11 +107,12 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
|
||||
_button = new Button
|
||||
{
|
||||
Height = 40,
|
||||
MinWidth = 172,
|
||||
MaxWidth = 248,
|
||||
Margin = new Thickness(0, 4, 0, 4),
|
||||
Padding = new Thickness(12, 4, 12, 4),
|
||||
Height = 36,
|
||||
Width = 180,
|
||||
MinWidth = 0,
|
||||
MaxWidth = 216,
|
||||
Margin = new Thickness(0, 6, 0, 6),
|
||||
Padding = new Thickness(10, 3, 10, 3),
|
||||
CornerRadius = new CornerRadius(20),
|
||||
Background = ModernUi.Surface,
|
||||
BorderBrush = ModernUi.Stroke,
|
||||
@@ -121,7 +123,7 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
AutomationProperties.SetName(_button, AppLocalizer.T("天气", "Weather"));
|
||||
ToolTipService.SetToolTip(_button, AppLocalizer.T("查看天气详情", "Show weather details"));
|
||||
Content = _button;
|
||||
SetDisplayMode(WeatherCapsuleDisplayMode.Full);
|
||||
SetLayout(new WeatherCapsuleLayoutResult(WeatherCapsuleDisplayMode.Full, 180, 180, 36));
|
||||
ApplySnapshot(_snapshot);
|
||||
Loaded += WeatherCapsuleControl_Loaded;
|
||||
Unloaded += WeatherCapsuleControl_Unloaded;
|
||||
@@ -131,8 +133,27 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
|
||||
public void SetDisplayMode(WeatherCapsuleDisplayMode mode)
|
||||
{
|
||||
DisplayMode = mode;
|
||||
Visibility = mode == WeatherCapsuleDisplayMode.Hidden
|
||||
var width = mode switch
|
||||
{
|
||||
WeatherCapsuleDisplayMode.Full => 180,
|
||||
WeatherCapsuleDisplayMode.Compact => 80,
|
||||
WeatherCapsuleDisplayMode.IconOnly => 32,
|
||||
_ => 0
|
||||
};
|
||||
var height = mode switch
|
||||
{
|
||||
WeatherCapsuleDisplayMode.Full => 36,
|
||||
WeatherCapsuleDisplayMode.Compact => 34,
|
||||
_ => 32
|
||||
};
|
||||
SetLayout(new WeatherCapsuleLayoutResult(mode, width, width, height));
|
||||
}
|
||||
|
||||
public void SetLayout(WeatherCapsuleLayoutResult layout)
|
||||
{
|
||||
var mode = layout.Mode;
|
||||
DisplayMode = layout.Mode;
|
||||
Visibility = layout.Mode == WeatherCapsuleDisplayMode.Hidden
|
||||
? Visibility.Collapsed
|
||||
: Visibility.Visible;
|
||||
|
||||
@@ -142,7 +163,7 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
_tempText.Visibility = mode is WeatherCapsuleDisplayMode.Full or WeatherCapsuleDisplayMode.Compact
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
_visual.ColumnSpacing = mode == WeatherCapsuleDisplayMode.Full ? 9 : 7;
|
||||
_visual.ColumnSpacing = mode == WeatherCapsuleDisplayMode.Full ? 8 : 5;
|
||||
_visual.ColumnDefinitions[1].Width = mode == WeatherCapsuleDisplayMode.Full
|
||||
? new GridLength(1, GridUnitType.Star)
|
||||
: new GridLength(0);
|
||||
@@ -153,22 +174,28 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
switch (mode)
|
||||
{
|
||||
case WeatherCapsuleDisplayMode.Full:
|
||||
_button.Width = double.NaN;
|
||||
_button.MinWidth = 172;
|
||||
_button.MaxWidth = 248;
|
||||
_button.Padding = new Thickness(12, 4, 12, 4);
|
||||
_button.Width = Math.Clamp(layout.TargetWidth, 156, 216);
|
||||
_button.Height = 36;
|
||||
_button.Margin = new Thickness(0, 6, 0, 6);
|
||||
_button.Padding = new Thickness(10, 3, 10, 3);
|
||||
_button.CornerRadius = new CornerRadius(18);
|
||||
_iconHost.Width = _iconHost.Height = 22;
|
||||
break;
|
||||
case WeatherCapsuleDisplayMode.Compact:
|
||||
_button.Width = double.NaN;
|
||||
_button.MinWidth = 76;
|
||||
_button.MaxWidth = 108;
|
||||
_button.Padding = new Thickness(10, 4, 10, 4);
|
||||
_button.Width = Math.Clamp(layout.TargetWidth, 72, 88);
|
||||
_button.Height = 34;
|
||||
_button.Margin = new Thickness(0, 7, 0, 7);
|
||||
_button.Padding = new Thickness(6, 3, 6, 3);
|
||||
_button.CornerRadius = new CornerRadius(17);
|
||||
_iconHost.Width = _iconHost.Height = 20;
|
||||
break;
|
||||
case WeatherCapsuleDisplayMode.IconOnly:
|
||||
_button.Width = 40;
|
||||
_button.MinWidth = 40;
|
||||
_button.MaxWidth = 40;
|
||||
_button.Padding = new Thickness(8);
|
||||
_button.Width = 32;
|
||||
_button.Height = 32;
|
||||
_button.Margin = new Thickness(0, 8, 0, 8);
|
||||
_button.Padding = new Thickness(6);
|
||||
_button.CornerRadius = new CornerRadius(16);
|
||||
_iconHost.Width = _iconHost.Height = 20;
|
||||
break;
|
||||
case WeatherCapsuleDisplayMode.Hidden:
|
||||
break;
|
||||
@@ -297,9 +324,10 @@ public sealed class WeatherCapsuleControl : UserControl
|
||||
Grid.SetColumn(queryLevel, 1);
|
||||
footer.Children.Add(queryLevel);
|
||||
|
||||
var availableWidth = XamlRoot?.Size.Width ?? 344;
|
||||
var panel = new StackPanel
|
||||
{
|
||||
Width = 320,
|
||||
Width = Math.Clamp(availableWidth - 24, 220, 320),
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
|
||||
@@ -8,23 +8,74 @@ public enum WeatherCapsuleDisplayMode
|
||||
Hidden
|
||||
}
|
||||
|
||||
public readonly record struct WeatherCapsuleLayoutInput(
|
||||
double WindowWidth,
|
||||
double BrandWidth,
|
||||
double CaptionButtonInset,
|
||||
double FixedActionsWidth,
|
||||
double MinimumDragWidth,
|
||||
double HorizontalPadding,
|
||||
double FontScale = 1);
|
||||
|
||||
public readonly record struct WeatherCapsuleLayoutResult(
|
||||
WeatherCapsuleDisplayMode Mode,
|
||||
double AvailableWidth,
|
||||
double TargetWidth,
|
||||
double TargetHeight);
|
||||
|
||||
public static class WeatherCapsuleLayout
|
||||
{
|
||||
private const double FullMinimumWidth = 156;
|
||||
private const double FullMaximumWidth = 216;
|
||||
private const double CompactMinimumWidth = 72;
|
||||
private const double CompactMaximumWidth = 88;
|
||||
private const double IconWidth = 32;
|
||||
|
||||
public static WeatherCapsuleLayoutResult Resolve(WeatherCapsuleLayoutInput input)
|
||||
{
|
||||
var availableWidth = Math.Max(
|
||||
0,
|
||||
input.WindowWidth -
|
||||
input.BrandWidth -
|
||||
input.CaptionButtonInset -
|
||||
input.FixedActionsWidth -
|
||||
input.MinimumDragWidth -
|
||||
input.HorizontalPadding);
|
||||
var fontScale = Math.Clamp(input.FontScale, 0.85, 1.35);
|
||||
var textScale = Math.Max(1, fontScale);
|
||||
|
||||
if (availableWidth >= FullMinimumWidth * textScale)
|
||||
{
|
||||
return new WeatherCapsuleLayoutResult(
|
||||
WeatherCapsuleDisplayMode.Full,
|
||||
availableWidth,
|
||||
Math.Min(FullMaximumWidth, availableWidth),
|
||||
36);
|
||||
}
|
||||
|
||||
if (availableWidth >= CompactMinimumWidth * textScale)
|
||||
{
|
||||
return new WeatherCapsuleLayoutResult(
|
||||
WeatherCapsuleDisplayMode.Compact,
|
||||
availableWidth,
|
||||
Math.Min(CompactMaximumWidth, availableWidth),
|
||||
34);
|
||||
}
|
||||
|
||||
return availableWidth >= IconWidth
|
||||
? new WeatherCapsuleLayoutResult(WeatherCapsuleDisplayMode.IconOnly, availableWidth, IconWidth, IconWidth)
|
||||
: new WeatherCapsuleLayoutResult(WeatherCapsuleDisplayMode.Hidden, availableWidth, 0, IconWidth);
|
||||
}
|
||||
|
||||
public static WeatherCapsuleDisplayMode SelectMode(double availableWidth, double fontScale = 1)
|
||||
{
|
||||
var scale = Math.Clamp(fontScale, 1, 2);
|
||||
if (availableWidth >= 168 * scale)
|
||||
{
|
||||
return WeatherCapsuleDisplayMode.Full;
|
||||
}
|
||||
|
||||
if (availableWidth >= 72 * scale)
|
||||
{
|
||||
return WeatherCapsuleDisplayMode.Compact;
|
||||
}
|
||||
|
||||
return availableWidth >= 40
|
||||
? WeatherCapsuleDisplayMode.IconOnly
|
||||
: WeatherCapsuleDisplayMode.Hidden;
|
||||
return Resolve(new WeatherCapsuleLayoutInput(
|
||||
availableWidth,
|
||||
BrandWidth: 0,
|
||||
CaptionButtonInset: 0,
|
||||
FixedActionsWidth: 0,
|
||||
MinimumDragWidth: 0,
|
||||
HorizontalPadding: 0,
|
||||
fontScale)).Mode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
private string? _activePluginSurfaceId;
|
||||
private StackPanel? _titleActionsPanel;
|
||||
private WinDispatcherQueueTimer? _responsiveShellTimer;
|
||||
private WinDispatcherQueueTimer? _moveRecoveryTimer;
|
||||
private WindowMovePerformanceHelper? _movePerformanceHelper;
|
||||
private IDisposable? _moveLightModeScope;
|
||||
private bool _hasResponsiveShellState;
|
||||
@@ -86,6 +85,8 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
private double _lastResponsiveRasterizationScale;
|
||||
private bool _windowMoveLoopActive;
|
||||
private bool _responsiveShellUpdateDeferred;
|
||||
private bool _shellThemeDirtyDuringMove;
|
||||
private bool _visualSettingsDirtyDuringMove;
|
||||
private AppShell? _shell;
|
||||
private WeatherCapsuleControl? WeatherCapsule;
|
||||
private bool _syncingQuickSettings;
|
||||
@@ -121,6 +122,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
private string? _activeToolboxSurfaceId;
|
||||
private bool _systemColorEventsSubscribed;
|
||||
private bool _highContrastEventsSubscribed;
|
||||
private string? _lastShellThemeKey;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -147,7 +149,6 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
_windowMoveLoopActive = active;
|
||||
if (active)
|
||||
{
|
||||
_moveRecoveryTimer?.Stop();
|
||||
_moveLightModeScope ??= _uiPerformanceCoordinator.EnterLightMode("main-window-move");
|
||||
_responsiveShellTimer?.Stop();
|
||||
_pageTransitionStoryboard?.Stop();
|
||||
@@ -155,33 +156,26 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
}
|
||||
else
|
||||
{
|
||||
_moveRecoveryTimer ??= CreateOneShotTimer(
|
||||
TimeSpan.FromMilliseconds(72),
|
||||
() =>
|
||||
{
|
||||
if (_windowMoveLoopActive || _isClosed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_moveLightModeScope?.Dispose();
|
||||
_moveLightModeScope = null;
|
||||
ApplyShellTheme();
|
||||
if (_responsiveShellUpdateDeferred)
|
||||
{
|
||||
_responsiveShellUpdateDeferred = false;
|
||||
ApplyResponsiveShell();
|
||||
}
|
||||
});
|
||||
_moveRecoveryTimer.Stop();
|
||||
_moveRecoveryTimer.Start();
|
||||
if (!_responsiveShellUpdateDeferred)
|
||||
_moveLightModeScope?.Dispose();
|
||||
_moveLightModeScope = null;
|
||||
if (_visualSettingsDirtyDuringMove)
|
||||
{
|
||||
_responsiveShellUpdateDeferred = true;
|
||||
_visualSettingsDirtyDuringMove = false;
|
||||
ApplyVisualSettings();
|
||||
}
|
||||
else if (_shellThemeDirtyDuringMove)
|
||||
{
|
||||
_shellThemeDirtyDuringMove = false;
|
||||
ApplyShellTheme();
|
||||
}
|
||||
if (_responsiveShellUpdateDeferred)
|
||||
{
|
||||
_responsiveShellUpdateDeferred = false;
|
||||
ApplyResponsiveShell();
|
||||
}
|
||||
}
|
||||
});
|
||||
ToastService.Attach(ToastPresenter);
|
||||
ToastService.Attach(ToastPresenter, _uiPerformanceCoordinator);
|
||||
_shellNotificationService.Attach(ToastPresenter);
|
||||
|
||||
ConfigureNavigationItems();
|
||||
@@ -222,7 +216,6 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
_settingsService.PropertyChanged -= _settingsChangedHandler;
|
||||
UnsubscribeSystemVisualEvents();
|
||||
_glassIntensitySaveDebounce.Stop();
|
||||
_moveRecoveryTimer?.Stop();
|
||||
_globalHotkeyService.Invoked -= GlobalHotkeyService_Invoked;
|
||||
_globalHotkeyService.Dispose();
|
||||
_desktopOverlayService.Dispose();
|
||||
@@ -2333,6 +2326,13 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
|
||||
public void ApplyVisualSettings()
|
||||
{
|
||||
if (_windowMoveLoopActive)
|
||||
{
|
||||
_visualSettingsDirtyDuringMove = true;
|
||||
_shellThemeDirtyDuringMove = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = _settingsService.Current;
|
||||
var backdrop = settings.HardwareAccelerationEnabled ? settings.WindowBackdrop : "solid";
|
||||
ThemeService.ApplyTheme(this, settings.Theme, backdrop, settings.SeedColor);
|
||||
@@ -2396,24 +2396,48 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
|
||||
private void ApplyShellTheme(double? previewIntensity = null)
|
||||
{
|
||||
var settings = _settingsService.Current;
|
||||
var isDark = ThemeService.ShouldUseDarkPalette(settings.Theme);
|
||||
if (_windowMoveLoopActive)
|
||||
{
|
||||
var solid = new SolidColorBrush(isDark
|
||||
? Color.FromArgb(255, 28, 30, 31)
|
||||
: Color.FromArgb(255, 247, 248, 249));
|
||||
AppTitleBar.Background = solid;
|
||||
RootNavigation.Background = solid;
|
||||
QuickSettingsPanel.Background = solid;
|
||||
RootLayout.Background = solid;
|
||||
_shellThemeDirtyDuringMove = true;
|
||||
return;
|
||||
}
|
||||
var stableMaterial = _windowMoveLoopActive || _accessibility.HighContrast || !settings.HardwareAccelerationEnabled;
|
||||
|
||||
var settings = _settingsService.Current;
|
||||
var isDark = ThemeService.ShouldUseDarkPalette(settings.Theme);
|
||||
var intensity = previewIntensity ?? settings.GlassIntensity;
|
||||
var backgroundStamp = string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(settings.BackgroundImage))
|
||||
{
|
||||
try
|
||||
{
|
||||
backgroundStamp = File.GetLastWriteTimeUtc(settings.BackgroundImage).Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
var themeKey = string.Join('|',
|
||||
isDark,
|
||||
settings.SeedColor,
|
||||
settings.WindowBackdrop,
|
||||
settings.TopBarMaterial,
|
||||
settings.SettingsPanelMaterial,
|
||||
intensity.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture),
|
||||
settings.HardwareAccelerationEnabled,
|
||||
_accessibility.HighContrast,
|
||||
settings.BackgroundImage,
|
||||
backgroundStamp,
|
||||
settings.BackgroundOpacity);
|
||||
if (string.Equals(_lastShellThemeKey, themeKey, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastShellThemeKey = themeKey;
|
||||
var stableMaterial = _accessibility.HighContrast || !settings.HardwareAccelerationEnabled;
|
||||
var transparentBackdrop = !stableMaterial && UsesTransparentBackdrop(settings.WindowBackdrop);
|
||||
var topBarMaterial = stableMaterial ? "solid" : settings.TopBarMaterial;
|
||||
var contentMaterial = stableMaterial ? "solid" : settings.SettingsPanelMaterial;
|
||||
var intensity = _windowMoveLoopActive ? 1 : previewIntensity ?? settings.GlassIntensity;
|
||||
AppTitleBar.Background = ThemeService.CreateTopBarBrush(topBarMaterial, isDark, settings.WindowBackdrop, intensity);
|
||||
RootNavigation.Background = ThemeService.CreateContentBrush(contentMaterial, isDark, intensity);
|
||||
QuickSettingsPanel.Background = ThemeService.CreateContentBrush(contentMaterial, isDark, intensity);
|
||||
@@ -2551,24 +2575,11 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
|
||||
GlobalSearchBox.Visibility = Visibility.Visible;
|
||||
GlobalSearchBox.MaxWidth = Math.Max(180, RootNavigation.OpenPaneLength - 32);
|
||||
if (_titleActionsPanel is not null)
|
||||
if (_titleActionsPanel is not null && _shell is not null)
|
||||
{
|
||||
var captionReserve = _windowChromeService.CaptionButtonReservedWidth(this, AppTitleBar.XamlRoot);
|
||||
_shell?.TitleBar.SetCaptionButtonReserve(captionReserve);
|
||||
_titleActionsPanel.Margin = new Thickness(0);
|
||||
var brandReserve = phone ? 132d : 152d;
|
||||
var dragReserve = phone ? 36d : 64d;
|
||||
var availableActionsWidth = Math.Max(
|
||||
108,
|
||||
width - captionReserve - brandReserve - dragReserve - 28);
|
||||
_titleActionsPanel.MaxWidth = Math.Min(360, availableActionsWidth);
|
||||
if (WeatherCapsule is not null)
|
||||
{
|
||||
const double fixedActionWidth = (32 * 3) + (6 * 3);
|
||||
var weatherWidth = Math.Max(0, availableActionsWidth - fixedActionWidth);
|
||||
var mode = WeatherCapsuleLayout.SelectMode(weatherWidth, fontScale);
|
||||
WeatherCapsule.SetDisplayMode(mode);
|
||||
}
|
||||
_shell.TitleBar.ApplyResponsiveLayout(width, captionReserve, fontScale, compactDensity);
|
||||
}
|
||||
|
||||
_hasResponsiveShellState = true;
|
||||
|
||||
@@ -18,11 +18,13 @@ public static class ToastService
|
||||
{
|
||||
private static StackPanel? _host;
|
||||
private static XamlRoot? _xamlRoot;
|
||||
private static IUiPerformanceCoordinator? _performanceCoordinator;
|
||||
|
||||
public static void Attach(StackPanel host)
|
||||
public static void Attach(StackPanel host, IUiPerformanceCoordinator? performanceCoordinator = null)
|
||||
{
|
||||
_host = host;
|
||||
_xamlRoot = host.XamlRoot;
|
||||
_performanceCoordinator = performanceCoordinator;
|
||||
}
|
||||
|
||||
public static void Show(string message, ToastKind kind = ToastKind.Success, TimeSpan? duration = null)
|
||||
@@ -36,11 +38,25 @@ public static class ToastService
|
||||
{
|
||||
var toast = BuildToast(message, kind);
|
||||
_host.Children.Add(toast);
|
||||
Animate(toast, show: true);
|
||||
if (_performanceCoordinator?.IsLightMode == true)
|
||||
{
|
||||
SetAnimationEndState(toast, show: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Animate(toast, show: true);
|
||||
}
|
||||
|
||||
await Task.Delay(duration ?? TimeSpan.FromSeconds(2));
|
||||
Animate(toast, show: false);
|
||||
await Task.Delay(180);
|
||||
if (_performanceCoordinator?.IsLightMode == true)
|
||||
{
|
||||
SetAnimationEndState(toast, show: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Animate(toast, show: false);
|
||||
await Task.Delay(180);
|
||||
}
|
||||
_host.Children.Remove(toast);
|
||||
});
|
||||
}
|
||||
@@ -116,4 +132,13 @@ public static class ToastService
|
||||
|
||||
storyboard.Begin();
|
||||
}
|
||||
|
||||
private static void SetAnimationEndState(Border toast, bool show)
|
||||
{
|
||||
toast.Opacity = show ? 1 : 0;
|
||||
if (toast.RenderTransform is TranslateTransform transform)
|
||||
{
|
||||
transform.X = show ? 0 : 24;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
private static int s_nextSubclassId;
|
||||
|
||||
private readonly Window _window;
|
||||
private readonly Func<string?> _backdropProvider;
|
||||
private readonly Action<bool>? _moveStateChanged;
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
private readonly DispatcherQueueTimer _restoreTimer;
|
||||
@@ -33,7 +32,7 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
Action<bool>? moveStateChanged)
|
||||
{
|
||||
_window = window;
|
||||
_backdropProvider = backdropProvider ?? (() => "mica");
|
||||
_ = backdropProvider;
|
||||
_moveStateChanged = moveStateChanged;
|
||||
_dispatcherQueue = window.DispatcherQueue;
|
||||
_restoreTimer = _dispatcherQueue.CreateTimer();
|
||||
@@ -60,6 +59,8 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
return new WindowMovePerformanceHelper(window, backdropProvider, moveStateChanged);
|
||||
}
|
||||
|
||||
public bool IsMoveLoopActive => _inMoveLoop || Volatile.Read(ref _nativeMoveState) != 0;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -152,10 +153,6 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
_restoreTimer.Stop();
|
||||
_inMoveLoop = true;
|
||||
_moveStateChanged?.Invoke(true);
|
||||
if (UsesCompositionBackdrop(_backdropProvider()))
|
||||
{
|
||||
ThemeService.ApplyWindowBackdrop(_window, "solid");
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleEndMoveLoop()
|
||||
@@ -178,12 +175,6 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
}
|
||||
|
||||
_inMoveLoop = false;
|
||||
var backdrop = _backdropProvider();
|
||||
if (UsesCompositionBackdrop(backdrop))
|
||||
{
|
||||
ThemeService.ApplyWindowBackdrop(_window, backdrop ?? "mica");
|
||||
}
|
||||
|
||||
_moveStateChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
@@ -192,11 +183,6 @@ public sealed class WindowMovePerformanceHelper : IDisposable
|
||||
Dispose();
|
||||
}
|
||||
|
||||
private static bool UsesCompositionBackdrop(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() is not ("solid" or "none");
|
||||
}
|
||||
|
||||
private delegate nint SubclassProc(nint hWnd, uint msg, nint wParam, nint lParam, nuint uIdSubclass, nint dwRefData);
|
||||
|
||||
[DllImport("comctl32.dll", SetLastError = true)]
|
||||
|
||||
@@ -139,7 +139,7 @@ openExternal 默认打开应用内安全浏览器。显式传入 { target: "syst
|
||||
插件不能通过相对路径逃逸插件目录,resources、entry 和 surface.entry 都会被校验。
|
||||
文件能力只通过系统选择器提供,不开放静默任意路径读写。
|
||||
页面直接 fetch/WebSocket 与宿主 fetch 使用同一精确来源白名单;HTTP、localhost、局域网、私网和越界重定向会被拒绝。
|
||||
旧清单以 LegacyWebOnly 运行,本地 HTML/CSS/JS 可用,Bridge、远程连接和外接运行时关闭。Shell/Script 仍不受支持;原生程序只能通过严格的 toolBindings 使用。
|
||||
manifestVersion 小于 3 的旧清单不再运行;扫描时自动停用并移动到 data/PluginRecycle 的可恢复目录。迁移失败时保持隐藏并在下次扫描重试,损坏清单不会被自动删除。Shell/Script 仍不受支持;原生程序只能通过严格的 toolBindings 使用。
|
||||
"""));
|
||||
docs.Children.Add(Section("10. 常见问题", """
|
||||
加载失败:检查 ymhut.plugin.json、README、entry 文件是否存在,路径是否在插件目录内。
|
||||
|
||||
@@ -43,6 +43,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
||||
private readonly ToolResultWebBridge _toolResultWebBridge = AppServices.GetRequiredService<ToolResultWebBridge>();
|
||||
private readonly ToolPageWebBridge _toolPageWebBridge = AppServices.GetRequiredService<ToolPageWebBridge>();
|
||||
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
|
||||
private bool _adaptiveLayoutDeferred;
|
||||
private readonly IToolUiDefinitionStore _toolUiDefinitionStore = AppServices.GetRequiredService<IToolUiDefinitionStore>();
|
||||
private readonly AdaptiveToolViewModel _viewModel;
|
||||
private readonly ToolPageSpec _spec;
|
||||
@@ -210,6 +211,12 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
||||
Unloaded += AdaptiveToolPage_Unloaded;
|
||||
SizeChanged += (_, e) =>
|
||||
{
|
||||
if (_uiPerformanceCoordinator.IsLightMode)
|
||||
{
|
||||
_adaptiveLayoutDeferred = true;
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateAdaptiveWebViewHeights(e.NewSize.Height);
|
||||
ArrangeGalleryToolGrid();
|
||||
};
|
||||
@@ -225,6 +232,16 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
||||
|
||||
private void UiPerformanceCoordinator_LightModeChanged(object? sender, UiPerformanceModeChangedEventArgs e)
|
||||
{
|
||||
if (!e.IsLightMode && _adaptiveLayoutDeferred)
|
||||
{
|
||||
_adaptiveLayoutDeferred = false;
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
UpdateAdaptiveWebViewHeights(ActualHeight);
|
||||
ArrangeGalleryToolGrid();
|
||||
});
|
||||
}
|
||||
|
||||
if (DispatcherQueue.HasThreadAccess)
|
||||
{
|
||||
SendPerformanceMode();
|
||||
|
||||
Reference in New Issue
Block a user