升级插件安全策略、包管理和运行时能力

This commit is contained in:
2026-08-17 09:06:32 +08:00
parent 92e5c330f2
commit c3a8737fd6
50 changed files with 4409 additions and 2646 deletions
+19 -57
View File
@@ -1,63 +1,25 @@
# Plugin Specification For AI Implementers
# AI Plugin Integration Guide
This document is the compact contract for generating YMhut Box plugins with another AI agent.
Generate manifest v3 WebView plugins by default. Local HTML, CSS, JavaScript, ES Module, Worker, Wasm, Canvas, WebAudio, localStorage and IndexedDB need no client permission. Never request Shell, Script, native process, arbitrary file system or environment access.
## Build A Minimal Local Package
## Required generation rules
Create exactly the files needed for a runnable local package:
- `ymhut.plugin.json`
- `README.md`
- `index.html`
- `style.css`
- `main.js`
Keep UI code and core logic local. Do not load remote scripts as runtime dependencies. Remote HTTP APIs are allowed only through declared permissions and graceful failure states.
## Manifest Rules
- Use a stable `id` with letters, numbers, `.`, `-`, or `_`.
- Do not prefix the id with `plugin:`.
- Include at least one `ToolboxTool` or `NavPage` surface.
- Include every local file in `resources`, including `README.md`.
- Request only permissions the plugin actually uses.
- Explain every requested permission in `README.md`.
## Runtime Bridge
Use `window.ymhut` for host abilities:
- `output.*` for reports and summaries.
- `storage.*` for plugin-private state.
- `http.fetch` for http/https requests.
- `network.*` for host network diagnostics.
- `clipboard.*` and `file.*` only when clearly user initiated.
- `openExternal(url)` for links, which opens the YMhut safe browser by default.
- `openExternal(url, { target: "system" })` only for an explicit system-browser action.
## UI And Window Boundaries
The plugin page owns only its WebView content area. Do not mimic system title bars, cover host controls, or create invisible click layers. Avoid full-screen fixed overlays; if a modal is necessary, provide a visible close control and restore focus.
Design for both embedded and independent-window use. Use responsive grids, readable card density, clear loading states, empty states, and error states. The host output area should not be used as the primary UI.
## Security Constraints
Do not modify or override:
- `server/`
- built-in app assets
- developer/about identity
- built-in tool IDs
- paths outside the plugin directory
All plugin resources must resolve inside the plugin folder. File access must go through host file pickers; never assume arbitrary filesystem access.
- Include `manifestVersion: 3`, `apiVersion: "2"`, `runtime: "WebView"`, README and at least one surface.
- Add a non-empty `permissionReasons` entry for every permission.
- Put only launch-critical permissions in `security.requiredPermissions`.
- For `Http`, declare exact public HTTPS/WSS origins with no path or wildcard.
- For external links and tools, declare exact `openExternalOrigins` and `runToolIds`.
- Keep scripts, styles, fonts and application logic local. Remote code, iframe and navigation are prohibited.
- Handle Bridge errors by `error.code`; do not retry permission or scope errors automatically.
- Do not generate native binaries, package managers, build-on-first-run behavior or commands that invoke PowerShell, Node or Python.
## Acceptance Checklist
- Plugin scans without validation errors.
- README explains features, permissions, boundaries, and known failures.
- Main UI runs without network and shows a useful degraded state.
- Output writes do not hide the main UI.
- Links open in the safe browser by default.
- No remote scripts, no unbounded z-index overlays, no hidden click blockers.
- Manifest passes `docs/plugins/ymhut.plugin.schema.json`.
- Every entry/resource stays inside the plugin directory and the package contains no links or junctions.
- Zero-permission mode still renders and its browser-private storage remains functional.
- Optional permission denial produces a clear UI state.
- Direct fetch/WebSocket and `ymhut.http.fetch` use only declared public origins.
- No remote script, iframe, popup, download, browser permission, `file://` or Tauri global API is used.
- Layout works in narrow and wide embedded surfaces with no full-screen transparent overlay.
- Logs and output do not include secrets, tokens or local filesystem paths.
+10
View File
@@ -0,0 +1,10 @@
# 从旧清单迁移到 manifest v3
1. 添加 `manifestVersion: 3``apiVersion: "2"``runtime: "WebView"`
2.`permissions` 中每项权限添加非空 `permissionReasons`
3. 将启动必需权限放入 `security.requiredPermissions`,其余保持可选。
4.`Http` 添加精确公网 HTTPS/WSS `network.allowedOrigins`
5. 为外链和内置工具分别添加 `openExternalOrigins``runToolIds`
6. 移除 Shell/Script、原生可执行文件、任意文件路径、`file://`、远程脚本、任意目标探测和 WebRTC/STUN 绕过。
7. 使用浏览器 `localStorage`/IndexedDB 保存纯 Web 私有状态;需要宿主 KV 时声明 `Storage`
8. 在插件页重新审阅权限并启用。旧授权不会自动继承到新策略指纹。
+47 -46
View File
@@ -1,8 +1,8 @@
# YMhut Box 插件开发说明
# YMhut Box manifest v3 插件
YMhut Box 插件是本地 WebView 插件包,用来扩展工具箱工具或插件页。插件运行在宿主隔离的 WebView 中,通过 `window.ymhut` Bridge 请求能力;插件不能直接访问应用核心资源、任意文件路径或系统浏览器
本地 HTML/CSS/JavaScript 是默认插件运行时。DOM、ES Module、Worker、Wasm、Canvas、WebAudio、`localStorage` 和 IndexedDB 不需要客户端权限,但仅存在于该插件自己的虚拟 HTTPS 原点中。客户端、系统、文件选择器、宿主存储、网络代理和外部服务能力只能通过 `window.ymhut` Bridge 使用
## 最小插件
## 最小包
```text
my-plugin/
@@ -13,78 +13,79 @@ my-plugin/
main.js
```
`README.md``README.txt``说明.md` 必须存在。`entry``resources` 和 surface 入口都必须留在插件目录内,不能使用 `../` 逃逸
插件包不能包含符号链接、目录联接或原生可执行文件。`entry`、surface 入口和资源必须位于包目录内
## manifest 核心字段
## manifest v3
```json
{
"$schema": "../../../docs/plugins/ymhut.plugin.schema.json",
"manifestVersion": 3,
"apiVersion": "2",
"id": "hello-tools",
"name": "Hello Tools",
"version": "1.0.0",
"author": "you",
"description": "A local YMhut Box plugin",
"description": "A local WebView plugin",
"entry": "index.html",
"permissions": ["Output", "Log", "Storage", "OpenExternal"],
"surfaces": [
{
"runtime": "WebView",
"requirements": {
"minimumClientVersion": "2.0.6.2",
"minimumWindowsBuild": 17763,
"architectures": ["X64", "Arm64"]
},
"permissions": ["Http", "Output"],
"permissionReasons": {
"Http": "Read data from the declared public API.",
"Output": "Write the user-requested result to the host output panel."
},
"security": { "requiredPermissions": ["Http"] },
"network": {
"allowedOrigins": ["https://api.example.com"],
"openExternalOrigins": [],
"runToolIds": []
},
"surfaces": [{
"kind": "ToolboxTool",
"id": "hello",
"name": "Hello",
"description": "Toolbox entry",
"entry": "index.html",
"category": "plugin"
}
],
}],
"resources": ["index.html", "style.css", "main.js", "README.md"]
}
```
`id` 只允许字母、数字、点、短横线和下划线,不能以 `plugin:` 开头。工具箱挂载后的工具 ID 由宿主生成,格式是 `plugin:<pluginId>:<surfaceId>`
每项权限必须同时出现在 `permissions``permissionReasons``security.requiredPermissions` 中的权限未授权时插件不能启用;其余权限为可选权限,未授权时 Bridge 返回稳定错误码。权限用途、必需状态、范围、运行时或插件版本改变会使旧授权指纹失效
## 权限
权限默认关闭。manifest 只声明插件需要什么,用户仍要在插件页启用插件并授予权限。
- `Input`:读取或写入插件输入。
- `Output`:写入宿主输出区。
- `Log`:写入 `plugin:<pluginId>` 日志。
- `Storage`:访问插件私有 key-value 状态。
- `Http`:通过宿主请求 http/https。
- `Clipboard`:读写剪贴板文本。
- `FilePicker`:通过系统选择器打开或保存文件。
- `RunTool`:调用允许的内置工具。
- `OpenExternal`:打开 http/https 外链,默认进入安全浏览器。
- `NetworkDiagnostics`:请求本机网络诊断能力。
`Http` 必须声明精确 `network.allowedOrigins`;仅允许公网 `https://``wss://` 原点,不允许路径、通配符、HTTP、localhost、局域网或私网。`OpenExternal`/`OpenSystemBrowser` 使用 `openExternalOrigins``RunTool` 使用 `runToolIds`
## Bridge
```js
await window.ymhut.output.set("report");
await window.ymhut.storage.set("lastRun", JSON.stringify(data));
await window.ymhut.http.fetch({ url: "https://example.com/api" });
await window.ymhut.openExternal("https://example.com");
await window.ymhut.openExternal("https://example.com", { target: "system" });
const response = await window.ymhut.http.fetch({
url: "https://api.example.com/data",
method: "GET"
});
await window.ymhut.output.set(response.content);
```
普通外链默认进入 YMhut Box 安全浏览器。系统浏览器只作为显式动作使用,并继续受 `OpenExternal` 权限控制
协议为 `PluginHostProtocol v2`。每个 surface 会话绑定插件 ID、surface ID、虚拟原点和一次性令牌。插件脚本无法选择或伪造令牌。单消息上限 256 KiB,每会话最多 64 个并发调用,统一超时 30 秒;宿主 HTTP 响应上限 2 MiB
## 窗口与输出
常用稳定错误码:`permission_not_declared``permission_not_granted``permission_scope_denied``network_denied``session_invalid``payload_too_large``concurrency_limit``timeout`
插件内容区承载主 UI;宿主输出区用于报告、日志摘要、复制结果和调试信息。不要用输出区做主交互,也不要在插件页面使用全屏 fixed 遮罩、透明点击层或超高 z-index 覆盖宿主控件。
## Web 安全边界
插件需要适配主窗口内嵌和独立窗口内容区。建议使用响应式网格、可滚动表格和清晰空态;不要假设窗口固定尺寸
- 每个 surface 使用独立的 `https://p-<hash>.plugin.ymhut.invalid` 原点和独立 WebView2 数据目录
- 本地脚本、内联脚本、ES Module、Worker、Blob 和 Wasm 可用。
- 远程脚本、样式、字体、iframe、对象、导航、新窗口、下载、外部拖放和浏览器敏感权限被拒绝。
- 页面直接 `fetch`/WebSocket 与 `ymhut.http.fetch` 使用同一来源白名单;宿主 fetch 可兼容无 CORS API。
- 系统浏览器需要 `OpenSystemBrowser`,且每次调用都由宿主确认。
- Shell、Script、PowerShell、Node、Python 和插件自带原生程序不受支持。
## 安全边界
## 运行时与独立窗口
插件 WebView 只允许加载插件目录内本地资源。非本地导航会被拦截并交给安全浏览器。插件不能覆盖内置工具 ID、应用图标、开发者信息、关于页核心身份或内置 `Assets` 路径
`WebView` 是默认和正式运行时。Tauri 独立窗口仅使用应用自带宿主,默认关闭;它要求插件开发者模式、`ExternalRuntime` 授权和按插件版本保存的二次确认。Tauri 插件内容运行在独立的 `https://p-<hash>.localhost` 原点和插件私有 WebView2 数据目录中,应用宿主页只通过一次性、当前用户命名管道把 Bridge 请求转发给同一个权限宿主。会话令牌仅存在于原生宿主内,不进入插件 JavaScript。插件包不能携带或构建原生可执行文件,也不能访问 Tauri 全局 API
网络结果、排行榜和第三方数据都应标明不确定性,并在失败时显示降级状态
## 常见问题
- 加载失败:检查 manifest、README、entry 和 resources 是否存在且路径合法。
- 权限拒绝:检查 manifest 是否声明权限,以及插件页是否已授权。
- 外链打不开:只支持绝对 http/https URL,默认安全浏览器。
- 输出区遮挡:将输出区用于报告,不要用它承载主 UI。
- 独立窗口异常:不要调用浏览器弹窗 API 创建系统浏览器窗口。
旧清单自动降级为 `LegacyWebOnly`:本地 Web 内容可运行,Bridge、远程连接和外接运行时全部关闭。迁移步骤见 [MIGRATION-v3.md](MIGRATION-v3.md),完整边界见 [SECURITY.md](SECURITY.md)TypeScript 声明见 [ymhut.d.ts](ymhut.d.ts)
+11
View File
@@ -0,0 +1,11 @@
# 插件安全边界
插件是“不可信 Web 内容”,不是客户端扩展进程。宿主只承诺本地 Web 平台能力和经授权的 Bridge;插件目录、脚本、远程响应和显示内容都不应被当作可信客户端代码。
宿主在注册、授权、会话和调用四层检查清单声明、用途、精确范围、用户授权与策略指纹。UI 进程只执行宿主批准的剪贴板、文件选择器和外链动作。页面卸载、窗口关闭或 WebView 进程异常会关闭 Bridge 会话。
禁止的能力包括任意文件系统、环境变量、进程创建、Shell/Script、Tauri 全局 API、插件自带原生二进制、跨插件资源读取、HTTP/私网连接、远程代码、插件创建的 iframe、页面导航、下载、摄像头、麦克风、定位和通知。
受控 Tauri 宿主不把插件文件加载到应用原点。应用自有页面通过跨原点 sandbox iframe 承载插件自定义 HTTPS 协议;插件原点按插件和 surface 唯一,浏览器配置目录独立。Bridge 只接受同源 POST,由原生宿主持有一次性会话令牌并通过当前用户命名管道转发。插件页面即使构造原始 Bridge 请求,也只能调用清单已声明、当前已授权且范围匹配的能力。
浏览器私有存储按虚拟原点与 WebView2 数据目录隔离。清除插件数据或回收式卸载会删除宿主 KV 和对应浏览器配置;卸载前插件目录会移入应用数据下的可恢复回收目录。
+22
View File
@@ -0,0 +1,22 @@
export {};
type BridgeErrorCode = "invalid_request" | "session_invalid" | "plugin_unavailable" | "legacy_bridge_disabled" | "permission_not_declared" | "permission_not_granted" | "permission_scope_denied" | "payload_too_large" | "concurrency_limit" | "timeout" | "network_denied" | "unsupported" | "host_failure";
interface BridgeError extends Error { code: BridgeErrorCode; }
interface HttpRequest { url: string; method?: string; headers?: Record<string, string>; body?: string; }
interface HttpResponse { status: number; ok: boolean; content: string; headers: Record<string, string>; }
interface YmhutBridge {
input: { get(): Promise<unknown>; set(value: unknown): Promise<boolean>; onInputChanged(handler: (value: unknown) => void): void };
output: { set(value: unknown): Promise<boolean>; append(value: unknown): Promise<boolean>; clear(): Promise<boolean> };
log: { info(message: string, detail?: string): Promise<boolean>; warn(message: string, detail?: string): Promise<boolean>; error(message: string, detail?: string): Promise<boolean> };
storage: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<boolean>; remove(key: string): Promise<boolean>; list(): Promise<Record<string, string>> };
http: { fetch(request: HttpRequest): Promise<HttpResponse> };
network: { diagnostics(): Promise<unknown>; ping(request: unknown): Promise<unknown>; dnsLookup(request: unknown): Promise<unknown>; traceRoute(request: unknown): Promise<unknown> };
clipboard: { readText(): Promise<string>; writeText(text: string): Promise<boolean> };
file: { openPicker(): Promise<{ name: string; content: string } | null>; savePicker(name: string, value: string): Promise<{ name: string } | null> };
tool: { run(toolId: string, input: string): Promise<unknown> };
openExternal(url: string, options?: { target?: "safe" | "system" }): Promise<boolean>;
}
declare global { interface Window { ymhut: YmhutBridge; } }
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ymhut.local/schemas/ymhut.plugin.v3.json",
"title": "YMhut Box Plugin Manifest v3",
"type": "object",
"required": [ "manifestVersion", "apiVersion", "id", "name", "version", "author", "description", "entry", "runtime", "permissions", "permissionReasons", "security", "surfaces", "resources" ],
"properties": {
"manifestVersion": { "const": 3 },
"apiVersion": { "const": "2" },
"id": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$" },
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"author": { "type": "string" },
"description": { "type": "string" },
"entry": { "$ref": "#/$defs/relativePath" },
"runtime": { "enum": [ "WebView", "Tauri", "Shell", "Script" ] },
"builtIn": { "type": "boolean", "default": false },
"permissions": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/permission" } },
"permissionReasons": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } },
"security": { "type": "object", "required": [ "requiredPermissions" ], "properties": { "requiredPermissions": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/permission" } } } },
"requirements": { "type": "object", "properties": { "minimumClientVersion": { "type": "string" }, "minimumWindowsBuild": { "type": "integer", "minimum": 0 }, "architectures": { "type": "array", "uniqueItems": true, "items": { "enum": [ "X64", "X86", "Arm64" ] } } } },
"network": { "type": "object", "properties": { "allowedOrigins": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^(https|wss)://[^/?#*]+$" } }, "openExternalOrigins": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^https://[^/?#*]+$" } }, "runToolIds": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$" } } } },
"surfaces": { "type": "array", "minItems": 1, "items": { "type": "object", "required": [ "kind", "id", "name", "description" ], "properties": { "kind": { "enum": [ "ToolboxTool", "NavPage" ] }, "id": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$" }, "name": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, "entry": { "$ref": "#/$defs/relativePath" }, "category": { "type": "string" }, "keywords": { "type": "array", "items": { "type": "string" } }, "iconGlyph": { "type": "string" } } } },
"resources": { "type": "array", "items": { "$ref": "#/$defs/relativePath" } }
},
"$defs": {
"relativePath": { "type": "string", "minLength": 1, "not": { "pattern": "(^[\\/]|(^|[\\/])\\.\\.([\\/]|$)|:)" } },
"permission": { "enum": [ "Input", "Output", "Log", "Storage", "Http", "Clipboard", "FilePicker", "RunTool", "OpenExternal", "OpenSystemBrowser", "ExternalRuntime", "NetworkDiagnostics" ] }
}
}
@@ -1,32 +1,16 @@
# IPCheck 网络工具箱内置示例插件
# IPCheck manifest v3 安全示例
这是 YMhut Box 随程序发布的内置示例插件。插件资源嵌入在 `YMhut.Box.Core.dll` 中,插件系统首次启用或扫描时会复制到用户插件目录;如果用户已经修改同 ID 插件,程序会保留用户版本
该内置插件演示 manifest v3、必需/可选权限、精确公网 HTTPS 来源和 `PluginHostProtocol v2` Bridge。页面和脚本全部随插件本地发布,不依赖远程代码
## 文件说明
## 固定能力
- `ymhut.plugin.json`:插件声明文件,定义插件 ID、权限、工具入口和工具箱分类
- `index.html`:插件页面入口,适配主窗口内嵌和独立窗口内容区
- `style.css`:原创黑白极简点阵界面样式,卡片圆角控制在 8px
- `main.js`:插件主脚本,负责公网 IP、IPv4/IPv6、Cloudflare Trace、DNS 泄漏、WebRTC、测速、Ping、MTR、Whois/RDAP、MAC 厂商、ASN 连通性、规则测试、可达性检查、本机接口和浏览器指纹检测
- `Http`:只访问 `api.ipify.org``ipwho.is``speed.cloudflare.com`
- `NetworkDiagnostics`:只读取本机摘要并 Ping 固定目标 `1.1.1.1`
- `Output``Storage``Clipboard``Log`:仅在用户点击对应操作时调用
- `OpenExternal`:只允许打开声明的 GitHub 来源,默认进入应用内安全浏览器
## Bridge 能力示例
页面底部的“Bridge 示例”卡片演示了三个常用能力:
- 写入输出区:`window.ymhut.output.set(report)`,适合报告、日志摘要、可复制结果。
- 保存私有状态:`window.ymhut.storage.set("lastSnapshot", value)`,只写入当前插件命名空间。
- 打开安全链接:`window.ymhut.openExternal(url)`,默认进入 YMhut Box 安全浏览器;如需系统浏览器,必须显式传入 `{ target: "system" }` 并获得权限。
## 权限说明
插件声明 `Http``NetworkDiagnostics``Log``Output``Storage``Clipboard``OpenExternal`。用户启用并授权后,插件可通过 YMhut Bridge 执行必要的公网观测请求、本机网络诊断、日志记录、输出区写入、状态保存、报告复制和安全链接打开。
## 安全与边界
本示例只复刻 IPCheck 类工具的功能覆盖、内容结构和黑白极简风格,不复制受保护页面源码、品牌资产或私有接口。页面本体与工具交互均为内置原创实现;公网 IP、DNS 泄漏、RDAP、测速等必须由远端观测点才能完成的指标,会在插件授权后通过公开端点探测,并在失败时显示清晰降级状态。
插件 UI 不应覆盖宿主标题栏、输出区或系统窗口按钮。需要展示长报告时写入宿主输出区;主操作界面应保留在插件内容区内,避免 fixed 全屏遮罩和超高 z-index 点击层。
本示例不接受任意 URL 或主机输入,不使用 WebRTC/STUN,不加载远程脚本、iframe 或字体。可选 Bridge 权限未授权时,页面显示结构化错误而不是尝试绕过。
## AI 实现提示
给其他 AI 生成插件时,可以把本示例作为最小可运行模板:保留 `ymhut.plugin.json``README.md``index.html``style.css``main.js` 五个核心文件,按需减少权限,并在 README 中解释每个权限的用途。不要依赖远程脚本或修改 YMhut Box 内置资源
生成插件时为每项权限填写 `permissionReasons`,为网络和外链声明精确原点,并在 UI 中处理 `permission_not_granted``permission_scope_denied``network_denied`。HTML/CSS/JS 自身无需权限;客户端和外部服务能力始终需要声明与授权
@@ -3,305 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCheck 网络工具箱</title>
<title>IPCheck 安全网络概览</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- 内置示例插件:页面、交互和工具面板全部随 YMhut Box 内嵌发布,不加载第三方页面源码或远程脚本。 -->
<main class="shell">
<section class="hero" id="top">
<div class="brandMark" aria-hidden="true">
<span></span>
</div>
<div class="heroText">
<p class="eyebrow">All in one IP Toolbox</p>
<h1 id="primaryIp">正在检测...</h1>
<p id="primarySummary" class="summary">正在并行检测公网视角、本机网络、DNS、WebRTC、浏览器指纹和链路质量。</p>
</div>
<div class="heroActions">
<button id="rerunBtn" type="button" class="primary">重新检测</button>
<button id="copyBtn" type="button">复制报告</button>
</div>
<main>
<header>
<div><p class="eyebrow">MANIFEST V3 SAMPLE</p><h1>IPCheck 安全网络概览</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>
<section class="metrics">
<article><span>Cloudflare 节点</span><strong id="colo">--</strong></article>
<article><span>TLS</span><strong id="tls">--</strong></article>
<article><span>固定目标延迟</span><strong id="latency">未授权</strong></article>
<article><span>活动接口</span><strong id="interfaces">未授权</strong></article>
</section>
<section class="quickStats" id="statusStrip" aria-label="检测摘要"></section>
<nav class="toolNav" aria-label="工具导航">
<a href="#overview">概览</a>
<a href="#leaks">泄漏检测</a>
<a href="#quality">质量</a>
<a href="#advanced">高级工具</a>
<a href="#fingerprint">指纹</a>
</nav>
<section id="overview" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">网络身份</p>
<h2>IP 地址与地理位置</h2>
</div>
<span id="ipStatus" class="badge pending">检测中</span>
</div>
<dl id="ipDetails" class="facts"></dl>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">协议栈</p>
<h2>IPv4 / IPv6</h2>
</div>
<span id="stackStatus" class="badge pending">检测中</span>
</div>
<div id="stackDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">边缘网络</p>
<h2>Cloudflare Trace</h2>
</div>
<span id="traceStatus" class="badge pending">检测中</span>
</div>
<dl id="traceDetails" class="facts compact"></dl>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">质量判断</p>
<h2>IP 质量与风险</h2>
</div>
<span id="qualityStatus" class="badge pending">检测中</span>
</div>
<div id="qualityDetails" class="metricList"></div>
</article>
</section>
<section id="leaks" class="grid">
<article class="card">
<div class="cardHeader">
<div>
<p class="label">隐私</p>
<h2>WebRTC 泄漏</h2>
</div>
<span id="webrtcStatus" class="badge pending">检测中</span>
</div>
<div id="webrtcDetails" class="monoBlock"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">解析</p>
<h2>DNS 泄漏</h2>
</div>
<span id="dnsStatus" class="badge pending">检测中</span>
</div>
<div id="dnsDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">可见性</p>
<h2>IP 泄漏对照</h2>
</div>
<span id="leakStatus" class="badge pending">检测中</span>
</div>
<div id="leakDetails" class="metricList"></div>
</article>
</section>
<section id="quality" class="grid">
<article class="card">
<div class="cardHeader">
<div>
<p class="label">连通性</p>
<h2>全球延迟</h2>
</div>
<span id="latencyStatus" class="badge pending">检测中</span>
</div>
<div id="latencyDetails" class="metricList"></div>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">吞吐</p>
<h2>网络测速</h2>
</div>
<span id="speedStatus" class="badge pending">检测中</span>
</div>
<div class="speedValue"><span id="speedValue">--</span><small id="speedUnit">Mbps</small></div>
<p id="speedNote" class="muted">下载与上传测速将在授权 HTTP 后执行;无网络时显示本机链路速率。</p>
</article>
<article class="card">
<div class="cardHeader">
<div>
<p class="label">规则</p>
<h2>安全检查清单</h2>
</div>
<span id="securityStatus" class="badge pending">检测中</span>
</div>
<div id="securityDetails" class="checkList"></div>
</article>
</section>
<section id="advanced" class="toolPanel">
<div class="sectionHeader">
<div>
<p class="eyebrow">Advanced Tools</p>
<h2>高级网络工具</h2>
</div>
<span id="toolStatus" class="badge pending">待输入</span>
</div>
<div class="toolGrid">
<article class="card toolCard">
<h3>IP 查询</h3>
<div class="formRow">
<input id="lookupInput" type="text" placeholder="输入 IP,例如 1.1.1.1">
<button id="lookupBtn" type="button">查询</button>
</div>
<div id="lookupOutput" class="monoBlock small">等待查询。</div>
</article>
<article class="card toolCard">
<h3>DNS Resolver</h3>
<div class="formRow">
<input id="dnsInput" type="text" placeholder="输入域名,例如 example.com">
<button id="dnsBtn" type="button">解析</button>
</div>
<div id="dnsOutput" class="monoBlock small">等待解析。</div>
</article>
<article class="card toolCard">
<h3>Ping / Global Latency</h3>
<div class="formRow">
<input id="pingInput" type="text" placeholder="输入主机,例如 cloudflare.com">
<button id="pingBtn" type="button">Ping</button>
</div>
<div id="pingOutput" class="monoBlock small">等待测试。</div>
</article>
<article class="card toolCard">
<h3>MTR / Trace Route</h3>
<div class="formRow">
<input id="traceInput" type="text" placeholder="输入主机,例如 1.1.1.1">
<button id="traceBtn" type="button">追踪</button>
</div>
<div id="traceOutput" class="monoBlock small">等待追踪。</div>
</article>
<article class="card toolCard">
<h3>Whois / RDAP</h3>
<div class="formRow">
<input id="whoisInput" type="text" placeholder="输入 IP 或域名">
<button id="whoisBtn" type="button">查询</button>
</div>
<div id="whoisOutput" class="monoBlock small">等待查询。</div>
</article>
<article class="card toolCard">
<h3>MAC 厂商查询</h3>
<div class="formRow">
<input id="macInput" type="text" placeholder="输入 MAC,例如 00:1A:2B:3C:4D:5E">
<button id="macBtn" type="button">识别</button>
</div>
<div id="macOutput" class="monoBlock small">等待识别。</div>
</article>
<article class="card toolCard">
<h3>ASN Connectivity</h3>
<div class="formRow">
<input id="asnInput" type="text" placeholder="输入 IP 或 ASN,默认当前公网 IP">
<button id="asnBtn" type="button">分析</button>
</div>
<div id="asnOutput" class="monoBlock small">等待分析。</div>
</article>
<article class="card toolCard">
<h3>Rule Test</h3>
<div class="formRow">
<input id="ruleTargetInput" type="text" placeholder="测试目标,例如 example.com 或当前 IP">
<button id="ruleBtn" type="button">测试</button>
</div>
<textarea id="ruleInput" spellcheck="false" placeholder="每行一条规则:DOMAIN-SUFFIX,example.com / IP-CIDR,1.1.1.0/24 / GEOIP,CN"></textarea>
<div id="ruleOutput" class="monoBlock small">等待测试。</div>
</article>
<article class="card toolCard">
<h3>Censorship Check</h3>
<div class="formRow">
<input id="censorInput" type="text" placeholder="可选:自定义 URL,默认测试常用站点">
<button id="censorBtn" type="button">检查</button>
</div>
<div id="censorOutput" class="monoBlock small">等待检查。</div>
</article>
<article class="card toolCard">
<h3>Invisibility Test</h3>
<div class="formRow">
<input id="invisibleInput" type="text" placeholder="可选:期望国家代码,例如 CN / US">
<button id="invisibleBtn" type="button">评估</button>
</div>
<div id="invisibleOutput" class="monoBlock small">等待评估。</div>
</article>
</div>
</section>
<section id="fingerprint" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">本机环境</p>
<h2>浏览器指纹</h2>
</div>
<span class="badge ok">本地</span>
</div>
<dl id="browserDetails" class="facts"></dl>
</article>
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">接口</p>
<h2>本机网络接口</h2>
</div>
<span id="hostStatus" class="badge pending">检测中</span>
</div>
<div id="interfaceDetails" class="interfaceList"></div>
</article>
</section>
<section id="bridge-demo" class="grid">
<article class="card span2">
<div class="cardHeader">
<div>
<p class="label">Bridge 示例</p>
<h2>输出、存储与安全链接</h2>
</div>
<span id="bridgeStatus" class="badge pending">待操作</span>
</div>
<p class="muted">这些按钮演示插件如何写入宿主输出区、保存插件私有状态,以及默认用安全浏览器打开外链。</p>
<div class="formRow bridgeActions">
<button id="outputDemoBtn" type="button">写入输出区</button>
<button id="storageDemoBtn" type="button">保存快照</button>
<button id="guideDemoBtn" type="button">打开插件规范</button>
</div>
<div id="bridgeOutput" class="monoBlock small">等待 Bridge 操作。</div>
</article>
</section>
<div class="copyState">
<span id="copyStatus" class="badge pending">报告未复制</span>
</div>
<pre id="details">尚无结果</pre>
<footer>
<button id="copy">复制报告</button>
<button id="save">保存快照</button>
<button id="output">发送到输出</button>
<button id="docs">插件文档</button>
</footer>
</main>
<script src="./main.js"></script>
<script type="module" src="./main.js"></script>
</body>
</html>
@@ -1,907 +1,42 @@
// 插件主脚本:所有页面、工具逻辑和降级策略均内置;公网 IP/测速/DNS 泄漏等必须依赖远端观测点的项目才通过授权 HTTP 探测。
const $ = (id) => document.getElementById(id);
const state = {
diagnostics: null,
publicIp: null,
trace: null,
browser: {},
webrtc: [],
dnsProbe: null,
latency: {},
speed: null,
lookup: null,
checks: {},
startedAt: null
};
const $ = id => document.getElementById(id);
let lastReport = "尚无结果";
const endpoints = {
ipApis: [
{ name: "ipapi.co", url: "https://ipapi.co/json/" },
{ name: "ipwho.is", url: "https://ipwho.is/" },
{ name: "ip.sb", url: "https://api.ip.sb/geoip" }
],
ipv4: "https://api.ipify.org?format=json",
ipv6: "https://api64.ipify.org?format=json",
trace: "https://speed.cloudflare.com/cdn-cgi/trace",
speedDown: "https://speed.cloudflare.com/__down?bytes=1000000",
speedUp: "https://speed.cloudflare.com/__up",
doh: "https://cloudflare-dns.com/dns-query",
rdapIp: "https://rdap.org/ip/",
rdapDomain: "https://rdap.org/domain/",
mac: "https://api.macvendors.com/"
};
const macVendors = {
"001A2B": "Ayecom Technology",
"001B63": "Apple",
"001C42": "Parallels",
"002248": "Microsoft",
"005056": "VMware",
"080027": "PCS Systemtechnik / VirtualBox",
"3C5A37": "Google",
"F4F5D8": "Google",
"D850E6": "ASUSTek",
"FCFBFB": "Cisco",
"B827EB": "Raspberry Pi"
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function setBadge(id, status, text) {
const el = $(id);
if (!el) return;
el.className = `badge ${status}`;
el.textContent = text;
}
function setFacts(id, rows) {
$(id).innerHTML = rows
.map(([key, value]) => `<dt>${escapeHtml(key)}</dt><dd>${escapeHtml(value || "--")}</dd>`)
.join("");
}
function setMetrics(id, rows) {
$(id).innerHTML = rows
.map(([key, value, tone = ""]) => `
<div class="metric">
<span>${escapeHtml(key)}</span>
<strong class="${tone ? `${tone}Text` : ""}">${escapeHtml(value || "--")}</strong>
</div>`)
.join("");
}
function setChecks(id, rows) {
$(id).innerHTML = rows
.map(([key, value, tone = ""]) => `
<div class="checkItem">
<span>${escapeHtml(key)}</span>
<strong class="${tone ? `${tone}Text` : ""}">${escapeHtml(value || "--")}</strong>
</div>`)
.join("");
}
function setOutput(id, value) {
$(id).textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function flatten(values) {
return [...new Set((values || []).flat().filter(Boolean))];
}
function activeInterfaces() {
return (state.diagnostics?.interfaces || []).filter((item) => item.status === "Up");
}
function shortJson(value) {
return JSON.stringify(value, null, 2)
.replaceAll("\\u0022", "\"")
.slice(0, 6000);
}
function parseJson(content) {
try {
return JSON.parse(content);
} catch {
return null;
}
}
async function bridgeFetch(request) {
const response = await window.ymhut.http.fetch(request);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
async function fetchJson(url, options = {}) {
const response = await bridgeFetch({ url, headers: options.headers, method: options.method, body: options.body });
return { data: parseJson(response.content), response };
}
async function firstSuccessful(tasks) {
const errors = [];
for (const task of tasks) {
try {
const value = await task();
return value;
} catch (error) {
errors.push(error.message);
}
}
throw new Error(errors.join("; "));
}
function normalizeIpInfo(source, data) {
if (!data) return null;
if (source === "ipapi.co") {
return {
source,
ip: data.ip,
version: data.version,
city: data.city,
region: data.region,
country: data.country_name || data.country,
countryCode: data.country_code,
timezone: data.timezone,
latitude: data.latitude,
longitude: data.longitude,
asn: data.asn,
isp: data.org,
postal: data.postal,
raw: data
};
}
if (source === "ipwho.is") {
return {
source,
ip: data.ip,
version: data.type,
city: data.city,
region: data.region,
country: data.country,
countryCode: data.country_code,
timezone: data.timezone?.id,
latitude: data.latitude,
longitude: data.longitude,
asn: data.connection?.asn ? `AS${data.connection.asn}` : "",
isp: data.connection?.isp || data.connection?.org,
postal: data.postal,
raw: data
};
}
return {
source,
ip: data.ip || data.address,
version: data.version,
city: data.city,
region: data.region,
country: data.country,
countryCode: data.country_code,
timezone: data.timezone,
latitude: data.latitude,
longitude: data.longitude,
asn: data.asn,
isp: data.organization || data.isp,
postal: data.postal_code,
raw: data
};
async function hostCall(action, fallback) {
try { return await action(); } catch (error) { return { unavailable: error.code || error.message, fallback }; }
}
function parseTrace(text) {
const rows = {};
String(text || "").split(/\r?\n/).forEach((line) => {
const index = line.indexOf("=");
if (index > 0) rows[line.slice(0, index)] = line.slice(index + 1);
});
return rows;
return Object.fromEntries(text.trim().split(/\r?\n/).map(line => line.split(/=(.*)/s)).filter(row => row.length >= 2));
}
async function loadDiagnostics() {
try {
const diagnostics = await window.ymhut.network.diagnostics();
state.diagnostics = diagnostics;
state.checks.host = true;
setBadge("hostStatus", "ok", "本地");
} catch (error) {
state.checks.host = false;
state.diagnostics = { interfaces: [], summary: {}, proxy: {}, note: error.message };
setBadge("hostStatus", "bad", "失败");
}
}
async function loadPublicIp() {
try {
const info = await firstSuccessful(endpoints.ipApis.map((item) => async () => {
const { data } = await fetchJson(item.url);
const normalized = normalizeIpInfo(item.name, data);
if (!normalized?.ip) throw new Error(`${item.name} 未返回 IP`);
return normalized;
}));
state.publicIp = info;
state.checks.publicIp = true;
} catch (error) {
state.publicIp = { error: error.message };
state.checks.publicIp = false;
}
}
async function loadTrace() {
try {
const response = await bridgeFetch({ url: endpoints.trace });
state.trace = parseTrace(response.content);
state.checks.trace = true;
} catch (error) {
state.trace = { error: error.message };
state.checks.trace = false;
}
}
async function loadIpVersions() {
const result = { ipv4: null, ipv6: null };
try {
const { data } = await fetchJson(endpoints.ipv4);
result.ipv4 = data?.ip || null;
} catch (error) {
result.ipv4Error = error.message;
}
try {
const { data } = await fetchJson(endpoints.ipv6);
result.ipv6 = data?.ip || null;
} catch (error) {
result.ipv6Error = error.message;
}
state.ipVersions = result;
}
function renderIdentity() {
const summary = state.diagnostics?.summary || {};
const active = activeInterfaces();
const localIpv4 = flatten(active.map((item) => item.ipv4));
const localIpv6 = flatten(active.map((item) => item.ipv6));
const publicIp = state.publicIp?.ip;
$("primaryIp").textContent = publicIp || localIpv4[0] || localIpv6[0] || "网络未就绪";
$("primarySummary").textContent = publicIp
? `${state.publicIp.isp || "未知 ISP"} · ${[state.publicIp.city, state.publicIp.region, state.publicIp.country].filter(Boolean).join(" / ") || "未知位置"} · ${state.publicIp.asn || "未知 ASN"}`
: "未获得公网观测结果,已展示本机可见网络信息。";
setFacts("ipDetails", [
["公网 IP", publicIp || "未获得"],
["ASN / ISP", [state.publicIp?.asn, state.publicIp?.isp].filter(Boolean).join(" / ")],
["国家地区", [state.publicIp?.city, state.publicIp?.region, state.publicIp?.country].filter(Boolean).join(" / ")],
["经纬度", state.publicIp?.latitude ? `${state.publicIp.latitude}, ${state.publicIp.longitude}` : ""],
["时区", state.publicIp?.timezone || state.diagnostics?.localTimeZone],
["活动接口", active.map((item) => item.name).join(" / ")],
["本机 IPv4", localIpv4.join(" / ")],
["本机 IPv6", localIpv6.join(" / ")],
["默认网关", (summary.defaultGateways || []).join(" / ")],
["DNS 服务器", (summary.dnsServers || []).join(" / ")],
["观测来源", state.publicIp?.source || state.publicIp?.error || "本地"]
async function refresh() {
$("summary").textContent = "正在读取已声明的固定来源...";
const [ipResponse, whoResponse, traceResponse, diagnostics, ping] = await Promise.all([
window.ymhut.http.fetch({ url: "https://api.ipify.org?format=json" }),
window.ymhut.http.fetch({ url: "https://ipwho.is/" }),
window.ymhut.http.fetch({ url: "https://speed.cloudflare.com/cdn-cgi/trace" }),
hostCall(() => window.ymhut.network.diagnostics(), null),
hostCall(() => window.ymhut.network.ping({ host: "1.1.1.1", count: 3, timeoutMs: 1800 }), null)
]);
setBadge("ipStatus", publicIp ? "ok" : "warn", publicIp ? "完成" : "降级");
const ip = JSON.parse(ipResponse.content);
const who = JSON.parse(whoResponse.content);
const trace = parseTrace(traceResponse.content);
$("ip").textContent = ip.ip || who.ip || "--";
$("location").textContent = [who.city, who.region, who.country].filter(Boolean).join(" · ") || "位置不可用";
$("colo").textContent = trace.colo || "--";
$("tls").textContent = trace.tls || "--";
$("latency").textContent = ping.unavailable ? `未授权 (${ping.unavailable})` : `${ping.avgMs ?? "--"} ms`;
$("interfaces").textContent = diagnostics.unavailable ? `未授权 (${diagnostics.unavailable})` : String(diagnostics.summary?.activeInterfaceCount ?? 0);
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);
}
function renderStack() {
const active = activeInterfaces();
const localIpv4 = flatten(active.map((item) => item.ipv4));
const localIpv6 = flatten(active.map((item) => item.ipv6));
const publicV4 = state.ipVersions?.ipv4;
const publicV6 = state.ipVersions?.ipv6;
setMetrics("stackDetails", [
["公网 IPv4", publicV4 || state.ipVersions?.ipv4Error || "未检测到", publicV4 ? "ok" : "warn"],
["公网 IPv6", publicV6 || state.ipVersions?.ipv6Error || "未检测到", publicV6 ? "ok" : "warn"],
["本机 IPv4", localIpv4.length ? `${localIpv4.length} 个地址` : "未发现", localIpv4.length ? "ok" : "bad"],
["本机 IPv6", localIpv6.length ? `${localIpv6.length} 个地址` : "未发现", localIpv6.length ? "ok" : "warn"],
["双栈状态", (publicV4 || localIpv4.length) && (publicV6 || localIpv6.length) ? "双栈可见" : "非完整双栈", (publicV4 || localIpv4.length) && (publicV6 || localIpv6.length) ? "ok" : "warn"]
]);
setBadge("stackStatus", publicV4 || publicV6 || localIpv4.length || localIpv6.length ? "ok" : "bad", "完成");
}
$("refresh").addEventListener("click", () => refresh().catch(error => { $("summary").textContent = `检测失败:${error.message}`; }));
$("copy").addEventListener("click", async () => { await window.ymhut.clipboard.writeText(lastReport); });
$("save").addEventListener("click", async () => { await window.ymhut.storage.set("lastSnapshot", lastReport); });
$("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"); });
function renderTrace() {
const trace = state.trace || {};
setFacts("traceDetails", [
["Colo", trace.colo || "--"],
["HTTP", trace.http || "--"],
["TLS", trace.tls || "--"],
["WARP", trace.warp || "--"],
["Gateway", trace.gateway || "--"],
["SNI", trace.sni || "--"],
["IP", trace.ip || "--"],
["错误", trace.error || ""]
]);
setBadge("traceStatus", trace.colo ? "ok" : "warn", trace.colo ? "完成" : "降级");
}
function renderQuality() {
const publicIp = state.publicIp || {};
const proxy = state.diagnostics?.proxy || {};
const trace = state.trace || {};
const active = activeInterfaces();
const localIps = flatten(active.map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const hints = [];
if (proxy.enabled) hints.push("系统代理已启用");
if (trace.warp === "on" || trace.warp === "plus") hints.push("检测到 WARP");
if (publicIp.error) hints.push("公网观测失败");
if (localIps.some((ip) => ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("172."))) hints.push("本机存在私网地址");
const risk = publicIp.error ? "中" : proxy.enabled ? "需复核" : "低";
setMetrics("qualityDetails", [
["质量评级", risk, risk === "低" ? "ok" : "warn"],
["代理/VPN 线索", hints.join(" / ") || "未发现明显线索", hints.length ? "warn" : "ok"],
["ASN 信息", publicIp.asn || "未知", publicIp.asn ? "ok" : "warn"],
["运营商", publicIp.isp || "未知", publicIp.isp ? "ok" : "warn"],
["观测一致性", state.ipVersions?.ipv4 && publicIp.ip && state.ipVersions.ipv4 !== publicIp.ip ? "IPv4 观测不一致" : "未发现冲突", "ok"]
]);
setBadge("qualityStatus", risk === "低" ? "ok" : "warn", risk);
}
function renderBrowser() {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
const debug = gl?.getExtension("WEBGL_debug_renderer_info");
state.browser = {
language: navigator.language,
languages: navigator.languages?.join(" / "),
platform: navigator.platform,
userAgent: navigator.userAgent,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height} / DPR ${window.devicePixelRatio}`,
online: navigator.onLine ? "在线" : "离线",
cookies: navigator.cookieEnabled ? "启用" : "禁用",
hardwareConcurrency: navigator.hardwareConcurrency,
memory: navigator.deviceMemory ? `${navigator.deviceMemory} GB` : "未暴露",
touch: navigator.maxTouchPoints || 0,
webglVendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "未暴露",
webglRenderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "未暴露"
};
setFacts("browserDetails", [
["语言", state.browser.language],
["语言列表", state.browser.languages],
["平台", state.browser.platform],
["时区", state.browser.timezone],
["屏幕", state.browser.screen],
["在线状态", state.browser.online],
["Cookie", state.browser.cookies],
["CPU 线程", state.browser.hardwareConcurrency],
["内存", state.browser.memory],
["触控点", state.browser.touch],
["WebGL Vendor", state.browser.webglVendor],
["WebGL Renderer", state.browser.webglRenderer],
["UA", state.browser.userAgent]
]);
}
async function renderWebRtc() {
if (!window.RTCPeerConnection) {
$("webrtcDetails").textContent = "当前 WebView2 环境不支持 RTCPeerConnection。";
setBadge("webrtcStatus", "warn", "不可用");
return;
}
const candidates = new Set();
try {
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc.createDataChannel("probe");
pc.onicecandidate = (event) => {
if (event.candidate?.candidate) candidates.add(event.candidate.candidate);
};
await pc.setLocalDescription(await pc.createOffer());
await new Promise((resolve) => setTimeout(resolve, 1800));
pc.close();
state.webrtc = Array.from(candidates);
$("webrtcDetails").textContent = state.webrtc.length
? state.webrtc.join("\n")
: "未暴露候选地址,或当前 WebView2 策略阻止采集。";
const leaksPublic = state.webrtc.some((line) => /(srflx|relay)/i.test(line));
setBadge("webrtcStatus", leaksPublic ? "warn" : "ok", leaksPublic ? "有候选" : "未发现");
} catch (error) {
$("webrtcDetails").textContent = `WebRTC 检测失败:${error.message}`;
setBadge("webrtcStatus", "bad", "失败");
}
}
async function renderDns() {
const active = activeInterfaces();
const rows = active.map((item) => [
item.name,
item.dnsServers?.length ? item.dnsServers.join(" / ") : "未配置",
item.dnsServers?.length ? "ok" : "warn"
]);
if (rows.length === 0) rows.push(["DNS", "未发现活动接口", "bad"]);
try {
const probeName = `ymhut-${Date.now()}.cloudflare.com`;
const query = `${endpoints.doh}?name=${encodeURIComponent(probeName)}&type=A`;
const response = await bridgeFetch({ url: query, headers: { accept: "application/dns-json" } });
state.dnsProbe = parseJson(response.content) || {};
rows.push(["DoH 探测", `${response.status} / ${Math.round(response.elapsedMs)} ms`, "ok"]);
rows.push(["泄漏判断", "已列出本机 DNS;远端递归出口需专用回显域名才能精确归因", "warn"]);
} catch (error) {
rows.push(["DoH 探测", error.message, "warn"]);
}
setMetrics("dnsDetails", rows);
setBadge("dnsStatus", rows.some((row) => row[2] === "ok") ? "ok" : "warn", "完成");
}
function renderLeakComparison() {
const local = flatten(activeInterfaces().map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const publicValues = [state.publicIp?.ip, state.ipVersions?.ipv4, state.ipVersions?.ipv6, state.trace?.ip].filter(Boolean);
const webrtcValues = state.webrtc.join("\n");
const leakedLocal = local.filter((ip) => webrtcValues.includes(ip));
setMetrics("leakDetails", [
["公网观测", publicValues.join(" / ") || "未获得", publicValues.length ? "ok" : "warn"],
["WebRTC 本机地址", leakedLocal.join(" / ") || "未暴露完整本机地址", leakedLocal.length ? "warn" : "ok"],
["代理一致性", state.diagnostics?.proxy?.enabled ? "系统代理已启用,建议复核浏览器出口" : "未启用系统代理", state.diagnostics?.proxy?.enabled ? "warn" : "ok"],
["Trace 对照", state.trace?.ip && state.publicIp?.ip && state.trace.ip !== state.publicIp.ip ? "不同观测点结果不一致" : "未发现明显冲突", "ok"]
]);
setBadge("leakStatus", leakedLocal.length ? "warn" : "ok", leakedLocal.length ? "需注意" : "正常");
}
async function renderLatency() {
const targets = [
["Cloudflare", "1.1.1.1"],
["Google DNS", "8.8.8.8"],
["Quad9", "9.9.9.9"]
];
const rows = [];
for (const [name, host] of targets) {
try {
const result = await window.ymhut.network.ping({ host, count: 3, timeoutMs: 1800 });
state.latency[name] = result;
rows.push([name, result.avgMs >= 0 ? `${result.avgMs} ms / 丢包 ${result.lossPercent}%` : "无响应", result.avgMs >= 0 ? "ok" : "warn"]);
} catch (error) {
rows.push([name, error.message, "warn"]);
}
}
rows.push(["DOM 响应", `${Math.round(performance.now() - state.startedAt)} ms`, "ok"]);
setMetrics("latencyDetails", rows);
setBadge("latencyStatus", rows.some((row) => row[2] === "ok") ? "ok" : "warn", "完成");
}
async function renderSpeed() {
const active = activeInterfaces();
const maxLink = Math.max(0, ...active.map((item) => Number(item.speedMbps || 0)));
try {
const downStart = performance.now();
const down = await bridgeFetch({ url: endpoints.speedDown });
const downSeconds = Math.max(0.001, (performance.now() - downStart) / 1000);
const downMbps = (Number(down.content.length || 1000000) * 8 / downSeconds / 1000000);
const upPayload = "0".repeat(250000);
const upStart = performance.now();
await bridgeFetch({ url: endpoints.speedUp, method: "POST", body: upPayload, headers: { "content-type": "text/plain" } });
const upSeconds = Math.max(0.001, (performance.now() - upStart) / 1000);
const upMbps = (upPayload.length * 8 / upSeconds / 1000000);
state.speed = { downloadMbps: downMbps, uploadMbps: upMbps };
$("speedValue").textContent = downMbps.toFixed(1);
$("speedUnit").textContent = "Mbps down";
$("speedNote").textContent = `上传 ${upMbps.toFixed(1)} Mbps;本机最大链路 ${maxLink ? `${maxLink.toLocaleString()} Mbps` : "未知"}`;
setBadge("speedStatus", "ok", "完成");
} catch (error) {
$("speedValue").textContent = maxLink ? maxLink.toLocaleString() : "--";
$("speedUnit").textContent = "Mbps link";
$("speedNote").textContent = maxLink
? `测速失败:${error.message}。当前显示本机网卡报告链路速率。`
: `测速失败:${error.message},且未发现可用链路速率。`;
setBadge("speedStatus", maxLink ? "warn" : "bad", maxLink ? "链路" : "失败");
}
}
function renderSecurity() {
const rows = [
["HTTPS / TLS", state.trace?.tls ? `TLS ${state.trace.tls}` : "未获得 Trace", state.trace?.tls ? "ok" : "warn"],
["Cloudflare WARP", state.trace?.warp || "未知", state.trace?.warp === "off" ? "ok" : "warn"],
["系统代理", state.diagnostics?.proxy?.enabled ? `${state.diagnostics.proxy.mode} ${state.diagnostics.proxy.host || ""}` : "未启用", state.diagnostics?.proxy?.enabled ? "warn" : "ok"],
["WebRTC 泄漏", state.webrtc.length ? "存在候选地址,需检查是否暴露真实地址" : "未发现候选地址", state.webrtc.length ? "warn" : "ok"],
["DNS 配置", (state.diagnostics?.summary?.dnsServers || []).length ? "已发现 DNS 服务器" : "未发现 DNS", (state.diagnostics?.summary?.dnsServers || []).length ? "ok" : "warn"],
["浏览器指纹", "已采集 UA、语言、屏幕、WebGL、硬件线程等本地指标", "info"]
];
setChecks("securityDetails", rows);
setBadge("securityStatus", rows.some((row) => row[2] === "bad") ? "bad" : rows.some((row) => row[2] === "warn") ? "warn" : "ok", "完成");
}
function renderInterfaces() {
const items = state.diagnostics?.interfaces || [];
if (items.length === 0) {
$("interfaceDetails").innerHTML = '<div class="metric"><span>接口</span><strong class="badText">未发现</strong></div>';
return;
}
$("interfaceDetails").innerHTML = items.map((item) => `
<div class="interfaceItem">
<div>
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.description)}</span>
</div>
<dl class="facts compact">
<dt>状态</dt><dd>${escapeHtml(item.status)} · ${escapeHtml(item.type)}</dd>
<dt>速率</dt><dd>${escapeHtml(item.speedMbps ? `${item.speedMbps} Mbps` : "--")}</dd>
<dt>IPv4</dt><dd>${escapeHtml((item.ipv4 || []).join(" / ") || "--")}</dd>
<dt>IPv6</dt><dd>${escapeHtml((item.ipv6 || []).join(" / ") || "--")}</dd>
<dt>网关</dt><dd>${escapeHtml((item.gateways || []).join(" / ") || "--")}</dd>
<dt>DNS</dt><dd>${escapeHtml((item.dnsServers || []).join(" / ") || "--")}</dd>
</dl>
</div>`).join("");
}
function renderStatusStrip() {
const summary = state.diagnostics?.summary || {};
const items = [
["公网 IP", state.publicIp?.ip || "未知"],
["IPv4 / IPv6", `${state.ipVersions?.ipv4 ? "4" : "-"} / ${state.ipVersions?.ipv6 ? "6" : "-"}`],
["活动接口", summary.activeInterfaceCount ?? 0],
["DNS", (summary.dnsServers || []).length]
];
$("statusStrip").innerHTML = items.map(([label, value]) => `
<div class="statusItem">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>`).join("");
}
function buildReport() {
const summary = state.diagnostics?.summary || {};
return [
"YMhut Box IPCheck 网络诊断报告",
`生成时间:${new Date().toLocaleString()}`,
`公网 IP${state.publicIp?.ip || "--"}`,
`ASN/ISP${[state.publicIp?.asn, state.publicIp?.isp].filter(Boolean).join(" / ") || "--"}`,
`位置:${[state.publicIp?.city, state.publicIp?.region, state.publicIp?.country].filter(Boolean).join(" / ") || "--"}`,
`IPv4${state.ipVersions?.ipv4 || "--"}`,
`IPv6${state.ipVersions?.ipv6 || "--"}`,
`Cloudflare Trace${state.trace?.colo || "--"} / WARP ${state.trace?.warp || "--"}`,
`活动接口:${summary.activeInterfaceCount ?? 0}`,
`DNS${(summary.dnsServers || []).join(" / ") || "--"}`,
`网关:${(summary.defaultGateways || []).join(" / ") || "--"}`,
`WebRTC${state.webrtc.join(" | ") || "未发现候选地址"}`,
`测速:${state.speed ? `${state.speed.downloadMbps.toFixed(1)} down / ${state.speed.uploadMbps.toFixed(1)} up Mbps` : "--"}`,
`浏览器:${state.browser.userAgent || "--"}`
].join("\n");
}
async function copyReport() {
try {
const report = buildReport();
await window.ymhut.clipboard.writeText(report);
await window.ymhut.output.set(report);
setBadge("copyStatus", "ok", "已复制");
} catch (error) {
setBadge("copyStatus", "bad", "复制失败");
await window.ymhut.log.warn("IPCheck copy failed", error.message);
}
}
async function writeOutputDemo() {
try {
const report = buildReport();
await window.ymhut.output.set(report);
$("bridgeOutput").textContent = "已把当前诊断报告写入宿主输出区。";
setBadge("bridgeStatus", "ok", "输出已写入");
} catch (error) {
$("bridgeOutput").textContent = `输出失败:${error.message}`;
setBadge("bridgeStatus", "bad", "输出失败");
}
}
async function saveSnapshotDemo() {
try {
const snapshot = {
savedAt: new Date().toISOString(),
ip: state.publicIp?.ip || "",
ipv4: state.ipVersions?.ipv4 || "",
ipv6: state.ipVersions?.ipv6 || "",
colo: state.trace?.colo || ""
};
await window.ymhut.storage.set("lastSnapshot", JSON.stringify(snapshot));
const stored = await window.ymhut.storage.get("lastSnapshot");
$("bridgeOutput").textContent = `已保存插件私有状态:\n${stored}`;
setBadge("bridgeStatus", "ok", "快照已保存");
} catch (error) {
$("bridgeOutput").textContent = `保存失败:${error.message}`;
setBadge("bridgeStatus", "bad", "保存失败");
}
}
async function openGuideDemo() {
try {
await window.ymhut.openExternal("https://github.com/YMhut/box-winUI3#plugins");
$("bridgeOutput").textContent = "已请求使用安全浏览器打开插件规范链接。";
setBadge("bridgeStatus", "ok", "链接已打开");
} catch (error) {
$("bridgeOutput").textContent = `打开链接失败:${error.message}`;
setBadge("bridgeStatus", "bad", "链接失败");
}
}
function cleanHost(value) {
return String(value || "")
.trim()
.replace(/^https?:\/\//i, "")
.split(/[/?#]/)[0];
}
function isIp(value) {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(":");
}
function ipToNumber(ip) {
const parts = String(ip).split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) return null;
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
}
function domainMatches(target, domain) {
const host = cleanHost(target).toLowerCase();
const needle = String(domain || "").toLowerCase();
return host === needle || host.endsWith(`.${needle}`);
}
function cidrMatches(ip, cidr) {
const [base, bitsText] = String(cidr || "").split("/");
const bits = Number(bitsText);
const ipNumber = ipToNumber(ip);
const baseNumber = ipToNumber(base);
if (ipNumber === null || baseNumber === null || Number.isNaN(bits) || bits < 0 || bits > 32) return false;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (ipNumber & mask) === (baseNumber & mask);
}
async function runLookup() {
const value = cleanHost($("lookupInput").value || $("primaryIp").textContent);
if (!value) return;
setBadge("toolStatus", "pending", "查询中");
setOutput("lookupOutput", "正在查询 IP 信息...");
try {
const url = isIp(value) ? `https://ipapi.co/${encodeURIComponent(value)}/json/` : `https://ipapi.co/${encodeURIComponent(value)}/json/`;
const { data } = await fetchJson(url);
state.lookup = normalizeIpInfo("ipapi.co", data);
setOutput("lookupOutput", shortJson(state.lookup || data));
setBadge("toolStatus", "ok", "完成");
} catch (error) {
setOutput("lookupOutput", `查询失败:${error.message}`);
setBadge("toolStatus", "bad", "失败");
}
}
async function runDnsLookup() {
const value = cleanHost($("dnsInput").value || "example.com");
setOutput("dnsOutput", "正在解析...");
try {
const bridge = await window.ymhut.network.dnsLookup({ host: value });
let doh = null;
try {
const { data } = await fetchJson(`${endpoints.doh}?name=${encodeURIComponent(value)}&type=A`, { headers: { accept: "application/dns-json" } });
doh = data;
} catch {
doh = null;
}
setOutput("dnsOutput", shortJson({ systemResolver: bridge, cloudflareDoh: doh }));
} catch (error) {
setOutput("dnsOutput", `解析失败:${error.message}`);
}
}
async function runPing() {
const value = cleanHost($("pingInput").value || "1.1.1.1");
setOutput("pingOutput", "正在 Ping...");
try {
const result = await window.ymhut.network.ping({ host: value, count: 5, timeoutMs: 2500 });
setOutput("pingOutput", shortJson(result));
} catch (error) {
setOutput("pingOutput", `Ping 失败:${error.message}`);
}
}
async function runTraceRoute() {
const value = cleanHost($("traceInput").value || "1.1.1.1");
setOutput("traceOutput", "正在追踪路由...");
try {
const result = await window.ymhut.network.traceRoute({ host: value, maxHops: 16, timeoutMs: 2200 });
setOutput("traceOutput", shortJson(result));
} catch (error) {
setOutput("traceOutput", `追踪失败:${error.message}`);
}
}
async function runWhois() {
const value = cleanHost($("whoisInput").value || $("primaryIp").textContent);
setOutput("whoisOutput", "正在查询 RDAP...");
try {
const url = `${isIp(value) ? endpoints.rdapIp : endpoints.rdapDomain}${encodeURIComponent(value)}`;
const { data } = await fetchJson(url);
setOutput("whoisOutput", shortJson(data));
} catch (error) {
setOutput("whoisOutput", `RDAP 查询失败:${error.message}`);
}
}
async function runMacLookup() {
const raw = $("macInput").value.trim();
const oui = raw.replace(/[^0-9a-f]/gi, "").slice(0, 6).toUpperCase();
if (oui.length < 6) {
setOutput("macOutput", "请输入至少 6 位十六进制 OUI。");
return;
}
if (macVendors[oui]) {
setOutput("macOutput", `${raw}\nOUI: ${oui}\n厂商: ${macVendors[oui]}\n来源: 内置常用 OUI 表`);
return;
}
setOutput("macOutput", "正在查询厂商...");
try {
const response = await bridgeFetch({ url: `${endpoints.mac}${encodeURIComponent(raw)}` });
setOutput("macOutput", `${raw}\nOUI: ${oui}\n厂商: ${response.content.trim()}`);
} catch (error) {
setOutput("macOutput", `${raw}\nOUI: ${oui}\n未在内置表命中,在线查询失败:${error.message}`);
}
}
async function runAsnConnectivity() {
const value = cleanHost($("asnInput").value || state.publicIp?.ip || $("primaryIp").textContent);
setOutput("asnOutput", "正在分析 ASN 连通性...");
try {
const ipValue = value.toUpperCase().startsWith("AS") ? state.publicIp?.ip : value;
const rdap = ipValue ? (await fetchJson(`${endpoints.rdapIp}${encodeURIComponent(ipValue)}`)).data : null;
const cidrs = (rdap?.cidr0_cidrs || []).map((item) => `${item.v4prefix || item.v6prefix}/${item.length}`);
const pings = [];
for (const target of ["1.1.1.1", "8.8.8.8", "9.9.9.9"]) {
try {
const ping = await window.ymhut.network.ping({ host: target, count: 2, timeoutMs: 1800 });
pings.push({ target, avgMs: ping.avgMs, lossPercent: ping.lossPercent });
} catch (error) {
pings.push({ target, error: error.message });
}
}
setOutput("asnOutput", shortJson({
input: value,
publicIp: state.publicIp?.ip,
asn: state.publicIp?.asn,
isp: state.publicIp?.isp,
rdapName: rdap?.name,
country: rdap?.country,
cidrs,
reachability: pings
}));
} catch (error) {
setOutput("asnOutput", `ASN 分析失败:${error.message}`);
}
}
async function runRuleTest() {
const target = cleanHost($("ruleTargetInput").value || state.publicIp?.ip || "example.com");
const ip = isIp(target) ? target : state.publicIp?.ip;
const country = (state.publicIp?.countryCode || "").toUpperCase();
const rules = ($("ruleInput").value || "DOMAIN-SUFFIX,example.com\nIP-CIDR,1.1.1.0/24\nGEOIP,CN")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const results = rules.map((line) => {
const [typeRaw, valueRaw] = line.split(",").map((part) => part?.trim());
const type = (typeRaw || "").toUpperCase();
const value = valueRaw || "";
let matched = false;
if (type === "DOMAIN" || type === "DOMAIN-SUFFIX") matched = domainMatches(target, value);
if (type === "IP-CIDR") matched = ip ? cidrMatches(ip, value) : false;
if (type === "GEOIP") matched = country === value.toUpperCase();
if (type === "KEYWORD") matched = target.toLowerCase().includes(value.toLowerCase());
return { rule: line, matched };
});
setOutput("ruleOutput", shortJson({ target, ip, country, results, firstMatch: results.find((item) => item.matched) || null }));
}
async function runCensorshipCheck() {
const custom = $("censorInput").value.trim();
const targets = custom ? [custom] : [
"https://www.cloudflare.com/cdn-cgi/trace",
"https://www.google.com/generate_204",
"https://www.wikipedia.org/",
"https://www.github.com/"
];
setOutput("censorOutput", "正在检查可达性...");
const rows = [];
for (const target of targets) {
const url = /^https?:\/\//i.test(target) ? target : `https://${target}`;
try {
const started = performance.now();
const response = await window.ymhut.http.fetch({ url });
rows.push({ url, ok: response.ok, status: response.status, elapsedMs: Math.round(performance.now() - started) });
} catch (error) {
rows.push({ url, ok: false, error: error.message });
}
}
setOutput("censorOutput", shortJson(rows));
}
async function runInvisibilityTest() {
const expected = $("invisibleInput").value.trim().toUpperCase();
const local = activeInterfaces();
const dns = flatten(local.map((item) => item.dnsServers));
const localIps = flatten(local.map((item) => [...(item.ipv4 || []), ...(item.ipv6 || [])]));
const publicIp = state.publicIp?.ip;
const country = (state.publicIp?.countryCode || "").toUpperCase();
const findings = [
{ item: "公网 IP", value: publicIp || "未知", status: publicIp ? "ok" : "warn" },
{ item: "期望国家", value: expected || "未指定", status: expected && country && expected !== country ? "warn" : "ok" },
{ item: "WebRTC 候选", value: state.webrtc.length ? `${state.webrtc.length}` : "未发现", status: state.webrtc.length ? "warn" : "ok" },
{ item: "本机私网地址", value: localIps.filter((ip) => ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("172.")).join(" / ") || "未发现", status: "ok" },
{ item: "DNS 服务器", value: dns.join(" / ") || "未知", status: dns.length ? "ok" : "warn" },
{ item: "系统代理", value: state.diagnostics?.proxy?.enabled ? state.diagnostics.proxy.mode : "未启用", status: state.diagnostics?.proxy?.enabled ? "warn" : "ok" },
{ item: "Trace WARP", value: state.trace?.warp || "未知", status: state.trace?.warp === "off" ? "ok" : "warn" }
];
setOutput("invisibleOutput", shortJson({
score: findings.filter((item) => item.status === "warn").length === 0 ? "隐私暴露线索较少" : "存在需要复核的暴露线索",
country,
expectedCountry: expected || null,
findings
}));
}
async function runAll() {
state.startedAt = performance.now();
["ipStatus", "stackStatus", "traceStatus", "hostStatus", "webrtcStatus", "dnsStatus", "latencyStatus", "speedStatus", "qualityStatus", "securityStatus", "leakStatus"].forEach((id) => setBadge(id, "pending", "检测中"));
$("primaryIp").textContent = "正在检测...";
$("primarySummary").textContent = "正在并行检测公网视角、本机网络、DNS、WebRTC、浏览器指纹和链路质量。";
await loadDiagnostics();
await Promise.allSettled([loadPublicIp(), loadTrace(), loadIpVersions()]);
renderBrowser();
renderIdentity();
renderStack();
renderTrace();
renderQuality();
await renderWebRtc();
await renderDns();
renderLeakComparison();
await renderLatency();
await renderSpeed();
renderSecurity();
renderInterfaces();
renderStatusStrip();
await window.ymhut.log.info("IPCheck diagnostics completed", JSON.stringify({
publicIp: state.publicIp?.ip,
ipv4: state.ipVersions?.ipv4,
ipv6: state.ipVersions?.ipv6,
colo: state.trace?.colo
}));
}
function wireAdvancedTools() {
$("lookupBtn").addEventListener("click", runLookup);
$("dnsBtn").addEventListener("click", runDnsLookup);
$("pingBtn").addEventListener("click", runPing);
$("traceBtn").addEventListener("click", runTraceRoute);
$("whoisBtn").addEventListener("click", runWhois);
$("macBtn").addEventListener("click", runMacLookup);
$("asnBtn").addEventListener("click", runAsnConnectivity);
$("ruleBtn").addEventListener("click", runRuleTest);
$("censorBtn").addEventListener("click", runCensorshipCheck);
$("invisibleBtn").addEventListener("click", runInvisibilityTest);
["lookupInput", "dnsInput", "pingInput", "traceInput", "whoisInput", "macInput", "asnInput", "ruleTargetInput", "censorInput", "invisibleInput"].forEach((id) => {
$(id).addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
const buttonId = {
ruleTargetInput: "ruleBtn",
censorInput: "censorBtn",
invisibleInput: "invisibleBtn"
}[id] || id.replace("Input", "Btn");
$(buttonId)?.click();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
$("rerunBtn").addEventListener("click", runAll);
$("copyBtn").addEventListener("click", copyReport);
$("outputDemoBtn").addEventListener("click", writeOutputDemo);
$("storageDemoBtn").addEventListener("click", saveSnapshotDemo);
$("guideDemoBtn").addEventListener("click", openGuideDemo);
wireAdvancedTools();
runAll();
});
refresh().catch(error => { $("summary").textContent = `检测失败:${error.message}`; });
@@ -1,519 +1,20 @@
/* 原创黑白极简网络仪表盘:点阵背景、紧凑卡片和状态徽章,适配独立插件窗口。 */
:root {
color-scheme: light;
--bg: #f7f7f4;
--ink: #101010;
--muted: #606064;
--line: #deded8;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: #ffffff;
--panel-soft: #efefeb;
--ok: #0b7a3b;
--warn: #946100;
--bad: #b42318;
--info: #245aa5;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
background:
radial-gradient(#d0d0ca 1px, transparent 1px) 0 0 / 22px 22px,
linear-gradient(180deg, #fbfbf8 0%, var(--bg) 100%);
}
button,
input {
font: inherit;
}
button {
border: 1px solid var(--ink);
background: #ffffff;
color: var(--ink);
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
button.primary,
button:hover {
background: var(--ink);
color: #ffffff;
}
button:disabled {
cursor: progress;
opacity: 0.58;
}
input {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 11px;
background: #ffffff;
color: var(--ink);
}
textarea {
width: 100%;
min-height: 84px;
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 11px;
resize: vertical;
background: #ffffff;
color: var(--ink);
font: 12px/1.5 "Cascadia Mono", Consolas, monospace;
}
input:focus,
textarea:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid #111111;
outline-offset: 2px;
}
a {
color: inherit;
}
.shell {
width: min(1220px, calc(100vw - 36px));
margin: 0 auto;
padding: 28px 0 34px;
}
.hero {
min-height: 172px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 20px;
align-items: end;
padding: 26px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.brandMark {
width: 58px;
height: 58px;
position: relative;
align-self: start;
display: grid;
place-items: center;
}
.brandMark::before,
.brandMark::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--ink);
border-radius: 50%;
opacity: 0.16;
}
.brandMark::after {
inset: 11px;
opacity: 0.36;
}
.brandMark span {
width: 18px;
height: 18px;
border: 2px solid var(--ink);
border-radius: 50%;
background: #ffffff;
}
.heroText {
min-width: 0;
}
.eyebrow,
.label {
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0;
font-weight: 800;
}
h1,
h2,
h3 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: clamp(34px, 5vw, 66px);
line-height: 1.02;
word-break: break-all;
}
h2 {
font-size: 18px;
}
h3 {
font-size: 16px;
}
.summary,
.muted {
margin: 10px 0 0;
color: var(--muted);
line-height: 1.58;
}
.heroActions,
.formRow {
display: flex;
gap: 10px;
}
.bridgeActions {
margin-top: 12px;
flex-wrap: wrap;
}
.heroActions {
flex-wrap: wrap;
justify-content: flex-end;
}
.quickStats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 12px 0;
}
.statusItem {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.78);
}
.statusItem span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.statusItem strong {
display: block;
font-size: 22px;
margin-top: 4px;
overflow-wrap: anywhere;
}
.toolNav {
position: sticky;
top: 0;
z-index: 3;
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 10px 0 12px;
backdrop-filter: blur(12px);
}
.toolNav a {
min-height: 32px;
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.86);
color: var(--muted);
text-decoration: none;
font-size: 13px;
font-weight: 750;
}
.toolNav a:hover {
color: var(--ink);
border-color: var(--ink);
}
.grid,
.toolGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.toolGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0;
}
.card,
.toolPanel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.card {
min-height: 248px;
padding: 18px;
}
.toolCard {
min-height: 260px;
}
.toolPanel {
padding: 18px;
margin-bottom: 12px;
}
.span2 {
grid-column: span 2;
}
.cardHeader,
.sectionHeader {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 26px;
border-radius: 999px;
border: 1px solid var(--line);
padding: 0 10px;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
}
.badge.ok {
color: var(--ok);
border-color: rgba(11, 122, 59, 0.35);
background: rgba(11, 122, 59, 0.08);
}
.badge.warn,
.badge.pending {
color: var(--warn);
border-color: rgba(148, 97, 0, 0.35);
background: rgba(148, 97, 0, 0.08);
}
.badge.bad {
color: var(--bad);
border-color: rgba(180, 35, 24, 0.35);
background: rgba(180, 35, 24, 0.08);
}
.badge.info {
color: var(--info);
border-color: rgba(36, 90, 165, 0.35);
background: rgba(36, 90, 165, 0.08);
}
.facts {
display: grid;
grid-template-columns: 134px minmax(0, 1fr);
gap: 10px 14px;
margin: 0;
}
.facts.compact {
grid-template-columns: 96px minmax(0, 1fr);
}
dt {
color: var(--muted);
font-weight: 700;
}
dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.metricList,
.checkList {
display: grid;
gap: 10px;
}
.metric,
.checkItem {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.metric:last-child,
.checkItem:last-child {
border-bottom: 0;
}
.metric strong,
.checkItem strong {
overflow-wrap: anywhere;
text-align: right;
}
.okText {
color: var(--ok);
}
.warnText {
color: var(--warn);
}
.badText {
color: var(--bad);
}
.monoBlock {
min-height: 148px;
padding: 12px;
border-radius: 6px;
background: var(--panel-soft);
color: #222222;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.55;
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.monoBlock.small {
min-height: 156px;
max-height: 260px;
margin-top: 12px;
}
.speedValue {
display: flex;
align-items: baseline;
gap: 8px;
font-weight: 850;
font-size: 52px;
}
.speedValue small {
color: var(--muted);
font-size: 18px;
}
.interfaceList {
display: grid;
gap: 12px;
}
.interfaceItem {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.interfaceItem strong,
.interfaceItem span {
display: block;
}
.interfaceItem span {
margin-top: 3px;
color: var(--muted);
overflow-wrap: anywhere;
}
.copyState {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 980px) {
.hero,
.quickStats,
.grid,
.toolGrid {
grid-template-columns: 1fr;
}
.span2 {
grid-column: auto;
}
.heroActions {
justify-content: flex-start;
}
}
@media (max-width: 620px) {
.shell {
width: min(100vw - 22px, 1220px);
padding-top: 16px;
}
.hero {
grid-template-columns: 1fr;
padding: 18px;
}
.brandMark {
width: 44px;
height: 44px;
}
.formRow {
flex-direction: column;
}
.facts,
.facts.compact,
.metric,
.checkItem {
grid-template-columns: 1fr;
}
.metric strong,
.checkItem strong {
text-align: left;
}
}
:root { color-scheme: light dark; font-family: "Segoe UI", system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: Canvas; color: CanvasText; }
main { max-width: 920px; margin: 0 auto; padding: 28px; }
header { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
h1 { margin: 2px 0 6px; font-size: 28px; letter-spacing: 0; }
p { margin: 0; color: color-mix(in srgb, CanvasText 68%, transparent); }
.eyebrow { color: #087e5b; font-size: 12px; font-weight: 700; }
.hero { margin-top: 24px; padding: 22px 0; border-block: 1px solid color-mix(in srgb, CanvasText 16%, transparent); display: grid; gap: 5px; }
.hero strong { font-size: 36px; font-weight: 650; word-break: break-all; }
.metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: color-mix(in srgb, CanvasText 12%, transparent); margin-top: 20px; }
article { background: Canvas; padding: 16px 0; display: grid; gap: 6px; }
article:nth-child(odd) { padding-right: 16px; }
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; }
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,52 +1,52 @@
{
"manifestVersion": 3,
"apiVersion": "2",
"id": "ipcheck-demo",
"name": "IPCheck 网络工具箱",
"version": "1.2.0",
"name": "IPCheck 安全网络概览",
"version": "2.0.0",
"author": "YMhut Box",
"builtIn": true,
"description": "内置示例插件:以独立窗口运行的 IP、地理位置、DNS、WebRTC、浏览器指纹、测速、Ping、MTR、Whois/RDAP 和网络质量检测工具。",
"description": "manifest v3 内置示例:固定来源公网信息、本机网络摘要、固定目标延迟与 Bridge 授权。",
"entry": "index.html",
"permissions": [
"Http",
"Log",
"Output",
"Storage",
"Clipboard",
"OpenExternal",
"NetworkDiagnostics"
"runtime": "WebView",
"requirements": {
"minimumWindowsBuild": 17763,
"architectures": [ "X64", "Arm64" ]
},
"permissions": [ "Http", "Log", "Output", "Storage", "Clipboard", "OpenExternal", "NetworkDiagnostics" ],
"permissionReasons": {
"Http": "仅访问清单中列出的公网 HTTPS 来源以读取公网 IP 和 Cloudflare Trace。",
"Log": "记录用户主动运行检测或 Bridge 调用失败的审计信息。",
"Output": "将检测报告发送到客户端插件输出面板。",
"Storage": "在客户端插件 KV 中保存用户主动创建的最近一次快照。",
"Clipboard": "仅在用户点击复制按钮后写入生成的检测报告。",
"OpenExternal": "仅在用户点击文档按钮后打开声明的 GitHub 文档来源。",
"NetworkDiagnostics": "读取本机接口摘要并对固定的 1.1.1.1 目标执行有限 Ping。"
},
"security": {
"requiredPermissions": [ "Http" ]
},
"network": {
"allowedOrigins": [
"https://api.ipify.org",
"https://ipwho.is",
"https://speed.cloudflare.com",
"https://1.1.1.1"
],
"openExternalOrigins": [ "https://github.com" ],
"runToolIds": []
},
"surfaces": [
{
"kind": "ToolboxTool",
"id": "ipcheck",
"name": "IPCheck 网络检测",
"description": "内置 IP 工具箱:公网 IP、ASN/ISP、地理位置、IPv4/IPv6、DNS/WebRTC 泄漏、测速、Ping、MTR、Whois/RDAP、MAC 厂商、规则测试、可达性与浏览器指纹。",
"name": "IPCheck 网络概览",
"description": "固定公共来源与本机网络摘要,不接受任意目标探测。",
"entry": "index.html",
"category": "plugin",
"keywords": [
"ip",
"network",
"dns",
"webrtc",
"ipv4",
"ipv6",
"latency",
"speed",
"whois",
"rdap",
"mtr",
"rule",
"censorship",
"asn",
"fingerprint"
],
"keywords": [ "ip", "network", "dns", "latency", "privacy", "plugin" ],
"iconGlyph": "\uE968"
}
],
"resources": [
"index.html",
"style.css",
"main.js",
"README.md"
]
"resources": [ "index.html", "style.css", "main.js", "README.md" ]
}
@@ -0,0 +1,3 @@
# 零权限 Web 能力示例
此插件不声明任何客户端权限。DOM、CSS、ES Module、Worker、Wasm、Canvas、WebAudio、localStorage 和 IndexedDB 均运行在插件独立的虚拟 HTTPS 原点内。它无法访问 `window.chrome.webview` 之外的客户端对象,且未注入 `window.ymhut` 以外的宿主 API。
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web 能力实验室</title><link rel="stylesheet" href="./style.css"></head>
<body><main><header><div><p>ZERO PERMISSION</p><h1>Web 能力实验室</h1></div><button id="run">运行全部</button></header><canvas id="canvas" width="800" height="240"></canvas><section id="results"></section></main><script type="module" src="./main.js"></script></body></html>
@@ -0,0 +1,35 @@
const results = document.querySelector('#results');
const addResult = (name, value) => results.insertAdjacentHTML('beforeend', `<article><strong>${name}</strong><span>${value}</span></article>`);
function drawCanvas() {
const canvas = document.querySelector('#canvas');
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#0d7f5f'; context.fillRect(24, 28, 230, 100);
context.fillStyle = '#2f6fed'; context.beginPath(); context.arc(390, 78, 52, 0, Math.PI * 2); context.fill();
context.fillStyle = '#b45309'; context.fillRect(510, 28, 250, 100);
}
async function indexedDbDemo() {
const request = indexedDB.open('web-capabilities', 1);
request.onupgradeneeded = () => request.result.createObjectStore('values');
const database = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const transaction = database.transaction('values', 'readwrite');
transaction.objectStore('values').put(new Date().toISOString(), 'lastRun');
await new Promise((resolve, reject) => { transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error); });
database.close();
}
async function run() {
results.replaceChildren(); drawCanvas(); addResult('DOM + Canvas', '正常');
localStorage.setItem('runs', String(Number(localStorage.getItem('runs') || 0) + 1)); addResult('localStorage', `${localStorage.getItem('runs')}`);
await indexedDbDemo(); addResult('IndexedDB', '插件私有数据库已写入');
const worker = new Worker('./worker.js', { type: 'module' });
const workerValue = await new Promise(resolve => { worker.onmessage = event => resolve(event.data); worker.postMessage(21); }); worker.terminate(); addResult('Worker', `${workerValue.value} / ${workerValue.thread}`);
const wasmBytes = Uint8Array.from([0,97,115,109,1,0,0,0,1,7,1,96,2,127,127,1,127,3,2,1,0,7,7,1,3,97,100,100,0,0,10,9,1,7,0,32,0,32,1,106,11]);
const wasm = await WebAssembly.instantiate(wasmBytes); addResult('WebAssembly', `20 + 22 = ${wasm.instance.exports.add(20,22)}`);
const audio = new AudioContext(); const oscillator = audio.createOscillator(); const gain = audio.createGain(); gain.gain.value = 0.025; oscillator.connect(gain).connect(audio.destination); oscillator.start(); oscillator.stop(audio.currentTime + 0.08); addResult('WebAudio', '短提示音已播放');
}
document.querySelector('#run').addEventListener('click', () => run().catch(error => addResult('错误', error.message)));
drawCanvas();
@@ -0,0 +1,12 @@
:root { color-scheme: light dark; font-family: "Segoe UI", system-ui, sans-serif; }
body { margin: 0; background: Canvas; color: CanvasText; }
main { max-width: 900px; margin: auto; padding: 28px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
header p { margin: 0; color: #087e5b; font-size: 12px; font-weight: 700; }
h1 { margin: 4px 0 18px; letter-spacing: 0; }
canvas { width: 100%; aspect-ratio: 10 / 3; border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); }
section { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 14px; }
article { border: 1px solid color-mix(in srgb, CanvasText 16%, transparent); padding: 12px; border-radius: 6px; }
article strong { display: block; margin-bottom: 4px; }
button { padding: 8px 13px; }
@media(max-width:600px){section{grid-template-columns:1fr}main{padding:18px}}
@@ -0,0 +1 @@
self.onmessage = event => self.postMessage({ value: Number(event.data) * 2, thread: "DedicatedWorker" });
@@ -0,0 +1,29 @@
{
"manifestVersion": 3,
"apiVersion": "2",
"id": "web-capabilities-demo",
"name": "零权限 Web 能力示例",
"version": "1.0.0",
"author": "YMhut Box",
"builtIn": true,
"description": "不申请客户端权限,展示本地 HTML/CSS、ES Module、Worker、Wasm、Canvas、WebAudio 和浏览器私有存储。",
"entry": "index.html",
"runtime": "WebView",
"permissions": [],
"permissionReasons": {},
"security": { "requiredPermissions": [] },
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
"surfaces": [
{
"kind": "ToolboxTool",
"id": "web-lab",
"name": "Web 能力实验室",
"description": "完全位于插件私有 Web 原点中的零权限示例。",
"entry": "index.html",
"category": "plugin",
"keywords": [ "html", "css", "javascript", "wasm", "worker", "canvas", "indexeddb" ],
"iconGlyph": "\uE943"
}
],
"resources": [ "index.html", "style.css", "main.js", "worker.js", "README.md" ]
}
@@ -9,6 +9,8 @@ namespace YMhut.Box.Core.Plugins;
public interface IBuiltInPluginInstallerService
{
Task EnsureInstalledAsync(CancellationToken cancellationToken = default);
Task<bool> ResetAsync(string pluginId, CancellationToken cancellationToken = default);
}
public sealed class BuiltInPluginInstallerService(
@@ -68,6 +70,31 @@ public sealed class BuiltInPluginInstallerService(
}
}
public async Task<bool> ResetAsync(string pluginId, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var plugin = DiscoverEmbeddedPlugins().FirstOrDefault(item => string.Equals(item.FolderName, pluginId, StringComparison.OrdinalIgnoreCase));
if (plugin is null)
{
return false;
}
var targetRoot = Path.Combine(PluginsRoot, plugin.FolderName);
if (Directory.Exists(targetRoot))
{
Directory.Delete(targetRoot, recursive: true);
}
await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false);
await WriteLogAsync("Information", "Built-in plugin reset", targetRoot, cancellationToken).ConfigureAwait(false);
return true;
}
finally
{
_gate.Release();
}
}
private static async Task<bool> IsUnmodifiedBuiltInPluginAsync(string targetRoot, CancellationToken cancellationToken)
{
var fingerprintPath = Path.Combine(targetRoot, FingerprintFileName);
@@ -0,0 +1,42 @@
namespace YMhut.Box.Core.Plugins;
public static class PluginBridgePolicy
{
private static readonly IReadOnlyDictionary<string, PluginPermission> Permissions = new Dictionary<string, PluginPermission>(StringComparer.Ordinal)
{
["input.get"] = PluginPermission.Input,
["input.set"] = PluginPermission.Input,
["output.set"] = PluginPermission.Output,
["output.append"] = PluginPermission.Output,
["output.clear"] = PluginPermission.Output,
["log.info"] = PluginPermission.Log,
["log.warn"] = PluginPermission.Log,
["log.error"] = PluginPermission.Log,
["storage.get"] = PluginPermission.Storage,
["storage.set"] = PluginPermission.Storage,
["storage.remove"] = PluginPermission.Storage,
["storage.list"] = PluginPermission.Storage,
["http.fetch"] = PluginPermission.Http,
["network.ping"] = PluginPermission.NetworkDiagnostics,
["network.dnsLookup"] = PluginPermission.NetworkDiagnostics,
["network.diagnostics"] = PluginPermission.NetworkDiagnostics,
["network.traceRoute"] = PluginPermission.NetworkDiagnostics,
["tool.run"] = PluginPermission.RunTool,
["clipboard.readText"] = PluginPermission.Clipboard,
["clipboard.writeText"] = PluginPermission.Clipboard,
["file.openPicker"] = PluginPermission.FilePicker,
["file.savePicker"] = PluginPermission.FilePicker,
["openExternal"] = PluginPermission.OpenExternal
};
public static IReadOnlyDictionary<string, PluginPermission> MethodPermissions => Permissions;
public static PluginPermission? RequiredPermission(string method, bool systemBrowser = false)
{
if (string.Equals(method, "openExternal", StringComparison.Ordinal) && systemBrowser)
{
return PluginPermission.OpenSystemBrowser;
}
return Permissions.TryGetValue(method, out var permission) ? permission : null;
}
}
@@ -5,7 +5,7 @@ namespace YMhut.Box.Core.Plugins;
public static class PluginHostProtocol
{
public const string Version = "1";
public const string Version = "2";
public const string Ready = "ready";
public const string Ping = "ping";
public const string Pong = "pong";
@@ -14,7 +14,10 @@ public static class PluginHostProtocol
public const string SetPluginEnabled = "setPluginEnabled";
public const string SetPermission = "setPermission";
public const string SetSurfaceMounted = "setSurfaceMounted";
public const string SetExternalRuntimeConfirmation = "setExternalRuntimeConfirmation";
public const string BridgeCall = "bridgeCall";
public const string OpenBridgeSession = "openBridgeSession";
public const string CloseBridgeSession = "closeBridgeSession";
public const string SnapshotChanged = "snapshotChanged";
public const string Shutdown = "shutdown";
public const string Error = "error";
@@ -58,7 +61,10 @@ public sealed record PluginHostMessage(
PluginSnapshot? Snapshot = null,
PluginBridgeRequest? BridgeRequest = null,
PluginBridgeResponse? BridgeResponse = null,
string? Error = null);
string? Error = null,
string? SessionToken = null,
string? Origin = null,
string? ExternalRuntimeConfirmation = null);
public sealed record PluginSnapshot(
bool PluginsEnabled,
@@ -99,7 +105,9 @@ public sealed record PluginRuntimeStateDto(
bool Enabled,
IReadOnlyList<PluginPermission> GrantedPermissions,
IReadOnlyList<string> MountedSurfaceIds,
DateTimeOffset? LastRunAt)
DateTimeOffset? LastRunAt,
IReadOnlyDictionary<PluginPermission, string>? PermissionPolicyFingerprints = null,
string? ExternalRuntimeConfirmation = null)
{
public PluginRuntimeState ToRuntimeState()
{
@@ -108,7 +116,9 @@ public sealed record PluginRuntimeStateDto(
Enabled,
GrantedPermissions.ToHashSet(),
MountedSurfaceIds.ToHashSet(StringComparer.OrdinalIgnoreCase),
LastRunAt);
LastRunAt,
PermissionPolicyFingerprints,
ExternalRuntimeConfirmation);
}
public static PluginRuntimeStateDto FromRuntimeState(PluginRuntimeState state)
@@ -118,7 +128,9 @@ public sealed record PluginRuntimeStateDto(
state.Enabled,
state.GrantedPermissions.ToArray(),
state.MountedSurfaceIds.ToArray(),
state.LastRunAt);
state.LastRunAt,
state.PermissionPolicyFingerprints,
state.ExternalRuntimeConfirmation);
}
}
@@ -154,10 +166,30 @@ public sealed record PluginBridgeRequest(
string PluginId,
string SurfaceId,
string Method,
string PayloadJson);
string PayloadJson,
string SessionToken = "",
string Origin = "");
public sealed record PluginBridgeResponse(
bool Ok,
string? ValueJson = null,
string? Error = null,
string? UiAction = null);
string? UiAction = null,
string? ErrorCode = null);
public static class PluginBridgeErrorCode
{
public const string InvalidRequest = "invalid_request";
public const string SessionInvalid = "session_invalid";
public const string PluginUnavailable = "plugin_unavailable";
public const string LegacyBridgeDisabled = "legacy_bridge_disabled";
public const string PermissionNotDeclared = "permission_not_declared";
public const string PermissionNotGranted = "permission_not_granted";
public const string PermissionScopeDenied = "permission_scope_denied";
public const string PayloadTooLarge = "payload_too_large";
public const string ConcurrencyLimit = "concurrency_limit";
public const string Timeout = "timeout";
public const string NetworkDenied = "network_denied";
public const string Unsupported = "unsupported";
public const string HostFailure = "host_failure";
}
+109 -4
View File
@@ -1,4 +1,7 @@
using System.Text.Json.Serialization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.Core.Plugins;
@@ -20,7 +23,9 @@ public enum PluginPermission
ProcessSpawn,
FileSystemRead,
FileSystemWrite,
EnvironmentRead
EnvironmentRead,
OpenSystemBrowser,
ExternalRuntime
}
public enum PluginSurfaceKind
@@ -37,6 +42,14 @@ public enum PluginRuntimeKind
Script
}
public enum PluginSecurityMode
{
StrictWeb,
ControlledExternal,
LegacyWebOnly,
UnsupportedRuntime
}
public sealed record PluginManifest(
string Id,
string Name,
@@ -47,14 +60,55 @@ public sealed record PluginManifest(
IReadOnlyList<PluginPermission> Permissions,
IReadOnlyList<PluginSurface> Surfaces,
IReadOnlyList<string> Resources,
PluginRuntimeKind Runtime = PluginRuntimeKind.Tauri,
PluginRuntimeKind Runtime = PluginRuntimeKind.WebView,
IReadOnlyList<PluginCommandSpec>? Commands = null,
PluginSecuritySpec? Security = null,
PluginTauriSpec? Tauri = null)
PluginTauriSpec? Tauri = null,
int ManifestVersion = 0,
string ApiVersion = "",
PluginRequirementsSpec? Requirements = null,
IReadOnlyDictionary<PluginPermission, string>? PermissionReasons = null,
PluginNetworkSpec? Network = null,
bool BuiltIn = false)
{
public static readonly string FileName = "ymhut.plugin.json";
public const int CurrentManifestVersion = 3;
public bool IsLegacy => ManifestVersion < CurrentManifestVersion;
public PluginSecurityMode SecurityMode => IsLegacy
? PluginSecurityMode.LegacyWebOnly
: Runtime switch
{
PluginRuntimeKind.WebView => PluginSecurityMode.StrictWeb,
PluginRuntimeKind.Tauri => PluginSecurityMode.ControlledExternal,
_ => PluginSecurityMode.UnsupportedRuntime
};
public string PermissionReason(PluginPermission permission) =>
PermissionReasons is not null && PermissionReasons.TryGetValue(permission, out var reason)
? reason.Trim()
: string.Empty;
}
public sealed record PluginRequirementsSpec(
string MinimumClientVersion = "",
int MinimumWindowsBuild = 0,
IReadOnlyList<string>? Architectures = null);
public sealed record PluginNetworkSpec(
IReadOnlyList<string>? AllowedOrigins = null,
IReadOnlyList<string>? OpenExternalOrigins = null,
IReadOnlyList<string>? RunToolIds = null);
public sealed record PluginCompatibilityResult(
bool IsCompatible,
string CurrentClientVersion,
int CurrentWindowsBuild,
string CurrentArchitecture,
IReadOnlyList<string> Issues);
public sealed record PluginTauriSpec(
string SourceDirectory = "plugin-app",
string Executable = "",
@@ -102,7 +156,15 @@ public sealed record PluginRuntimeState(
bool Enabled,
IReadOnlySet<PluginPermission> GrantedPermissions,
IReadOnlySet<string> MountedSurfaceIds,
DateTimeOffset? LastRunAt);
DateTimeOffset? LastRunAt,
IReadOnlyDictionary<PluginPermission, string>? PermissionPolicyFingerprints = null,
string? ExternalRuntimeConfirmation = null)
{
public string? PermissionFingerprint(PluginPermission permission) =>
PermissionPolicyFingerprints is not null && PermissionPolicyFingerprints.TryGetValue(permission, out var value)
? value
: null;
}
public sealed record LoadedPlugin(
PluginManifest Manifest,
@@ -111,6 +173,47 @@ public sealed record LoadedPlugin(
IReadOnlyList<string> Errors)
{
public bool IsValid => Errors.Count == 0;
public PluginSecurityMode SecurityMode => Manifest.SecurityMode;
public bool CanStart => IsValid && State.Enabled && SecurityMode != PluginSecurityMode.UnsupportedRuntime;
}
public static class PluginPermissionPolicy
{
public static bool IsRequired(PluginManifest manifest, PluginPermission permission) =>
manifest.Security?.RequiredPermissions?.Contains(permission) == true;
public static string Fingerprint(PluginManifest manifest, PluginPermission permission)
{
var policy = new
{
manifest.ManifestVersion,
manifest.ApiVersion,
manifest.Runtime,
Permission = permission,
Required = IsRequired(manifest, permission),
Reason = manifest.PermissionReason(permission),
AllowedOrigins = permission is PluginPermission.Http or PluginPermission.NetworkDiagnostics
? Normalize(manifest.Network?.AllowedOrigins)
: [],
OpenExternalOrigins = permission is PluginPermission.OpenExternal or PluginPermission.OpenSystemBrowser
? Normalize(manifest.Network?.OpenExternalOrigins)
: [],
RunToolIds = permission == PluginPermission.RunTool
? Normalize(manifest.Network?.RunToolIds)
: []
};
var json = JsonSerializer.Serialize(policy);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
}
public static bool IsGrantCurrent(PluginManifest manifest, PluginRuntimeState state, PluginPermission permission) =>
state.GrantedPermissions.Contains(permission) &&
string.Equals(state.PermissionFingerprint(permission), Fingerprint(manifest, permission), StringComparison.Ordinal);
private static string[] Normalize(IReadOnlyList<string>? values) =>
(values ?? []).Select(value => value.Trim().ToLowerInvariant()).Order(StringComparer.Ordinal).ToArray();
}
public sealed class PluginToolModule(LoadedPlugin plugin, PluginSurface surface) : IToolModule
@@ -173,5 +276,7 @@ public static class PluginIds
[JsonSerializable(typeof(PluginSurface))]
[JsonSerializable(typeof(PluginCommandSpec))]
[JsonSerializable(typeof(PluginSecuritySpec))]
[JsonSerializable(typeof(PluginRequirementsSpec))]
[JsonSerializable(typeof(PluginNetworkSpec))]
[JsonSerializable(typeof(PluginTauriSpec))]
internal sealed partial class PluginJsonContext : JsonSerializerContext;
@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
namespace YMhut.Box.Core.Plugins;
public static class PluginNetworkPolicy
{
public static bool TryNormalizePublicOrigin(string? value, bool allowWebSocket, out string origin)
{
origin = string.Empty;
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri) ||
!string.IsNullOrEmpty(uri.UserInfo) ||
!string.IsNullOrEmpty(uri.Query) ||
!string.IsNullOrEmpty(uri.Fragment) ||
uri.AbsolutePath != "/" ||
(uri.Scheme != Uri.UriSchemeHttps && (!allowWebSocket || uri.Scheme != "wss")) ||
!IsPublicHostName(uri.Host))
{
return false;
}
origin = uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
return true;
}
public static bool IsAllowed(Uri uri, IReadOnlyList<string>? declaredOrigins, bool allowWebSocket = false)
{
if (!TryNormalizePublicOrigin(uri.GetLeftPart(UriPartial.Authority), allowWebSocket, out var candidate))
{
return false;
}
return (declaredOrigins ?? []).Any(value =>
TryNormalizePublicOrigin(value, allowWebSocket, out var allowed) &&
string.Equals(candidate, allowed, StringComparison.OrdinalIgnoreCase));
}
public static async Task<bool> ResolvesToPublicAddressAsync(string host, CancellationToken cancellationToken = default)
{
if (!IsPublicHostName(host))
{
return false;
}
if (IPAddress.TryParse(host, out var literal))
{
return IsPublicAddress(literal);
}
try
{
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
return addresses.Length > 0 && addresses.All(IsPublicAddress);
}
catch (SocketException)
{
return false;
}
}
public static bool IsPublicHostName(string host)
{
if (string.IsNullOrWhiteSpace(host) ||
string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase) ||
!host.Contains('.') && !IPAddress.TryParse(host, out _))
{
return false;
}
return !IPAddress.TryParse(host, out var address) || IsPublicAddress(address);
}
public static bool IsPublicAddress(IPAddress address)
{
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) ||
address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None))
{
return false;
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
return !address.IsIPv6LinkLocal && !address.IsIPv6SiteLocal && !address.IsIPv6Multicast &&
!(address.GetAddressBytes()[0] is 0xFC or 0xFD);
}
var bytes = address.GetAddressBytes();
return bytes[0] != 0 && bytes[0] != 10 && bytes[0] != 127 &&
!(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) &&
!(bytes[0] == 169 && bytes[1] == 254) &&
!(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) &&
!(bytes[0] == 192 && bytes[1] == 168) &&
!(bytes[0] == 198 && bytes[1] is 18 or 19) &&
bytes[0] < 224;
}
}
public static class PluginWebOrigin
{
public static string Create(string pluginId, string surfaceId)
{
var value = $"{PluginIds.Normalize(pluginId)}:{PluginIds.Normalize(surfaceId)}";
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..32];
return $"https://p-{hash}.plugin.ymhut.invalid";
}
}
public static class PluginExternalWebOrigin
{
public static string ProtocolName(string pluginId, string surfaceId)
{
var value = $"external:{PluginIds.Normalize(pluginId)}:{PluginIds.Normalize(surfaceId)}";
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..32];
return $"p-{hash}";
}
public static string Create(string pluginId, string surfaceId)
{
return $"https://{ProtocolName(pluginId, surfaceId)}.localhost";
}
}
public static class PluginResourcePathPolicy
{
public static bool IsSafeRequestUri(Uri uri)
{
var original = uri.OriginalString;
var authorityStart = original.IndexOf("://", StringComparison.Ordinal);
var pathStart = authorityStart < 0 ? -1 : original.IndexOf('/', authorityStart + 3);
var escaped = pathStart < 0 ? string.Empty : original[pathStart..].Split(['?', '#'], 2)[0].TrimStart('/');
if (escaped.Contains("%25", StringComparison.OrdinalIgnoreCase) ||
escaped.Contains("%2e", StringComparison.OrdinalIgnoreCase) ||
escaped.Contains("%5c", StringComparison.OrdinalIgnoreCase) ||
escaped.Contains("%00", StringComparison.OrdinalIgnoreCase) ||
escaped.Contains('\\') ||
escaped.Contains(':'))
{
return false;
}
string decoded;
try
{
decoded = Uri.UnescapeDataString(escaped).Replace('\\', '/');
}
catch (UriFormatException)
{
return false;
}
return !decoded.StartsWith("/", StringComparison.Ordinal) &&
decoded.Split('/', StringSplitOptions.RemoveEmptyEntries)
.All(segment => segment is not "." and not ".." && !Path.IsPathFullyQualified(segment));
}
}
@@ -0,0 +1,306 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using YMhut.Box.Core.App;
namespace YMhut.Box.Core.Plugins;
public enum PluginTemplateKind
{
ZeroPermissionWeb,
BridgeNetwork
}
public sealed record PluginPackageResult(bool Succeeded, string Message, string? PluginId = null, string? Path = null);
public interface IPluginPackageService
{
Task<PluginPackageResult> CreateTemplateAsync(string pluginsRoot, string id, string name, PluginTemplateKind kind, CancellationToken cancellationToken = default);
Task<PluginPackageResult> ImportFolderAsync(string sourceDirectory, string pluginsRoot, CancellationToken cancellationToken = default);
Task<PluginPackageResult> ClearDataAsync(LoadedPlugin plugin, CancellationToken cancellationToken = default);
Task<PluginPackageResult> UninstallAsync(LoadedPlugin plugin, string pluginsRoot, CancellationToken cancellationToken = default);
}
public sealed class PluginPackageService(AppPaths paths, IPluginStateStore stateStore) : IPluginPackageService
{
private static readonly HashSet<string> NativeExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".exe", ".dll", ".com", ".scr", ".sys", ".msi", ".msix", ".msixbundle", ".appx", ".appxbundle"
};
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter<PluginPermission>(),
new JsonStringEnumConverter<PluginSurfaceKind>(),
new JsonStringEnumConverter<PluginRuntimeKind>()
}
};
public async Task<PluginPackageResult> CreateTemplateAsync(
string pluginsRoot,
string id,
string name,
PluginTemplateKind kind,
CancellationToken cancellationToken = default)
{
id = PluginIds.Normalize(id);
if (!PluginIds.IsSafeId(id))
{
return new(false, "Plugin ID may contain only letters, digits, '.', '_' and '-'.");
}
var target = Path.GetFullPath(Path.Combine(pluginsRoot, id));
if (!PluginRegistryService.IsInside(pluginsRoot, target) || Directory.Exists(target))
{
return new(false, "The plugin ID already exists or resolves outside the plugin root.");
}
Directory.CreateDirectory(target);
try
{
var manifest = CreateManifest(id, string.IsNullOrWhiteSpace(name) ? id : name.Trim(), kind);
await File.WriteAllTextAsync(Path.Combine(target, PluginManifest.FileName), JsonSerializer.Serialize(manifest, JsonOptions), Encoding.UTF8, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(Path.Combine(target, "README.md"), TemplateReadme(manifest), Encoding.UTF8, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(Path.Combine(target, "index.html"), TemplateHtml(manifest), Encoding.UTF8, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(Path.Combine(target, "style.css"), TemplateCss, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(Path.Combine(target, "main.js"), TemplateJavaScript(kind), Encoding.UTF8, cancellationToken).ConfigureAwait(false);
if (kind == PluginTemplateKind.ZeroPermissionWeb)
{
await File.WriteAllTextAsync(Path.Combine(target, "worker.js"), "self.onmessage = e => self.postMessage({ doubled: Number(e.data) * 2 });\n", Encoding.UTF8, cancellationToken).ConfigureAwait(false);
await File.WriteAllBytesAsync(Path.Combine(target, "add.wasm"), Convert.FromHexString("0061736D0100000001070160027F7F017F030201000707010361646400000A09010700200020016A0B"), cancellationToken).ConfigureAwait(false);
}
return new(true, "Plugin template created.", id, target);
}
catch
{
_ = TryDeleteDirectory(target);
throw;
}
}
public async Task<PluginPackageResult> ImportFolderAsync(string sourceDirectory, string pluginsRoot, CancellationToken cancellationToken = default)
{
var source = Path.GetFullPath(sourceDirectory);
string? unsafeEntry = null;
if (!Directory.Exists(source) || HasUnsafePackageEntry(source, out unsafeEntry))
{
return new(false, unsafeEntry is null ? "The source folder does not exist." : $"Unsafe package entry: {Path.GetFileName(unsafeEntry)}");
}
var stagingRoot = Path.Combine(paths.Cache, "PluginImports", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(stagingRoot);
try
{
await CopyDirectoryAsync(source, stagingRoot, cancellationToken).ConfigureAwait(false);
var manifestPath = Path.Combine(stagingRoot, PluginManifest.FileName);
if (!File.Exists(manifestPath))
{
return new(false, "The selected folder has no ymhut.plugin.json.");
}
PluginManifest? manifest;
await using (var stream = File.OpenRead(manifestPath))
{
manifest = await JsonSerializer.DeserializeAsync<PluginManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
}
if (manifest is null || !PluginIds.IsSafeId(manifest.Id))
{
return new(false, "The plugin manifest has an invalid ID.");
}
if (manifest.BuiltIn)
{
return new(false, "Imported packages cannot claim built-in status.");
}
var errors = PluginRegistryService.ValidatePackage(manifest, stagingRoot);
if (errors.Count > 0)
{
return new(false, $"Plugin validation failed: {string.Join("; ", errors.Take(4))}", manifest.Id);
}
var target = Path.GetFullPath(Path.Combine(pluginsRoot, PluginIds.Normalize(manifest.Id)));
if (!PluginRegistryService.IsInside(pluginsRoot, target) || Directory.Exists(target))
{
return new(false, "A plugin with the same ID already exists.", manifest.Id);
}
Directory.CreateDirectory(pluginsRoot);
Directory.Move(stagingRoot, target);
return new(true, "Plugin folder imported.", manifest.Id, target);
}
finally
{
_ = TryDeleteDirectory(stagingRoot);
}
}
public async Task<PluginPackageResult> ClearDataAsync(LoadedPlugin plugin, CancellationToken cancellationToken = default)
{
await stateStore.ClearPluginDataAsync(plugin.Manifest.Id, includeState: false, cancellationToken).ConfigureAwait(false);
var profile = Path.Combine(paths.Cache, "WebView2", "Plugins", plugin.Manifest.Id);
var profileCleared = TryDeleteDirectory(profile);
return new(profileCleared, profileCleared
? "Plugin storage and browser profile were cleared."
: "Plugin host storage was cleared, but the browser profile is still in use. Close the plugin surface and retry.", plugin.Manifest.Id);
}
public async Task<PluginPackageResult> UninstallAsync(LoadedPlugin plugin, string pluginsRoot, CancellationToken cancellationToken = default)
{
if (plugin.Manifest.BuiltIn)
{
return new(false, "Built-in plugins are protected. Use reset built-in samples instead.", plugin.Manifest.Id);
}
if (!PluginRegistryService.IsInside(pluginsRoot, plugin.RootPath) || !Directory.Exists(plugin.RootPath))
{
return new(false, "The plugin directory is outside the configured plugin root.", plugin.Manifest.Id);
}
var recycleRoot = Path.Combine(paths.Data, "PluginRecycle");
Directory.CreateDirectory(recycleRoot);
var recycled = Path.Combine(recycleRoot, $"{PluginIds.Normalize(plugin.Manifest.Id)}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}");
Directory.Move(plugin.RootPath, recycled);
await stateStore.ClearPluginDataAsync(plugin.Manifest.Id, includeState: true, cancellationToken).ConfigureAwait(false);
_ = TryDeleteDirectory(Path.Combine(paths.Cache, "WebView2", "Plugins", plugin.Manifest.Id));
return new(true, "Plugin moved to the recoverable recycle directory.", plugin.Manifest.Id, recycled);
}
private static PluginManifest CreateManifest(string id, string name, PluginTemplateKind kind)
{
var permissions = kind == PluginTemplateKind.BridgeNetwork
? new[] { PluginPermission.Http, PluginPermission.Log, PluginPermission.Output }
: [];
var reasons = kind == PluginTemplateKind.BridgeNetwork
? new Dictionary<PluginPermission, string>
{
[PluginPermission.Http] = "Request the public IP from the exact sample HTTPS endpoint.",
[PluginPermission.Log] = "Write an explicit sample action to the plugin audit log.",
[PluginPermission.Output] = "Display the sample result in the client output panel."
}
: null;
var network = kind == PluginTemplateKind.BridgeNetwork
? new PluginNetworkSpec(["https://api.ipify.org"], [], [])
: null;
var resources = kind == PluginTemplateKind.ZeroPermissionWeb
? new[] { "index.html", "style.css", "main.js", "worker.js", "add.wasm", "README.md" }
: new[] { "index.html", "style.css", "main.js", "README.md" };
return new PluginManifest(
id,
name,
"1.0.0",
"Local developer",
kind == PluginTemplateKind.ZeroPermissionWeb ? "Zero-permission local Web API sample." : "Declared Bridge and exact-origin network sample.",
"index.html",
permissions,
[new PluginSurface(PluginSurfaceKind.ToolboxTool, "main", name, "Plugin sample surface", "index.html", "plugin")],
resources,
PluginRuntimeKind.WebView,
Security: new PluginSecuritySpec(kind == PluginTemplateKind.BridgeNetwork ? [PluginPermission.Http] : []),
ManifestVersion: PluginManifest.CurrentManifestVersion,
ApiVersion: "2",
PermissionReasons: reasons,
Network: network);
}
private static string TemplateReadme(PluginManifest manifest) => $"# {manifest.Name}\n\nManifest v3 WebView sample. Local HTML/CSS/JS needs no client permission. All client and remote-service access must use declared scopes.\n";
private static string TemplateHtml(PluginManifest manifest) => $"""
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="./style.css"><title>{manifest.Name}</title></head>
<body><main><h1>{manifest.Name}</h1><p id="status">Ready</p><canvas id="canvas" width="480" height="180"></canvas><button id="run">Run sample</button></main><script type="module" src="./main.js"></script></body></html>
""";
private const string TemplateCss = """
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; background: Canvas; color: CanvasText; }
main { max-width: 760px; margin: 0 auto; padding: 32px; }
canvas { width: 100%; border: 1px solid color-mix(in srgb, CanvasText 22%, transparent); border-radius: 6px; }
button { margin-top: 16px; padding: 9px 14px; }
""";
private static string TemplateJavaScript(PluginTemplateKind kind) => kind == PluginTemplateKind.ZeroPermissionWeb
? """
const status = document.querySelector('#status');
const canvas = document.querySelector('#canvas');
const context = canvas.getContext('2d');
context.fillStyle = '#16825d'; context.fillRect(20, 20, 160, 80);
const worker = new Worker('./worker.js', { type: 'module' });
worker.onmessage = event => status.textContent = `Worker result: ${event.data.doubled}`;
document.querySelector('#run').addEventListener('click', async () => {
localStorage.setItem('lastRun', new Date().toISOString());
worker.postMessage(21);
const wasm = await WebAssembly.instantiateStreaming(fetch('./add.wasm'));
status.textContent = `Wasm result: ${wasm.instance.exports.add(20, 22)}`;
});
"""
: """
const status = document.querySelector('#status');
document.querySelector('#run').addEventListener('click', async () => {
const response = await window.ymhut.http.fetch({ url: 'https://api.ipify.org?format=json' });
const data = JSON.parse(response.content);
status.textContent = `Public IP: ${data.ip}`;
await window.ymhut.output.set(status.textContent);
await window.ymhut.log.info('Bridge/network sample completed');
});
""";
private static bool HasUnsafePackageEntry(string root, out string? unsafeEntry)
{
unsafeEntry = null;
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var directory = pending.Pop();
foreach (var entry in Directory.EnumerateFileSystemEntries(directory))
{
var attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0 ||
((attributes & FileAttributes.Directory) == 0 && NativeExtensions.Contains(Path.GetExtension(entry))))
{
unsafeEntry = entry;
return true;
}
if ((attributes & FileAttributes.Directory) != 0)
{
pending.Push(entry);
}
}
}
return false;
}
private static async Task CopyDirectoryAsync(string source, string target, CancellationToken cancellationToken)
{
Directory.CreateDirectory(target);
foreach (var file in Directory.EnumerateFiles(source))
{
cancellationToken.ThrowIfCancellationRequested();
var destination = Path.Combine(target, Path.GetFileName(file));
await using var input = File.OpenRead(file);
await using var output = File.Create(destination);
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
foreach (var directory in Directory.EnumerateDirectories(source))
{
await CopyDirectoryAsync(directory, Path.Combine(target, Path.GetFileName(directory)), cancellationToken).ConfigureAwait(false);
}
}
private static bool TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
return true;
}
catch
{
return false;
}
}
}
@@ -1,9 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Runtime.InteropServices;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
using YMhut.Box.Core.Updates;
namespace YMhut.Box.Core.Plugins;
@@ -21,7 +23,8 @@ public sealed class PluginRegistryService(
IPluginStateStore stateStore,
ILogService? logService = null,
ISettingsService? settingsService = null,
IBuiltInPluginInstallerService? builtInInstaller = null) : IPluginRegistryService
IBuiltInPluginInstallerService? builtInInstaller = null,
string? currentClientVersion = null) : IPluginRegistryService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
@@ -45,6 +48,14 @@ public sealed class PluginRegistryService(
: Path.GetFullPath(Environment.ExpandEnvironmentVariables(configuredPath.Trim()));
}
public static IReadOnlyList<string> ValidatePackage(PluginManifest manifest, string root)
{
var errors = new List<string>();
var builtInIds = ToolCatalog.DefaultModules().Select(module => module.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
ValidateManifest(manifest, root, new HashSet<string>(StringComparer.OrdinalIgnoreCase), builtInIds, errors, null);
return errors;
}
public async Task<IReadOnlyList<LoadedPlugin>> LoadPluginsAsync(CancellationToken cancellationToken = default)
{
if (settingsService is not null && !settingsService.Current.PluginsEnabled)
@@ -89,10 +100,11 @@ public sealed class PluginRegistryService(
}
manifest ??= new PluginManifest(Path.GetFileName(directory), Path.GetFileName(directory), "0.0.0", string.Empty, string.Empty, string.Empty, [], [], []);
ValidateManifest(manifest, directory, seen, builtInIds, errors);
ValidateManifest(manifest, directory, seen, builtInIds, errors, currentClientVersion);
seen.Add(manifest.Id);
var state = await stateStore.GetStateAsync(manifest.Id, cancellationToken).ConfigureAwait(false);
state = await ReconcilePermissionsAsync(manifest, state, cancellationToken).ConfigureAwait(false);
var loaded = new LoadedPlugin(manifest, directory, state, errors);
plugins.Add(loaded);
if (errors.Count > 0)
@@ -114,7 +126,7 @@ public sealed class PluginRegistryService(
var plugins = await LoadPluginsAsync(cancellationToken).ConfigureAwait(false);
return plugins
.Where(plugin => plugin.IsValid && plugin.State.Enabled)
.Where(plugin => plugin.CanStart && HasRequiredPermissions(plugin))
.SelectMany(plugin => plugin.Manifest.Surfaces
.Where(surface => surface.Kind == PluginSurfaceKind.ToolboxTool &&
(plugin.State.MountedSurfaceIds.Count == 0 || plugin.State.MountedSurfaceIds.Contains(surface.Id)))
@@ -127,7 +139,8 @@ public sealed class PluginRegistryService(
string root,
ISet<string> seen,
ISet<string> builtInIds,
IList<string> errors)
IList<string> errors,
string? clientVersion)
{
if (!PluginIds.IsSafeId(manifest.Id) || PluginIds.IsPluginToolId(manifest.Id))
{
@@ -154,6 +167,10 @@ public sealed class PluginRegistryService(
{
errors.Add("Plugin package must include README.md, README.txt, or 说明.md.");
}
if (ContainsPackageReparsePoint(root))
{
errors.Add("Plugin packages cannot contain symbolic links or directory junctions.");
}
var surfaceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var surface in manifest.Surfaces)
@@ -190,12 +207,30 @@ public sealed class PluginRegistryService(
}
}
ValidateManifestV2(manifest, root, errors);
ValidateManifestV3(manifest, root, errors, clientVersion);
}
private static void ValidateManifestV2(PluginManifest manifest, string root, IList<string> errors)
private static void ValidateManifestV3(PluginManifest manifest, string root, IList<string> errors, string? clientVersion)
{
var strict = !manifest.IsLegacy;
if (strict && manifest.ManifestVersion != PluginManifest.CurrentManifestVersion)
{
errors.Add($"Unsupported manifestVersion: {manifest.ManifestVersion}. Expected {PluginManifest.CurrentManifestVersion}.");
}
if (strict && string.IsNullOrWhiteSpace(manifest.ApiVersion))
{
errors.Add("apiVersion is required for manifest v3 plugins.");
}
var declaredPermissions = manifest.Permissions.ToHashSet();
foreach (var permission in strict ? declaredPermissions : [])
{
if (string.IsNullOrWhiteSpace(manifest.PermissionReason(permission)))
{
errors.Add($"permissionReasons must explain why {permission} is needed.");
}
}
var requiredPermissions = manifest.Security?.RequiredPermissions ?? [];
foreach (var permission in requiredPermissions)
{
@@ -205,6 +240,36 @@ public sealed class PluginRegistryService(
}
}
if (strict && manifest.Runtime == PluginRuntimeKind.Tauri &&
(!declaredPermissions.Contains(PluginPermission.ExternalRuntime) ||
!requiredPermissions.Contains(PluginPermission.ExternalRuntime)))
{
errors.Add("Tauri plugins must declare ExternalRuntime as a required permission.");
}
var unsupportedPermissions = (strict ? declaredPermissions : []).Intersect([
PluginPermission.ShellExecute,
PluginPermission.ScriptExecute,
PluginPermission.ProcessSpawn,
PluginPermission.FileSystemRead,
PluginPermission.FileSystemWrite,
PluginPermission.EnvironmentRead
]).ToArray();
foreach (var permission in unsupportedPermissions)
{
errors.Add($"Permission is not available in the strict plugin sandbox: {permission}");
}
if (strict)
{
ValidateOrigins(manifest, declaredPermissions, errors);
var compatibility = EvaluateRequirements(manifest.Requirements, clientVersion);
foreach (var issue in compatibility.Issues)
{
errors.Add(issue);
}
}
foreach (var readPath in manifest.Security?.ReadPaths ?? [])
{
if (!IsSafeRelativePath(root, readPath))
@@ -255,12 +320,12 @@ public sealed class PluginRegistryService(
}
}
if (manifest.Runtime is PluginRuntimeKind.Shell or PluginRuntimeKind.Script && commands.Count == 0)
if (strict && manifest.Runtime is PluginRuntimeKind.Shell or PluginRuntimeKind.Script)
{
errors.Add("Shell and script plugins must declare at least one command.");
errors.Add("Shell and Script runtimes are disabled because they cannot meet the required isolation boundary.");
}
if (commands.Count > 0)
if (strict && commands.Count > 0)
{
if (!declaredPermissions.Contains(PluginPermission.ShellExecute) &&
!declaredPermissions.Contains(PluginPermission.ScriptExecute))
@@ -278,7 +343,7 @@ public sealed class PluginRegistryService(
}
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
return IsInside(root, fullPath) && File.Exists(fullPath);
return IsInside(root, fullPath) && File.Exists(fullPath) && !ContainsReparsePoint(root, fullPath);
}
public static bool IsSafeRelativePath(string root, string relativePath)
@@ -289,7 +354,7 @@ public sealed class PluginRegistryService(
}
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
return IsInside(root, fullPath);
return IsInside(root, fullPath) && !ContainsReparsePoint(root, fullPath);
}
public static bool IsInside(string root, string path)
@@ -310,4 +375,212 @@ public sealed class PluginRegistryService(
File.Exists(Path.Combine(root, "README.txt")) ||
File.Exists(Path.Combine(root, "说明.md"));
}
public static bool HasRequiredPermissions(LoadedPlugin plugin)
{
if (plugin.Manifest.IsLegacy)
{
return true;
}
return (plugin.Manifest.Security?.RequiredPermissions ?? [])
.All(permission => PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, permission));
}
private async Task<PluginRuntimeState> ReconcilePermissionsAsync(
PluginManifest manifest,
PluginRuntimeState state,
CancellationToken cancellationToken)
{
foreach (var permission in state.GrantedPermissions.ToArray())
{
if (manifest.IsLegacy ||
!manifest.Permissions.Contains(permission) ||
!PluginPermissionPolicy.IsGrantCurrent(manifest, state, permission))
{
await stateStore.SetPermissionAsync(manifest.Id, permission, false, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
if (!string.IsNullOrWhiteSpace(state.ExternalRuntimeConfirmation) &&
(!string.Equals(state.ExternalRuntimeConfirmation, manifest.Version, StringComparison.Ordinal) || manifest.Runtime != PluginRuntimeKind.Tauri))
{
await stateStore.SetExternalRuntimeConfirmationAsync(manifest.Id, null, cancellationToken).ConfigureAwait(false);
}
var reconciled = await stateStore.GetStateAsync(manifest.Id, cancellationToken).ConfigureAwait(false);
if (!manifest.IsLegacy && reconciled.Enabled &&
(manifest.Security?.RequiredPermissions ?? [])
.Any(permission => !PluginPermissionPolicy.IsGrantCurrent(manifest, reconciled, permission)))
{
await stateStore.SetEnabledAsync(manifest.Id, false, cancellationToken).ConfigureAwait(false);
reconciled = await stateStore.GetStateAsync(manifest.Id, cancellationToken).ConfigureAwait(false);
}
return reconciled;
}
private static void ValidateOrigins(PluginManifest manifest, ISet<PluginPermission> permissions, IList<string> errors)
{
var allowedOrigins = manifest.Network?.AllowedOrigins ?? [];
if ((permissions.Contains(PluginPermission.Http) || permissions.Contains(PluginPermission.NetworkDiagnostics)) && allowedOrigins.Count == 0)
{
errors.Add("Http/NetworkDiagnostics permission requires network.allowedOrigins.");
}
foreach (var origin in allowedOrigins)
{
if (!PluginNetworkPolicy.TryNormalizePublicOrigin(origin, allowWebSocket: true, out _))
{
errors.Add($"Invalid public HTTPS/WSS origin: {origin}");
}
}
var externalOrigins = manifest.Network?.OpenExternalOrigins ?? [];
if ((permissions.Contains(PluginPermission.OpenExternal) || permissions.Contains(PluginPermission.OpenSystemBrowser)) && externalOrigins.Count == 0)
{
errors.Add("OpenExternal/OpenSystemBrowser requires network.openExternalOrigins.");
}
foreach (var origin in externalOrigins)
{
if (!PluginNetworkPolicy.TryNormalizePublicOrigin(origin, allowWebSocket: false, out _))
{
errors.Add($"Invalid external HTTPS origin: {origin}");
}
}
if (permissions.Contains(PluginPermission.RunTool) && (manifest.Network?.RunToolIds?.Count ?? 0) == 0)
{
errors.Add("RunTool permission requires network.runToolIds.");
}
foreach (var id in manifest.Network?.RunToolIds ?? [])
{
if (!PluginIds.IsSafeId(id))
{
errors.Add($"Invalid RunTool scope: {id}");
}
}
}
public static PluginCompatibilityResult EvaluateRequirements(PluginRequirementsSpec? requirements, string? clientVersion = null)
{
var issues = new List<string>();
var resolvedVersion = ResolveClientVersion(clientVersion);
var windowsBuild = Environment.OSVersion.Version.Build;
var architecture = RuntimeInformation.ProcessArchitecture.ToString();
if (requirements is null)
{
return new(true, resolvedVersion, windowsBuild, architecture, issues);
}
if (!string.IsNullOrWhiteSpace(requirements.MinimumClientVersion) &&
UpdateVersionComparer.CompareNormalized(resolvedVersion, requirements.MinimumClientVersion) < 0)
{
issues.Add($"Requires client version {requirements.MinimumClientVersion} or newer (current {resolvedVersion}).");
}
if (requirements.MinimumWindowsBuild > 0 && windowsBuild < requirements.MinimumWindowsBuild)
{
issues.Add($"Requires Windows build {requirements.MinimumWindowsBuild} or newer.");
}
if ((requirements.Architectures?.Count ?? 0) > 0 &&
!requirements.Architectures!.Any(value => string.Equals(value, architecture, StringComparison.OrdinalIgnoreCase)))
{
issues.Add($"Unsupported process architecture: {architecture}.");
}
return new(issues.Count == 0, resolvedVersion, windowsBuild, architecture, issues);
}
private static string ResolveClientVersion(string? supplied)
{
if (!string.IsNullOrWhiteSpace(supplied))
{
return UpdateVersionComparer.NormalizeVersion(supplied);
}
foreach (var candidate in ClientVersionCandidates())
{
try
{
if (!File.Exists(candidate))
{
continue;
}
using var document = JsonDocument.Parse(File.ReadAllText(candidate));
var root = document.RootElement;
var version = root.TryGetProperty("version", out var versionElement) ? versionElement.GetString() : null;
var build = root.TryGetProperty("build", out var buildElement) ? buildElement.ToString() : null;
if (!string.IsNullOrWhiteSpace(version))
{
return UpdateVersionComparer.NormalizeVersion(version, build);
}
}
catch (Exception exception) when (exception is IOException or JsonException or UnauthorizedAccessException)
{
}
}
return UpdateVersionComparer.NormalizeVersion(typeof(PluginRegistryService).Assembly.GetName().Version?.ToString() ?? "0.0.0");
}
private static IEnumerable<string> ClientVersionCandidates()
{
var directory = AppContext.BaseDirectory;
for (var depth = 0; depth < 5 && !string.IsNullOrWhiteSpace(directory); depth++)
{
yield return Path.Combine(directory, "version.json");
directory = Path.GetDirectoryName(directory) ?? string.Empty;
}
}
private static bool ContainsReparsePoint(string root, string path)
{
if (!IsInside(root, path))
{
return true;
}
var current = Path.GetFullPath(path);
var rootPath = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
while (!string.Equals(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), rootPath, StringComparison.OrdinalIgnoreCase))
{
if ((File.Exists(current) || Directory.Exists(current)) &&
(File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
return true;
}
current = Path.GetDirectoryName(current) ?? rootPath;
}
return false;
}
private static bool ContainsPackageReparsePoint(string root)
{
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var directory = pending.Pop();
IEnumerable<string> entries;
try
{
entries = Directory.EnumerateFileSystemEntries(directory).ToArray();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return true;
}
foreach (var entry in entries)
{
var attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
return true;
}
if ((attributes & FileAttributes.Directory) != 0)
{
pending.Push(entry);
}
}
}
return false;
}
}
+139 -7
View File
@@ -11,7 +11,11 @@ public interface IPluginStateStore
Task SetEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default);
Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default);
Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, string? policyFingerprint = null, CancellationToken cancellationToken = default);
Task SetExternalRuntimeConfirmationAsync(string pluginId, string? confirmation, CancellationToken cancellationToken = default);
Task ClearPluginDataAsync(string pluginId, bool includeState, CancellationToken cancellationToken = default);
Task SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default);
@@ -61,14 +65,43 @@ public sealed class PluginStateStore : IPluginStateStore
}
}
var permissions = await ReadStringSetAsync(connection, "plugin_permissions", "permission", pluginId, cancellationToken).ConfigureAwait(false);
var permissions = new HashSet<PluginPermission>();
var fingerprints = new Dictionary<PluginPermission, string>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT permission, policy_fingerprint FROM plugin_permissions WHERE plugin_id = $plugin_id;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (!Enum.TryParse<PluginPermission>(reader.GetString(0), out var permission))
{
continue;
}
permissions.Add(permission);
if (!reader.IsDBNull(1))
{
fingerprints[permission] = reader.GetString(1);
}
}
}
var surfaces = await ReadStringSetAsync(connection, "plugin_surfaces", "surface_id", pluginId, cancellationToken).ConfigureAwait(false);
string? externalRuntimeConfirmation = null;
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT external_runtime_confirmation FROM plugin_states WHERE plugin_id = $plugin_id;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
externalRuntimeConfirmation = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string;
}
return new PluginRuntimeState(
pluginId,
enabled,
permissions.Select(Enum.Parse<PluginPermission>).ToHashSet(),
permissions,
surfaces.ToHashSet(StringComparer.OrdinalIgnoreCase),
lastRun);
lastRun,
fingerprints,
externalRuntimeConfirmation);
}
finally
{
@@ -79,9 +112,83 @@ public sealed class PluginStateStore : IPluginStateStore
public Task SetEnabledAsync(string pluginId, bool enabled, CancellationToken cancellationToken = default)
=> UpsertStateAsync(pluginId, enabled: enabled, markRun: false, cancellationToken);
public async Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, CancellationToken cancellationToken = default)
public async Task SetPermissionAsync(string pluginId, PluginPermission permission, bool granted, string? policyFingerprint = null, CancellationToken cancellationToken = default)
{
await SetStringFlagAsync("plugin_permissions", "permission", pluginId, permission.ToString(), granted, cancellationToken).ConfigureAwait(false);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = granted
? """
INSERT INTO plugin_permissions(plugin_id, permission, policy_fingerprint, granted_at)
VALUES ($plugin_id, $permission, $fingerprint, $granted_at)
ON CONFLICT(plugin_id, permission) DO UPDATE SET
policy_fingerprint = excluded.policy_fingerprint,
granted_at = excluded.granted_at;
"""
: "DELETE FROM plugin_permissions WHERE plugin_id = $plugin_id AND permission = $permission;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$permission", permission.ToString());
command.Parameters.AddWithValue("$fingerprint", (object?)policyFingerprint ?? DBNull.Value);
command.Parameters.AddWithValue("$granted_at", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task SetExternalRuntimeConfirmationAsync(string pluginId, string? confirmation, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO plugin_states(plugin_id, enabled, external_runtime_confirmation)
VALUES ($plugin_id, 0, $confirmation)
ON CONFLICT(plugin_id) DO UPDATE SET external_runtime_confirmation = excluded.external_runtime_confirmation;
""";
command.Parameters.AddWithValue("$plugin_id", pluginId);
command.Parameters.AddWithValue("$confirmation", (object?)confirmation ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ClearPluginDataAsync(string pluginId, bool includeState, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await using var connection = OpenConnection();
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var tables = includeState
? new[] { "plugin_kv", "plugin_permissions", "plugin_surfaces", "plugin_states" }
: new[] { "plugin_kv" };
foreach (var table in tables)
{
await using var command = connection.CreateCommand();
command.Transaction = (SqliteTransaction)transaction;
command.CommandText = $"DELETE FROM {table} WHERE plugin_id = $plugin_id;";
command.Parameters.AddWithValue("$plugin_id", pluginId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default)
@@ -258,11 +365,14 @@ public sealed class PluginStateStore : IPluginStateStore
CREATE TABLE IF NOT EXISTS plugin_states (
plugin_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT NULL
last_run_at TEXT NULL,
external_runtime_confirmation TEXT NULL
);
CREATE TABLE IF NOT EXISTS plugin_permissions (
plugin_id TEXT NOT NULL,
permission TEXT NOT NULL,
policy_fingerprint TEXT NULL,
granted_at TEXT NULL,
PRIMARY KEY(plugin_id, permission)
);
CREATE TABLE IF NOT EXISTS plugin_surfaces (
@@ -278,9 +388,31 @@ public sealed class PluginStateStore : IPluginStateStore
);
""";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "plugin_states", "external_runtime_confirmation", "TEXT NULL", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "plugin_permissions", "policy_fingerprint", "TEXT NULL", cancellationToken).ConfigureAwait(false);
await EnsureColumnAsync(connection, "plugin_permissions", "granted_at", "TEXT NULL", cancellationToken).ConfigureAwait(false);
_initialized = true;
}
private static async Task EnsureColumnAsync(SqliteConnection connection, string table, string column, string definition, CancellationToken cancellationToken)
{
await using var read = connection.CreateCommand();
read.CommandText = $"PRAGMA table_info({table});";
await using var reader = await read.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
await reader.DisposeAsync().ConfigureAwait(false);
await using var alter = connection.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {definition};";
await alter.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={DatabasePath}");
@@ -6,7 +6,7 @@ namespace YMhut.Box.Core.Plugins.Runtime;
public static class PluginRuntimeProtocol
{
public const string Version = "1";
public const string Version = "2";
public const string Ready = "ready";
public const string Ping = "ping";
public const string Pong = "pong";
@@ -60,4 +60,6 @@ public sealed record PluginRuntimeMessage(
string? LogMessage = null,
string? LogDetail = null,
int? ExitCode = null,
string? Error = null);
string? Error = null,
string? SessionToken = null,
string? Origin = null);
@@ -106,6 +106,8 @@ public sealed class AppSettings
public bool PluginsEnabled { get; set; }
public bool PluginDeveloperMode { get; set; }
public string PluginRootPath { get; set; } = string.Empty;
public string FeedbackDefaultContact { get; set; } = string.Empty;
@@ -238,6 +238,7 @@ public sealed class AppSettingsStore
AssignBool(root, value => settings.HardwareAccelerationEnabled = value, ref found, "hardware_acceleration_enabled", "hardwareAccelerationEnabled");
AssignInt(root, value => settings.ProxyTestTimeoutSeconds = value, ref found, "proxy_test_timeout_seconds", "proxyTestTimeoutSeconds");
AssignBool(root, value => settings.PluginsEnabled = value, ref found, "plugins_enabled", "pluginsEnabled");
AssignBool(root, value => settings.PluginDeveloperMode = value, ref found, "plugin_developer_mode", "pluginDeveloperMode");
AssignString(root, value => settings.PluginRootPath = value, ref found, "plugin_root_path", "pluginRootPath");
AssignString(root, value => settings.FeedbackDefaultContact = value, ref found, "feedback_default_contact", "feedbackDefaultContact");
AssignString(root, value => settings.FeedbackDefaultType = NormalizeFeedbackType(value), ref found, "feedback_default_type", "feedbackDefaultType");
+334 -34
View File
@@ -39,6 +39,7 @@ var registry = new PluginRegistryService(appPaths, stateStore, logService, setti
var writeGate = new SemaphoreSlim(1, 1);
var snapshotGate = new SemaphoreSlim(1, 1);
var runtimeValues = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
var bridgeSessions = new ConcurrentDictionary<string, BridgeSession>(StringComparer.Ordinal);
FileSystemWatcher? watcher = null;
CancellationTokenSource? reloadDebounce = null;
PluginSnapshot? currentSnapshot = null;
@@ -76,6 +77,10 @@ while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
watcher?.Dispose();
reloadDebounce?.Cancel();
reloadDebounce?.Dispose();
foreach (var session in bridgeSessions.Values)
{
session.Dispose();
}
return 0;
async Task HandleAsync(PluginHostMessage message)
@@ -103,12 +108,47 @@ async Task HandleAsync(PluginHostMessage message)
await WriteAsync(new PluginHostMessage(PluginHostProtocol.Reload, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetPluginEnabled:
await stateStore.SetEnabledAsync(Required(message.PluginId), message.Enabled == true).ConfigureAwait(false);
var enablePlugin = await FindPluginAsync(Required(message.PluginId)).ConfigureAwait(false)
?? throw new InvalidOperationException("Plugin was not found.");
if (message.Enabled == true &&
(!enablePlugin.IsValid || enablePlugin.SecurityMode == PluginSecurityMode.UnsupportedRuntime ||
!PluginRegistryService.HasRequiredPermissions(enablePlugin)))
{
throw new InvalidOperationException("Plugin cannot be enabled until validation and all required permissions succeed.");
}
await stateStore.SetEnabledAsync(enablePlugin.Manifest.Id, message.Enabled == true).ConfigureAwait(false);
if (message.Enabled != true)
{
foreach (var item in bridgeSessions.Where(item => string.Equals(item.Value.PluginId, enablePlugin.Manifest.Id, StringComparison.OrdinalIgnoreCase)).ToArray())
{
if (bridgeSessions.TryRemove(item.Key, out var stoppedSession))
{
stoppedSession.Dispose();
}
}
}
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetPluginEnabled, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetPermission:
await stateStore.SetPermissionAsync(Required(message.PluginId), message.Permission ?? throw new InvalidOperationException("Missing permission."), message.Granted == true).ConfigureAwait(false);
var permissionPlugin = await FindPluginAsync(Required(message.PluginId)).ConfigureAwait(false)
?? throw new InvalidOperationException("Plugin was not found.");
var permission = message.Permission ?? throw new InvalidOperationException("Missing permission.");
if (permissionPlugin.Manifest.IsLegacy ||
!permissionPlugin.Manifest.Permissions.Contains(permission) ||
string.IsNullOrWhiteSpace(permissionPlugin.Manifest.PermissionReason(permission)))
{
throw new InvalidOperationException("Plugin did not declare this permission with a purpose.");
}
if (message.Granted != true && permissionPlugin.State.Enabled && PluginPermissionPolicy.IsRequired(permissionPlugin.Manifest, permission))
{
throw new InvalidOperationException("Disable the plugin before revoking a required permission.");
}
await stateStore.SetPermissionAsync(
permissionPlugin.Manifest.Id,
permission,
message.Granted == true,
message.Granted == true ? PluginPermissionPolicy.Fingerprint(permissionPlugin.Manifest, permission) : null).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetPermission, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
@@ -117,6 +157,45 @@ async Task HandleAsync(PluginHostMessage message)
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetSurfaceMounted, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.SetExternalRuntimeConfirmation:
var externalPlugin = await FindPluginAsync(Required(message.PluginId)).ConfigureAwait(false)
?? throw new InvalidOperationException("Plugin was not found.");
if (!settingsService.Current.PluginDeveloperMode ||
externalPlugin.Manifest.Runtime != PluginRuntimeKind.Tauri ||
!PluginPermissionPolicy.IsGrantCurrent(externalPlugin.Manifest, externalPlugin.State, PluginPermission.ExternalRuntime))
{
throw new UnauthorizedAccessException("External runtime confirmation is not allowed.");
}
var confirmation = message.ExternalRuntimeConfirmation;
if (confirmation is not null && !string.Equals(confirmation, externalPlugin.Manifest.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException("External runtime confirmation must match the current plugin version.");
}
await stateStore.SetExternalRuntimeConfirmationAsync(externalPlugin.Manifest.Id, confirmation).ConfigureAwait(false);
await ReloadSnapshotAsync(broadcast: true, CancellationToken.None).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.SetExternalRuntimeConfirmation, message.RequestId, Snapshot: currentSnapshot), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.OpenBridgeSession:
var sessionPlugin = await FindPluginAsync(Required(message.PluginId)).ConfigureAwait(false)
?? throw new InvalidOperationException("Plugin was not found.");
var sessionSurface = Required(message.SurfaceId);
var expectedOrigin = PluginWebOrigin.Create(sessionPlugin.Manifest.Id, sessionSurface);
if (!sessionPlugin.CanStart || !PluginRegistryService.HasRequiredPermissions(sessionPlugin) ||
sessionPlugin.Manifest.IsLegacy ||
!sessionPlugin.Manifest.Surfaces.Any(item => string.Equals(item.Id, sessionSurface, StringComparison.OrdinalIgnoreCase)) ||
!string.Equals(expectedOrigin, Required(message.Origin), StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException("Plugin bridge session is not allowed.");
}
var sessionToken = Required(message.SessionToken);
bridgeSessions[sessionToken] = new BridgeSession(sessionPlugin.Manifest.Id, sessionSurface, expectedOrigin);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.OpenBridgeSession, message.RequestId, SessionToken: sessionToken), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.CloseBridgeSession:
bridgeSessions.TryRemove(Required(message.SessionToken), out var removedSession);
removedSession?.Dispose();
await WriteAsync(new PluginHostMessage(PluginHostProtocol.CloseBridgeSession, message.RequestId), CancellationToken.None).ConfigureAwait(false);
break;
case PluginHostProtocol.BridgeCall:
var response = await HandleBridgeAsync(message.BridgeRequest ?? throw new InvalidOperationException("Missing bridge request.")).ConfigureAwait(false);
await WriteAsync(new PluginHostMessage(PluginHostProtocol.BridgeCall, message.RequestId, BridgeResponse: response), CancellationToken.None).ConfigureAwait(false);
@@ -259,21 +338,83 @@ void PluginFilesChanged(object sender, FileSystemEventArgs e)
async Task<PluginBridgeResponse> HandleBridgeAsync(PluginBridgeRequest request)
{
if (Encoding.UTF8.GetByteCount(request.PayloadJson ?? string.Empty) > 256 * 1024)
{
return Fail("Plugin bridge payload exceeds 256 KiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
if (string.IsNullOrWhiteSpace(request.SessionToken) ||
!bridgeSessions.TryGetValue(request.SessionToken, out var session) ||
!session.Matches(request.PluginId, request.SurfaceId, request.Origin))
{
return Fail("Plugin bridge session is invalid.", PluginBridgeErrorCode.SessionInvalid);
}
if (!await session.Concurrency.WaitAsync(0).ConfigureAwait(false))
{
return Fail("Plugin bridge concurrency limit reached.", PluginBridgeErrorCode.ConcurrencyLimit);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var plugin = await FindPluginAsync(request.PluginId).ConfigureAwait(false);
if (plugin is null || !plugin.IsValid || !plugin.State.Enabled)
{
return Fail("Plugin is not enabled or valid.");
session.Concurrency.Release();
return Fail("Plugin is not enabled or valid.", PluginBridgeErrorCode.PluginUnavailable);
}
if (plugin.Manifest.IsLegacy)
{
session.Concurrency.Release();
return Fail("Legacy plugins cannot use the client bridge.", PluginBridgeErrorCode.LegacyBridgeDisabled);
}
Task<PluginBridgeResponse>? operation = null;
var releaseSlot = true;
try
{
using var document = string.IsNullOrWhiteSpace(request.PayloadJson)
? JsonDocument.Parse("null")
: JsonDocument.Parse(request.PayloadJson);
var payload = document.RootElement;
return request.Method switch
var payload = document.RootElement.Clone();
var systemBrowser = request.Method == "openExternal" && payload.ValueKind == JsonValueKind.Object &&
payload.TryGetProperty("options", out var bridgeOptions) &&
string.Equals(ReadString(bridgeOptions, "target"), "system", StringComparison.OrdinalIgnoreCase);
var requiredPermission = PluginBridgePolicy.RequiredPermission(request.Method, systemBrowser);
if (requiredPermission is not null)
{
"input.get" => JsonOk(GetRuntime(plugin.Manifest.Id, "input", "{}")),
EnsurePluginPermission(plugin, requiredPermission.Value);
}
operation = DispatchBridgeAsync(plugin, request.Method, payload);
return await operation.WaitAsync(timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
releaseSlot = false;
_ = operation?.ContinueWith(
_ => session.Concurrency.Release(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return Fail("Plugin bridge call timed out.", PluginBridgeErrorCode.Timeout);
}
catch (Exception exception)
{
return exception is PluginBridgeException bridgeException
? Fail(bridgeException.Message, bridgeException.Code)
: Fail("The plugin host could not complete this request.", PluginBridgeErrorCode.HostFailure);
}
finally
{
if (releaseSlot)
{
session.Concurrency.Release();
}
}
}
async Task<PluginBridgeResponse> DispatchBridgeAsync(LoadedPlugin plugin, string method, JsonElement payload)
{
return method switch
{
"input.get" => ReadRuntime(plugin, "input", "{}", PluginPermission.Input),
"input.set" => SetRuntime(plugin.Manifest.Id, "input", JsonValue(payload), PluginPermission.Input),
"output.set" => AuthorizeUi(plugin, PluginPermission.Output),
"output.append" => AuthorizeUi(plugin, PluginPermission.Output),
@@ -296,14 +437,9 @@ async Task<PluginBridgeResponse> HandleBridgeAsync(PluginBridgeRequest request)
"file.openPicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.openPicker"),
"file.savePicker" => AuthorizeUi(plugin, PluginPermission.FilePicker, "file.savePicker"),
"openExternal" => ValidateExternal(plugin, payload),
_ => Fail($"Unknown plugin bridge method: {request.Method}")
_ => Fail($"Unknown plugin bridge method: {method}")
};
}
catch (Exception exception)
{
return Fail(exception.Message);
}
}
async Task<LoadedPlugin?> FindPluginAsync(string pluginId)
{
@@ -326,6 +462,12 @@ PluginBridgeResponse SetRuntime(string pluginId, string key, string value, Plugi
return JsonOk(true);
}
PluginBridgeResponse ReadRuntime(LoadedPlugin plugin, string key, string fallback, PluginPermission permission)
{
EnsurePluginPermission(plugin, permission);
return JsonOk(GetRuntime(plugin.Manifest.Id, key, fallback));
}
PluginBridgeResponse AuthorizeUi(LoadedPlugin plugin, PluginPermission permission, string? uiAction = null)
{
EnsurePluginPermission(plugin, permission);
@@ -334,14 +476,21 @@ PluginBridgeResponse AuthorizeUi(LoadedPlugin plugin, PluginPermission permissio
PluginBridgeResponse ValidateExternal(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.OpenExternal);
var value = payload.ValueKind == JsonValueKind.Object
? ReadString(payload, "url") ?? ReadString(payload, "uri") ?? string.Empty
: JsonValue(payload);
var target = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("options", out var options)
? ReadString(options, "target")
: null;
var permission = string.Equals(target, "system", StringComparison.OrdinalIgnoreCase)
? PluginPermission.OpenSystemBrowser
: PluginPermission.OpenExternal;
EnsurePluginPermission(plugin, permission);
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
uri.Scheme != Uri.UriSchemeHttps ||
!PluginNetworkPolicy.IsAllowed(uri, plugin.Manifest.Network?.OpenExternalOrigins))
{
return Fail("Only absolute http/https URLs can be opened externally.");
return Fail("The external HTTPS origin is not declared for this plugin.", PluginBridgeErrorCode.PermissionScopeDenied);
}
return new PluginBridgeResponse(true, JsonSerializer.Serialize(true), UiAction: "openExternal");
@@ -350,8 +499,8 @@ PluginBridgeResponse ValidateExternal(LoadedPlugin plugin, JsonElement payload)
async Task<PluginBridgeResponse> LogAsync(LoadedPlugin plugin, string level, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Log);
var message = ReadString(payload, "message") ?? JsonValue(payload);
var detail = ReadString(payload, "detail");
var message = LimitText(ReadString(payload, "message") ?? JsonValue(payload), 4096);
var detail = LimitText(ReadString(payload, "detail"), 8192);
await logService.WriteAsync(level, $"plugin:{plugin.Manifest.Id}", message, detail).ConfigureAwait(false);
return JsonOk(true);
}
@@ -359,15 +508,28 @@ async Task<PluginBridgeResponse> LogAsync(LoadedPlugin plugin, string level, Jso
async Task<PluginBridgeResponse> GetStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
var value = await stateStore.GetValueAsync(plugin.Manifest.Id, JsonValue(payload)).ConfigureAwait(false);
var key = ValidateStorageKey(JsonValue(payload));
var value = await stateStore.GetValueAsync(plugin.Manifest.Id, key).ConfigureAwait(false);
return JsonOk(value);
}
async Task<PluginBridgeResponse> SetStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
var key = ReadString(payload, "key") ?? throw new InvalidOperationException("storage.set requires key.");
var key = ValidateStorageKey(ReadString(payload, "key") ?? throw new InvalidOperationException("storage.set requires key."));
var value = ReadString(payload, "value") ?? JsonValue(payload.GetProperty("value"));
if (Encoding.UTF8.GetByteCount(value) > 64 * 1024)
{
return Fail("A plugin storage value cannot exceed 64 KiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
var existing = await stateStore.ListValuesAsync(plugin.Manifest.Id).ConfigureAwait(false);
var projectedSize = existing.Where(item => !string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
.Sum(item => Encoding.UTF8.GetByteCount(item.Key) + Encoding.UTF8.GetByteCount(item.Value)) +
Encoding.UTF8.GetByteCount(key) + Encoding.UTF8.GetByteCount(value);
if (projectedSize > 1024 * 1024)
{
return Fail("Plugin host storage cannot exceed 1 MiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
await stateStore.SetValueAsync(plugin.Manifest.Id, key, value).ConfigureAwait(false);
return JsonOk(true);
}
@@ -375,7 +537,7 @@ async Task<PluginBridgeResponse> SetStorageAsync(LoadedPlugin plugin, JsonElemen
async Task<PluginBridgeResponse> RemoveStorageAsync(LoadedPlugin plugin, JsonElement payload)
{
EnsurePluginPermission(plugin, PluginPermission.Storage);
await stateStore.RemoveValueAsync(plugin.Manifest.Id, JsonValue(payload)).ConfigureAwait(false);
await stateStore.RemoveValueAsync(plugin.Manifest.Id, ValidateStorageKey(JsonValue(payload))).ConfigureAwait(false);
return JsonOk(true);
}
@@ -390,25 +552,88 @@ async Task<PluginBridgeResponse> FetchAsync(LoadedPlugin plugin, JsonElement pay
EnsurePluginPermission(plugin, PluginPermission.Http);
var rawUrl = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "url") ?? ReadString(payload, "uri") : JsonValue(payload);
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
uri.Scheme != Uri.UriSchemeHttps ||
!PluginNetworkPolicy.IsAllowed(uri, plugin.Manifest.Network?.AllowedOrigins, allowWebSocket: true))
{
return Fail("ymhut.http.fetch only accepts absolute http/https URLs.");
return Fail("The HTTPS origin is not declared for this plugin.", PluginBridgeErrorCode.NetworkDenied);
}
if (!await PluginNetworkPolicy.ResolvesToPublicAddressAsync(uri.Host).ConfigureAwait(false))
{
return Fail("The destination did not resolve exclusively to public addresses.", PluginBridgeErrorCode.NetworkDenied);
}
var method = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "method") ?? "GET" : "GET";
var body = payload.ValueKind == JsonValueKind.Object ? ReadString(payload, "body") : null;
var headers = ReadHeaders(payload);
var result = await httpService.SendAsync(uri, method, body, headers, ensureSuccess: false).ConfigureAwait(false);
await logService.WriteAsync("Information", $"plugin:{plugin.Manifest.Id}", "Plugin HTTP fetch", uri.Host).ConfigureAwait(false);
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(25) };
HttpResponseMessage? response = null;
var currentUri = uri;
for (var redirect = 0; redirect <= 5; redirect++)
{
using var message = new HttpRequestMessage(new HttpMethod(method), currentUri);
if (body is not null)
{
message.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
foreach (var header in headers ?? new Dictionary<string, string>())
{
if (!header.Key.StartsWith("Proxy-", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase) &&
!message.Headers.TryAddWithoutValidation(header.Key, header.Value))
{
message.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
response?.Dispose();
response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if ((int)response.StatusCode is < 300 or >= 400 || response.Headers.Location is null)
{
break;
}
currentUri = response.Headers.Location.IsAbsoluteUri
? response.Headers.Location
: new Uri(currentUri, response.Headers.Location);
if (currentUri.Scheme != Uri.UriSchemeHttps ||
!PluginNetworkPolicy.IsAllowed(currentUri, plugin.Manifest.Network?.AllowedOrigins, allowWebSocket: true) ||
!await PluginNetworkPolicy.ResolvesToPublicAddressAsync(currentUri.Host).ConfigureAwait(false))
{
response.Dispose();
return Fail("A redirect left the declared public HTTPS origins.", PluginBridgeErrorCode.NetworkDenied);
}
}
using (response)
{
if (response is null)
{
return Fail("No HTTP response was received.", PluginBridgeErrorCode.HostFailure);
}
if (response.Content.Headers.ContentLength > 2 * 1024 * 1024)
{
return Fail("The HTTP response exceeds 2 MiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
if (bytes.Length > 2 * 1024 * 1024)
{
return Fail("The HTTP response exceeds 2 MiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
var content = Encoding.UTF8.GetString(bytes);
var responseHeaders = response.Headers.Concat(response.Content.Headers)
.ToDictionary(item => item.Key, item => string.Join(", ", item.Value), StringComparer.OrdinalIgnoreCase);
await logService.WriteAsync("Information", $"plugin:{plugin.Manifest.Id}", "Plugin HTTP fetch", currentUri.Host).ConfigureAwait(false);
return JsonOk(new
{
status = (int)result.StatusCode,
ok = (int)result.StatusCode is >= 200 and < 300,
content = result.Content,
headers = result.Headers,
elapsedMs = (long)result.Elapsed.TotalMilliseconds
status = (int)response.StatusCode,
ok = (int)response.StatusCode is >= 200 and < 300,
content,
headers = responseHeaders
});
}
}
async Task<PluginBridgeResponse> PingAsync(LoadedPlugin plugin, JsonElement payload)
{
@@ -418,6 +643,7 @@ async Task<PluginBridgeResponse> PingAsync(LoadedPlugin plugin, JsonElement payl
{
return Fail("network.ping requires host.");
}
EnsureNetworkHostScope(plugin, host);
var count = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "count", 4, 1, 12) : 4;
var timeout = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "timeoutMs", 2500, 500, 10000) : 2500;
@@ -470,6 +696,7 @@ async Task<PluginBridgeResponse> DnsLookupAsync(LoadedPlugin plugin, JsonElement
{
return Fail("network.dnsLookup requires host.");
}
EnsureNetworkHostScope(plugin, host);
try
{
@@ -500,6 +727,7 @@ async Task<PluginBridgeResponse> TraceRouteAsync(LoadedPlugin plugin, JsonElemen
{
return Fail("network.traceRoute requires host.");
}
EnsureNetworkHostScope(plugin, host);
var maxHops = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "maxHops", 12, 1, 30) : 12;
var timeout = payload.ValueKind == JsonValueKind.Object ? ReadInt(payload, "timeoutMs", 2200, 500, 8000) : 2200;
@@ -609,6 +837,10 @@ async Task<PluginBridgeResponse> RunToolAsync(LoadedPlugin plugin, JsonElement p
{
return Fail("Plugins cannot call plugin tools through ymhut.tool.run.");
}
if (!(plugin.Manifest.Network?.RunToolIds ?? []).Contains(toolId, StringComparer.OrdinalIgnoreCase))
{
return Fail("The built-in tool is outside this plugin's declared scope.", PluginBridgeErrorCode.PermissionScopeDenied);
}
var catalog = new ToolCatalog();
var module = catalog.GetById(toolId) ?? throw new InvalidOperationException($"Tool was not found: {toolId}");
@@ -631,9 +863,27 @@ void EnsurePermissionById(string pluginId, PluginPermission permission)
static void EnsurePluginPermission(LoadedPlugin plugin, PluginPermission permission)
{
if (!plugin.State.GrantedPermissions.Contains(permission))
if (plugin.Manifest.IsLegacy)
{
throw new UnauthorizedAccessException($"Plugin permission is not granted: {permission}");
throw new PluginBridgeException(PluginBridgeErrorCode.LegacyBridgeDisabled, "Legacy plugins cannot use the client bridge.");
}
if (!plugin.Manifest.Permissions.Contains(permission) || string.IsNullOrWhiteSpace(plugin.Manifest.PermissionReason(permission)))
{
throw new PluginBridgeException(PluginBridgeErrorCode.PermissionNotDeclared, $"Plugin permission is not declared: {permission}");
}
if (!PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, permission))
{
throw new PluginBridgeException(PluginBridgeErrorCode.PermissionNotGranted, $"Plugin permission is not granted: {permission}");
}
}
static void EnsureNetworkHostScope(LoadedPlugin plugin, string host)
{
if (!(plugin.Manifest.Network?.AllowedOrigins ?? []).Any(origin =>
Uri.TryCreate(origin, UriKind.Absolute, out var allowed) &&
string.Equals(allowed.Host, host.Trim(), StringComparison.OrdinalIgnoreCase)))
{
throw new PluginBridgeException(PluginBridgeErrorCode.PermissionScopeDenied, "The host is outside this plugin's declared network scope.");
}
}
@@ -649,12 +899,31 @@ string GetRuntime(string pluginId, string key, string fallback)
static PluginBridgeResponse JsonOk(object? value)
{
return new PluginBridgeResponse(true, JsonSerializer.Serialize(value));
var json = JsonSerializer.Serialize(value);
return Encoding.UTF8.GetByteCount(json) <= 512 * 1024
? new PluginBridgeResponse(true, json)
: Fail("Plugin bridge response exceeds 512 KiB.", PluginBridgeErrorCode.PayloadTooLarge);
}
static PluginBridgeResponse Fail(string error)
static PluginBridgeResponse Fail(string error, string code = PluginBridgeErrorCode.InvalidRequest)
{
return new PluginBridgeResponse(false, Error: error);
return new PluginBridgeResponse(false, Error: error, ErrorCode: code);
}
static string ValidateStorageKey(string key)
{
key = key.Trim();
if (key.Length is < 1 or > 128 || Encoding.UTF8.GetByteCount(key) > 256 || key.Any(char.IsControl))
{
throw new PluginBridgeException(PluginBridgeErrorCode.InvalidRequest, "Plugin storage keys must be 1-128 printable characters.");
}
return key;
}
static string LimitText(string? value, int maxLength)
{
value ??= string.Empty;
return value.Length <= maxLength ? value : value[..maxLength];
}
async Task WriteAsync(PluginHostMessage message, CancellationToken cancellationToken)
@@ -747,3 +1016,34 @@ static string? ReadPipeName(string[] args)
return null;
}
sealed class BridgeSession : IDisposable
{
private readonly string _surfaceId;
private readonly string _origin;
public BridgeSession(string pluginId, string surfaceId, string origin)
{
PluginId = pluginId;
_surfaceId = surfaceId;
_origin = origin;
}
public string PluginId { get; }
public SemaphoreSlim Concurrency { get; } = new(64, 64);
public bool Matches(string candidatePluginId, string candidateSurfaceId, string candidateOrigin) =>
string.Equals(PluginId, candidatePluginId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(_surfaceId, candidateSurfaceId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(_origin, candidateOrigin, StringComparison.OrdinalIgnoreCase);
public void Dispose()
{
}
}
sealed class PluginBridgeException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
+36 -97
View File
@@ -7,140 +7,79 @@
<script type="module" src="/src/main.js"></script>
<style>
:root {
color-scheme: dark;
--bg: #0f141a;
--panel: #171d24;
--panel-strong: #0a0d12;
--stroke: #2a323d;
--text: #f4f7fb;
--muted: #9aa8b8;
--accent: #4da3ff;
color-scheme: light dark;
--bg: light-dark(#f5f7fa, #101419);
--panel: light-dark(#ffffff, #171d24);
--stroke: light-dark(#d8dee7, #303945);
--text: light-dark(#17202b, #f3f6fa);
--muted: light-dark(#5f6b7a, #a8b4c2);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; }
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
color: var(--text);
background: var(--bg);
font-family: "Segoe UI", system-ui, sans-serif;
}
main {
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
padding-bottom: 44px;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
height: 100%;
}
header {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid var(--stroke);
background: var(--panel);
}
.title-stack { min-width: 0; }
#title { display: block; font-size: 15px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#meta { margin-top: 2px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.identity { min-width: 0; }
.header-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
button {
border: 1px solid var(--stroke);
border-radius: 6px;
padding: 7px 11px;
color: var(--text);
background: #1f2730;
font: inherit;
cursor: pointer;
#title, #meta, #status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button:hover { border-color: #445365; background: #26313d; }
button.primary { border-color: #2f6fb3; background: #184f8f; }
#title { display: block; font-size: 15px; font-weight: 650; }
#meta, #status { color: var(--muted); font-size: 12px; }
#meta { margin-top: 2px; }
iframe {
width: 100%;
height: 100%;
border: 0;
background: white;
}
.drawer {
position: fixed;
inset: auto 0 0 0;
height: 44px;
display: grid;
grid-template-rows: 44px 1fr;
border-top: 1px solid var(--stroke);
background: var(--panel-strong);
transition: height 160ms ease;
z-index: 10;
}
.drawer.expanded { height: min(42vh, 320px); }
.drawer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 12px;
background: #111821;
}
.drawer-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--muted);
font-size: 12px;
}
.drawer-title strong { color: var(--text); font-size: 13px; }
.drawer-actions { display: flex; gap: 8px; flex: 0 0 auto; }
pre {
margin: 0;
padding: 12px 16px 18px;
overflow: auto;
color: #d7e3f4;
background: #05070a;
font: 12px/1.45 Consolas, "Cascadia Mono", monospace;
white-space: pre-wrap;
background: Canvas;
}
</style>
</head>
<body>
<main>
<header>
<div class="title-stack">
<div class="identity">
<strong id="title">YMhut Plugin Host</strong>
<div id="meta"></div>
</div>
<div class="header-actions">
<button id="open-folder-top" type="button">Open Plugin Folder</button>
<div id="meta">Preparing controlled runtime...</div>
</div>
<div id="status">Connecting to permission broker...</div>
</header>
<iframe id="plugin-frame" title="Plugin surface"></iframe>
<iframe
id="plugin-frame"
title="Plugin surface"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="autoplay"
referrerpolicy="no-referrer"></iframe>
</main>
<aside id="drawer" class="drawer" aria-label="Shell output drawer">
<div class="drawer-bar">
<div class="drawer-title">
<strong>Shell Output</strong>
<span id="drawer-summary">ready</span>
</div>
<div class="drawer-actions">
<button id="open-folder-bottom" type="button">Open Plugin Folder</button>
<button id="toggle-drawer" class="primary" type="button" aria-expanded="false">Expand</button>
</div>
</div>
<pre id="shell-log">Waiting for plugin shell output...</pre>
</aside>
</body>
</html>
+19
View File
@@ -1700,6 +1700,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3537,6 +3547,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -4300,6 +4316,9 @@ dependencies = [
name = "ymhut-box-plugin-tauri-host"
version = "0.1.0"
dependencies = [
"http",
"mime_guess",
"percent-encoding",
"serde",
"serde_json",
"tauri",
@@ -7,6 +7,9 @@ edition = "2021"
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
http = "1"
mime_guess = "2"
percent-encoding = "2"
File diff suppressed because it is too large Load Diff
@@ -10,14 +10,7 @@
"frontendDist": "../"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "YMhut Plugin Host",
"width": 1100,
"height": 760
}
]
"withGlobalTauri": false,
"windows": []
}
}
+15 -176
View File
@@ -1,181 +1,20 @@
const params = new URLSearchParams(location.search);
const invoke = window.__TAURI__?.core?.invoke ?? (async () => {
throw new Error("Tauri invoke API is not available.");
});
const runtime = window.__YMHT_RUNTIME__;
const title = document.querySelector("#title");
const meta = document.querySelector("#meta");
const status = document.querySelector("#status");
const frame = document.querySelector("#plugin-frame");
const log = document.querySelector("#shell-log");
const drawer = document.querySelector("#drawer");
const drawerSummary = document.querySelector("#drawer-summary");
const toggleDrawer = document.querySelector("#toggle-drawer");
const openFolderTop = document.querySelector("#open-folder-top");
const openFolderBottom = document.querySelector("#open-folder-bottom");
let runtime = {
session: params.get("session") || "",
pluginId: params.get("pluginId") || "YMhut Plugin Host",
surfaceId: params.get("surfaceId") || "unknown",
runtimeKind: params.get("runtimeKind") || "tauri",
pluginRoot: params.get("pluginRoot") || "",
entry: params.get("entry") || ""
};
function normalizePath(path) {
if (!path) return "";
return path.replaceAll("\\", "/");
if (!runtime?.pluginOrigin || !runtime?.entryUrl) {
title.textContent = "YMhut Plugin Host";
meta.textContent = "The controlled runtime configuration is unavailable.";
status.textContent = "Plugin content was not loaded.";
frame.hidden = true;
} else {
title.textContent = runtime.pluginId;
meta.textContent = `${runtime.surfaceId} · controlled ${runtime.runtimeKind} runtime`;
status.textContent = "Loading isolated plugin content...";
frame.addEventListener("load", () => {
status.textContent = `Isolated origin: ${runtime.pluginOrigin}`;
}, { once: true });
frame.src = runtime.entryUrl;
}
function directoryUrl(path) {
const normalized = normalizePath(path);
if (!normalized) return "";
const directory = normalized.slice(0, normalized.lastIndexOf("/") + 1);
return new URL(`file:///${directory}`).href;
}
function createYmhutBridgeScript() {
return `
<script>
(() => {
if (window.ymhut) return;
const hostPost = (line) => {
try { parent.window.ymhutPluginHost?.appendShellLine?.(line); } catch {}
};
const storagePrefix = "ymhut-plugin:" + ${JSON.stringify(runtime.pluginId || "unknown")} + ":";
async function ok(value) { return value; }
async function fetchViaBrowser(request) {
const input = typeof request === "string" ? { url: request } : (request || {});
const response = await fetch(input.url, {
method: input.method || "GET",
headers: input.headers || {},
body: input.body
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
headers: Object.fromEntries(response.headers.entries()),
content: await response.text()
};
}
window.ymhut = {
input: { get: () => ok(""), set: value => ok(value), onInputChanged: () => {} },
output: {
set: value => { hostPost("[output] " + String(value ?? "")); return ok(true); },
append: value => { hostPost("[output] " + String(value ?? "")); return ok(true); },
clear: () => ok(true)
},
log: {
info: (message, detail) => { hostPost("[info] " + message + (detail ? " " + detail : "")); return ok(true); },
warn: (message, detail) => { hostPost("[warn] " + message + (detail ? " " + detail : "")); return ok(true); },
error: (message, detail) => { hostPost("[error] " + message + (detail ? " " + detail : "")); return ok(true); }
},
storage: {
get: key => ok(localStorage.getItem(storagePrefix + key)),
set: (key, value) => { localStorage.setItem(storagePrefix + key, value); return ok(true); },
remove: key => { localStorage.removeItem(storagePrefix + key); return ok(true); },
list: () => ok(Object.keys(localStorage).filter(k => k.startsWith(storagePrefix)).map(k => k.slice(storagePrefix.length)))
},
http: { fetch: fetchViaBrowser },
network: {
diagnostics: () => ok({ interfaces: [], summary: {}, proxy: {}, note: "Tauri compatibility bridge: native diagnostics are not connected yet." }),
ping: request => ok({ request, note: "Ping is not available in the Tauri compatibility bridge yet." }),
dnsLookup: request => ok({ request, addresses: [], note: "DNS lookup is not available in the Tauri compatibility bridge yet." }),
traceRoute: request => ok({ request, hops: [], note: "Trace route is not available in the Tauri compatibility bridge yet." })
},
clipboard: {
readText: () => navigator.clipboard?.readText?.() ?? ok(""),
writeText: text => navigator.clipboard?.writeText?.(String(text ?? "")) ?? ok(false)
},
file: {
openPicker: () => ok(null),
savePicker: (name, value) => {
const blob = new Blob([String(value ?? "")], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name || "plugin-output.txt";
a.click();
URL.revokeObjectURL(url);
return ok({ name: a.download });
}
},
tool: { run: (toolId, input) => ok({ toolId, input, note: "Tool bridge is not available in the Tauri compatibility bridge yet." }) },
openExternal: (url) => { window.open(url, "_blank", "noopener,noreferrer"); return ok(true); }
};
})();
<\/script>`;
}
async function loadPluginEntry() {
const entry = runtime.entry || `${runtime.pluginRoot}\\index.html`;
if (!entry) {
appendShellLine("[host:error] Plugin entry is empty.");
return;
}
const base = directoryUrl(entry);
try {
const html = await invoke("read_plugin_entry", { path: entry, pluginRoot: runtime.pluginRoot });
frame.srcdoc = html.replace(/<head([^>]*)>/i, `<head$1><base href="${base}">${createYmhutBridgeScript()}`);
appendShellLine(`[host] Loaded plugin entry through compatibility bridge: ${entry}`);
} catch (error) {
frame.src = new URL(`file:///${normalizePath(entry)}`).href;
appendShellLine(`[host:warn] Falling back to direct file load: ${error}`);
appendShellLine(`[host] Loaded plugin entry: ${entry}`);
}
}
function appendShellLine(line) {
if (!line) return;
if (log.textContent === "Waiting for plugin shell output...") {
log.textContent = "";
}
log.textContent += `${log.textContent ? "\n" : ""}${line}`;
log.scrollTop = log.scrollHeight;
drawerSummary.textContent = line.length > 96 ? `${line.slice(0, 96)}...` : line;
}
async function openPluginFolder() {
try {
if (!runtime.pluginRoot) {
throw new Error("Plugin folder path is empty.");
}
await invoke("open_plugin_folder", { path: runtime.pluginRoot });
appendShellLine(`[host] Opened plugin folder: ${runtime.pluginRoot}`);
} catch (error) {
appendShellLine(`[host:error] ${error}`);
}
}
function applyRuntime(runtimeArgs) {
runtime = { ...runtime, ...runtimeArgs };
title.textContent = runtime.pluginId || "YMhut Plugin Host";
meta.textContent = `session=${runtime.session || "unknown"} surface=${runtime.surfaceId || "unknown"} runtime=${runtime.runtimeKind || "tauri"}`;
loadPluginEntry();
}
window.ymhutPluginHost = {
appendShellLine
};
toggleDrawer.addEventListener("click", () => {
const expanded = !drawer.classList.contains("expanded");
drawer.classList.toggle("expanded", expanded);
toggleDrawer.textContent = expanded ? "Collapse" : "Expand";
toggleDrawer.setAttribute("aria-expanded", String(expanded));
});
openFolderTop.addEventListener("click", openPluginFolder);
openFolderBottom.addEventListener("click", openPluginFolder);
invoke("runtime_args")
.then(applyRuntime)
.catch((error) => {
appendShellLine(`[host:error] Failed to load runtime args: ${error}`);
applyRuntime(runtime);
});
+336
View File
@@ -0,0 +1,336 @@
using Microsoft.Data.Sqlite;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Plugins.Runtime;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class PluginSecurityTests
{
[TestMethod]
public void ManifestV3RequiresReasonsAndExactScopes()
{
using var workspace = TempWorkspace();
var root = CreatePackage(workspace.Path);
var manifest = StrictManifest() with
{
Permissions = [PluginPermission.Http, PluginPermission.OpenExternal, PluginPermission.RunTool],
PermissionReasons = new Dictionary<PluginPermission, string>
{
[PluginPermission.Http] = "Read the declared API.",
[PluginPermission.OpenExternal] = ""
},
Network = new PluginNetworkSpec(["https://example.com/api"], ["http://example.com"], [])
};
var errors = PluginRegistryService.ValidatePackage(manifest, root);
Assert.IsTrue(errors.Any(error => error.Contains("OpenExternal", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(errors.Any(error => error.Contains("Invalid public", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(errors.Any(error => error.Contains("Invalid external", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(errors.Any(error => error.Contains("RunTool", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void PublicOriginPolicyRejectsLocalPrivateWildcardAndPaths()
{
Assert.IsTrue(PluginNetworkPolicy.TryNormalizePublicOrigin("https://api.example.com", true, out var origin));
Assert.AreEqual("https://api.example.com", origin);
Assert.IsTrue(PluginNetworkPolicy.TryNormalizePublicOrigin("wss://stream.example.com:8443", true, out _));
Assert.IsFalse(PluginNetworkPolicy.TryNormalizePublicOrigin("http://example.com", true, out _));
Assert.IsFalse(PluginNetworkPolicy.TryNormalizePublicOrigin("https://localhost", true, out _));
Assert.IsFalse(PluginNetworkPolicy.TryNormalizePublicOrigin("https://192.168.1.10", true, out _));
Assert.IsFalse(PluginNetworkPolicy.TryNormalizePublicOrigin("https://*.example.com", true, out _));
Assert.IsFalse(PluginNetworkPolicy.TryNormalizePublicOrigin("https://example.com/api", true, out _));
}
[TestMethod]
public void ResourcePolicyRejectsTraversalDoubleEncodingAndAbsolutePaths()
{
Assert.IsTrue(PluginResourcePathPolicy.IsSafeRequestUri(new Uri("https://p-demo.plugin.ymhut.invalid/assets/main.js")));
Assert.IsFalse(PluginResourcePathPolicy.IsSafeRequestUri(new Uri("https://p-demo.plugin.ymhut.invalid/assets/%252e%252e/secret")));
Assert.IsFalse(PluginResourcePathPolicy.IsSafeRequestUri(new Uri("https://p-demo.plugin.ymhut.invalid/assets/%2e%2e/secret")));
Assert.IsFalse(PluginResourcePathPolicy.IsSafeRequestUri(new Uri("https://p-demo.plugin.ymhut.invalid/C:%5cWindows%5cwin.ini")));
}
[TestMethod]
public void LegacyManifestIsWebOnlyAndVirtualOriginsAreIsolated()
{
var legacy = StrictManifest() with { ManifestVersion = 0, Runtime = PluginRuntimeKind.Tauri };
Assert.IsTrue(legacy.IsLegacy);
Assert.AreEqual(PluginSecurityMode.LegacyWebOnly, legacy.SecurityMode);
Assert.AreNotEqual(PluginWebOrigin.Create("plugin-a", "main"), PluginWebOrigin.Create("plugin-b", "main"));
Assert.AreNotEqual(PluginWebOrigin.Create("plugin-a", "main"), PluginWebOrigin.Create("plugin-a", "settings"));
Assert.AreEqual(
$"https://{PluginExternalWebOrigin.ProtocolName("plugin-a", "main")}.localhost",
PluginExternalWebOrigin.Create("plugin-a", "main"));
Assert.AreNotEqual(PluginExternalWebOrigin.Create("plugin-a", "main"), PluginExternalWebOrigin.Create("plugin-b", "main"));
Assert.AreNotEqual(PluginExternalWebOrigin.Create("plugin-a", "main"), PluginExternalWebOrigin.Create("plugin-a", "settings"));
}
[TestMethod]
public void StrictShellIsRejectedAndTauriRequiresExternalRuntime()
{
using var workspace = TempWorkspace();
var root = CreatePackage(workspace.Path);
var shellErrors = PluginRegistryService.ValidatePackage(StrictManifest() with { Runtime = PluginRuntimeKind.Shell }, root);
Assert.IsTrue(shellErrors.Any(error => error.Contains("Shell and Script", StringComparison.OrdinalIgnoreCase)));
var tauriErrors = PluginRegistryService.ValidatePackage(StrictManifest() with { Runtime = PluginRuntimeKind.Tauri }, root);
Assert.IsTrue(tauriErrors.Any(error => error.Contains("ExternalRuntime", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void PermissionFingerprintChangesOnlyForRelevantScope()
{
var original = StrictManifest() with
{
Permissions = [PluginPermission.Http, PluginPermission.Output],
PermissionReasons = new Dictionary<PluginPermission, string>
{
[PluginPermission.Http] = "Read data.",
[PluginPermission.Output] = "Show data."
},
Network = new PluginNetworkSpec(["https://one.example.com"], [], [])
};
var changed = original with { Network = new PluginNetworkSpec(["https://two.example.com"], [], []) };
Assert.AreNotEqual(PluginPermissionPolicy.Fingerprint(original, PluginPermission.Http), PluginPermissionPolicy.Fingerprint(changed, PluginPermission.Http));
Assert.AreEqual(PluginPermissionPolicy.Fingerprint(original, PluginPermission.Output), PluginPermissionPolicy.Fingerprint(changed, PluginPermission.Output));
}
[TestMethod]
public void RequirementsCheckClientVersionBuildAndArchitecture()
{
var result = PluginRegistryService.EvaluateRequirements(
new PluginRequirementsSpec("9.0.0", int.MaxValue, ["ImpossibleArchitecture"]),
"2.0.7.12");
Assert.IsFalse(result.IsCompatible);
Assert.HasCount(3, result.Issues);
}
[TestMethod]
public async Task StateStoreMigratesOldDatabaseAndPreservesKv()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(workspace.Path);
Directory.CreateDirectory(paths.Data);
var databasePath = Path.Combine(paths.Data, "plugins.db");
await using (var connection = new SqliteConnection($"Data Source={databasePath}"))
{
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE plugin_states (plugin_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, last_run_at TEXT NULL);
CREATE TABLE plugin_permissions (plugin_id TEXT NOT NULL, permission TEXT NOT NULL, PRIMARY KEY(plugin_id, permission));
CREATE TABLE plugin_surfaces (plugin_id TEXT NOT NULL, surface_id TEXT NOT NULL, PRIMARY KEY(plugin_id, surface_id));
CREATE TABLE plugin_kv (plugin_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(plugin_id, key));
INSERT INTO plugin_kv(plugin_id, key, value) VALUES ('demo', 'saved', 'kept');
""";
await command.ExecuteNonQueryAsync();
}
var store = new PluginStateStore(paths);
Assert.AreEqual("kept", await store.GetValueAsync("demo", "saved"));
await store.SetPermissionAsync("demo", PluginPermission.Http, true, "FINGERPRINT");
await store.SetExternalRuntimeConfirmationAsync("demo", "1.2.3");
var state = await new PluginStateStore(paths).GetStateAsync("demo");
Assert.AreEqual("FINGERPRINT", state.PermissionFingerprint(PluginPermission.Http));
Assert.AreEqual("1.2.3", state.ExternalRuntimeConfirmation);
}
[TestMethod]
public async Task RequiredPermissionAndStaleFingerprintBlockEnabledTool()
{
using var workspace = TempWorkspace();
var pluginRoot = Path.Combine(workspace.Path, "Plugins", "required-demo");
Directory.CreateDirectory(pluginRoot);
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<!doctype html>");
File.WriteAllText(Path.Combine(pluginRoot, "README.md"), "# Required");
File.WriteAllText(Path.Combine(pluginRoot, PluginManifest.FileName), """
{
"manifestVersion": 3,
"apiVersion": "2",
"id": "required-demo",
"name": "Required Demo",
"version": "1.0.0",
"author": "tester",
"description": "Required permission test",
"entry": "index.html",
"runtime": "WebView",
"permissions": ["Output"],
"permissionReasons": { "Output": "Show the requested result." },
"security": { "requiredPermissions": ["Output"] },
"network": { "allowedOrigins": [], "openExternalOrigins": [], "runToolIds": [] },
"surfaces": [{ "kind": "ToolboxTool", "id": "main", "name": "Main", "description": "Main" }],
"resources": ["index.html", "README.md"]
}
""");
var paths = AppPaths.ForCurrentUser(workspace.Path);
var store = new PluginStateStore(paths);
await store.SetEnabledAsync("required-demo", true);
await store.SetPermissionAsync("required-demo", PluginPermission.Output, true, "STALE");
var registry = new PluginRegistryService(paths, store, currentClientVersion: "2.0.7.12");
var plugin = (await registry.LoadPluginsAsync()).Single();
Assert.DoesNotContain(PluginPermission.Output, plugin.State.GrantedPermissions);
Assert.HasCount(0, await registry.LoadEnabledToolModulesAsync());
await store.SetPermissionAsync("required-demo", PluginPermission.Output, true, PluginPermissionPolicy.Fingerprint(plugin.Manifest, PluginPermission.Output));
await store.SetEnabledAsync("required-demo", true);
Assert.HasCount(1, await registry.LoadEnabledToolModulesAsync());
}
[TestMethod]
public async Task PackageServiceCreatesTemplatesRejectsNativeFilesAndProtectsBuiltIns()
{
using var workspace = TempWorkspace();
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "app"));
var service = new PluginPackageService(paths, new PluginStateStore(paths));
var sourceRoot = Path.Combine(workspace.Path, "source");
var targetRoot = Path.Combine(workspace.Path, "target");
var created = await service.CreateTemplateAsync(sourceRoot, "web-demo", "Web Demo", PluginTemplateKind.ZeroPermissionWeb);
Assert.IsTrue(created.Succeeded, created.Message);
Assert.IsTrue(File.Exists(Path.Combine(created.Path!, "worker.js")));
Assert.IsTrue(File.Exists(Path.Combine(created.Path!, "add.wasm")));
File.WriteAllText(Path.Combine(created.Path!, "native.exe"), "not an executable");
var rejected = await service.ImportFolderAsync(created.Path!, targetRoot);
Assert.IsFalse(rejected.Succeeded);
StringAssert.Contains(rejected.Message, "Unsafe");
var clean = await service.CreateTemplateAsync(sourceRoot, "bridge-demo", "Bridge Demo", PluginTemplateKind.BridgeNetwork);
var imported = await service.ImportFolderAsync(clean.Path!, targetRoot);
Assert.IsTrue(imported.Succeeded, imported.Message);
var userManifest = StrictManifest() with { Id = "bridge-demo" };
var userState = new PluginRuntimeState("bridge-demo", false, new HashSet<PluginPermission>(), new HashSet<string>(), null);
var userPlugin = new LoadedPlugin(userManifest, imported.Path!, userState, []);
var removed = await service.UninstallAsync(userPlugin, targetRoot);
Assert.IsTrue(removed.Succeeded, removed.Message);
Assert.IsFalse(Directory.Exists(imported.Path));
Assert.IsTrue(Directory.Exists(removed.Path));
var manifest = StrictManifest() with { BuiltIn = true };
var state = new PluginRuntimeState(manifest.Id, false, new HashSet<PluginPermission>(), new HashSet<string>(), null);
var protectedPlugin = new LoadedPlugin(manifest, created.Path!, state, []);
var uninstall = await service.UninstallAsync(protectedPlugin, sourceRoot);
Assert.IsFalse(uninstall.Succeeded);
}
[TestMethod]
public void ProtocolV2RoundTripsSessionBindingAndErrorCode()
{
var request = new PluginBridgeRequest("demo", "main", "input.get", "null", "token", PluginWebOrigin.Create("demo", "main"));
var response = new PluginBridgeResponse(false, Error: "Denied", ErrorCode: PluginBridgeErrorCode.PermissionNotGranted);
var message = new PluginHostMessage(PluginHostProtocol.BridgeCall, BridgeRequest: request, BridgeResponse: response);
var parsed = PluginHostProtocol.Deserialize(PluginHostProtocol.Serialize(message));
Assert.AreEqual("token", parsed?.BridgeRequest?.SessionToken);
Assert.AreEqual(PluginBridgeErrorCode.PermissionNotGranted, parsed?.BridgeResponse?.ErrorCode);
}
[TestMethod]
public void ExternalRuntimeProtocolV2CarriesOnlyNativeSessionBinding()
{
var origin = PluginExternalWebOrigin.Create("demo", "main");
var message = new PluginRuntimeMessage(
PluginRuntimeProtocol.Ready,
Version: PluginRuntimeProtocol.Version,
SessionToken: "native-token",
Origin: origin);
var parsed = PluginRuntimeProtocol.Deserialize(PluginRuntimeProtocol.Serialize(message));
Assert.AreEqual("2", parsed?.Version);
Assert.AreEqual("native-token", parsed?.SessionToken);
Assert.AreEqual(origin, parsed?.Origin);
Assert.IsNull(parsed?.BridgeRequest);
}
[TestMethod]
public void BridgePermissionMatrixCoversEveryPublicMethod()
{
var expected = new Dictionary<string, PluginPermission>
{
["input.get"] = PluginPermission.Input,
["input.set"] = PluginPermission.Input,
["output.set"] = PluginPermission.Output,
["output.append"] = PluginPermission.Output,
["output.clear"] = PluginPermission.Output,
["log.info"] = PluginPermission.Log,
["log.warn"] = PluginPermission.Log,
["log.error"] = PluginPermission.Log,
["storage.get"] = PluginPermission.Storage,
["storage.set"] = PluginPermission.Storage,
["storage.remove"] = PluginPermission.Storage,
["storage.list"] = PluginPermission.Storage,
["http.fetch"] = PluginPermission.Http,
["network.ping"] = PluginPermission.NetworkDiagnostics,
["network.dnsLookup"] = PluginPermission.NetworkDiagnostics,
["network.diagnostics"] = PluginPermission.NetworkDiagnostics,
["network.traceRoute"] = PluginPermission.NetworkDiagnostics,
["tool.run"] = PluginPermission.RunTool,
["clipboard.readText"] = PluginPermission.Clipboard,
["clipboard.writeText"] = PluginPermission.Clipboard,
["file.openPicker"] = PluginPermission.FilePicker,
["file.savePicker"] = PluginPermission.FilePicker,
["openExternal"] = PluginPermission.OpenExternal
};
CollectionAssert.AreEquivalent(expected.Keys.ToArray(), PluginBridgePolicy.MethodPermissions.Keys.ToArray());
foreach (var item in expected)
{
Assert.AreEqual(item.Value, PluginBridgePolicy.RequiredPermission(item.Key));
}
Assert.AreEqual(PluginPermission.OpenSystemBrowser, PluginBridgePolicy.RequiredPermission("openExternal", systemBrowser: true));
}
private static PluginManifest StrictManifest() => new(
"security-test",
"Security Test",
"1.0.0",
"tester",
"Security test plugin",
"index.html",
[],
[new PluginSurface(PluginSurfaceKind.ToolboxTool, "main", "Main", "Main surface")],
["index.html", "README.md"],
PluginRuntimeKind.WebView,
Security: new PluginSecuritySpec([]),
ManifestVersion: 3,
ApiVersion: "2",
PermissionReasons: new Dictionary<PluginPermission, string>(),
Network: new PluginNetworkSpec([], [], []));
private static string CreatePackage(string root)
{
Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, "index.html"), "<!doctype html>");
File.WriteAllText(Path.Combine(root, "README.md"), "# Test");
return root;
}
private static TempDirectory TempWorkspace() => new(Path.Combine(Path.GetTempPath(), "ymhut-plugin-security-tests", Guid.NewGuid().ToString("N")));
private sealed class TempDirectory(string path) : IDisposable
{
public string Path { get; } = Create(path);
public void Dispose()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
private static string Create(string value)
{
Directory.CreateDirectory(value);
return value;
}
}
}
+2 -2
View File
@@ -217,7 +217,7 @@ public sealed class PluginTests
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("IPCheck 安全网络概览", StringComparison.OrdinalIgnoreCase));
var manifestText = File.ReadAllText(installedManifest);
Assert.IsTrue(manifestText.Contains("\"Http\"", StringComparison.OrdinalIgnoreCase));
Assert.IsTrue(manifestText.Contains("\"OpenExternal\"", StringComparison.OrdinalIgnoreCase));
@@ -248,7 +248,7 @@ public sealed class PluginTests
CollectionAssert.Contains(plugin.Manifest.Resources.ToList(), "README.md");
var readme = File.ReadAllText(Path.Combine(plugin.RootPath, "README.md"));
StringAssert.Contains(readme, "Bridge 能力示例");
StringAssert.Contains(readme, "manifest v3");
StringAssert.Contains(readme, "安全浏览器");
StringAssert.Contains(readme, "AI 实现提示");
}
+5 -3
View File
@@ -1411,12 +1411,14 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
{
ClearActivePluginHost();
SafeNavigate(
() => new PluginRuntimePage(plugin, surface, () => ShowToolboxPage()),
() => plugin.Manifest.Runtime == PluginRuntimeKind.WebView || plugin.Manifest.IsLegacy
? new PluginHostPage(plugin, surface, () => ShowToolboxPage())
: new PluginRuntimePage(plugin, surface, () => ShowToolboxPage()),
ShellPage.Toolbox,
"plugin-runtime",
$"Open Tauri plugin runtime: {plugin.Manifest.Id}/{surface.Id}",
$"Open plugin runtime: {plugin.Manifest.Id}/{surface.Id}",
plugin.Manifest.Runtime.ToString());
_ = _logService.WriteAsync("Information", "plugin", $"Open Tauri plugin runtime: {plugin.Manifest.Id}/{surface.Id}", plugin.Manifest.Runtime.ToString());
_ = _logService.WriteAsync("Information", "plugin", $"Open plugin runtime: {plugin.Manifest.Id}/{surface.Id}", plugin.Manifest.Runtime.ToString());
}
public async Task SetPluginsEnabledAsync(bool enabled)
+4 -1
View File
@@ -44,11 +44,14 @@ public static class AppServices
provider.GetRequiredService<IStartupCheckStore>(),
provider.GetService<ILogService>()));
services.AddSingleton<IPluginStateStore, PluginStateStore>();
services.AddSingleton<IPluginPackageService, PluginPackageService>();
services.AddSingleton<PluginLogService>();
services.AddSingleton<IPlatformCapabilities, WindowsPlatformCapabilities>();
services.AddSingleton<IShellRuntime>(provider => new ShellRuntimeService(provider.GetService<ILogService>()));
services.AddSingleton<IPluginRuntimeLauncher>(provider => new TauriPluginProcessService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IPluginHostProcessService>(),
provider.GetRequiredService<IToolLinkNavigationService>(),
provider.GetRequiredService<ISettingsService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IBuiltInPluginInstallerService>(provider => new BuiltInPluginInstallerService(
provider.GetRequiredService<AppPaths>(),
@@ -26,8 +26,14 @@ public interface IPluginHostProcessService : IDisposable
Task<PluginSnapshot> SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default);
Task<PluginSnapshot> SetExternalRuntimeConfirmationAsync(string pluginId, string? version, CancellationToken cancellationToken = default);
Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default);
Task<string> OpenBridgeSessionAsync(string pluginId, string surfaceId, string origin, CancellationToken cancellationToken = default);
Task CloseBridgeSessionAsync(string sessionToken, CancellationToken cancellationToken = default);
void ResetFailedState();
void Stop();
@@ -92,12 +98,45 @@ public sealed class PluginHostProcessService(ILogService? logService = null) : I
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> SetExternalRuntimeConfirmationAsync(string pluginId, string? version, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(
PluginHostProtocol.SetExternalRuntimeConfirmation,
PluginId: pluginId,
ExternalRuntimeConfirmation: version), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.BridgeCall, BridgeRequest: request), cancellationToken).ConfigureAwait(false);
return response.BridgeResponse ?? new PluginBridgeResponse(false, Error: response.Error ?? "Plugin host returned no bridge response.");
}
public async Task<string> OpenBridgeSessionAsync(string pluginId, string surfaceId, string origin, CancellationToken cancellationToken = default)
{
var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
var response = await SendRequestAsync(new PluginHostMessage(
PluginHostProtocol.OpenBridgeSession,
PluginId: pluginId,
SurfaceId: surfaceId,
SessionToken: token,
Origin: origin), cancellationToken).ConfigureAwait(false);
return string.IsNullOrWhiteSpace(response.SessionToken)
? throw new InvalidOperationException(response.Error ?? "Plugin host did not open the bridge session.")
: response.SessionToken;
}
public async Task CloseBridgeSessionAsync(string sessionToken, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sessionToken) || Status != PluginHostStatus.Ready)
{
return;
}
await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.CloseBridgeSession, SessionToken: sessionToken), cancellationToken).ConfigureAwait(false);
}
public void Stop()
{
_stopping = true;
@@ -1,19 +1,40 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.System;
using WinRT.Interop;
using YMhut.Box.Core.App;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Platform;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Plugins.Runtime;
using YMhut.Box.Core.Settings;
using YMhut.Box.Core.Tools;
namespace YMhut.Box.WinUI.Services;
public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logService = null) : IPluginRuntimeLauncher, IDisposable
public sealed class TauriPluginProcessService(
AppPaths paths,
IPluginHostProcessService pluginHost,
IToolLinkNavigationService linkNavigationService,
ISettingsService settingsService,
ILogService? logService = null) : IPluginRuntimeLauncher, IDisposable
{
private static readonly TimeSpan BrokerConnectTimeout = TimeSpan.FromSeconds(12);
private readonly ConcurrentDictionary<string, PluginRuntimeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, Process> _processes = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, List<ShellOutputEvent>> _output = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, ExternalRuntimeBroker> _brokers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _externalOutputs = new(StringComparer.OrdinalIgnoreCase);
private long _sequence;
private int _snapshotSubscribed;
private bool _disposed;
public async Task<PluginRuntimeSession> LaunchAsync(PluginRuntimeLaunchRequest request, CancellationToken cancellationToken = default)
@@ -47,6 +68,69 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
return session;
}
ExternalRuntimeBroker? broker = null;
Process? process = null;
try
{
EnsureSnapshotSubscription();
if (!settingsService.Current.PluginDeveloperMode)
{
throw new UnauthorizedAccessException("The controlled external runtime requires plugin developer mode.");
}
var snapshot = await pluginHost.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
var pluginDto = snapshot.Plugins.FirstOrDefault(candidate =>
string.Equals(candidate.Manifest.Id, request.PluginId, StringComparison.OrdinalIgnoreCase));
if (pluginDto is null || !pluginDto.IsValid || !pluginDto.State.Enabled ||
pluginDto.Manifest.Runtime != PluginRuntimeKind.Tauri)
{
throw new UnauthorizedAccessException("The Tauri plugin is unavailable, disabled, or invalid.");
}
var plugin = pluginDto.ToLoadedPlugin();
if (!PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalRuntime) ||
!string.Equals(plugin.State.ExternalRuntimeConfirmation, plugin.Manifest.Version, StringComparison.Ordinal))
{
throw new UnauthorizedAccessException("The external runtime permission or version confirmation is not current.");
}
var registeredRoot = Path.GetFullPath(plugin.RootPath);
var requestedRoot = Path.GetFullPath(request.PluginRoot);
var requestedEntry = Path.GetFullPath(request.Entry);
if (!string.Equals(registeredRoot.TrimEnd(Path.DirectorySeparatorChar), requestedRoot.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase) ||
!PluginRegistryService.IsInside(registeredRoot, requestedEntry) ||
!File.Exists(requestedEntry))
{
throw new InvalidDataException("The external runtime launch paths do not match the registered plugin package.");
}
var allowedOrigins = await ResolveAllowedOriginsAsync(plugin, cancellationToken).ConfigureAwait(false);
var origin = PluginExternalWebOrigin.Create(request.PluginId, request.SurfaceId);
var protocolName = PluginExternalWebOrigin.ProtocolName(request.PluginId, request.SurfaceId);
var bridgeSessionToken = await pluginHost.OpenBridgeSessionAsync(
request.PluginId,
request.SurfaceId,
origin,
cancellationToken).ConfigureAwait(false);
var pipeName = $"YMhutBoxExternalPlugin-{Environment.ProcessId}-{Guid.NewGuid():N}";
var pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
broker = new ExternalRuntimeBroker(
sessionId,
request.PluginId,
request.SurfaceId,
origin,
bridgeSessionToken,
RuntimePolicyKey(plugin),
pipe);
_brokers[sessionId] = broker;
var profileRoot = Path.Combine(paths.Cache, "WebView2", "Plugins", request.PluginId, "Tauri", request.SurfaceId);
Directory.CreateDirectory(profileRoot);
var startInfo = new ProcessStartInfo
{
FileName = host,
@@ -57,29 +141,23 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
CreateNoWindow = false
};
startInfo.ArgumentList.Add("--session");
startInfo.ArgumentList.Add(sessionId);
startInfo.ArgumentList.Add("--plugin-id");
startInfo.ArgumentList.Add(request.PluginId);
startInfo.ArgumentList.Add("--surface-id");
startInfo.ArgumentList.Add(request.SurfaceId);
startInfo.ArgumentList.Add("--runtime-kind");
startInfo.ArgumentList.Add(request.RuntimeKind.ToString());
startInfo.ArgumentList.Add("--plugin-root");
startInfo.ArgumentList.Add(request.PluginRoot);
startInfo.ArgumentList.Add("--manifest");
startInfo.ArgumentList.Add(request.ManifestPath);
startInfo.ArgumentList.Add("--entry");
startInfo.ArgumentList.Add(request.Entry);
if (!string.IsNullOrWhiteSpace(request.CommandId))
AddArgument(startInfo, "--session", sessionId);
AddArgument(startInfo, "--plugin-id", request.PluginId);
AddArgument(startInfo, "--surface-id", request.SurfaceId);
AddArgument(startInfo, "--runtime-kind", request.RuntimeKind.ToString());
AddArgument(startInfo, "--plugin-root", registeredRoot);
AddArgument(startInfo, "--entry", requestedEntry);
AddArgument(startInfo, "--protocol-name", protocolName);
AddArgument(startInfo, "--plugin-origin", origin);
AddArgument(startInfo, "--profile-root", profileRoot);
AddArgument(startInfo, "--broker-pipe", pipeName);
AddArgument(startInfo, "--developer-mode", settingsService.Current.PluginDeveloperMode ? "true" : "false");
foreach (var allowedOrigin in allowedOrigins)
{
startInfo.ArgumentList.Add("--command");
startInfo.ArgumentList.Add(request.CommandId);
AddArgument(startInfo, "--allowed-origin", allowedOrigin);
}
try
{
var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start Tauri plugin host.");
process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start Tauri plugin host.");
process.EnableRaisingEvents = true;
process.OutputDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stdout, args.Data);
process.ErrorDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stderr, args.Data);
@@ -88,6 +166,18 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
process.BeginErrorReadLine();
_processes[sessionId] = process;
await pipe.WaitForConnectionAsync(cancellationToken)
.WaitAsync(BrokerConnectTimeout, cancellationToken)
.ConfigureAwait(false);
broker.Reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
broker.Writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true) { AutoFlush = true };
await broker.Writer.WriteLineAsync(PluginRuntimeProtocol.Serialize(new PluginRuntimeMessage(
PluginRuntimeProtocol.Ready,
Version: PluginRuntimeProtocol.Version,
SessionToken: bridgeSessionToken,
Origin: origin))).ConfigureAwait(false);
broker.LoopTask = Task.Run(() => RunBrokerAsync(broker), CancellationToken.None);
session = session with
{
ProcessId = process.Id,
@@ -99,6 +189,20 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
}
catch (Exception exception)
{
if (process is { HasExited: false })
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
}
}
if (broker is not null)
{
await CloseBrokerAsync(sessionId, broker).ConfigureAwait(false);
}
session = session with
{
Status = PluginRuntimeSessionStatus.Failed,
@@ -114,6 +218,10 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
public async Task StopAsync(string sessionId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_brokers.TryGetValue(sessionId, out var broker))
{
await CloseBrokerAsync(sessionId, broker).ConfigureAwait(false);
}
if (_processes.TryRemove(sessionId, out var process))
{
try
@@ -142,6 +250,7 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
};
await WriteLogAsync("Information", session.PluginId, "Tauri plugin host stopped", $"session={sessionId}", cancellationToken).ConfigureAwait(false);
}
_externalOutputs.TryRemove(sessionId, out _);
}
public Task<PluginRuntimeSession?> GetSessionAsync(string sessionId, CancellationToken cancellationToken = default)
@@ -173,6 +282,10 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
}
_disposed = true;
if (Interlocked.Exchange(ref _snapshotSubscribed, 0) != 0)
{
pluginHost.SnapshotChanged -= PluginHost_SnapshotChanged;
}
foreach (var sessionId in _processes.Keys.ToArray())
{
try
@@ -189,6 +302,11 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
{
try
{
if (_brokers.TryGetValue(sessionId, out var broker))
{
_ = CloseBrokerAsync(sessionId, broker);
}
_externalOutputs.TryRemove(sessionId, out _);
int? exitCode = process.HasExited ? process.ExitCode : null;
if (_sessions.TryGetValue(sessionId, out var session))
{
@@ -237,6 +355,438 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
await (logService?.WriteAsync(level, $"plugin:{pluginId}:runtime", message, detail, cancellationToken) ?? Task.CompletedTask).ConfigureAwait(false);
}
private async Task<IReadOnlyList<string>> ResolveAllowedOriginsAsync(LoadedPlugin plugin, CancellationToken cancellationToken)
{
if (!PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.Http))
{
return [];
}
var origins = new List<string>();
foreach (var value in plugin.Manifest.Network?.AllowedOrigins ?? [])
{
if (!PluginNetworkPolicy.TryNormalizePublicOrigin(value, allowWebSocket: true, out var origin) ||
!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
!await PluginNetworkPolicy.ResolvesToPublicAddressAsync(uri.Host, cancellationToken).ConfigureAwait(false))
{
throw new UnauthorizedAccessException("A declared external runtime origin did not resolve exclusively to public addresses.");
}
origins.Add(origin);
}
return origins.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private void EnsureSnapshotSubscription()
{
if (Interlocked.Exchange(ref _snapshotSubscribed, 1) == 0)
{
pluginHost.SnapshotChanged += PluginHost_SnapshotChanged;
}
}
private void PluginHost_SnapshotChanged(object? sender, PluginSnapshot snapshot)
{
foreach (var broker in _brokers.Values)
{
var plugin = snapshot.Plugins.FirstOrDefault(candidate =>
string.Equals(candidate.Manifest.Id, broker.PluginId, StringComparison.OrdinalIgnoreCase));
if (plugin is null || !plugin.IsValid || RuntimePolicyKey(plugin.ToLoadedPlugin()) != broker.PolicyKey)
{
_ = StopAsync(broker.SessionId);
}
}
}
private static string RuntimePolicyKey(LoadedPlugin plugin)
{
var state = plugin.State;
return JsonSerializer.Serialize(new
{
plugin.Manifest.Version,
plugin.Manifest.Runtime,
plugin.Manifest.Permissions,
plugin.Manifest.Security,
plugin.Manifest.PermissionReasons,
plugin.Manifest.Network,
plugin.Manifest.Requirements,
state.Enabled,
Granted = state.GrantedPermissions.OrderBy(value => value).ToArray(),
Fingerprints = state.PermissionPolicyFingerprints?.OrderBy(value => value.Key).ToArray(),
state.ExternalRuntimeConfirmation
});
}
private async Task RunBrokerAsync(ExternalRuntimeBroker broker)
{
try
{
while (!broker.Cancellation.IsCancellationRequested && broker.Reader is not null)
{
var line = await broker.Reader.ReadLineAsync(broker.Cancellation.Token).ConfigureAwait(false);
if (line is null)
{
break;
}
if (Encoding.UTF8.GetByteCount(line) > 512 * 1024)
{
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
PluginRuntimeProtocol.Error,
Error: "External runtime message exceeded the size limit.")).ConfigureAwait(false);
break;
}
PluginRuntimeMessage? message;
try
{
message = PluginRuntimeProtocol.Deserialize(line);
}
catch (JsonException)
{
message = null;
}
if (message is null)
{
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
PluginRuntimeProtocol.Error,
Error: "External runtime sent an invalid message.")).ConfigureAwait(false);
continue;
}
if (string.Equals(message.Type, PluginRuntimeProtocol.Ping, StringComparison.Ordinal))
{
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
PluginRuntimeProtocol.Pong,
message.RequestId,
Version: PluginRuntimeProtocol.Version)).ConfigureAwait(false);
continue;
}
if (!string.Equals(message.Type, PluginRuntimeProtocol.BridgeCall, StringComparison.Ordinal) ||
message.BridgeRequest is null)
{
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
PluginRuntimeProtocol.Error,
message.RequestId,
Error: "External runtime message type is not supported.")).ConfigureAwait(false);
continue;
}
var response = await HandleBrokerBridgeCallAsync(broker, message.BridgeRequest).ConfigureAwait(false);
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
PluginRuntimeProtocol.BridgeCall,
message.RequestId,
BridgeResponse: response)).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
CaptureOutput(broker.SessionId, ShellOutputStream.System, "External runtime broker stopped unexpectedly.");
await WriteLogAsync(
"Warning",
broker.PluginId,
"External runtime broker failed",
AppLocalizer.SanitizeSensitiveText(exception.Message, 220),
CancellationToken.None).ConfigureAwait(false);
}
finally
{
await CloseBrokerAsync(broker.SessionId, broker).ConfigureAwait(false);
}
}
private async Task<PluginBridgeResponse> HandleBrokerBridgeCallAsync(ExternalRuntimeBroker broker, PluginBridgeRequest request)
{
if (!string.Equals(request.PluginId, broker.PluginId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(request.SurfaceId, broker.SurfaceId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(request.SessionToken, broker.BridgeSessionToken, StringComparison.Ordinal) ||
!string.Equals(request.Origin, broker.Origin, StringComparison.OrdinalIgnoreCase) ||
Encoding.UTF8.GetByteCount(request.PayloadJson) > 256 * 1024)
{
return new PluginBridgeResponse(
false,
Error: "The external runtime bridge session is invalid.",
ErrorCode: PluginBridgeErrorCode.SessionInvalid);
}
var response = await pluginHost.BridgeCallAsync(request, broker.Cancellation.Token).ConfigureAwait(false);
if (!response.Ok)
{
return response;
}
try
{
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(request.PayloadJson) ? "null" : request.PayloadJson);
var payload = document.RootElement.Clone();
var handled = false;
object? value = null;
if (response.UiAction is not null)
{
handled = true;
value = await ExecuteUiActionAsync(broker, response.UiAction, payload).ConfigureAwait(false);
}
else if (request.Method is "output.set" or "output.append" or "output.clear")
{
handled = true;
value = UpdateExternalOutput(broker.SessionId, request.Method, payload);
}
return handled
? response with { ValueJson = JsonSerializer.Serialize(value), UiAction = null }
: response with { UiAction = null };
}
catch (OperationCanceledException) when (broker.Cancellation.IsCancellationRequested)
{
return new PluginBridgeResponse(false, Error: "The external runtime session was closed.", ErrorCode: PluginBridgeErrorCode.SessionInvalid);
}
catch (Exception exception)
{
await WriteLogAsync(
"Warning",
broker.PluginId,
"External runtime UI action failed",
AppLocalizer.SanitizeSensitiveText(exception.Message, 220),
CancellationToken.None).ConfigureAwait(false);
return new PluginBridgeResponse(false, Error: "The external runtime UI action failed.", ErrorCode: PluginBridgeErrorCode.HostFailure);
}
}
private Task<object?> ExecuteUiActionAsync(ExternalRuntimeBroker broker, string uiAction, JsonElement payload)
{
return RunOnUiThreadAsync<object?>(async () => uiAction switch
{
"clipboard.readText" => await ReadClipboardAsync().ConfigureAwait(true),
"clipboard.writeText" => WriteClipboard(payload),
"file.openPicker" => await OpenFilePickerAsync().ConfigureAwait(true),
"file.savePicker" => await SaveFilePickerAsync(payload).ConfigureAwait(true),
"openExternal" => await OpenExternalAsync(broker.PluginId, payload).ConfigureAwait(true),
_ => throw new NotSupportedException("The approved UI action is not supported by the external runtime broker.")
});
}
private object UpdateExternalOutput(string sessionId, string method, JsonElement payload)
{
if (method == "output.clear")
{
_externalOutputs.TryRemove(sessionId, out _);
CaptureOutput(sessionId, ShellOutputStream.System, "Plugin output cleared.");
return true;
}
var value = JsonValue(payload);
var next = method == "output.append" && _externalOutputs.TryGetValue(sessionId, out var current)
? current + value
: value;
if (next.Length > 256 * 1024)
{
next = next[(next.Length - (256 * 1024))..];
}
_externalOutputs[sessionId] = next;
CaptureOutput(sessionId, ShellOutputStream.System, $"Plugin output updated ({next.Length} chars).");
return true;
}
private static async Task<object?> ReadClipboardAsync()
{
var content = Clipboard.GetContent();
return content.Contains(StandardDataFormats.Text)
? await content.GetTextAsync().AsTask().ConfigureAwait(true)
: string.Empty;
}
private static object WriteClipboard(JsonElement payload)
{
var package = new DataPackage();
package.SetText(JsonValue(payload));
Clipboard.SetContent(package);
return true;
}
private static async Task<object?> OpenFilePickerAsync()
{
if (App.CurrentWindow is null)
{
throw new InvalidOperationException("No active window is available for the file picker.");
}
var picker = new FileOpenPicker();
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
picker.FileTypeFilter.Add("*");
var file = await picker.PickSingleFileAsync();
if (file is null)
{
return null;
}
var properties = await file.GetBasicPropertiesAsync();
if (properties.Size > 2 * 1024 * 1024)
{
throw new InvalidDataException("Selected plugin input files cannot exceed 2 MiB.");
}
return new { name = file.Name, content = await FileIO.ReadTextAsync(file) };
}
private static async Task<object?> SaveFilePickerAsync(JsonElement payload)
{
if (App.CurrentWindow is null)
{
throw new InvalidOperationException("No active window is available for the file picker.");
}
var suggestedName = ReadString(payload, "name") ?? "plugin-output.txt";
var value = ReadString(payload, "value") ?? ReadString(payload, "bytesOrText") ?? ReadString(payload, "text") ?? string.Empty;
var extension = Path.GetExtension(suggestedName);
if (string.IsNullOrWhiteSpace(extension))
{
extension = ".txt";
suggestedName += extension;
}
var picker = new FileSavePicker { SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedName) };
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
picker.FileTypeChoices.Add("Plugin output", [extension]);
var file = await picker.PickSaveFileAsync();
if (file is null)
{
return null;
}
await FileIO.WriteTextAsync(file, value);
return new { name = file.Name };
}
private async Task<object> OpenExternalAsync(string pluginId, JsonElement payload)
{
var value = ReadString(payload, "url") ?? JsonValue(payload);
var target = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("options", out var options)
? ReadString(options, "target")
: null;
var linkTarget = string.Equals(target, "system", StringComparison.OrdinalIgnoreCase)
? ToolLinkTarget.SystemBrowser
: ToolLinkTarget.SafeBrowser;
if (linkTarget == ToolLinkTarget.SystemBrowser)
{
var root = App.CurrentWindow?.Content as FrameworkElement;
if (root?.XamlRoot is null)
{
throw new InvalidOperationException("No active window is available for confirmation.");
}
var dialog = new ContentDialog
{
Title = "允许插件打开系统浏览器?",
Content = $"插件 {pluginId} 请求打开:\n{value}",
PrimaryButtonText = "允许本次",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = root.XamlRoot
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
throw new UnauthorizedAccessException("The system browser request was cancelled.");
}
}
if (!await linkNavigationService.OpenAsync(value, linkTarget).ConfigureAwait(true))
{
throw new InvalidOperationException("The approved external link could not be opened.");
}
return true;
}
private static Task<T> RunOnUiThreadAsync<T>(Func<Task<T>> action)
{
var dispatcher = App.CurrentWindow?.DispatcherQueue
?? throw new InvalidOperationException("No active UI dispatcher is available.");
if (dispatcher.HasThreadAccess)
{
return action();
}
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!dispatcher.TryEnqueue(async () =>
{
try
{
completion.TrySetResult(await action().ConfigureAwait(true));
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
}))
{
completion.TrySetException(new InvalidOperationException("The UI dispatcher rejected the external runtime action."));
}
return completion.Task;
}
private static async Task WriteBrokerResponseAsync(ExternalRuntimeBroker broker, PluginRuntimeMessage message)
{
if (broker.Writer is null || broker.Cancellation.IsCancellationRequested)
{
return;
}
await broker.WriteGate.WaitAsync(broker.Cancellation.Token).ConfigureAwait(false);
try
{
await broker.Writer.WriteLineAsync(PluginRuntimeProtocol.Serialize(message)).ConfigureAwait(false);
}
finally
{
broker.WriteGate.Release();
}
}
private async Task CloseBrokerAsync(string sessionId, ExternalRuntimeBroker broker)
{
if (Interlocked.Exchange(ref broker.Closed, 1) != 0)
{
return;
}
_brokers.TryRemove(sessionId, out _);
broker.Cancellation.Cancel();
try
{
broker.Reader?.Dispose();
broker.Writer?.Dispose();
broker.Pipe.Dispose();
}
catch
{
}
try
{
await pluginHost.CloseBridgeSessionAsync(broker.BridgeSessionToken).ConfigureAwait(false);
}
catch
{
}
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{
startInfo.ArgumentList.Add(name);
startInfo.ArgumentList.Add(value);
}
private static string JsonValue(JsonElement element)
{
return element.ValueKind == JsonValueKind.String ? element.GetString() ?? string.Empty : element.GetRawText();
}
private static string? ReadString(JsonElement element, string property)
{
return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value)
? JsonValue(value)
: null;
}
private string? ResolveHostExecutable()
{
var candidates = new List<string>
@@ -256,4 +806,28 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
return candidates.FirstOrDefault(File.Exists);
}
private sealed class ExternalRuntimeBroker(
string sessionId,
string pluginId,
string surfaceId,
string origin,
string bridgeSessionToken,
string policyKey,
NamedPipeServerStream pipe)
{
public string SessionId { get; } = sessionId;
public string PluginId { get; } = pluginId;
public string SurfaceId { get; } = surfaceId;
public string Origin { get; } = origin;
public string BridgeSessionToken { get; } = bridgeSessionToken;
public string PolicyKey { get; } = policyKey;
public NamedPipeServerStream Pipe { get; } = pipe;
public CancellationTokenSource Cancellation { get; } = new();
public SemaphoreSlim WriteGate { get; } = new(1, 1);
public StreamReader? Reader { get; set; }
public StreamWriter? Writer { get; set; }
public Task? LoopTask { get; set; }
public int Closed;
}
}
+95 -11
View File
@@ -13,11 +13,15 @@ using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
internal sealed class PluginBridge(LoadedPlugin plugin, PluginSurface surface, string origin, WebView2 webView) : IAsyncDisposable
{
private const int MaxMessageBytes = 256 * 1024;
private readonly IPluginHostProcessService _pluginHost = AppServices.GetRequiredService<IPluginHostProcessService>();
private readonly IToolLinkNavigationService _linkNavigationService = AppServices.GetRequiredService<IToolLinkNavigationService>();
private readonly Dictionary<string, string> _runtimeValues = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _concurrency = new(64, 64);
private string _sessionToken = string.Empty;
private bool _attached;
public string BootstrapScript => """
(() => {
@@ -34,7 +38,11 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
if (!item) return;
pending.delete(String(message.id));
if (message.ok) item.resolve(message.value);
else item.reject(new Error(message.error || "Plugin bridge call failed"));
else {
const error = new Error(message.error || "Plugin bridge call failed");
error.code = message.errorCode || "host_failure";
item.reject(error);
}
};
chrome.webview.addEventListener("message", e => window.__ymhutBridgeResolve(e.data || {}));
window.ymhut = {
@@ -59,32 +67,79 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
public event EventHandler<string>? OutputChanged;
public void Attach()
public async Task AttachAsync(CancellationToken cancellationToken = default)
{
if (_attached)
{
return;
}
_sessionToken = await _pluginHost.OpenBridgeSessionAsync(plugin.Manifest.Id, surface.Id, origin, cancellationToken).ConfigureAwait(true);
webView.WebMessageReceived += WebView_WebMessageReceived;
_attached = true;
}
public void Detach()
public async ValueTask DisposeAsync()
{
if (!_attached)
{
return;
}
webView.WebMessageReceived -= WebView_WebMessageReceived;
_attached = false;
try
{
await _pluginHost.CloseBridgeSessionAsync(_sessionToken).ConfigureAwait(true);
}
catch
{
}
_sessionToken = string.Empty;
}
private async void WebView_WebMessageReceived(WebView2 sender, CoreWebView2WebMessageReceivedEventArgs args)
{
string id = string.Empty;
var acquired = false;
try
{
if (!string.Equals(new Uri(args.Source).GetLeftPart(UriPartial.Authority), origin, StringComparison.OrdinalIgnoreCase) ||
System.Text.Encoding.UTF8.GetByteCount(args.WebMessageAsJson) > MaxMessageBytes)
{
await ReplyAsync(id, ok: false, "Plugin message origin or size is invalid.", PluginBridgeErrorCode.InvalidRequest).ConfigureAwait(true);
return;
}
if (!await _concurrency.WaitAsync(0).ConfigureAwait(true))
{
await ReplyAsync(id, ok: false, "Plugin bridge concurrency limit reached.", PluginBridgeErrorCode.ConcurrencyLimit).ConfigureAwait(true);
return;
}
acquired = true;
using var document = JsonDocument.Parse(args.WebMessageAsJson);
var root = document.RootElement;
id = root.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? string.Empty : string.Empty;
var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? string.Empty : string.Empty;
var payload = root.TryGetProperty("payload", out var payloadElement) ? payloadElement : default;
var result = await HandleAsync(method, payload).ConfigureAwait(true);
var result = await HandleAsync(method, payload).WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(true);
await ReplyAsync(id, ok: true, result).ConfigureAwait(true);
}
catch (TimeoutException)
{
await ReplyAsync(id, ok: false, "Plugin bridge call timed out.", PluginBridgeErrorCode.Timeout).ConfigureAwait(true);
}
catch (PluginBridgeCallException exception)
{
await ReplyAsync(id, ok: false, exception.Message, exception.Code).ConfigureAwait(true);
}
catch (Exception exception)
{
await ReplyAsync(id, ok: false, exception.Message).ConfigureAwait(true);
await ReplyAsync(id, ok: false, exception.Message, PluginBridgeErrorCode.HostFailure).ConfigureAwait(true);
}
finally
{
if (acquired)
{
_concurrency.Release();
}
}
}
@@ -92,12 +147,14 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
{
var response = await _pluginHost.BridgeCallAsync(new PluginBridgeRequest(
plugin.Manifest.Id,
string.Empty,
surface.Id,
method,
payload.ValueKind == JsonValueKind.Undefined ? "null" : payload.GetRawText())).ConfigureAwait(true);
payload.ValueKind == JsonValueKind.Undefined ? "null" : payload.GetRawText(),
_sessionToken,
origin)).ConfigureAwait(true);
if (!response.Ok)
{
throw new InvalidOperationException(response.Error ?? "Plugin bridge call failed.");
throw new PluginBridgeCallException(response.ErrorCode ?? PluginBridgeErrorCode.HostFailure, response.Error ?? "Plugin bridge call failed.");
}
var uiResult = response.UiAction switch
@@ -168,6 +225,22 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
var linkTarget = string.Equals(target, "system", StringComparison.OrdinalIgnoreCase)
? ToolLinkTarget.SystemBrowser
: ToolLinkTarget.SafeBrowser;
if (linkTarget == ToolLinkTarget.SystemBrowser)
{
var dialog = new ContentDialog
{
Title = "允许插件打开系统浏览器?",
Content = $"{plugin.Manifest.Name} 请求打开:\n{value}",
PrimaryButtonText = "允许本次",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = webView.XamlRoot
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
throw new PluginBridgeCallException(PluginBridgeErrorCode.PermissionNotGranted, "The system browser request was cancelled.");
}
}
await _linkNavigationService.OpenAsync(value, linkTarget).ConfigureAwait(true);
return true;
}
@@ -188,6 +261,12 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
return null;
}
var properties = await file.GetBasicPropertiesAsync();
if (properties.Size > 2 * 1024 * 1024)
{
throw new PluginBridgeCallException(PluginBridgeErrorCode.PayloadTooLarge, "Selected plugin input files cannot exceed 2 MiB.");
}
var text = await FileIO.ReadTextAsync(file);
return new { name = file.Name, content = text };
}
@@ -221,11 +300,11 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
return new { name = file.Name };
}
private async Task ReplyAsync(string id, bool ok, object? valueOrError)
private async Task ReplyAsync(string id, bool ok, object? valueOrError, string? errorCode = null)
{
var payload = ok
? JsonSerializer.Serialize(new { id, ok = true, value = valueOrError })
: JsonSerializer.Serialize(new { id, ok = false, error = valueOrError?.ToString() ?? "Plugin bridge call failed" });
: JsonSerializer.Serialize(new { id, ok = false, error = valueOrError?.ToString() ?? "Plugin bridge call failed", errorCode });
webView.CoreWebView2?.PostWebMessageAsJson(payload);
await Task.CompletedTask;
}
@@ -241,4 +320,9 @@ internal sealed class PluginBridge(LoadedPlugin plugin, WebView2 webView)
? JsonValue(value)
: null;
}
private sealed class PluginBridgeCallException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
}
+9 -7
View File
@@ -63,17 +63,18 @@ NavPage:显示在插件页,可作为插件主页面或控制台。
surface.entry manifest.entry使 manifest.entry WebView WebView
"""));
docs.Children.Add(Section("4. 权限模型", """
manifest 使
manifest v3 permissionReasonssecurity.requiredPermissions Bridge
Input/
Output宿
Log plugin:<pluginId>
Storage访 key-value
Http宿 HTTP http/https
Http访 network.allowedOrigins HTTPS/WSS
Clipboard
FilePicker
RunTool
OpenExternal http/https
OpenExternal openExternalOrigins HTTPS
OpenSystemBrowser HTTPS 宿
NetworkDiagnosticspingDNStrace route 宿
"""));
docs.Children.Add(Section("5. JS Bridge", """
@@ -101,7 +102,7 @@ window.ymhut.file.savePicker(name, value)
window.ymhut.tool.run(toolId, input)
window.ymhut.openExternal(url, { target?: "safe" | "system" })
openExternal { target: "system" } OpenExternal
openExternal { target: "system" } OpenSystemBrowser 宿
"""));
docs.Children.Add(Section("6. 独立窗口、输出区与遮挡", """
使宿
@@ -111,16 +112,17 @@ openExternal 默认打开应用内安全浏览器。只有显式传入 { target:
使 fixed z-index
"""));
docs.Children.Add(Section("7. 安全边界", """
WebView file:// 资源;非本地导航会被拦截并交给安全浏览器
surface 使 HTTPS WebView2 使 file://。跨原点导航、新窗口、下载、iframe 与浏览器敏感权限会被拒绝
ID Assets
resourcesentry surface.entry
宿
fetch/WebSocket 宿 fetch 使HTTPlocalhost
LegacyWebOnly HTML/CSS/JS BridgeShell/Script
"""));
docs.Children.Add(Section("8. 常见问题", """
ymhut.plugin.jsonREADMEentry
manifest
http/https URL
openExternalOrigins HTTPS
output UI
DOM API Edge/
Http NetworkDiagnostics
+190 -17
View File
@@ -2,10 +2,12 @@ using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using Windows.System;
using System.Text;
using System.Text.Json;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Tools;
using YMhut.Box.Core.Settings;
using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
@@ -16,9 +18,10 @@ public sealed class PluginHostPage : ToolPageBase
private readonly PluginSurface _surface;
private readonly Action? _goBack;
private readonly IPluginStateStore _stateStore = AppServices.GetRequiredService<IPluginStateStore>();
private readonly IPluginHostProcessService _pluginHost = AppServices.GetRequiredService<IPluginHostProcessService>();
private readonly ILogService _logService = AppServices.GetRequiredService<ILogService>();
private readonly WebView2EnvironmentFactory _webViewEnvironmentFactory = AppServices.GetRequiredService<WebView2EnvironmentFactory>();
private readonly IToolLinkNavigationService _linkNavigationService = AppServices.GetRequiredService<IToolLinkNavigationService>();
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly WebView2 _webView = new();
private readonly TextBox _outputBox = new()
{
@@ -30,6 +33,8 @@ public sealed class PluginHostPage : ToolPageBase
};
private readonly TextBlock _statusText = ModernUi.Text("等待加载插件页面", 13, foreground: ModernUi.TextSecondary);
private bool _loaded;
private PluginBridge? _bridge;
private string _origin = string.Empty;
public PluginHostPage(LoadedPlugin plugin, PluginSurface surface, Action? goBack = null)
{
@@ -39,6 +44,7 @@ public sealed class PluginHostPage : ToolPageBase
Background = ModernUi.AppBackground;
Content = BuildContent();
Loaded += PluginHostPage_Loaded;
Unloaded += PluginHostPage_Unloaded;
}
private UIElement BuildContent()
@@ -52,16 +58,28 @@ public sealed class PluginHostPage : ToolPageBase
root.RowDefinitions.Add(new RowDefinition());
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var openExternal = ModernUi.IconButton("\uE8A7", "打开插件目录", async () =>
var reload = ModernUi.IconButton("\uE72C", "安全重新加载", () =>
{
await Launcher.LaunchFolderPathAsync(_plugin.RootPath);
_webView.CoreWebView2?.Reload();
});
var disable = ModernUi.IconButton("\uE71A", "停用插件", async () =>
{
await _pluginHost.SetPluginEnabledAsync(_plugin.Manifest.Id, false);
await DisposeBridgeAsync();
_webView.CoreWebView2?.Stop();
_statusText.Text = "插件已停用";
});
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { reload, disable } };
if (_settingsService.Current.PluginDeveloperMode)
{
actions.Children.Insert(1, ModernUi.IconButton("\uE943", "打开 WebView DevTools", () => _webView.CoreWebView2?.OpenDevToolsWindow()));
}
root.Children.Add(ModernUi.PageHeader(
_surface.Name,
$"{_plugin.Manifest.Name} v{_plugin.Manifest.Version} · {_surface.Description}",
"\uE943",
actions: openExternal,
actions: actions,
back: _goBack,
backTooltip: "返回插件页"));
@@ -107,20 +125,44 @@ public sealed class PluginHostPage : ToolPageBase
var options = environment.CreateCoreWebView2ControllerOptions();
options.IsInPrivateModeEnabled = false;
await _webView.EnsureCoreWebView2Async(environment, options);
_webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
_webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
_webView.CoreWebView2.Settings.AreDevToolsEnabled = _settingsService.Current.PluginDeveloperMode;
_webView.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false;
_webView.CoreWebView2.Settings.IsGeneralAutofillEnabled = false;
_webView.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false;
_webView.AllowDrop = false;
_origin = PluginWebOrigin.Create(_plugin.Manifest.Id, _surface.Id);
var virtualHost = new Uri(_origin).Host;
_webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
virtualHost,
_plugin.RootPath,
CoreWebView2HostResourceAccessKind.DenyCors);
_webView.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
_webView.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested;
_webView.CoreWebView2.NavigationStarting += CoreWebView2_NavigationStarting;
_webView.CoreWebView2.FrameNavigationStarting += CoreWebView2_FrameNavigationStarting;
_webView.CoreWebView2.NewWindowRequested += CoreWebView2_NewWindowRequested;
_webView.CoreWebView2.DownloadStarting += CoreWebView2_DownloadStarting;
_webView.CoreWebView2.PermissionRequested += CoreWebView2_PermissionRequested;
_webView.CoreWebView2.ProcessFailed += CoreWebView2_ProcessFailed;
var bridge = new PluginBridge(_plugin, _webView);
bridge.OutputChanged += (_, output) => _outputBox.Text = output;
bridge.Attach();
await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(bridge.BootstrapScript);
await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(BuildCspBootstrap());
if (!_plugin.Manifest.IsLegacy)
{
_bridge = new PluginBridge(_plugin, _surface, _origin, _webView);
_bridge.OutputChanged += (_, output) => _outputBox.Text = output;
await _bridge.AttachAsync();
await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(_bridge.BootstrapScript);
}
_webView.Source = new Uri(entryPath);
var relativeEntry = Path.GetRelativePath(_plugin.RootPath, entryPath).Replace('\\', '/');
var encodedEntry = string.Join('/', relativeEntry.Split('/').Select(Uri.EscapeDataString));
_webView.Source = new Uri($"{_origin}/{encodedEntry}");
await _stateStore.MarkRunAsync(_plugin.Manifest.Id);
await _logService.WriteAsync("Information", $"plugin:{_plugin.Manifest.Id}", "Plugin surface opened", _surface.Id);
_statusText.Text = "插件页面已加载";
_statusText.Text = _plugin.Manifest.IsLegacy
? "旧版插件以 LegacyWebOnly 模式加载:Bridge 与远程网络均已关闭"
: "插件已在独立 HTTPS 安全容器中加载";
}
catch (Exception exception)
{
@@ -132,17 +174,148 @@ public sealed class PluginHostPage : ToolPageBase
private void CoreWebView2_NavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args)
{
if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri) || !uri.IsFile)
if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri) ||
!string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin, StringComparison.OrdinalIgnoreCase))
{
args.Cancel = true;
_ = _linkNavigationService.OpenAsync(args.Uri);
}
}
private void CoreWebView2_FrameNavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args) => args.Cancel = true;
private static void CoreWebView2_NewWindowRequested(CoreWebView2 sender, CoreWebView2NewWindowRequestedEventArgs args) => args.Handled = true;
private static void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2DownloadStartingEventArgs args) => args.Cancel = true;
private static void CoreWebView2_PermissionRequested(CoreWebView2 sender, CoreWebView2PermissionRequestedEventArgs args)
{
args.State = CoreWebView2PermissionState.Deny;
args.Handled = true;
}
private void CoreWebView2_ProcessFailed(CoreWebView2 sender, CoreWebView2ProcessFailedEventArgs args)
{
_statusText.Text = "插件 WebView 进程异常,Bridge 会话已关闭,可使用重新加载恢复";
_ = DisposeBridgeAsync();
}
private async void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
{
if (!Uri.TryCreate(args.Request.Uri, UriKind.Absolute, out var uri))
{
BlockRequest(sender, args);
return;
}
var localPath = Uri.UnescapeDataString(uri.LocalPath);
if (!PluginRegistryService.IsInside(_plugin.RootPath, localPath))
if (string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin, StringComparison.OrdinalIgnoreCase) &&
PluginResourcePathPolicy.IsSafeRequestUri(uri))
{
args.Cancel = true;
var relative = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
if (PluginRegistryService.IsSafeRelativeFile(_plugin.RootPath, relative))
{
return;
}
}
var destination = string.Empty;
try
{
destination = args.Request.Headers.GetHeader("Sec-Fetch-Dest");
}
catch
{
}
var dataRequest = string.IsNullOrWhiteSpace(destination) || string.Equals(destination, "empty", StringComparison.OrdinalIgnoreCase);
if (!_plugin.Manifest.IsLegacy &&
dataRequest &&
PluginPermissionPolicy.IsGrantCurrent(_plugin.Manifest, _plugin.State, PluginPermission.Http) &&
PluginNetworkPolicy.IsAllowed(uri, _plugin.Manifest.Network?.AllowedOrigins, allowWebSocket: true))
{
var deferral = args.GetDeferral();
try
{
if (await PluginNetworkPolicy.ResolvesToPublicAddressAsync(uri.Host).ConfigureAwait(true))
{
return;
}
}
finally
{
deferral.Complete();
}
}
BlockRequest(sender, args);
}
private static void BlockRequest(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
{
args.Response = sender.Environment.CreateWebResourceResponse(
null,
403,
"Forbidden",
"Content-Type: text/plain; charset=utf-8\r\nCache-Control: no-store");
}
private string BuildCspBootstrap()
{
var connect = new List<string> { "'self'" };
if (!_plugin.Manifest.IsLegacy && PluginPermissionPolicy.IsGrantCurrent(_plugin.Manifest, _plugin.State, PluginPermission.Http))
{
connect.AddRange((_plugin.Manifest.Network?.AllowedOrigins ?? [])
.Where(value => PluginNetworkPolicy.TryNormalizePublicOrigin(value, allowWebSocket: true, out _)));
}
var csp = string.Join("; ",
"default-src 'self' blob: data:",
"script-src 'self' 'unsafe-inline' blob: 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"media-src 'self' blob: data:",
$"connect-src {string.Join(' ', connect)}",
"worker-src 'self' blob:",
"font-src 'self' data:",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'none'");
return $$"""
(() => {
const meta = document.createElement('meta');
meta.httpEquiv = 'Content-Security-Policy';
meta.content = {{JsonSerializer.Serialize(csp)}};
(document.head || document.documentElement).prepend(meta);
})();
""";
}
private async void PluginHostPage_Unloaded(object sender, RoutedEventArgs e)
{
await DisposeBridgeAsync();
if (_webView.CoreWebView2 is not null)
{
_webView.CoreWebView2.Stop();
_webView.CoreWebView2.WebResourceRequested -= CoreWebView2_WebResourceRequested;
_webView.CoreWebView2.NavigationStarting -= CoreWebView2_NavigationStarting;
_webView.CoreWebView2.FrameNavigationStarting -= CoreWebView2_FrameNavigationStarting;
_webView.CoreWebView2.NewWindowRequested -= CoreWebView2_NewWindowRequested;
_webView.CoreWebView2.DownloadStarting -= CoreWebView2_DownloadStarting;
_webView.CoreWebView2.PermissionRequested -= CoreWebView2_PermissionRequested;
_webView.CoreWebView2.ProcessFailed -= CoreWebView2_ProcessFailed;
if (!string.IsNullOrWhiteSpace(_origin))
{
_webView.CoreWebView2.ClearVirtualHostNameToFolderMapping(new Uri(_origin).Host);
}
}
_webView.Close();
}
private async Task DisposeBridgeAsync()
{
if (_bridge is null)
{
return;
}
await _bridge.DisposeAsync();
_bridge = null;
}
}
+3 -197
View File
@@ -1,14 +1,9 @@
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using Windows.Graphics;
using Windows.System;
using WinRT.Interop;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
@@ -17,14 +12,14 @@ public sealed class PluginHostWindow : Window
{
private readonly LoadedPlugin _plugin;
private readonly PluginSurface _surface;
private readonly PluginHostWindowPage _page;
private readonly PluginHostPage _page;
public PluginHostWindow(LoadedPlugin plugin, PluginSurface surface)
{
_plugin = plugin;
_surface = surface;
Title = $"{surface.Name} - {plugin.Manifest.Name}";
_page = new PluginHostWindowPage(plugin, surface);
_page = new PluginHostPage(plugin, surface);
Content = _page;
WindowIconService.ApplyAppIcon(this);
Closed += PluginHostWindow_Closed;
@@ -33,7 +28,7 @@ public sealed class PluginHostWindow : Window
private void PluginHostWindow_Closed(object sender, WindowEventArgs args)
{
_page.Dispose();
Content = null;
}
private void ConfigureWindowSize()
@@ -46,195 +41,6 @@ public sealed class PluginHostWindow : Window
}
}
internal sealed class PluginHostWindowPage : Page, IDisposable
{
private readonly LoadedPlugin _plugin;
private readonly PluginSurface _surface;
private readonly IPluginStateStore _stateStore = AppServices.GetRequiredService<IPluginStateStore>();
private readonly ILogService _logService = AppServices.GetRequiredService<ILogService>();
private readonly WebView2EnvironmentFactory _webViewEnvironmentFactory = AppServices.GetRequiredService<WebView2EnvironmentFactory>();
private readonly WebView2 _webView = new();
private readonly Expander _outputExpander = new()
{
Header = AppLocalizer.T("输出", "Output"),
IsExpanded = false
};
private readonly TextBox _outputBox = new()
{
IsReadOnly = true,
AcceptsReturn = true,
MinHeight = 92,
MaxHeight = 180,
TextWrapping = TextWrapping.Wrap
};
private readonly TextBlock _statusText = ModernUi.Text(AppLocalizer.T("等待加载插件窗口", "Waiting to load plugin window"), 13, foreground: ModernUi.TextSecondary);
private PluginBridge? _bridge;
private bool _loaded;
private bool _disposed;
public PluginHostWindowPage(LoadedPlugin plugin, PluginSurface surface)
{
_plugin = plugin;
_surface = surface;
Background = ModernUi.AppBackground;
Content = BuildContent();
Loaded += PluginHostWindowPage_Loaded;
Unloaded += (_, _) => Dispose();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_webView.CoreWebView2 is not null)
{
_webView.CoreWebView2.NavigationStarting -= CoreWebView2_NavigationStarting;
}
_bridge?.Detach();
_webView.Close();
}
private UIElement BuildContent()
{
var root = new Grid
{
RowSpacing = 0
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition());
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var openFolder = ModernUi.IconButton("\uE8A7", AppLocalizer.T("打开插件目录", "Open plugin folder"), async () =>
{
await Launcher.LaunchFolderPathAsync(_plugin.RootPath);
});
var title = new StackPanel
{
Spacing = 2,
Children =
{
ModernUi.Text(_surface.Name, 22, Microsoft.UI.Text.FontWeights.SemiBold, maxLines: 1),
ModernUi.Text($"{_plugin.Manifest.Name} v{_plugin.Manifest.Version} · {_surface.Description}", 13, foreground: ModernUi.TextSecondary, maxLines: 1)
}
};
var header = new Grid
{
Padding = new Thickness(16, 12, 16, 12),
ColumnSpacing = 12,
Background = ModernUi.Surface
};
header.ColumnDefinitions.Add(new ColumnDefinition());
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
header.Children.Add(title);
Grid.SetColumn(openFolder, 1);
header.Children.Add(openFolder);
root.Children.Add(header);
var browserHost = new Grid
{
BorderBrush = ModernUi.Stroke,
BorderThickness = new Thickness(0, 1, 0, 1),
Background = ModernUi.Surface
};
browserHost.Children.Add(_webView);
Grid.SetRow(browserHost, 1);
root.Children.Add(browserHost);
var bottom = new Grid
{
Padding = new Thickness(16, 10, 16, 12),
RowSpacing = 8,
Background = ModernUi.AppBackground
};
bottom.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
bottom.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
bottom.Children.Add(_statusText);
_outputExpander.Content = _outputBox;
Grid.SetRow(_outputExpander, 1);
bottom.Children.Add(_outputExpander);
Grid.SetRow(bottom, 2);
root.Children.Add(bottom);
return root;
}
private async void PluginHostWindowPage_Loaded(object sender, RoutedEventArgs e)
{
if (_loaded)
{
return;
}
_loaded = true;
await LoadAsync();
}
private async Task LoadAsync()
{
try
{
var entryPath = Path.GetFullPath(Path.Combine(_plugin.RootPath, _surface.EffectiveEntry(_plugin.Manifest.Entry)));
if (!PluginRegistryService.IsInside(_plugin.RootPath, entryPath) || !File.Exists(entryPath))
{
throw new FileNotFoundException("Plugin entry was not found inside the plugin directory.", entryPath);
}
var environment = await _webViewEnvironmentFactory.CreateAsync($"PluginWindows\\{_plugin.Manifest.Id}\\{_surface.Id}");
var options = environment.CreateCoreWebView2ControllerOptions();
options.IsInPrivateModeEnabled = false;
await _webView.EnsureCoreWebView2Async(environment, options);
_webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
_webView.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = true;
_webView.CoreWebView2.Settings.IsGeneralAutofillEnabled = false;
_webView.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false;
_webView.CoreWebView2.NavigationStarting += CoreWebView2_NavigationStarting;
_bridge = new PluginBridge(_plugin, _webView);
_bridge.OutputChanged += (_, output) =>
{
_outputBox.Text = output;
_outputExpander.Header = string.IsNullOrWhiteSpace(output)
? AppLocalizer.T("输出", "Output")
: AppLocalizer.T($"输出({output.Length} 字符)", $"Output ({output.Length} chars)");
};
_bridge.Attach();
await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(_bridge.BootstrapScript);
_webView.Source = new Uri(entryPath);
await _stateStore.MarkRunAsync(_plugin.Manifest.Id);
await _logService.WriteAsync("Information", $"plugin:{_plugin.Manifest.Id}", "Plugin surface opened in window", _surface.Id);
_statusText.Text = AppLocalizer.T("插件窗口已加载", "Plugin window loaded");
}
catch (Exception exception)
{
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 220);
_statusText.Text = AppLocalizer.T($"插件加载失败:{safe}", $"Plugin load failed: {safe}");
await _logService.WriteAsync("Error", $"plugin:{_plugin.Manifest.Id}", "Plugin window failed", safe);
}
}
private void CoreWebView2_NavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args)
{
if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri) || !uri.IsFile)
{
args.Cancel = true;
return;
}
var localPath = Uri.UnescapeDataString(uri.LocalPath);
if (!PluginRegistryService.IsInside(_plugin.RootPath, localPath))
{
args.Cancel = true;
}
}
}
internal static class PluginHostWindowManager
{
private static readonly Dictionary<string, PluginHostWindow> Windows = new(StringComparer.OrdinalIgnoreCase);
+265 -12
View File
@@ -1,8 +1,11 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage.Pickers;
using WinRT.Interop;
using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Settings;
using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
@@ -14,6 +17,9 @@ public sealed class PluginPage : Page
private readonly IPluginHostProcessService _pluginHost;
private readonly ILogService _logService;
private readonly IPluginPackageService _pluginPackages = AppServices.GetRequiredService<IPluginPackageService>();
private readonly IBuiltInPluginInstallerService _builtInInstaller = AppServices.GetRequiredService<IBuiltInPluginInstallerService>();
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly Action _openDocs;
private readonly Action<LoadedPlugin, PluginSurface> _openSurface;
private readonly Func<PluginSnapshot, Task>? _catalogChanged;
@@ -64,13 +70,15 @@ public sealed class PluginPage : Page
var help = ModernUi.IconButton("\uE897", AppLocalizer.T("插件说明", "Plugin documentation"), _openDocs);
var scan = ModernUi.PillButton(AppLocalizer.T("扫描插件", "Scan plugins"), "\uE72C", async () => await ScanAsync());
var create = ModernUi.PillButton(AppLocalizer.T("新建示例", "New sample"), "\uE710", async () => await CreateTemplateAsync());
var import = ModernUi.PillButton(AppLocalizer.T("导入文件夹", "Import folder"), "\uE8B5", async () => await ImportFolderAsync());
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Children = { scan, help }
Children = { create, import, scan, help }
};
root.Children.Add(ModernUi.PageHeader(
@@ -265,9 +273,22 @@ public sealed class PluginPage : Page
};
enable.Toggled += async (_, _) =>
{
try
{
if (enable.IsOn && !await ConfirmAndGrantRequiredPermissionsAsync(plugin))
{
enable.IsOn = false;
return;
}
var snapshot = await _pluginHost.SetPluginEnabledAsync(plugin.Manifest.Id, enable.IsOn);
RenderSnapshot(snapshot);
await NotifyCatalogChangedAsync(snapshot);
}
catch (Exception exception)
{
enable.IsOn = plugin.State.Enabled;
_statusText.Text = $"插件状态更新失败:{AppLocalizer.SanitizeSensitiveText(exception.Message, 180)}";
}
};
Grid.SetColumn(enable, 1);
titleRow.Children.Add(enable);
@@ -281,11 +302,16 @@ public sealed class PluginPage : Page
var badges = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
badges.Children.Add(ModernUi.SmallBadge(plugin.IsValid ? "校验通过" : "校验失败", plugin.IsValid ? ModernUi.Success : ModernUi.Danger, ModernUi.SurfaceAlt));
badges.Children.Add(ModernUi.SmallBadge(plugin.State.Enabled ? "启用" : "停用", ModernUi.TextSecondary, ModernUi.SurfaceAlt));
badges.Children.Add(ModernUi.SmallBadge(plugin.SecurityMode.ToString(), ModernUi.TextSecondary, ModernUi.SurfaceAlt));
badges.Children.Add(ModernUi.SmallBadge($"已授权 {GrantedPermissionCount(plugin)}/{DeclaredPermissionCount(plugin)}", ModernUi.TextSecondary, ModernUi.SurfaceAlt));
badges.Children.Add(ModernUi.SmallBadge($"已挂载 {MountedSurfaceCount(plugin)}/{plugin.Manifest.Surfaces.Count}", ModernUi.TextSecondary, ModernUi.SurfaceAlt));
panel.Children.Add(badges);
panel.Children.Add(ModernUi.Text(plugin.Manifest.Description, 13, foreground: ModernUi.TextSecondary, maxLines: 2));
panel.Children.Add(BuildStateSummary(plugin));
if (plugin.Manifest.IsLegacy)
{
panel.Children.Add(ModernUi.Text("此旧版清单以 LegacyWebOnly 运行:本地 HTML/CSS/JS 可用,Bridge、远程网络和外接运行时均关闭。请迁移到 manifest v3。", 13, foreground: ModernUi.Warning));
}
if (plugin.Errors.Count > 0)
{
@@ -313,8 +339,10 @@ public sealed class PluginPage : Page
MaxWidth = 720
};
content.Children.Add(BuildStateSummary(plugin));
content.Children.Add(BuildSecuritySummary(plugin));
content.Children.Add(BuildPermissionPanel(plugin));
content.Children.Add(BuildSurfacePanel(plugin));
content.Children.Add(BuildLocalManagementPanel(plugin));
content.Children.Add(await BuildLogPanelAsync(plugin));
var scroll = new ScrollViewer
@@ -336,6 +364,191 @@ public sealed class PluginPage : Page
await dialog.ShowAsync();
}
private static UIElement BuildSecuritySummary(LoadedPlugin plugin)
{
var compatibility = PluginRegistryService.EvaluateRequirements(plugin.Manifest.Requirements);
var required = plugin.Manifest.Security?.RequiredPermissions ?? [];
var origins = plugin.Manifest.Network?.AllowedOrigins ?? [];
var external = plugin.Manifest.Network?.OpenExternalOrigins ?? [];
var text = string.Join(Environment.NewLine, new[]
{
$"运行时:{plugin.Manifest.Runtime} · 安全模式:{plugin.SecurityMode}",
$"清单:v{plugin.Manifest.ManifestVersion} · API{plugin.Manifest.ApiVersion}",
$"要求检查:{(compatibility.IsCompatible ? "" : string.Join("", compatibility.Issues))}",
$"必需权限:{(required.Count == 0 ? "" : string.Join(", ", required))}",
$"数据来源:{(origins.Count == 0 ? "" : string.Join(", ", origins))}",
$"外链来源:{(external.Count == 0 ? "" : string.Join(", ", external))}"
});
return Section("安全与要求", new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap,
IsTextSelectionEnabled = true,
Foreground = ModernUi.TextSecondary
});
}
private UIElement BuildLocalManagementPanel(LoadedPlugin plugin)
{
var clear = ModernUi.PillButton("清除插件数据", "\uE74D", async () => await ClearPluginDataAsync(plugin));
var uninstall = ModernUi.PillButton("卸载到回收目录", "\uE74D", async () => await UninstallPluginAsync(plugin));
uninstall.IsEnabled = !plugin.Manifest.BuiltIn;
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children = { clear, uninstall }
};
if (plugin.Manifest.BuiltIn)
{
actions.Children.Add(ModernUi.PillButton("重置内置示例", "\uE777", async () => await ResetBuiltInAsync(plugin)));
}
if (plugin.Manifest.Runtime == PluginRuntimeKind.Tauri)
{
var confirm = ModernUi.PillButton("确认当前 Tauri 版本", "\uE73E", async () => await ConfirmExternalRuntimeAsync(plugin));
confirm.IsEnabled = _settingsService.Current.PluginDeveloperMode &&
PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalRuntime);
actions.Children.Add(confirm);
}
return Section("本地管理", actions);
}
private async Task CreateTemplateAsync()
{
var id = new TextBox { Header = "插件 ID", PlaceholderText = "my-plugin" };
var name = new TextBox { Header = "显示名称", PlaceholderText = "我的插件" };
var kind = new ComboBox
{
Header = "模板",
SelectedIndex = 0,
Items =
{
"零权限 Web 示例",
"Bridge / 精确网络示例"
}
};
var dialog = new ContentDialog
{
Title = "创建本地插件示例",
Content = new StackPanel { Spacing = 10, Children = { id, name, kind } },
PrimaryButtonText = "创建",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = XamlRoot
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
var root = _pluginHost.CurrentSnapshot?.PluginsRoot;
if (string.IsNullOrWhiteSpace(root))
{
_statusText.Text = "插件根目录尚不可用。";
return;
}
var result = await _pluginPackages.CreateTemplateAsync(
root,
id.Text,
name.Text,
kind.SelectedIndex == 0 ? PluginTemplateKind.ZeroPermissionWeb : PluginTemplateKind.BridgeNetwork);
_statusText.Text = result.Message;
if (result.Succeeded)
{
await ScanAsync();
}
}
private async Task ImportFolderAsync()
{
if (App.CurrentWindow is null)
{
return;
}
var picker = new FolderPicker();
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
var folder = await picker.PickSingleFolderAsync();
var root = _pluginHost.CurrentSnapshot?.PluginsRoot;
if (folder is null || string.IsNullOrWhiteSpace(root))
{
return;
}
var result = await _pluginPackages.ImportFolderAsync(folder.Path, root);
_statusText.Text = result.Message;
if (result.Succeeded)
{
await ScanAsync();
}
}
private async Task ClearPluginDataAsync(LoadedPlugin plugin)
{
if (plugin.State.Enabled)
{
await _pluginHost.SetPluginEnabledAsync(plugin.Manifest.Id, false);
}
var result = await _pluginPackages.ClearDataAsync(plugin);
_statusText.Text = result.Message;
await ScanAsync();
}
private async Task UninstallPluginAsync(LoadedPlugin plugin)
{
var confirm = new ContentDialog
{
Title = $"卸载 {plugin.Manifest.Name}",
Content = "插件将移到应用数据的可恢复回收目录,同时清理授权、KV 和浏览器配置。",
PrimaryButtonText = "卸载",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = XamlRoot
};
if (await confirm.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
if (plugin.State.Enabled)
{
await _pluginHost.SetPluginEnabledAsync(plugin.Manifest.Id, false);
}
var root = _pluginHost.CurrentSnapshot?.PluginsRoot ?? Path.GetDirectoryName(plugin.RootPath)!;
var result = await _pluginPackages.UninstallAsync(plugin, root);
_statusText.Text = result.Message;
await ScanAsync();
}
private async Task ResetBuiltInAsync(LoadedPlugin plugin)
{
if (plugin.State.Enabled)
{
await _pluginHost.SetPluginEnabledAsync(plugin.Manifest.Id, false);
}
var reset = await _builtInInstaller.ResetAsync(plugin.Manifest.Id);
_statusText.Text = reset ? "内置示例已恢复为随应用提供的版本。" : "未找到对应的内置示例。";
await ScanAsync();
}
private async Task ConfirmExternalRuntimeAsync(LoadedPlugin plugin)
{
var dialog = new ContentDialog
{
Title = "确认受控外接运行时",
Content = $"仅确认 {plugin.Manifest.Name} v{plugin.Manifest.Version}。插件版本或权限策略变化后需要重新确认。外接宿主不会获得 Tauri 全局 API、任意文件读取或原生程序执行能力。",
PrimaryButtonText = "确认此版本",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = XamlRoot
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
var snapshot = await _pluginHost.SetExternalRuntimeConfirmationAsync(plugin.Manifest.Id, plugin.Manifest.Version);
RenderSnapshot(snapshot);
await NotifyCatalogChangedAsync(snapshot);
}
private static Button RoundIconButton(string glyph, string tooltip, Func<Task>? click = null)
{
var button = new Button
@@ -365,13 +578,7 @@ public sealed class PluginPage : Page
private UIElement BuildPermissionPanel(LoadedPlugin plugin)
{
var panel = new VariableSizedWrapGrid
{
Orientation = Orientation.Horizontal,
ItemWidth = 154,
ItemHeight = 36,
HorizontalChildrenAlignment = HorizontalAlignment.Left
};
var panel = new StackPanel { Spacing = 8 };
if (plugin.Manifest.Permissions.Count == 0)
{
return Section("权限授权", ModernUi.Text("未声明需要授权的 Bridge 权限。", 13, foreground: ModernUi.TextSecondary));
@@ -381,9 +588,10 @@ public sealed class PluginPage : Page
{
var check = new CheckBox
{
Content = permission.ToString(),
IsChecked = plugin.State.GrantedPermissions.Contains(permission),
IsEnabled = plugin.IsValid
Content = $"{permission} · {(PluginPermissionPolicy.IsRequired(plugin.Manifest, permission) ? "" : "")}",
IsChecked = PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, permission),
IsEnabled = plugin.IsValid && !plugin.Manifest.IsLegacy &&
!(plugin.State.Enabled && PluginPermissionPolicy.IsRequired(plugin.Manifest, permission))
};
check.Checked += async (_, _) =>
{
@@ -397,12 +605,57 @@ public sealed class PluginPage : Page
RenderSnapshot(snapshot);
await NotifyCatalogChangedAsync(snapshot);
};
panel.Children.Add(check);
panel.Children.Add(ModernUi.Card(new StackPanel
{
Spacing = 3,
Children =
{
check,
ModernUi.Text(plugin.Manifest.PermissionReason(permission), 12, foreground: ModernUi.TextSecondary, maxLines: 3)
}
}, new Thickness(10), radius: 6, background: ModernUi.SurfaceAlt));
}
return Section("权限授权", panel);
}
private async Task<bool> ConfirmAndGrantRequiredPermissionsAsync(LoadedPlugin plugin)
{
if (plugin.Manifest.IsLegacy)
{
return true;
}
var missing = (plugin.Manifest.Security?.RequiredPermissions ?? [])
.Where(permission => !PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, permission))
.Distinct()
.ToArray();
if (missing.Length == 0)
{
return true;
}
var explanation = string.Join(Environment.NewLine + Environment.NewLine, missing.Select(permission =>
$"{permission}\n{plugin.Manifest.PermissionReason(permission)}"));
var dialog = new ContentDialog
{
Title = $"启用 {plugin.Manifest.Name} 所需权限",
Content = new TextBlock { Text = explanation, TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true },
PrimaryButtonText = "授权并启用",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close,
XamlRoot = XamlRoot
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return false;
}
foreach (var permission in missing)
{
await _pluginHost.SetPermissionAsync(plugin.Manifest.Id, permission, true);
}
return true;
}
private UIElement BuildSurfaceLaunchPanel(LoadedPlugin plugin)
{
var toolboxSurfaces = plugin.Manifest.Surfaces
+14 -6
View File
@@ -6,6 +6,7 @@ using YMhut.Box.Core.Logging;
using YMhut.Box.Core.Platform;
using YMhut.Box.Core.Plugins;
using YMhut.Box.Core.Plugins.Runtime;
using YMhut.Box.Core.Settings;
using YMhut.Box.WinUI.Services;
namespace YMhut.Box.WinUI.Views;
@@ -18,6 +19,7 @@ public sealed class PluginRuntimePage : ToolPageBase
private readonly IPluginRuntimeLauncher _runtimeLauncher = AppServices.GetRequiredService<IPluginRuntimeLauncher>();
private readonly IPluginStateStore _stateStore = AppServices.GetRequiredService<IPluginStateStore>();
private readonly ILogService _logService = AppServices.GetRequiredService<ILogService>();
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly TextBlock _statusText = ModernUi.Text(AppLocalizer.T("准备启动插件运行时", "Preparing plugin runtime"), 13, foreground: ModernUi.TextSecondary);
private readonly TextBox _outputBox = new()
{
@@ -100,6 +102,17 @@ public sealed class PluginRuntimePage : ToolPageBase
{
try
{
if (_plugin.Manifest.Runtime is PluginRuntimeKind.Shell or PluginRuntimeKind.Script)
{
throw new NotSupportedException("Shell and Script plugin runtimes are disabled because ordinary Windows processes cannot satisfy the plugin isolation boundary.");
}
if (!_settingsService.Current.PluginDeveloperMode ||
_plugin.Manifest.Runtime != PluginRuntimeKind.Tauri ||
!PluginPermissionPolicy.IsGrantCurrent(_plugin.Manifest, _plugin.State, PluginPermission.ExternalRuntime) ||
!string.Equals(_plugin.State.ExternalRuntimeConfirmation, _plugin.Manifest.Version, StringComparison.Ordinal))
{
throw new UnauthorizedAccessException("The controlled external runtime requires developer mode, ExternalRuntime permission, and confirmation for this plugin version.");
}
var entryPath = Path.GetFullPath(Path.Combine(_plugin.RootPath, _surface.EffectiveEntry(_plugin.Manifest.Entry)));
if (!PluginRegistryService.IsInside(_plugin.RootPath, entryPath) || !File.Exists(entryPath))
{
@@ -109,7 +122,7 @@ public sealed class PluginRuntimePage : ToolPageBase
var request = new PluginRuntimeLaunchRequest(
_plugin.Manifest.Id,
_surface.Id,
NormalizeRuntimeKind(_plugin.Manifest.Runtime),
_plugin.Manifest.Runtime,
_plugin.RootPath,
Path.Combine(_plugin.RootPath, PluginManifest.FileName),
entryPath,
@@ -130,11 +143,6 @@ public sealed class PluginRuntimePage : ToolPageBase
}
}
private static PluginRuntimeKind NormalizeRuntimeKind(PluginRuntimeKind runtime)
{
return runtime == PluginRuntimeKind.WebView ? PluginRuntimeKind.Tauri : runtime;
}
private PluginCommandSpec? SelectCommand()
{
var commands = _plugin.Manifest.Commands ?? [];
+21
View File
@@ -86,6 +86,7 @@ public sealed class SettingsPage : Page
private readonly ToggleSwitch _updateSwitch = new();
private readonly ToggleSwitch _hardwareAccelerationSwitch = new();
private readonly ToggleSwitch _pluginSwitch = new();
private readonly ToggleSwitch _pluginDeveloperModeSwitch = new();
private readonly ToggleSwitch _toolboxCompactSwitch = new();
private readonly ToggleSwitch _toolboxRecentFirstSwitch = new();
private readonly ToggleSwitch _hardwareLogoSwitch = new();
@@ -729,6 +730,7 @@ public sealed class SettingsPage : Page
]),
BuildSection(AppLocalizer.T("插件系统", "Plugin system"), "\uECAA", [
BuildToggleRow("\uECAA", AppLocalizer.T("启用插件系统", "Enable plugin system"), AppLocalizer.T("关闭时不加载插件工具,也不显示插件页面入口。", "When disabled, plugin tools are not loaded and the plugin page entry is hidden."), _pluginSwitch),
BuildToggleRow("\uE943", AppLocalizer.T("插件开发者模式", "Plugin developer mode"), AppLocalizer.T("允许 WebView 调试工具和经版本确认的 Tauri 外接宿主。默认关闭。", "Allow WebView developer tools and version-confirmed Tauri hosts. Disabled by default."), _pluginDeveloperModeSwitch),
BuildActionRow("\uE838", AppLocalizer.T("插件根目录", "Plugin root"), _pluginRootSummary, ChoosePluginRootAsync),
BuildActionRow("\uE777", AppLocalizer.T("恢复默认插件目录", "Restore default plugin folder"), ModernUi.Text(AppLocalizer.T("使用用户数据目录下的 Plugins 文件夹。", "Use the Plugins folder under user data."), 13, foreground: ModernUi.TextSecondary), ResetPluginRootAsync),
BuildActionRow("\uE8A7", AppLocalizer.T("打开插件目录", "Open plugin folder"), ModernUi.Text(AppLocalizer.T("在资源管理器中打开当前插件根目录。", "Open the current plugin root in File Explorer."), 13, foreground: ModernUi.TextSecondary), OpenPluginRootAsync)
@@ -831,6 +833,7 @@ public sealed class SettingsPage : Page
AddSettingsPage("plugins", AppLocalizer.T("插件", "Plugins"), AppLocalizer.T("插件系统开关和根目录。", "Plugin system switch and root folder."), "\uECAA",
BuildSection(AppLocalizer.T("插件系统", "Plugin system"), "\uECAA", [
BuildToggleRow("\uECAA", AppLocalizer.T("启用插件系统", "Enable plugin system"), AppLocalizer.T("关闭时不加载插件工具,也不显示插件页入口。", "When disabled, plugin tools are not loaded and the plugin page entry is hidden."), _pluginSwitch),
BuildToggleRow("\uE943", AppLocalizer.T("插件开发者模式", "Plugin developer mode"), AppLocalizer.T("允许 WebView 调试工具和经版本确认的 Tauri 外接宿主。默认关闭。", "Allow WebView developer tools and version-confirmed Tauri hosts. Disabled by default."), _pluginDeveloperModeSwitch),
BuildActionRow("\uE838", AppLocalizer.T("插件根目录", "Plugin root"), _pluginRootSummary, ChoosePluginRootAsync),
BuildActionRow("\uE777", AppLocalizer.T("恢复默认插件目录", "Restore default plugin folder"), ModernUi.Text(AppLocalizer.T("使用用户数据目录下的 Plugins 文件夹。", "Use the Plugins folder under user data."), 13, foreground: ModernUi.TextSecondary), ResetPluginRootAsync),
BuildActionRow("\uE8A7", AppLocalizer.T("打开插件目录", "Open plugin folder"), ModernUi.Text(AppLocalizer.T("在资源管理器中打开当前插件根目录。", "Open the current plugin root in File Explorer."), 13, foreground: ModernUi.TextSecondary), OpenPluginRootAsync)
@@ -1625,6 +1628,7 @@ public sealed class SettingsPage : Page
_updateSwitch.IsOn = _settings.UpdateNotification;
_hardwareAccelerationSwitch.IsOn = _settings.HardwareAccelerationEnabled;
_pluginSwitch.IsOn = _settings.PluginsEnabled;
_pluginDeveloperModeSwitch.IsOn = _settings.PluginDeveloperMode;
_toolboxCompactSwitch.IsOn = _settings.ToolboxCompactCards;
_toolboxRecentFirstSwitch.IsOn = _settings.ToolboxShowRecentFirst;
_hardwareLogoSwitch.IsOn = _settings.ShowHardwareBrandLogo;
@@ -1648,6 +1652,7 @@ public sealed class SettingsPage : Page
_updateSwitch.Toggled -= UpdateSwitch_Toggled;
_hardwareAccelerationSwitch.Toggled -= HardwareAccelerationSwitch_Toggled;
_pluginSwitch.Toggled -= PluginSwitch_Toggled;
_pluginDeveloperModeSwitch.Toggled -= PluginDeveloperModeSwitch_Toggled;
_toolboxCompactSwitch.Toggled -= ToolboxCompactSwitch_Toggled;
_toolboxRecentFirstSwitch.Toggled -= ToolboxRecentFirstSwitch_Toggled;
_hardwareLogoSwitch.Toggled -= HardwareLogoSwitch_Toggled;
@@ -1663,6 +1668,7 @@ public sealed class SettingsPage : Page
_updateSwitch.Toggled += UpdateSwitch_Toggled;
_hardwareAccelerationSwitch.Toggled += HardwareAccelerationSwitch_Toggled;
_pluginSwitch.Toggled += PluginSwitch_Toggled;
_pluginDeveloperModeSwitch.Toggled += PluginDeveloperModeSwitch_Toggled;
_toolboxCompactSwitch.Toggled += ToolboxCompactSwitch_Toggled;
_toolboxRecentFirstSwitch.Toggled += ToolboxRecentFirstSwitch_Toggled;
_hardwareLogoSwitch.Toggled += HardwareLogoSwitch_Toggled;
@@ -2599,6 +2605,21 @@ public sealed class SettingsPage : Page
ToastKind.Success);
}
private async void PluginDeveloperModeSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (!_loaded)
{
return;
}
await _settingsService.UpdateAsync(settings => settings.PluginDeveloperMode = _pluginDeveloperModeSwitch.IsOn);
_settings.PluginDeveloperMode = _pluginDeveloperModeSwitch.IsOn;
ToastService.Show(
_pluginDeveloperModeSwitch.IsOn
? AppLocalizer.T("插件开发者模式已开启", "Plugin developer mode enabled")
: AppLocalizer.T("插件开发者模式已关闭", "Plugin developer mode disabled"),
_pluginDeveloperModeSwitch.IsOn ? ToastKind.Warning : ToastKind.Success);
}
private async void ToolboxCompactSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (!_loaded)
@@ -47,7 +47,9 @@ public sealed partial class ToolPageRegistry : IToolPageFactory
throw new InvalidOperationException($"Plugin tool module is not loaded: {module.Id}");
}
return new PluginRuntimePage(typedPluginModule.Plugin, typedPluginModule.Surface, goBack);
return typedPluginModule.Plugin.Manifest.Runtime == PluginRuntimeKind.WebView || typedPluginModule.Plugin.Manifest.IsLegacy
? new PluginHostPage(typedPluginModule.Plugin, typedPluginModule.Surface, goBack)
: new PluginRuntimePage(typedPluginModule.Plugin, typedPluginModule.Surface, goBack);
}
if (!_descriptors.TryGetValue(module.Id, out var descriptor))
+11 -15
View File
@@ -98,9 +98,17 @@
<ExternalToolPayload Include="..\..\tubatool-参考补充项目\Tools\**\*.*" />
<ExternalToolMetadata Include="..\..\tubatool-参考补充项目\Metadata\**\*.*" />
</ItemGroup>
<ItemGroup>
<TauriPluginHostPayload Include="..\YMhut.Box.PluginTauriHost\**\*.*"
Exclude="..\YMhut.Box.PluginTauriHost\node_modules\**\*.*;..\YMhut.Box.PluginTauriHost\src-tauri\target\**\*.*" />
<PropertyGroup>
<TauriPluginHostProfile Condition="'$(Configuration)' == 'Release'">release</TauriPluginHostProfile>
<TauriPluginHostProfile Condition="'$(TauriPluginHostProfile)' == '' and Exists('..\YMhut.Box.PluginTauriHost\src-tauri\target\debug\ymhut-box-plugin-tauri-host.exe')">debug</TauriPluginHostProfile>
<TauriPluginHostProfile Condition="'$(TauriPluginHostProfile)' == ''">release</TauriPluginHostProfile>
<TauriPluginHostExecutable>..\YMhut.Box.PluginTauriHost\src-tauri\target\$(TauriPluginHostProfile)\ymhut-box-plugin-tauri-host.exe</TauriPluginHostExecutable>
</PropertyGroup>
<ItemGroup Condition="Exists('$(TauriPluginHostExecutable)')">
<Content Include="$(TauriPluginHostExecutable)">
<Link>tauri-host\ymhut-box-plugin-tauri-host.exe</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Page Remove="Views\**\*.xaml" />
@@ -126,18 +134,6 @@
SkipUnchangedFiles="true"
Condition="'$(PublishDir)' != '' and '@(ExternalToolMetadata)' != ''" />
</Target>
<Target Name="CopyTauriPluginHostTemplate" AfterTargets="Build">
<Copy SourceFiles="@(TauriPluginHostPayload)"
DestinationFiles="@(TauriPluginHostPayload->'$(OutDir)tauri-host-template\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
Condition="'@(TauriPluginHostPayload)' != ''" />
</Target>
<Target Name="CopyTauriPluginHostTemplatePublish" AfterTargets="Publish">
<Copy SourceFiles="@(TauriPluginHostPayload)"
DestinationFiles="@(TauriPluginHostPayload->'$(PublishDir)tauri-host-template\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
Condition="'$(PublishDir)' != '' and '@(TauriPluginHostPayload)' != ''" />
</Target>
<Target Name="CopyToolWorkerOutput" AfterTargets="Build">
<RemoveDir Directories="$(OutDir)worker\runtimes;$(OutDir)worker\net6.0;$(OutDir)plugin-host\runtimes;$(OutDir)plugin-host\net6.0;$(OutDir)Assets\fonts" />
<Delete Files="$(OutDir)Assets\images\loading.gif;$(OutDir)worker\e_sqlite3.dll;$(OutDir)plugin-host\e_sqlite3.dll" />