From c3a8737fd6d10185cf125fa850f95372f95d03a4 Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Mon, 17 Aug 2026 09:06:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=8F=92=E4=BB=B6=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E7=AD=96=E7=95=A5=E3=80=81=E5=8C=85=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E8=BF=90=E8=A1=8C=E6=97=B6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plugins/AI-INTEGRATION.md | 76 +- docs/plugins/MIGRATION-v3.md | 10 + docs/plugins/README.md | 105 +- docs/plugins/SECURITY.md | 11 + docs/plugins/ymhut.d.ts | 22 + docs/plugins/ymhut.plugin.schema.json | 30 + .../Plugins/BuiltIn/ipcheck-demo/README.md | 34 +- .../Plugins/BuiltIn/ipcheck-demo/index.html | 315 +----- .../Plugins/BuiltIn/ipcheck-demo/main.js | 929 +--------------- .../Plugins/BuiltIn/ipcheck-demo/style.css | 539 +--------- .../BuiltIn/ipcheck-demo/ymhut.plugin.json | 74 +- .../BuiltIn/web-capabilities-demo/README.md | 3 + .../BuiltIn/web-capabilities-demo/index.html | 3 + .../BuiltIn/web-capabilities-demo/main.js | 35 + .../BuiltIn/web-capabilities-demo/style.css | 12 + .../BuiltIn/web-capabilities-demo/worker.js | 1 + .../web-capabilities-demo/ymhut.plugin.json | 29 + .../Plugins/BuiltInPluginInstallerService.cs | 27 + .../Plugins/PluginBridgePolicy.cs | 42 + .../Plugins/PluginHostProtocol.cs | 46 +- src/YMhut.Box.Core/Plugins/PluginModels.cs | 113 +- .../Plugins/PluginNetworkPolicy.cs | 160 +++ .../Plugins/PluginPackageService.cs | 306 ++++++ .../Plugins/PluginRegistryService.cs | 295 +++++- .../Plugins/PluginStateStore.cs | 146 ++- .../Plugins/Runtime/PluginRuntimeProtocol.cs | 6 +- src/YMhut.Box.Core/Settings/AppSettings.cs | 2 + .../Settings/AppSettingsStore.cs | 1 + src/YMhut.Box.PluginHost/Program.cs | 410 +++++++- src/YMhut.Box.PluginTauriHost/index.html | 133 +-- .../src-tauri/Cargo.lock | 19 + .../src-tauri/Cargo.toml | 5 +- .../src-tauri/src/main.rs | 995 ++++++++++++++++-- .../src-tauri/tauri.conf.json | 11 +- src/YMhut.Box.PluginTauriHost/src/main.js | 191 +--- src/YMhut.Box.Tests/PluginSecurityTests.cs | 336 ++++++ src/YMhut.Box.Tests/PluginTests.cs | 4 +- src/box-winUI/MainWindow.xaml.cs | 8 +- src/box-winUI/Services/AppServices.cs | 5 +- .../Services/PluginHostProcessService.cs | 39 + .../Services/TauriPluginProcessService.cs | 636 ++++++++++- src/box-winUI/Views/PluginBridge.cs | 106 +- src/box-winUI/Views/PluginDocsPage.cs | 16 +- src/box-winUI/Views/PluginHostPage.cs | 215 +++- src/box-winUI/Views/PluginHostWindow.cs | 200 +--- src/box-winUI/Views/PluginPage.cs | 283 ++++- src/box-winUI/Views/PluginRuntimePage.cs | 20 +- src/box-winUI/Views/SettingsPage.cs | 21 + src/box-winUI/Views/Tools/ToolPageRegistry.cs | 4 +- src/box-winUI/YMhut.Box.WinUI.csproj | 26 +- 50 files changed, 4409 insertions(+), 2646 deletions(-) create mode 100644 docs/plugins/MIGRATION-v3.md create mode 100644 docs/plugins/SECURITY.md create mode 100644 docs/plugins/ymhut.d.ts create mode 100644 docs/plugins/ymhut.plugin.schema.json create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/README.md create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/index.html create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/main.js create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/style.css create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/worker.js create mode 100644 src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/ymhut.plugin.json create mode 100644 src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs create mode 100644 src/YMhut.Box.Core/Plugins/PluginNetworkPolicy.cs create mode 100644 src/YMhut.Box.Core/Plugins/PluginPackageService.cs create mode 100644 src/YMhut.Box.Tests/PluginSecurityTests.cs diff --git a/docs/plugins/AI-INTEGRATION.md b/docs/plugins/AI-INTEGRATION.md index c4450aa..ec3b641 100644 --- a/docs/plugins/AI-INTEGRATION.md +++ b/docs/plugins/AI-INTEGRATION.md @@ -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. diff --git a/docs/plugins/MIGRATION-v3.md b/docs/plugins/MIGRATION-v3.md new file mode 100644 index 0000000..d701689 --- /dev/null +++ b/docs/plugins/MIGRATION-v3.md @@ -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. 在插件页重新审阅权限并启用。旧授权不会自动继承到新策略指纹。 diff --git a/docs/plugins/README.md b/docs/plugins/README.md index e346c4e..495f253 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -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": [ - { - "kind": "ToolboxTool", - "id": "hello", - "name": "Hello", - "description": "Toolbox entry", - "entry": "index.html", - "category": "plugin" - } - ], + "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::`。 +每项权限必须同时出现在 `permissions` 和 `permissionReasons`。`security.requiredPermissions` 中的权限未授权时插件不能启用;其余权限为可选权限,未授权时 Bridge 返回稳定错误码。权限用途、必需状态、范围、运行时或插件版本改变会使旧授权指纹失效。 -## 权限 - -权限默认关闭。manifest 只声明插件需要什么,用户仍要在插件页启用插件并授予权限。 - -- `Input`:读取或写入插件输入。 -- `Output`:写入宿主输出区。 -- `Log`:写入 `plugin:` 日志。 -- `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-.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-.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)。 diff --git a/docs/plugins/SECURITY.md b/docs/plugins/SECURITY.md new file mode 100644 index 0000000..a055c6b --- /dev/null +++ b/docs/plugins/SECURITY.md @@ -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 和对应浏览器配置;卸载前插件目录会移入应用数据下的可恢复回收目录。 diff --git a/docs/plugins/ymhut.d.ts b/docs/plugins/ymhut.d.ts new file mode 100644 index 0000000..7520740 --- /dev/null +++ b/docs/plugins/ymhut.d.ts @@ -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; body?: string; } +interface HttpResponse { status: number; ok: boolean; content: string; headers: Record; } + +interface YmhutBridge { + input: { get(): Promise; set(value: unknown): Promise; onInputChanged(handler: (value: unknown) => void): void }; + output: { set(value: unknown): Promise; append(value: unknown): Promise; clear(): Promise }; + log: { info(message: string, detail?: string): Promise; warn(message: string, detail?: string): Promise; error(message: string, detail?: string): Promise }; + storage: { get(key: string): Promise; set(key: string, value: string): Promise; remove(key: string): Promise; list(): Promise> }; + http: { fetch(request: HttpRequest): Promise }; + network: { diagnostics(): Promise; ping(request: unknown): Promise; dnsLookup(request: unknown): Promise; traceRoute(request: unknown): Promise }; + clipboard: { readText(): Promise; writeText(text: string): Promise }; + file: { openPicker(): Promise<{ name: string; content: string } | null>; savePicker(name: string, value: string): Promise<{ name: string } | null> }; + tool: { run(toolId: string, input: string): Promise }; + openExternal(url: string, options?: { target?: "safe" | "system" }): Promise; +} + +declare global { interface Window { ymhut: YmhutBridge; } } diff --git a/docs/plugins/ymhut.plugin.schema.json b/docs/plugins/ymhut.plugin.schema.json new file mode 100644 index 0000000..0d46887 --- /dev/null +++ b/docs/plugins/ymhut.plugin.schema.json @@ -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" ] } + } +} diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/README.md b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/README.md index 3c81521..652c96b 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/README.md +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/README.md @@ -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 自身无需权限;客户端和外部服务能力始终需要声明与授权。 diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/index.html b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/index.html index 3b51c2d..f04a172 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/index.html +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/index.html @@ -3,305 +3,30 @@ - IPCheck 网络工具箱 + IPCheck 安全网络概览 - -
-
- -
-

All in one IP Toolbox

-

正在检测...

-

正在并行检测公网视角、本机网络、DNS、WebRTC、浏览器指纹和链路质量。

-
-
- - -
+
+
+

MANIFEST V3 SAMPLE

IPCheck 安全网络概览

等待检测

+ +
+
公网 IP--未读取
+
+
Cloudflare 节点--
+
TLS--
+
固定目标延迟未授权
+
活动接口未授权
- -
- - - -
-
-
-
-

网络身份

-

IP 地址与地理位置

-
- 检测中 -
-
-
- -
-
-
-

协议栈

-

IPv4 / IPv6

-
- 检测中 -
-
-
- -
-
-
-

边缘网络

-

Cloudflare Trace

-
- 检测中 -
-
-
- -
-
-
-

质量判断

-

IP 质量与风险

-
- 检测中 -
-
-
-
- -
-
-
-
-

隐私

-

WebRTC 泄漏

-
- 检测中 -
-
-
- -
-
-
-

解析

-

DNS 泄漏

-
- 检测中 -
-
-
- -
-
-
-

可见性

-

IP 泄漏对照

-
- 检测中 -
-
-
-
- -
-
-
-
-

连通性

-

全球延迟

-
- 检测中 -
-
-
- -
-
-
-

吞吐

-

网络测速

-
- 检测中 -
-
--Mbps
-

下载与上传测速将在授权 HTTP 后执行;无网络时显示本机链路速率。

-
- -
-
-
-

规则

-

安全检查清单

-
- 检测中 -
-
-
-
- -
-
-
-

Advanced Tools

-

高级网络工具

-
- 待输入 -
- -
-
-

IP 查询

-
- - -
-
等待查询。
-
- -
-

DNS Resolver

-
- - -
-
等待解析。
-
- -
-

Ping / Global Latency

-
- - -
-
等待测试。
-
- -
-

MTR / Trace Route

-
- - -
-
等待追踪。
-
- -
-

Whois / RDAP

-
- - -
-
等待查询。
-
- -
-

MAC 厂商查询

-
- - -
-
等待识别。
-
- -
-

ASN Connectivity

-
- - -
-
等待分析。
-
- -
-

Rule Test

-
- - -
- -
等待测试。
-
- -
-

Censorship Check

-
- - -
-
等待检查。
-
- -
-

Invisibility Test

-
- - -
-
等待评估。
-
-
-
- -
-
-
-
-

本机环境

-

浏览器指纹

-
- 本地 -
-
-
- -
-
-
-

接口

-

本机网络接口

-
- 检测中 -
-
-
-
- -
-
-
-
-

Bridge 示例

-

输出、存储与安全链接

-
- 待操作 -
-

这些按钮演示插件如何写入宿主输出区、保存插件私有状态,以及默认用安全浏览器打开外链。

-
- - - -
-
等待 Bridge 操作。
-
-
- -
- 报告未复制 -
+
尚无结果
+
+ + + + +
- - + diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/main.js b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/main.js index 840086f..e3814a7 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/main.js +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/main.js @@ -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("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -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]) => `
${escapeHtml(key)}
${escapeHtml(value || "--")}
`) - .join(""); -} - -function setMetrics(id, rows) { - $(id).innerHTML = rows - .map(([key, value, tone = ""]) => ` -
- ${escapeHtml(key)} - ${escapeHtml(value || "--")} -
`) - .join(""); -} - -function setChecks(id, rows) { - $(id).innerHTML = rows - .map(([key, value, tone = ""]) => ` -
- ${escapeHtml(key)} - ${escapeHtml(value || "--")} -
`) - .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 = '
接口未发现
'; - return; - } - $("interfaceDetails").innerHTML = items.map((item) => ` -
-
- ${escapeHtml(item.name)} - ${escapeHtml(item.description)} -
-
-
状态
${escapeHtml(item.status)} · ${escapeHtml(item.type)}
-
速率
${escapeHtml(item.speedMbps ? `${item.speedMbps} Mbps` : "--")}
-
IPv4
${escapeHtml((item.ipv4 || []).join(" / ") || "--")}
-
IPv6
${escapeHtml((item.ipv6 || []).join(" / ") || "--")}
-
网关
${escapeHtml((item.gateways || []).join(" / ") || "--")}
-
DNS
${escapeHtml((item.dnsServers || []).join(" / ") || "--")}
-
-
`).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]) => ` -
- ${escapeHtml(label)} - ${escapeHtml(value)} -
`).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}`; }); diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/style.css b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/style.css index 0d4e64f..f18b5f2 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/style.css +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/style.css @@ -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; } } diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/ymhut.plugin.json b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/ymhut.plugin.json index 8c1c008..baf52e2 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/ymhut.plugin.json +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/ipcheck-demo/ymhut.plugin.json @@ -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" ] } diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/README.md b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/README.md new file mode 100644 index 0000000..aab86e5 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/README.md @@ -0,0 +1,3 @@ +# 零权限 Web 能力示例 + +此插件不声明任何客户端权限。DOM、CSS、ES Module、Worker、Wasm、Canvas、WebAudio、localStorage 和 IndexedDB 均运行在插件独立的虚拟 HTTPS 原点内。它无法访问 `window.chrome.webview` 之外的客户端对象,且未注入 `window.ymhut` 以外的宿主 API。 diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/index.html b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/index.html new file mode 100644 index 0000000..0c6659c --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/index.html @@ -0,0 +1,3 @@ + +Web 能力实验室 +

ZERO PERMISSION

Web 能力实验室

diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/main.js b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/main.js new file mode 100644 index 0000000..ada84bd --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/main.js @@ -0,0 +1,35 @@ +const results = document.querySelector('#results'); +const addResult = (name, value) => results.insertAdjacentHTML('beforeend', `
${name}${value}
`); + +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(); diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/style.css b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/style.css new file mode 100644 index 0000000..4a6e239 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/style.css @@ -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}} diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/worker.js b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/worker.js new file mode 100644 index 0000000..64067bf --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/worker.js @@ -0,0 +1 @@ +self.onmessage = event => self.postMessage({ value: Number(event.data) * 2, thread: "DedicatedWorker" }); diff --git a/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/ymhut.plugin.json b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/ymhut.plugin.json new file mode 100644 index 0000000..a3e3caf --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/BuiltIn/web-capabilities-demo/ymhut.plugin.json @@ -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" ] +} diff --git a/src/YMhut.Box.Core/Plugins/BuiltInPluginInstallerService.cs b/src/YMhut.Box.Core/Plugins/BuiltInPluginInstallerService.cs index e5ccf0e..0e5dffd 100644 --- a/src/YMhut.Box.Core/Plugins/BuiltInPluginInstallerService.cs +++ b/src/YMhut.Box.Core/Plugins/BuiltInPluginInstallerService.cs @@ -9,6 +9,8 @@ namespace YMhut.Box.Core.Plugins; public interface IBuiltInPluginInstallerService { Task EnsureInstalledAsync(CancellationToken cancellationToken = default); + + Task ResetAsync(string pluginId, CancellationToken cancellationToken = default); } public sealed class BuiltInPluginInstallerService( @@ -68,6 +70,31 @@ public sealed class BuiltInPluginInstallerService( } } + public async Task 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 IsUnmodifiedBuiltInPluginAsync(string targetRoot, CancellationToken cancellationToken) { var fingerprintPath = Path.Combine(targetRoot, FingerprintFileName); diff --git a/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs b/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs new file mode 100644 index 0000000..42c88f9 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/PluginBridgePolicy.cs @@ -0,0 +1,42 @@ +namespace YMhut.Box.Core.Plugins; + +public static class PluginBridgePolicy +{ + private static readonly IReadOnlyDictionary Permissions = new Dictionary(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 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; + } +} diff --git a/src/YMhut.Box.Core/Plugins/PluginHostProtocol.cs b/src/YMhut.Box.Core/Plugins/PluginHostProtocol.cs index a242088..862453c 100644 --- a/src/YMhut.Box.Core/Plugins/PluginHostProtocol.cs +++ b/src/YMhut.Box.Core/Plugins/PluginHostProtocol.cs @@ -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 GrantedPermissions, IReadOnlyList MountedSurfaceIds, - DateTimeOffset? LastRunAt) + DateTimeOffset? LastRunAt, + IReadOnlyDictionary? 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"; +} diff --git a/src/YMhut.Box.Core/Plugins/PluginModels.cs b/src/YMhut.Box.Core/Plugins/PluginModels.cs index 703ed74..5e527d8 100644 --- a/src/YMhut.Box.Core/Plugins/PluginModels.cs +++ b/src/YMhut.Box.Core/Plugins/PluginModels.cs @@ -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 Permissions, IReadOnlyList Surfaces, IReadOnlyList Resources, - PluginRuntimeKind Runtime = PluginRuntimeKind.Tauri, + PluginRuntimeKind Runtime = PluginRuntimeKind.WebView, IReadOnlyList? Commands = null, PluginSecuritySpec? Security = null, - PluginTauriSpec? Tauri = null) + PluginTauriSpec? Tauri = null, + int ManifestVersion = 0, + string ApiVersion = "", + PluginRequirementsSpec? Requirements = null, + IReadOnlyDictionary? 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? Architectures = null); + +public sealed record PluginNetworkSpec( + IReadOnlyList? AllowedOrigins = null, + IReadOnlyList? OpenExternalOrigins = null, + IReadOnlyList? RunToolIds = null); + +public sealed record PluginCompatibilityResult( + bool IsCompatible, + string CurrentClientVersion, + int CurrentWindowsBuild, + string CurrentArchitecture, + IReadOnlyList Issues); + public sealed record PluginTauriSpec( string SourceDirectory = "plugin-app", string Executable = "", @@ -102,7 +156,15 @@ public sealed record PluginRuntimeState( bool Enabled, IReadOnlySet GrantedPermissions, IReadOnlySet MountedSurfaceIds, - DateTimeOffset? LastRunAt); + DateTimeOffset? LastRunAt, + IReadOnlyDictionary? 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 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? 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; diff --git a/src/YMhut.Box.Core/Plugins/PluginNetworkPolicy.cs b/src/YMhut.Box.Core/Plugins/PluginNetworkPolicy.cs new file mode 100644 index 0000000..4c52134 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/PluginNetworkPolicy.cs @@ -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? 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 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)); + } +} diff --git a/src/YMhut.Box.Core/Plugins/PluginPackageService.cs b/src/YMhut.Box.Core/Plugins/PluginPackageService.cs new file mode 100644 index 0000000..2b49f16 --- /dev/null +++ b/src/YMhut.Box.Core/Plugins/PluginPackageService.cs @@ -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 CreateTemplateAsync(string pluginsRoot, string id, string name, PluginTemplateKind kind, CancellationToken cancellationToken = default); + + Task ImportFolderAsync(string sourceDirectory, string pluginsRoot, CancellationToken cancellationToken = default); + + Task ClearDataAsync(LoadedPlugin plugin, CancellationToken cancellationToken = default); + + Task UninstallAsync(LoadedPlugin plugin, string pluginsRoot, CancellationToken cancellationToken = default); +} + +public sealed class PluginPackageService(AppPaths paths, IPluginStateStore stateStore) : IPluginPackageService +{ + private static readonly HashSet 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(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter() + } + }; + + public async Task 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 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(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 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 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.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) => $""" + + {manifest.Name} +

{manifest.Name}

Ready

+ """; + + 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(); + 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; + } + } +} diff --git a/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs b/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs index d799981..a932a95 100644 --- a/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs +++ b/src/YMhut.Box.Core/Plugins/PluginRegistryService.cs @@ -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 ValidatePackage(PluginManifest manifest, string root) + { + var errors = new List(); + var builtInIds = ToolCatalog.DefaultModules().Select(module => module.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + ValidateManifest(manifest, root, new HashSet(StringComparer.OrdinalIgnoreCase), builtInIds, errors, null); + return errors; + } + public async Task> 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 seen, ISet builtInIds, - IList errors) + IList 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(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 errors) + private static void ValidateManifestV3(PluginManifest manifest, string root, IList 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 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 permissions, IList 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(); + 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 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(); + pending.Push(root); + while (pending.Count > 0) + { + var directory = pending.Pop(); + IEnumerable 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; + } } diff --git a/src/YMhut.Box.Core/Plugins/PluginStateStore.cs b/src/YMhut.Box.Core/Plugins/PluginStateStore.cs index 64e540a..4ee525d 100644 --- a/src/YMhut.Box.Core/Plugins/PluginStateStore.cs +++ b/src/YMhut.Box.Core/Plugins/PluginStateStore.cs @@ -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(); + var fingerprints = new Dictionary(); + 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(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).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}"); diff --git a/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeProtocol.cs b/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeProtocol.cs index ee4326a..1c39f0f 100644 --- a/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeProtocol.cs +++ b/src/YMhut.Box.Core/Plugins/Runtime/PluginRuntimeProtocol.cs @@ -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); diff --git a/src/YMhut.Box.Core/Settings/AppSettings.cs b/src/YMhut.Box.Core/Settings/AppSettings.cs index 9cf5c23..9b1c44c 100644 --- a/src/YMhut.Box.Core/Settings/AppSettings.cs +++ b/src/YMhut.Box.Core/Settings/AppSettings.cs @@ -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; diff --git a/src/YMhut.Box.Core/Settings/AppSettingsStore.cs b/src/YMhut.Box.Core/Settings/AppSettingsStore.cs index 220781f..f2c94e0 100644 --- a/src/YMhut.Box.Core/Settings/AppSettingsStore.cs +++ b/src/YMhut.Box.Core/Settings/AppSettingsStore.cs @@ -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"); diff --git a/src/YMhut.Box.PluginHost/Program.cs b/src/YMhut.Box.PluginHost/Program.cs index 9face23..75eed6b 100644 --- a/src/YMhut.Box.PluginHost/Program.cs +++ b/src/YMhut.Box.PluginHost/Program.cs @@ -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>(StringComparer.OrdinalIgnoreCase); +var bridgeSessions = new ConcurrentDictionary(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,50 +338,107 @@ void PluginFilesChanged(object sender, FileSystemEventArgs e) async Task 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? 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", "{}")), - "input.set" => SetRuntime(plugin.Manifest.Id, "input", JsonValue(payload), PluginPermission.Input), - "output.set" => AuthorizeUi(plugin, PluginPermission.Output), - "output.append" => AuthorizeUi(plugin, PluginPermission.Output), - "output.clear" => AuthorizeUi(plugin, PluginPermission.Output), - "log.info" => await LogAsync(plugin, "Information", payload).ConfigureAwait(false), - "log.warn" => await LogAsync(plugin, "Warning", payload).ConfigureAwait(false), - "log.error" => await LogAsync(plugin, "Error", payload).ConfigureAwait(false), - "storage.get" => await GetStorageAsync(plugin, payload).ConfigureAwait(false), - "storage.set" => await SetStorageAsync(plugin, payload).ConfigureAwait(false), - "storage.remove" => await RemoveStorageAsync(plugin, payload).ConfigureAwait(false), - "storage.list" => await ListStorageAsync(plugin).ConfigureAwait(false), - "http.fetch" => await FetchAsync(plugin, payload).ConfigureAwait(false), - "network.ping" => await PingAsync(plugin, payload).ConfigureAwait(false), - "network.dnsLookup" => await DnsLookupAsync(plugin, payload).ConfigureAwait(false), - "network.diagnostics" => NetworkDiagnostics(plugin), - "network.traceRoute" => await TraceRouteAsync(plugin, payload).ConfigureAwait(false), - "tool.run" => await RunToolAsync(plugin, payload).ConfigureAwait(false), - "clipboard.readText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.readText"), - "clipboard.writeText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.writeText"), - "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}") - }; + 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 Fail(exception.Message); + 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 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), + "output.clear" => AuthorizeUi(plugin, PluginPermission.Output), + "log.info" => await LogAsync(plugin, "Information", payload).ConfigureAwait(false), + "log.warn" => await LogAsync(plugin, "Warning", payload).ConfigureAwait(false), + "log.error" => await LogAsync(plugin, "Error", payload).ConfigureAwait(false), + "storage.get" => await GetStorageAsync(plugin, payload).ConfigureAwait(false), + "storage.set" => await SetStorageAsync(plugin, payload).ConfigureAwait(false), + "storage.remove" => await RemoveStorageAsync(plugin, payload).ConfigureAwait(false), + "storage.list" => await ListStorageAsync(plugin).ConfigureAwait(false), + "http.fetch" => await FetchAsync(plugin, payload).ConfigureAwait(false), + "network.ping" => await PingAsync(plugin, payload).ConfigureAwait(false), + "network.dnsLookup" => await DnsLookupAsync(plugin, payload).ConfigureAwait(false), + "network.diagnostics" => NetworkDiagnostics(plugin), + "network.traceRoute" => await TraceRouteAsync(plugin, payload).ConfigureAwait(false), + "tool.run" => await RunToolAsync(plugin, payload).ConfigureAwait(false), + "clipboard.readText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.readText"), + "clipboard.writeText" => AuthorizeUi(plugin, PluginPermission.Clipboard, "clipboard.writeText"), + "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: {method}") + }; } async Task 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 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 LogAsync(LoadedPlugin plugin, string level, Jso async Task 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 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 SetStorageAsync(LoadedPlugin plugin, JsonElemen async Task 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,24 +552,87 @@ async Task 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); - return JsonOk(new + 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++) { - status = (int)result.StatusCode, - ok = (int)result.StatusCode is >= 200 and < 300, - content = result.Content, - headers = result.Headers, - elapsedMs = (long)result.Elapsed.TotalMilliseconds - }); + 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()) + { + 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)response.StatusCode, + ok = (int)response.StatusCode is >= 200 and < 300, + content, + headers = responseHeaders + }); + } } async Task PingAsync(LoadedPlugin plugin, JsonElement payload) @@ -418,6 +643,7 @@ async Task 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 DnsLookupAsync(LoadedPlugin plugin, JsonElement { return Fail("network.dnsLookup requires host."); } + EnsureNetworkHostScope(plugin, host); try { @@ -500,6 +727,7 @@ async Task 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 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; +} diff --git a/src/YMhut.Box.PluginTauriHost/index.html b/src/YMhut.Box.PluginTauriHost/index.html index 9842a8f..54b6417 100644 --- a/src/YMhut.Box.PluginTauriHost/index.html +++ b/src/YMhut.Box.PluginTauriHost/index.html @@ -7,140 +7,79 @@
-
+
YMhut Plugin Host -
-
-
- +
Preparing controlled runtime...
+
Connecting to permission broker...
- +
- diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.lock b/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.lock index d6065f3..823fdfe 100644 --- a/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.lock +++ b/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.lock @@ -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", diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.toml b/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.toml index 3739f0c..80e3c25 100644 --- a/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.toml +++ b/src/YMhut.Box.PluginTauriHost/src-tauri/Cargo.toml @@ -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" diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs b/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs index dcf28ce..b068c6b 100644 --- a/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs +++ b/src/YMhut.Box.PluginTauriHost/src-tauri/src/main.rs @@ -1,9 +1,31 @@ -use serde::Serialize; -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::process::Command; -use tauri::Manager; +use http::{ + header::{self, HeaderValue}, + Method, Request, Response, StatusCode, +}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}, + net::IpAddr, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; +use tauri::{webview::NewWindowResponse, Url, WebviewUrl, WebviewWindowBuilder}; + +const MAX_BRIDGE_MESSAGE: usize = 256 * 1024; +const MAX_BROKER_MESSAGE: usize = 512 * 1024; +const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] @@ -13,104 +35,907 @@ struct RuntimeArgs { surface_id: String, runtime_kind: String, plugin_root: String, - manifest: String, entry: String, - command: Option, + entry_relative: String, + protocol_name: String, + plugin_origin: String, + profile_root: String, + broker_pipe: String, + developer_mode: bool, + allowed_origins: Vec, } -#[tauri::command] -fn runtime_args(state: tauri::State<'_, RuntimeArgs>) -> RuntimeArgs { - state.inner().clone() +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeMessage { + #[serde(rename = "type")] + message_type: String, + #[serde(default)] + request_id: String, + #[serde(default)] + version: Option, + #[serde(default)] + bridge_request: Option, + #[serde(default)] + bridge_response: Option, + #[serde(default)] + error: Option, + #[serde(default)] + session_token: Option, + #[serde(default)] + origin: Option, } -#[tauri::command] -fn open_plugin_folder(path: String) -> Result<(), String> { - if path.trim().is_empty() { - return Err("Plugin folder path is empty.".into()); - } - - let status = if cfg!(target_os = "windows") { - Command::new("explorer").arg(path).status() - } else if cfg!(target_os = "macos") { - Command::new("open").arg(path).status() - } else { - Command::new("xdg-open").arg(path).status() - } - .map_err(|error| error.to_string())?; - - if status.success() { - Ok(()) - } else { - Err(format!("Open folder command exited with {status}.")) - } +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeRequest { + plugin_id: String, + surface_id: String, + method: String, + payload_json: String, + session_token: String, + origin: String, } -#[tauri::command] -fn read_plugin_entry(path: String, plugin_root: String) -> Result { - if path.trim().is_empty() { - return Err("Plugin entry path is empty.".into()); - } - - let root = fs::canonicalize(Path::new(&plugin_root)).map_err(|error| error.to_string())?; - let entry = fs::canonicalize(Path::new(&path)).map_err(|error| error.to_string())?; - if !entry.starts_with(&root) { - return Err("Plugin entry must stay inside the plugin directory.".into()); - } - - fs::read_to_string(entry).map_err(|error| error.to_string()) +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeResponse { + ok: bool, + #[serde(default)] + value_json: Option, + #[serde(default)] + error: Option, + #[serde(default)] + ui_action: Option, + #[serde(default)] + error_code: Option, } -fn parse_runtime_args() -> RuntimeArgs { - let raw: Vec = std::env::args().collect(); - let mut values = HashMap::new(); - let mut index = 1; - while index < raw.len() { - if let Some(key) = raw[index].strip_prefix("--") { - if index + 1 < raw.len() && !raw[index + 1].starts_with("--") { - values.insert(key.to_string(), raw[index + 1].clone()); - index += 2; - continue; - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BrowserBridgeCall { + #[serde(default)] + id: String, + method: String, + #[serde(default)] + payload: Value, +} + +struct PipeBroker { + reader: BufReader, + writer: File, + plugin_id: String, + surface_id: String, + session_token: String, + origin: String, + sequence: u64, +} + +impl PipeBroker { + fn connect(runtime: &RuntimeArgs) -> Result { + let path = format!(r"\\.\pipe\{}", runtime.broker_pipe); + let writer = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|_| "Unable to connect to the external runtime broker.".to_string())?; + let reader_file = writer + .try_clone() + .map_err(|_| "Unable to initialize the external runtime broker.".to_string())?; + let mut reader = BufReader::new(reader_file); + let mut ready_line = String::new(); + reader + .read_line(&mut ready_line) + .map_err(|_| "The external runtime broker handshake failed.".to_string())?; + if ready_line.len() > MAX_BROKER_MESSAGE { + return Err("The external runtime broker handshake was too large.".into()); + } + let ready: RuntimeMessage = serde_json::from_str(&ready_line).map_err(|_| { + "The external runtime broker returned an invalid handshake.".to_string() + })?; + let session_token = ready.session_token.unwrap_or_default(); + let origin = ready.origin.unwrap_or_default(); + if ready.message_type != "ready" + || ready.version.as_deref() != Some("2") + || session_token.is_empty() + || origin != runtime.plugin_origin + { + return Err("The external runtime broker rejected the session binding.".into()); } - index += 1; + Ok(Self { + reader, + writer, + plugin_id: runtime.plugin_id.clone(), + surface_id: runtime.surface_id.clone(), + session_token, + origin, + sequence: 0, + }) } - RuntimeArgs { - session: values.remove("session").unwrap_or_default(), - plugin_id: values.remove("plugin-id").unwrap_or_else(|| "unknown".into()), - surface_id: values.remove("surface-id").unwrap_or_else(|| "default".into()), - runtime_kind: values.remove("runtime-kind").unwrap_or_else(|| "tauri".into()), - plugin_root: values.remove("plugin-root").unwrap_or_default(), - manifest: values.remove("manifest").unwrap_or_default(), - entry: values.remove("entry").unwrap_or_default(), - command: values.remove("command"), + fn call(&mut self, method: String, payload: Value) -> Result { + self.sequence = self.sequence.wrapping_add(1); + let request_id = format!("external-{}", self.sequence); + let payload_json = serde_json::to_string(&payload) + .map_err(|_| "The plugin bridge payload is invalid.".to_string())?; + if payload_json.len() > MAX_BRIDGE_MESSAGE { + return Ok(bridge_error( + "payload_too_large", + "The plugin bridge payload exceeded the size limit.", + )); + } + let message = RuntimeMessage { + message_type: "bridgeCall".into(), + request_id: request_id.clone(), + bridge_request: Some(BridgeRequest { + plugin_id: self.plugin_id.clone(), + surface_id: self.surface_id.clone(), + method, + payload_json, + session_token: self.session_token.clone(), + origin: self.origin.clone(), + }), + ..Default::default() + }; + let line = serde_json::to_string(&message) + .map_err(|_| "The external runtime request could not be serialized.".to_string())?; + if line.len() > MAX_BROKER_MESSAGE { + return Ok(bridge_error( + "payload_too_large", + "The external runtime request exceeded the size limit.", + )); + } + self.writer + .write_all(line.as_bytes()) + .and_then(|_| self.writer.write_all(b"\n")) + .and_then(|_| self.writer.flush()) + .map_err(|_| "The external runtime broker connection was lost.".to_string())?; + + let mut response_line = String::new(); + self.reader + .read_line(&mut response_line) + .map_err(|_| "The external runtime broker response failed.".to_string())?; + if response_line.is_empty() || response_line.len() > MAX_BROKER_MESSAGE { + return Err("The external runtime broker returned an invalid response.".into()); + } + let response: RuntimeMessage = serde_json::from_str(&response_line) + .map_err(|_| "The external runtime broker returned malformed JSON.".to_string())?; + if response.request_id != request_id || response.message_type != "bridgeCall" { + return Err("The external runtime broker response did not match the request.".into()); + } + response.bridge_response.ok_or_else(|| { + response + .error + .unwrap_or_else(|| "The external runtime broker returned no result.".into()) + }) } } fn main() { - let runtime = parse_runtime_args(); - let window_title_runtime = runtime.clone(); - let ready = serde_json::json!({ - "type": "ready", - "version": "1", - "session": &runtime.session, - "pluginId": &runtime.plugin_id, - "surfaceId": &runtime.surface_id, - "runtimeKind": &runtime.runtime_kind - }); - println!("{ready}"); + let runtime = match parse_runtime_args().and_then(validate_runtime) { + Ok(value) => Arc::new(value), + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + }; + let broker = match PipeBroker::connect(&runtime) { + Ok(value) => Arc::new(Mutex::new(value)), + Err(error) => { + eprintln!("{error}"); + std::process::exit(3); + } + }; - tauri::Builder::default() - .manage(runtime.clone()) - .invoke_handler(tauri::generate_handler![runtime_args, open_plugin_folder, read_plugin_entry]) + let protocol_name = runtime.protocol_name.clone(); + let protocol_runtime = runtime.clone(); + let protocol_broker = broker.clone(); + let setup_runtime = runtime.clone(); + let result = tauri::Builder::default() + .register_asynchronous_uri_scheme_protocol(protocol_name, move |_context, request, responder| { + let runtime = protocol_runtime.clone(); + let broker = protocol_broker.clone(); + std::thread::spawn(move || { + responder.respond(handle_protocol_request(&runtime, &broker, request)); + }); + }) .setup(move |app| { - if let Some(window) = app.get_webview_window("main") { - let title = format!("{} - YMhut Plugin Host", window_title_runtime.plugin_id); - let _ = window.set_title(&title); - } + let initialization_script = build_initialization_script(&setup_runtime); + let allowed_origin = setup_runtime.plugin_origin.clone(); + let frame_origin = setup_runtime.plugin_origin.clone(); + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title(format!("{} - YMhut Plugin Host", setup_runtime.plugin_id)) + .inner_size(1100.0, 760.0) + .min_inner_size(720.0, 520.0) + .data_directory(PathBuf::from(&setup_runtime.profile_root)) + .use_https_scheme(true) + .devtools(setup_runtime.developer_mode) + .general_autofill_enabled(false) + .browser_extensions_enabled(false) + .zoom_hotkeys_enabled(false) + .drag_and_drop(false) + .initialization_script_for_all_frames(initialization_script) + .on_web_resource_request(move |_request, response| { + insert_header( + response.headers_mut(), + "Content-Security-Policy", + &format!("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src {frame_origin}; object-src 'none'; base-uri 'none'; form-action 'none'"), + ); + insert_header(response.headers_mut(), "Permissions-Policy", "camera=(), microphone=(), geolocation=(), notifications=(), clipboard-read=(), clipboard-write=()"); + }) + .on_navigation(move |url| { + url.scheme() == "tauri" + || url.as_str().starts_with("https://tauri.localhost/") + || url.as_str().starts_with(&format!("{allowed_origin}/")) + || url.as_str() == "about:blank" + }) + .on_new_window(|_, _| NewWindowResponse::Deny) + .on_download(|_, _| false) + .build()?; Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running YMhut plugin host"); + .run(tauri::generate_context!()); + + match result { + Ok(()) => println!( + "{}", + json!({ + "type": "ready", + "version": "2", + "session": runtime.session, + "pluginId": runtime.plugin_id, + "surfaceId": runtime.surface_id, + "runtimeKind": runtime.runtime_kind + }) + ), + Err(_) => { + eprintln!("The controlled external runtime exited unexpectedly."); + std::process::exit(4); + } + } +} + +fn parse_runtime_args() -> Result { + let raw: Vec = std::env::args().collect(); + let mut runtime = RuntimeArgs::default(); + let mut index = 1; + while index < raw.len() { + let key = raw[index].as_str(); + if index + 1 >= raw.len() { + return Err("The external runtime received an incomplete argument.".into()); + } + let value = raw[index + 1].clone(); + match key { + "--session" => runtime.session = value, + "--plugin-id" => runtime.plugin_id = value, + "--surface-id" => runtime.surface_id = value, + "--runtime-kind" => runtime.runtime_kind = value, + "--plugin-root" => runtime.plugin_root = value, + "--entry" => runtime.entry = value, + "--protocol-name" => runtime.protocol_name = value, + "--plugin-origin" => runtime.plugin_origin = value, + "--profile-root" => runtime.profile_root = value, + "--broker-pipe" => runtime.broker_pipe = value, + "--developer-mode" => runtime.developer_mode = value.eq_ignore_ascii_case("true"), + "--allowed-origin" => runtime.allowed_origins.push(value), + _ => return Err("The external runtime received an unsupported argument.".into()), + } + index += 2; + } + Ok(runtime) +} + +fn validate_runtime(mut runtime: RuntimeArgs) -> Result { + if runtime.session.is_empty() + || runtime.plugin_id.is_empty() + || runtime.surface_id.is_empty() + || !runtime.runtime_kind.eq_ignore_ascii_case("tauri") + || runtime.broker_pipe.is_empty() + || runtime.profile_root.is_empty() + || runtime.protocol_name.len() > 48 + || !runtime + .protocol_name + .chars() + .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-') + { + return Err("The external runtime arguments are invalid.".into()); + } + let expected_origin = format!("https://{}.localhost", runtime.protocol_name); + if runtime.plugin_origin != expected_origin { + return Err("The external runtime origin binding is invalid.".into()); + } + if runtime + .allowed_origins + .iter() + .any(|origin| !is_safe_origin(origin)) + { + return Err("The external runtime received an invalid network origin.".into()); + } + + let root = fs::canonicalize(&runtime.plugin_root) + .map_err(|_| "The registered plugin directory is unavailable.".to_string())?; + let entry = fs::canonicalize(&runtime.entry) + .map_err(|_| "The registered plugin entry is unavailable.".to_string())?; + if !entry.starts_with(&root) || !entry.is_file() || contains_reparse_component(&root, &entry) { + return Err("The plugin entry escaped the registered package boundary.".into()); + } + let relative = entry + .strip_prefix(&root) + .map_err(|_| "The plugin entry is outside the registered package.".to_string())?; + runtime.plugin_root = root.to_string_lossy().to_string(); + runtime.entry = entry.to_string_lossy().to_string(); + runtime.entry_relative = relative.to_string_lossy().replace('\\', "/"); + fs::create_dir_all(&runtime.profile_root) + .map_err(|_| "The external runtime browser profile could not be created.".to_string())?; + Ok(runtime) +} + +fn handle_protocol_request( + runtime: &RuntimeArgs, + broker: &Arc>, + request: Request>, +) -> Response> { + if request.uri().path() == "/__ymhut_bridge__" { + return handle_bridge_request(runtime, broker, request); + } + if request.method() != Method::GET && request.method() != Method::HEAD { + return simple_response( + StatusCode::METHOD_NOT_ALLOWED, + "text/plain; charset=utf-8", + b"Method not allowed".to_vec(), + ); + } + serve_plugin_file(runtime, request) +} + +fn handle_bridge_request( + runtime: &RuntimeArgs, + broker: &Arc>, + request: Request>, +) -> Response> { + if request.method() != Method::POST || request.body().len() > MAX_BRIDGE_MESSAGE { + return browser_error_response( + "", + "invalid_request", + "The plugin bridge request is invalid.", + ); + } + let origin = request + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if origin != runtime.plugin_origin { + return browser_error_response( + "", + "session_invalid", + "The plugin bridge origin is invalid.", + ); + } + let call: BrowserBridgeCall = match serde_json::from_slice(request.body()) { + Ok(value) => value, + Err(_) => { + return browser_error_response( + "", + "invalid_request", + "The plugin bridge request is malformed.", + ) + } + }; + if call.id.len() > 128 || call.method.is_empty() || call.method.len() > 128 { + return browser_error_response( + &call.id, + "invalid_request", + "The plugin bridge request is invalid.", + ); + } + let call_id = call.id; + let response = match broker.lock() { + Ok(mut value) => match value.call(call.method, call.payload) { + Ok(response) => response, + Err(_) => bridge_error( + "host_failure", + "The external runtime broker is unavailable.", + ), + }, + Err(_) => bridge_error( + "host_failure", + "The external runtime broker is unavailable.", + ), + }; + browser_bridge_response(&call_id, response) +} + +fn serve_plugin_file(runtime: &RuntimeArgs, request: Request>) -> Response> { + let raw_path = request.uri().path(); + let lower_path = raw_path.to_ascii_lowercase(); + if lower_path.contains("%25") + || lower_path.contains("%2e") + || lower_path.contains("%5c") + || lower_path.contains("%00") + || raw_path.contains('\\') + || raw_path.contains(':') + { + return simple_response( + StatusCode::BAD_REQUEST, + "text/plain; charset=utf-8", + b"Invalid path".to_vec(), + ); + } + let decoded = match percent_decode_str(raw_path.trim_start_matches('/')).decode_utf8() { + Ok(value) => value, + Err(_) => { + return simple_response( + StatusCode::BAD_REQUEST, + "text/plain; charset=utf-8", + b"Invalid path".to_vec(), + ) + } + }; + let relative = if decoded.is_empty() { + runtime.entry_relative.as_str() + } else { + decoded.as_ref() + }; + if relative + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return simple_response( + StatusCode::BAD_REQUEST, + "text/plain; charset=utf-8", + b"Invalid path".to_vec(), + ); + } + + let root = Path::new(&runtime.plugin_root); + let candidate = root.join(relative); + let canonical = match fs::canonicalize(&candidate) { + Ok(value) => value, + Err(_) => { + return simple_response( + StatusCode::NOT_FOUND, + "text/plain; charset=utf-8", + b"Not found".to_vec(), + ) + } + }; + if !canonical.starts_with(root) + || !canonical.is_file() + || contains_reparse_component(root, &canonical) + { + return simple_response( + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + b"Forbidden".to_vec(), + ); + } + + let mut file = match File::open(&canonical) { + Ok(value) => value, + Err(_) => { + return simple_response( + StatusCode::NOT_FOUND, + "text/plain; charset=utf-8", + b"Not found".to_vec(), + ) + } + }; + let length = match file.metadata() { + Ok(value) => value.len(), + Err(_) => { + return simple_response( + StatusCode::NOT_FOUND, + "text/plain; charset=utf-8", + b"Not found".to_vec(), + ) + } + }; + let requested_range = match parse_range(request.headers().get(header::RANGE), length) { + Ok(value) => value, + Err(()) => { + let mut response = simple_response( + StatusCode::RANGE_NOT_SATISFIABLE, + "text/plain; charset=utf-8", + Vec::new(), + ); + insert_header( + response.headers_mut(), + header::CONTENT_RANGE, + &format!("bytes */{length}"), + ); + return response; + } + }; + let (start, end, status) = requested_range + .map(|(start, end)| (start, end, StatusCode::PARTIAL_CONTENT)) + .unwrap_or((0, length.saturating_sub(1), StatusCode::OK)); + let content_length = if length == 0 { 0 } else { end - start + 1 }; + let mut body = Vec::new(); + if request.method() != Method::HEAD && content_length > 0 { + body.resize(content_length as usize, 0); + if file.seek(SeekFrom::Start(start)).is_err() || file.read_exact(&mut body).is_err() { + return simple_response( + StatusCode::INTERNAL_SERVER_ERROR, + "text/plain; charset=utf-8", + b"Read failed".to_vec(), + ); + } + } + + let mime = mime_guess::from_path(&canonical).first_or_octet_stream(); + let mut response = simple_response(status, mime.essence_str(), body); + insert_header(response.headers_mut(), header::ACCEPT_RANGES, "bytes"); + insert_header( + response.headers_mut(), + header::CONTENT_LENGTH, + &content_length.to_string(), + ); + insert_header(response.headers_mut(), header::CACHE_CONTROL, "no-store"); + insert_header( + response.headers_mut(), + header::X_CONTENT_TYPE_OPTIONS, + "nosniff", + ); + insert_header( + response.headers_mut(), + header::REFERRER_POLICY, + "no-referrer", + ); + insert_header( + response.headers_mut(), + "Cross-Origin-Resource-Policy", + "same-origin", + ); + insert_header( + response.headers_mut(), + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(), notifications=(), clipboard-read=(), clipboard-write=(), autoplay=(self)", + ); + if status == StatusCode::PARTIAL_CONTENT { + insert_header( + response.headers_mut(), + header::CONTENT_RANGE, + &format!("bytes {start}-{end}/{length}"), + ); + } + if mime.type_() == mime_guess::mime::TEXT && mime.subtype() == mime_guess::mime::HTML { + insert_header( + response.headers_mut(), + "Content-Security-Policy", + &content_security_policy(runtime), + ); + } + response +} + +fn content_security_policy(runtime: &RuntimeArgs) -> String { + let mut connect = vec!["'self'".to_string()]; + connect.extend(runtime.allowed_origins.iter().cloned()); + format!( + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; connect-src {}; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors https://tauri.localhost", + connect.join(" ") + ) +} + +fn build_initialization_script(runtime: &RuntimeArgs) -> String { + let entry_url = format!( + "{}/{}", + runtime.plugin_origin, + runtime + .entry_relative + .split('/') + .map(|segment| utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string()) + .collect::>() + .join("/") + ); + let public_runtime = json!({ + "pluginId": runtime.plugin_id, + "surfaceId": runtime.surface_id, + "runtimeKind": runtime.runtime_kind, + "pluginOrigin": runtime.plugin_origin, + "entryUrl": entry_url, + "developerMode": runtime.developer_mode + }); + BRIDGE_BOOTSTRAP + .replace( + "__PLUGIN_ORIGIN_JSON__", + &serde_json::to_string(&runtime.plugin_origin).unwrap_or_else(|_| "\"\"".into()), + ) + .replace("__RUNTIME_JSON__", &public_runtime.to_string()) +} + +const BRIDGE_BOOTSTRAP: &str = r#" +(() => { + const pluginOrigin = __PLUGIN_ORIGIN_JSON__; + if (window === window.top && location.origin !== pluginOrigin) { + Object.defineProperty(window, "__YMHT_RUNTIME__", { value: Object.freeze(__RUNTIME_JSON__), configurable: false }); + return; + } + if (location.origin !== pluginOrigin || window.ymhut) return; + try { delete window.__TAURI__; delete window.__TAURI_INTERNALS__; } catch {} + let sequence = 1; + let active = 0; + async function call(method, payload = null) { + if (active >= 64) { + const error = new Error("Plugin bridge concurrency limit reached"); + error.code = "concurrency_limit"; + throw error; + } + const request = { id: String(sequence++), method, payload }; + const body = JSON.stringify(request); + if (new TextEncoder().encode(body).byteLength > 256 * 1024) { + const error = new Error("Plugin bridge payload exceeded the size limit"); + error.code = "payload_too_large"; + throw error; + } + active += 1; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 35000); + try { + const response = await fetch("/__ymhut_bridge__", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + credentials: "omit", + cache: "no-store", + redirect: "error", + signal: controller.signal + }); + const result = await response.json(); + if (!result.ok) { + const error = new Error(result.error || "Plugin bridge call failed"); + error.code = result.errorCode || "host_failure"; + throw error; + } + return result.value; + } catch (error) { + if (error?.name === "AbortError") { + const timeoutError = new Error("Plugin bridge call timed out"); + timeoutError.code = "timeout"; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timeout); + active -= 1; + } + } + const api = { + input: { get: () => call("input.get"), set: value => call("input.set", value), onInputChanged: handler => window.addEventListener("ymhut-input", event => handler(event.detail)) }, + output: { set: value => call("output.set", value), append: value => call("output.append", value), clear: () => call("output.clear") }, + log: { info: (message, detail) => call("log.info", { message, detail }), warn: (message, detail) => call("log.warn", { message, detail }), error: (message, detail) => call("log.error", { message, detail }) }, + storage: { get: key => call("storage.get", key), set: (key, value) => call("storage.set", { key, value }), remove: key => call("storage.remove", key), list: () => call("storage.list") }, + http: { fetch: request => call("http.fetch", request) }, + network: { diagnostics: () => call("network.diagnostics"), ping: request => call("network.ping", request), dnsLookup: request => call("network.dnsLookup", request), traceRoute: request => call("network.traceRoute", request) }, + clipboard: { readText: () => call("clipboard.readText"), writeText: text => call("clipboard.writeText", text) }, + file: { openPicker: () => call("file.openPicker"), savePicker: (name, value) => call("file.savePicker", { name, value }) }, + tool: { run: (toolId, input) => call("tool.run", { toolId, input }) }, + openExternal: (url, options) => call("openExternal", { url, options }) + }; + Object.defineProperty(window, "ymhut", { value: Object.freeze(api), writable: false, configurable: false }); +})(); +"#; + +fn browser_bridge_response(id: &str, response: BridgeResponse) -> Response> { + let value = response + .value_json + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + .unwrap_or(Value::Null); + let body = json!({ + "id": id, + "ok": response.ok, + "value": value, + "error": response.error, + "errorCode": response.error_code + }); + let mut result = simple_response( + StatusCode::OK, + "application/json; charset=utf-8", + body.to_string().into_bytes(), + ); + insert_header(result.headers_mut(), header::CACHE_CONTROL, "no-store"); + result +} + +fn browser_error_response(id: &str, code: &str, message: &str) -> Response> { + browser_bridge_response(id, bridge_error(code, message)) +} + +fn bridge_error(code: &str, message: &str) -> BridgeResponse { + BridgeResponse { + ok: false, + error: Some(message.into()), + error_code: Some(code.into()), + ..Default::default() + } +} + +fn simple_response(status: StatusCode, content_type: &str, body: Vec) -> Response> { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, content_type) + .body(body) + .unwrap_or_else(|_| Response::new(Vec::new())) +} + +fn insert_header( + headers: &mut http::HeaderMap, + name: impl header::IntoHeaderName, + value: &str, +) { + if let Ok(value) = HeaderValue::from_str(value) { + headers.insert(name, value); + } +} + +fn parse_range(value: Option<&HeaderValue>, length: u64) -> Result, ()> { + let Some(value) = value else { + return Ok(None); + }; + if length == 0 { + return Err(()); + } + let raw = value.to_str().map_err(|_| ())?; + let range = raw.strip_prefix("bytes=").ok_or(())?; + if range.contains(',') { + return Err(()); + } + let (start, end) = range.split_once('-').ok_or(())?; + if start.is_empty() { + let suffix = end.parse::().map_err(|_| ())?; + if suffix == 0 { + return Err(()); + } + let start = length.saturating_sub(suffix.min(length)); + return Ok(Some((start, length - 1))); + } + let start = start.parse::().map_err(|_| ())?; + let end = if end.is_empty() { + length - 1 + } else { + end.parse::().map_err(|_| ())?.min(length - 1) + }; + if start >= length || start > end { + return Err(()); + } + Ok(Some((start, end))) +} + +fn is_safe_origin(origin: &str) -> bool { + let Ok(url) = Url::parse(origin) else { + return false; + }; + let host = url.host_str().unwrap_or_default(); + matches!(url.scheme(), "https" | "wss") + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && !origin.ends_with('/') + && !origin.contains('*') + && !host.eq_ignore_ascii_case("localhost") + && !host.ends_with(".localhost") + && !host.ends_with(".local") + && !host.ends_with(".internal") + && (host.contains('.') || host.parse::().is_ok()) + && host.parse::().map(is_public_ip).unwrap_or(true) +} + +fn is_public_ip(address: IpAddr) -> bool { + match address { + IpAddr::V4(value) => { + let bytes = value.octets(); + !value.is_loopback() + && !value.is_unspecified() + && !value.is_multicast() + && bytes[0] != 10 + && !(bytes[0] == 100 && (64..=127).contains(&bytes[1])) + && !(bytes[0] == 169 && bytes[1] == 254) + && !(bytes[0] == 172 && (16..=31).contains(&bytes[1])) + && !(bytes[0] == 192 && bytes[1] == 168) + && !(bytes[0] == 198 && matches!(bytes[1], 18 | 19)) + && bytes[0] < 224 + } + IpAddr::V6(value) => { + let bytes = value.octets(); + !value.is_loopback() + && !value.is_unspecified() + && !value.is_multicast() + && !(bytes[0] & 0xfe == 0xfc) + && !(bytes[0] == 0xfe && bytes[1] & 0xc0 == 0x80) + } + } +} + +fn contains_reparse_component(root: &Path, candidate: &Path) -> bool { + if is_reparse(root) { + return true; + } + let relative = match candidate.strip_prefix(root) { + Ok(value) => value, + Err(_) => return true, + }; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + if is_reparse(¤t) { + return true; + } + } + false +} + +#[cfg(windows)] +fn is_reparse(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & 0x400 != 0) + .unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn origins_require_exact_public_https_or_wss() { + assert!(is_safe_origin("https://api.example.com")); + assert!(is_safe_origin("wss://stream.example.com:8443")); + assert!(!is_safe_origin("http://api.example.com")); + assert!(!is_safe_origin("https://api.example.com/path")); + assert!(!is_safe_origin("https://localhost")); + assert!(!is_safe_origin("https://192.168.1.20")); + assert!(!is_safe_origin("https://[fc00::1]")); + } + + #[test] + fn range_parser_accepts_one_bounded_range() { + assert_eq!(parse_range(None, 100), Ok(None)); + assert_eq!( + parse_range(Some(&HeaderValue::from_static("bytes=10-19")), 100), + Ok(Some((10, 19))) + ); + assert_eq!( + parse_range(Some(&HeaderValue::from_static("bytes=-10")), 100), + Ok(Some((90, 99))) + ); + assert_eq!( + parse_range(Some(&HeaderValue::from_static("bytes=100-")), 100), + Err(()) + ); + assert_eq!( + parse_range(Some(&HeaderValue::from_static("bytes=0-1,4-5")), 100), + Err(()) + ); + } + + #[test] + fn bootstrap_exposes_no_native_session_secret_or_path() { + let runtime = RuntimeArgs { + plugin_id: "demo".into(), + surface_id: "main".into(), + runtime_kind: "Tauri".into(), + plugin_origin: "https://p-demo.localhost".into(), + entry_relative: "index.html".into(), + developer_mode: true, + broker_pipe: "secret-pipe".into(), + plugin_root: r"C:\private\plugin".into(), + ..Default::default() + }; + let script = build_initialization_script(&runtime); + assert!(script.contains("https://p-demo.localhost")); + assert!(!script.contains("secret-pipe")); + assert!(!script.contains(r"C:\private\plugin")); + } +} + +#[cfg(not(windows))] +fn is_reparse(path: &Path) -> bool { + fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(true) } diff --git a/src/YMhut.Box.PluginTauriHost/src-tauri/tauri.conf.json b/src/YMhut.Box.PluginTauriHost/src-tauri/tauri.conf.json index b0f30a6..085208e 100644 --- a/src/YMhut.Box.PluginTauriHost/src-tauri/tauri.conf.json +++ b/src/YMhut.Box.PluginTauriHost/src-tauri/tauri.conf.json @@ -10,14 +10,7 @@ "frontendDist": "../" }, "app": { - "withGlobalTauri": true, - "windows": [ - { - "label": "main", - "title": "YMhut Plugin Host", - "width": 1100, - "height": 760 - } - ] + "withGlobalTauri": false, + "windows": [] } } diff --git a/src/YMhut.Box.PluginTauriHost/src/main.js b/src/YMhut.Box.PluginTauriHost/src/main.js index b630021..08f0645 100644 --- a/src/YMhut.Box.PluginTauriHost/src/main.js +++ b/src/YMhut.Box.PluginTauriHost/src/main.js @@ -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 ` -