优化天气胶囊拖动性能与插件示例迁移

完成天气胶囊在高 DPI 和窄窗口下的逐级降级布局,减少窗口拖动期间的主题与材质重建。新增三个停用的 manifest v3 插件示例、Tauri 伴随程序和构建哈希校验;旧版插件自动回收到可恢复目录。安装器升级成功后递归清理旧静态资源空目录,并补充相关文档和测试。
This commit is contained in:
2026-08-20 09:09:10 +08:00
parent e88fc8532d
commit c92f358774
28 changed files with 939 additions and 196 deletions
+1
View File
@@ -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/
+2
View File
@@ -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`,其余保持可选。
+1 -1
View File
@@ -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)。
+77
View File
@@ -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,4 +1,4 @@
# IPCheck manifest v3 安全示例
# Bridge 能力 manifest v3 示例
该内置插件演示 manifest v3、必需/可选权限、精确公网 HTTPS 来源和 `PluginHostProtocol v2` Bridge。页面和脚本全部随插件本地发布,不依赖远程代码。
@@ -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>
@@ -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}`; });
@@ -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; } }
@@ -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)
+21
View File
@@ -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>
+27
View File
@@ -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]
+9 -12
View File
@@ -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)
+100 -5
View File
@@ -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);
}
}
+51 -9
View File
@@ -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)
+54 -26
View File
@@ -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;
}
}
+63 -52
View File
@@ -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;
+29 -4
View File
@@ -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)]
+1 -1
View File
@@ -139,7 +139,7 @@ openExternal 默认打开应用内安全浏览器。显式传入 { target: "syst
resourcesentry surface.entry
fetch/WebSocket 宿 fetch 使HTTPlocalhost
LegacyWebOnly HTML/CSS/JS BridgeShell/Script toolBindings 使
manifestVersion 3 data/PluginRecycle Shell/Script toolBindings 使
"""));
docs.Children.Add(Section("10. 常见问题", """
ymhut.plugin.jsonREADMEentry
@@ -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();