diff --git a/AGENTS.md b/AGENTS.md index c46b70f4b..f87214529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,9 @@ don't bulk-load `.deepwiki/`. Treat it as background only: current code and the ## Project Overview -ScriptCat is a Manifest V3 browser extension for Tampermonkey-compatible user scripts, built with TypeScript, +ScriptCat is a Manifest V3 browser extension for userscripts inspired by Tampermonkey, built with TypeScript, React 19, and Rspack. **pnpm** is required by `preinstall`. The presentation layer (`src/pages/`) uses shadcn/ui -and Tailwind CSS v4 (migrated from Arco Design + UnoCSS). +and Tailwind CSS v4. ## Engineering Principles @@ -68,14 +68,6 @@ downstream prose does not override it. establish a root-cause fix; report the trigger, evidence, and remaining uncertainty. Follow the asynchronous observation and timing guidance in [`docs/references/develop-testing.md`](docs/references/develop-testing.md#observation-rules-for-asynchronous-tests). -- **Shared E2E helpers must model both outcomes.** A helper that drives a save, install, or other mutation must make - the expected success or failure explicit and wait for that operation's matching signal. Negative cases must opt into - the failure contract; never make them pass by accepting an arbitrary toast, an old notification, or a page shell. -- **Performance-sensitive UI fixtures must stay bounded.** Use the smallest synthetic fixture that crosses the - required boundary; for filtering or pagination, do not eagerly render unrelated rows before the trigger. Obvious - explicit one-page-plus fixtures need a line-level `scriptcat/no-test-large-boundary-fixture` rationale; do not hide - their cost by raising the test timeout. The detailed fixture and measurement rules live in - [`docs/references/develop-testing.md`](docs/references/develop-testing.md#vitest-performance-hygiene). - **Dnd-kit list rendering must keep the drag boundary cheap.** Keep sensor options, modifiers, callbacks, and the sortable item-list reference stable when their values are unchanged; render plain rows/cards while dragging is disabled instead of mounting `DndContext`/`SortableContext`. Stabilize item identity with a collision-safe @@ -232,6 +224,13 @@ Service Worker (src/service_worker.ts) > SW → Offscreen uses `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage`) on Chrome and > `EventPageOffscreenManager` on Firefox MV3; Offscreen replies to SW over `ExtensionMessage`. `WindowMessage` > is the Offscreen ↔ Sandbox channel. +> +> USER_SCRIPT content and MAIN inject runtimes normally use native extension channels directly to the SW. +> The `scripting` bundle is a document-start extension content script registered per matching frame. It runs a +> page-bridge runtime and is a supporting per-document helper rather than a separate service/background context in +> this five-context model. Those bridges carry the content bootstrap handoff, MAIN bootstrap/fallback and runtime update packets, +> synchronous DOM handles, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC falls back through +> `PageMessage`, the scripting runtime validates its execution handle and grant before forwarding it to the SW. - **Service Worker** — central hub for script CRUD, Chrome APIs, permission verification, resource caching, and message routing. - **Content** — bridges SW and inject script. @@ -244,9 +243,13 @@ Sandbox. ### Message Passing (`packages/message/`) -`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `WindowMessage` (postMessage — Offscreen ↔ -Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), -`CustomEventMessage` (CustomEvent — Content ↔ Inject), and `MessageQueue` (cross-context broadcast). +`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `PageMessage` (`window.postMessage` — +scripting ↔ Inject page bridge, including validated MAIN RPC fallback), `CustomEventMessage` (CustomEvent — +bootstrap handoff and DOM handles), +`WindowMessage` (`postMessage` — Offscreen ↔ Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + +`postMessage` — SW → Offscreen on Chrome), and `MessageQueue` (cross-context broadcast). Page-visible bridges do +not establish an authenticated extension origin; MAIN requests relayed through `PageMessage` must pass the +`PageRpcRegistry` checks before forwarding. ### Service & Data Layers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 298356a1e..eee700113 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ If you want to run ScriptCat locally, you can use the following commands: ```bash pnpm run dev -# Please note that for unknown reasons, if you need to use incognito windows, you need to use the following command for development +# Development build without source maps pnpm run dev:noMap ``` diff --git a/README.md b/README.md index 4774f0573..469385724 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,9 @@ ScriptCat ## About ScriptCat -ScriptCat is a powerful userscript manager based on Tampermonkey's design philosophy, fully compatible with Tampermonkey -scripts. It not only supports traditional userscripts but also innovatively implements a background script execution -framework with rich API extensions, enabling scripts to accomplish more powerful functions. It features an excellent -built-in code editor with intelligent completion and syntax checking, making script development more efficient and -smooth. +ScriptCat is a userscript manager inspired by Tampermonkey. It adds a background script execution framework with +extended APIs. Its built-in code editor provides intelligent completion and syntax checking to make script development +more efficient. **If you find it useful, please give us a Star ⭐ This is the greatest support for us!** @@ -43,7 +41,7 @@ smooth. ### 🔧 Powerful Functions -- **Full Tampermonkey Compatibility**: Seamlessly migrate existing Tampermonkey scripts with zero learning curve +- **Tampermonkey Script Support**: Compatibility depends on a script's APIs and metadata; some may need adjustments - **Background Scripts**: Innovative background execution mechanism, keeping scripts running continuously without page limitations - **Scheduled Scripts**: Support timed execution tasks for auto check-ins, scheduled reminders, and more @@ -87,8 +85,8 @@ If you cannot access extension stores, download the latest ZIP package from 1. **Get from Script Markets**: Visit [ScriptCat Script Store](https://scriptcat.org/en/search) or other userscript markets 2. **Background Scripts Zone**: Experience unique [Background Scripts](https://scriptcat.org/en/search?script_type=3) -3. **Compatibility**: Supports most Tampermonkey scripts, can be installed directly. If you encounter incompatible - scripts, please report them to us through [issues](https://github.com/scriptscat/scriptcat/issues). +3. **Compatibility**: Compatibility depends on each script's APIs and metadata. If a script does not run, report it + through [issues](https://github.com/scriptscat/scriptcat/issues). #### Developing Scripts diff --git a/docs/CONTRIBUTING_RU.md b/docs/CONTRIBUTING_RU.md index fb5fcefc9..f983378fc 100644 --- a/docs/CONTRIBUTING_RU.md +++ b/docs/CONTRIBUTING_RU.md @@ -94,7 +94,7 @@ pnpm run lint ```bash pnpm run dev -# Обратите внимание: по неизвестным причинам, если вам нужно использовать режим инкогнито, используйте следующую команду для разработки +# Сборка для разработки без source maps pnpm run dev:noMap ``` diff --git a/docs/CONTRIBUTING_ZH.md b/docs/CONTRIBUTING_ZH.md index 201ec8efd..9129854c8 100644 --- a/docs/CONTRIBUTING_ZH.md +++ b/docs/CONTRIBUTING_ZH.md @@ -96,7 +96,7 @@ ScriptCat 的页面开发使用了以下技术: ```bash pnpm run dev -# 请注意,由于未知原因,如果你需要使用隐身窗口,你需要使用下面的命令进行开发 +# 不生成 source map 的开发构建 pnpm run dev:noMap ``` diff --git a/docs/README_RU.md b/docs/README_RU.md index 02a159236..31c4155d1 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -22,11 +22,9 @@ ## О проекте -ScriptCat — это мощный менеджер пользовательских скриптов, основанный на философии Tampermonkey и полностью совместимый -с его скриптами. Он не только поддерживает традиционные пользовательские скрипты, но и инновационно реализует фреймворк -для выполнения фоновых скриптов, предоставляет богатый API для расширений, позволяя скриптам выполнять более мощные -функции. Встроенный превосходный редактор кода с поддержкой интеллектуального дополнения и проверки синтаксиса делает -разработку скриптов более эффективной и плавной. **Если вам понравилось, пожалуйста, поставьте нам звезду (Star) ⭐ — +ScriptCat — это менеджер пользовательских скриптов, вдохновлённый Tampermonkey. Он также предоставляет фреймворк +фоновых скриптов с расширенным API. Встроенный редактор кода с автодополнением и проверкой синтаксиса упрощает +разработку скриптов. **Если вам понравилось, пожалуйста, поставьте нам звезду (Star) ⭐ — это лучшая поддержка для нас!** ## ✨ Ключевые особенности @@ -40,8 +38,8 @@ ScriptCat — это мощный менеджер пользовательск ### 🔧 Мощный функционал -- **Полная совместимость с Tampermonkey**: Бесшовная миграция существующих скриптов Tampermonkey, нулевая кривая - обучения. +- **Совместимость со скриптами Tampermonkey**: Она зависит от API и метаданных скрипта; некоторым может потребоваться + доработка. - **Фоновые скрипты**: Уникальный механизм фонового выполнения позволяет скриптам работать непрерывно без ограничений со стороны страницы. - **Скрипты по расписанию**: Поддержка выполнения задач по расписанию для реализации автоматического подтверждения @@ -87,8 +85,7 @@ ScriptCat — это мощный менеджер пользовательск другие маркетплейсы пользовательских скриптов. 2. **Раздел фоновых скриптов**: Ознакомьтесь с уникальными [фоновыми скриптами](https://scriptcat.org/ru/search?script_type=3). -3. **Совместимость**: Поддерживается подавляющее большинство скриптов для Tampermonkey, их можно устанавливать и - использовать напрямую. Если вы столкнетесь с несовместимым скриптом, пожалуйста, сообщите нам через +3. **Совместимость**: Она зависит от API и метаданных скрипта. Если скрипт не работает, сообщите нам через [issue](https://github.com/scriptscat/scriptcat/issues). #### Разработка скриптов diff --git a/docs/README_ja.md b/docs/README_ja.md index 7ae46ba03..18ada28a2 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -25,9 +25,8 @@ ScriptCat ## ScriptCat について -ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザースクリプトマネージャーで、Tampermonkey のスクリプトと完全な互換性を持ちます。 -従来のユーザースクリプトをサポートするだけでなく、豊富な API 拡張を備えたバックグラウンドスクリプト実行フレームワークを革新的に実装し、スクリプトでより強力な機能を実現できます。 -また、優れた内蔵コードエディタを搭載し、インテリジェント補完や構文チェックに対応しており、スクリプト開発をより効率的かつスムーズに行えます。 +ScriptCat は Tampermonkey の設計思想を参考にしたユーザースクリプトマネージャーです。 +拡張 API を備えたバックグラウンドスクリプト実行機能も提供します。内蔵コードエディタは補完と構文チェックに対応し、スクリプト開発を効率化します。 **便利だと感じたら、ぜひ Star ⭐ を付けて応援してください!** @@ -40,7 +39,7 @@ ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザース ### 🔧 強力な機能 -- **Tampermonkey と完全互換**:既存の Tampermonkey スクリプトを学習コストなしでそのまま移行可能 +- **Tampermonkey スクリプトへの対応**:互換性はスクリプトが使う API やメタデータによって異なり、修正が必要な場合があります - **バックグラウンドスクリプト**:ページに依存せず連続実行できる革新的なバックグラウンド実行機構 - **スケジュールスクリプト**:自動チェックイン、リマインダーなどの定時実行をサポート - **豊富な API**:Tampermonkey 以上の強力な API 群を提供 @@ -81,8 +80,7 @@ ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザース 1. **スクリプトセンターから取得**: [ScriptCat スクリプトセンター](https://scriptcat.org/ja/search) またはその他のユーザースクリプトセンターへアクセス 2. **バックグラウンドスクリプトセンター**:ユニークな [バックグラウンドスクリプト](https://scriptcat.org/ja/search?script_type=3) を体験 -3. **互換性**:多くの Tampermonkey スクリプトをサポートしており、そのままインストール可能。不具合があれば - [issues](https://github.com/scriptscat/scriptcat/issues) にてご報告ください。 +3. **互換性**:スクリプトによって対応状況は異なります。動作しない場合は [issues](https://github.com/scriptscat/scriptcat/issues) にてご報告ください。 #### スクリプト開発 diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index 241c6beb3..e6d753dbc 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -25,7 +25,7 @@ ScriptCat ## 关于 -ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于油猴的设计理念,完全兼容油猴脚本。它不仅支持传统的用户脚本,还创新性地实现了后台脚本运行框架,提供丰富的API扩展,让脚本能够完成更多强大的功能。内置优秀的代码编辑器,支持智能补全和语法检查,让脚本开发更加高效流畅。 +ScriptCat(脚本猫)是一个参考油猴设计理念的用户脚本管理器,也提供具备扩展 API 的后台脚本运行框架。内置代码编辑器支持智能补全和语法检查,帮助提高脚本开发效率。 **如果觉得好用,请给我们一个 Star ⭐ 这是对我们最大的支持!** @@ -38,7 +38,7 @@ ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于 ### 🔧 强大功能 -- **完全兼容油猴**:无缝迁移现有油猴脚本,零学习成本 +- **油猴脚本兼容性**:兼容情况取决于脚本使用的 API 和元数据,部分脚本可能需要调整 - **后台脚本**:独创后台运行机制,让脚本持续运行不受页面限制 - **定时脚本**:支持定时执行任务,实现自动签到、定时提醒等功能 - **丰富 API**:相比油猴提供更多强大 API,解锁更多可能性 @@ -79,7 +79,7 @@ ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于 1. **从脚本市场获取**:访问 [ScriptCat 脚本站](https://scriptcat.org/search) 或其他用户脚本市场 2. **后台脚本专区**:体验独有的 [后台脚本](https://scriptcat.org/zh-CN/search?script_type=3) -3. **兼容性**:支持绝大部分油猴脚本,可直接安装使用,如果遇到不兼容的脚本,欢迎通过 +3. **兼容性**:兼容情况取决于脚本使用的 API 和元数据。如果脚本无法运行,欢迎通过 [issue](https://github.com/scriptscat/scriptcat/issues) 反馈给我们。 #### 开发脚本 diff --git a/docs/README_zh-TW.md b/docs/README_zh-TW.md index 0cf8ec4cb..1ca4be3ad 100644 --- a/docs/README_zh-TW.md +++ b/docs/README_zh-TW.md @@ -25,9 +25,8 @@ ScriptCat ## 關於 ScriptCat -ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管理器,完全相容 Tampermonkey 腳本。 -它不僅支援傳統使用者腳本,還創新實作了背景腳本執行框架,並擁有豐富的 API 擴充能力,使腳本能完成更強大的功能。 -內建優秀的程式碼編輯器,具備智慧補全與語法檢查,讓腳本開發更加高效與順暢。 +ScriptCat 是一款參考 Tampermonkey 設計理念的使用者腳本管理器,也提供具備擴充 API 的背景腳本執行框架。 +內建程式碼編輯器支援智慧補全與語法檢查,讓腳本開發更有效率。 **如果你覺得 ScriptCat 很有用,歡迎幫我們點一顆 Star ⭐ 這是對我們最好的支持!** @@ -40,7 +39,7 @@ ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管 ### 🔧 強大功能 -- **完整 Tampermonkey 相容性**:可無縫遷移現有 Tampermonkey 腳本,零學習成本 +- **Tampermonkey 腳本支援**:相容性取決於腳本使用的 API 和中繼資料,部分腳本可能需要調整 - **背景腳本**:創新的背景執行機制,使腳本可持續運作,不受頁面限制 - **排程腳本**:支援定時執行的任務,如自動簽到、定時提醒等 - **豐富 API**:提供比 Tampermonkey 更強大的 API,解鎖更多可能性 @@ -81,8 +80,7 @@ ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管 1. **從腳本市場取得**:前往 [ScriptCat 腳本站](https://scriptcat.org/zh-TW/search) 或其他使用者腳本市場 2. **背景腳本區**:體驗獨特的 [背景腳本](https://scriptcat.org/zh-TW/search?script_type=3) -3. **相容性**:支援多數 Tampermonkey 腳本,可直接安裝。若遇到不相容腳本,歡迎至 - [issues](https://github.com/scriptscat/scriptcat/issues) 回報給我們。 +3. **相容性**:相容性取決於腳本使用的 API 和中繼資料。若腳本無法執行,歡迎至 [issues](https://github.com/scriptscat/scriptcat/issues) 回報給我們。 #### 開發腳本 diff --git a/docs/architecture.md b/docs/architecture.md index 06436d77f..939835c1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,16 @@ Three ideas explain almost everything in the codebase: inject, and sandbox don't hold a MessageQueue instance. ``` +The diagram compresses the page-facing routes: USER_SCRIPT content and MAIN inject runtimes also connect directly +to the Service Worker through native extension channels on the preferred path. The `scripting` bundle is a +document-start extension content script registered per matching frame; it runs a page-bridge runtime and is a +supporting per-document helper rather than a separate service/background context in this five-context model. +`CustomEventMessage` carries the content bootstrap +handoff and synchronous DOM handles; `PageMessage` carries MAIN bootstrap/fallback traffic, runtime event/value +updates, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC uses the page bridge fallback, the scripting +runtime validates the execution handle and grant before forwarding it to the Service Worker; the page bridge +itself is not an authenticated extension origin. + --- ## The Five Contexts (Process Model) @@ -86,13 +96,15 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | Isolated content-script world. Bridges SW and the page. | `CustomEventMessage` channel to inject + `Server("content")` → `ScriptRuntime` | -| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Receives a document bootstrap token through the page-side bridge, then uses a native extension channel for script loading, GM RPC, value updates, and callbacks. Dedicated USER_SCRIPT listeners are used when available; otherwise the regular port is token-bound. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` for bootstrap handoff and DOM handles | +| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | Native extension port for the preferred GM RPC path; `PageMessage` for bootstrap/fallback, whitelisted external API, and validated GM RPC fallback; `CustomEventMessage` for synchronous DOM handles | | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | -There is also a sixth bundle, [`src/scripting.ts`](../src/scripting.ts), injected via `chrome.userScripts` / -`chrome.scripting` to carry the compiled page-script payload (see [Script execution](./references/architecture-execution.md)). +The [`scripting` bundle](../src/scripting.ts) is a document-start content script registered through +`chrome.scripting`; it supplies the per-document page bridge. Compiled userscript payloads and the `inject.js` / +`content.js` runners are registered separately through `chrome.userScripts` (see +[Script execution](./references/architecture-execution.md)). ### Service-worker bootstrap @@ -167,8 +179,9 @@ communication styles** over **several transports**. | Class | File | Connects | Underlying API | |---|---|---|---| -| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox) | -| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect`; browser-identified USER_SCRIPT messages are action-gated, and regular-port fallbacks are token-bound | +| `PageMessage` | [`page_message.ts`](../packages/message/page_message.ts) | `scripting` ↔ Inject | `window.postMessage`; page-visible MAIN bootstrap/fallback, runtime updates, whitelisted external API, and GM RPC fallback validated by `PageRpcRegistry` | +| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ `scripting` page helper | DOM `CustomEvent`; bootstrap handoff and synchronous DOM references, not privileged GM RPC | | `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | | `MessageQueue` | [`message_queue.ts`](../packages/message/message_queue.ts) | Broadcast among the contexts that instantiate it — SW, Offscreen, UI pages | `chrome.runtime.sendMessage` + local `EventEmitter3` | diff --git a/docs/design.md b/docs/design.md index ebd220e15..e299c29dd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -25,15 +25,12 @@ owned by [`develop.md` § UI](./develop.md#ui) — linked from here, never resta Every UI change must satisfy all of these. They are the bar for "friendly, consistent UI/UX" in this codebase. -- **Use tokens, not literal colors — one value, one place.** Never write a hex (`#1296db`), an `rgb()`, or a palette class (`text-blue-500`). Always use a semantic token — `bg-background`, `text-foreground`, `border-border`, `text-primary`, `bg-primary-background`, `text-muted-foreground`, … ([tokens](./references/design-tokens.md)). All color values live in exactly one place — the token definitions in `src/index.css` — so the palette stays unified and a single edit re-skins everything. One semantic concept maps to **one** token: before adding a color, check [tokens](./references/design-tokens.md) for an existing token and reuse it; don't introduce a near-duplicate (a second slightly-different gray or blue). Only add a new token when the concept is genuinely new — with both light and dark values — and document it in [tokens](./references/design-tokens.md). -- **Both themes, always.** Light and dark are first-class. Because every color comes from a token that has a `:root` and a `.dark` value, using tokens makes a component theme-correct for free. Verify on real light *and* dark before considering anything done ([theming](#theming)). +- **Both themes, always.** Verify the rendered change in light and dark before considering the UI work done ([theming](#theming)). - **Design for mobile too.** The UI is responsive around a single `768px` breakpoint (`useIsMobile`). Mobile is **a different shell, not a shrunk desktop** — side nav becomes bottom tabs + drawer, tables become cards, rows stack, details/code collapse, actions move into a sticky bar ([layout & responsive](./references/design-patterns.md#layout--responsive)). A feature isn't finished until it works on a narrow viewport. -- **No inline `style={{}}` for what Tailwind can express.** Compose utility classes via `cn()` (`clsx` + `tailwind-merge`); build variants with `class-variance-authority` (CVA). Inline styles only for genuinely dynamic values (e.g. a computed width). -- **Hover/focus are CSS, not state.** Express interactive visuals with pseudo-classes (`hover:bg-primary-background/90`, `focus-visible:ring-ring/50`). React state is for data/logic, not styling. -- **Reuse components before building new ones.** Default to the shadcn primitives in `src/pages/components/ui/` ([components](./references/design-components.md)); icons come from `lucide-react` only — don't hand-roll a control that already exists. Beyond primitives, search the existing pages for a composed block (card row, identity header, permission card, state screen…) that already does what you need and reuse it. When the same block appears in two or more places, extract one shared component instead of copy-pasting — keep one implementation per concept so behavior and styling stay consistent and a fix lands everywhere at once. +- **Reuse components before building new ones.** Default to the shadcn primitives in `src/pages/components/ui/` ([components](./references/design-components.md)). Beyond primitives, search the existing pages for a composed block (card row, identity header, permission card, state screen…) that already does what you need and reuse it. When the same block appears in two or more places, extract one shared component instead of copy-pasting — keep one implementation per concept so behavior and styling stay consistent and a fix lands everywhere at once. - **Keep motion restrained.** Enter/leave in `150–250ms`, `ease-out`; reuse the existing `@utility` animations rather than inlining `@keyframes`; prefer `transition-colors` over `transition-all` ([motion](./references/design-patterns.md#motion)). - **No silent operations.** Every async flow surfaces loading / empty / error / success (and progress for long-running work). The user must always know whether their action worked ([state patterns](./references/design-patterns.md#state-patterns)). -- **Don't introduce new colors or fonts ad hoc.** New color → add a token in `src/index.css` (with both light and dark values) and document it here. New font → add a `--font-*` token; don't reference an unconfigured family. +- **Don't introduce unconfigured fonts.** Add a `--font-*` token before using a new font family. --- @@ -96,7 +93,7 @@ setTheme("auto"); // "auto" follows the system theme and updates on change | `font-sans` (`--font-sans`) | `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"` | Body / UI text. Applied on `body` via `@apply font-sans`, so everything inherits it by default; this is the default — you rarely write `font-sans` explicitly | | `font-mono` (`--font-mono`) | `ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", Consolas, "Liberation Mono", "PingFang SC", "Microsoft YaHei", monospace` | Code, version numbers, `@match`/permission rules, stored values — anything monospaced (`font-mono`) | -> **No webfont, no `@font-face`.** Don't reference a family that isn't actually packaged (it would silently fall back and mislead — Constraint 9). If a brand font is genuinely required, self-host it (woff2, local `@font-face`, never a CDN), keep the CJK fallback, and update this table. +> **No webfont, no `@font-face`.** Don't reference a family that isn't actually packaged (it would silently fall back and mislead). If a brand font is genuinely required, self-host it (woff2, local `@font-face`, never a CDN), keep the CJK fallback, and update this table. ### Radius @@ -124,12 +121,12 @@ When building a new page or dialog, run this checklist to stay consistent: - [ ] **Entry** reuses the existing `main.tsx` pattern — mount `ThemeProvider`, `Toaster` (and `TooltipProvider` if needed); don't roll your own theme logic. - [ ] **Shell:** sticky TopBar + `.scrollbar-custom` scroll container + sticky ActionBar ([layout & responsive](./references/design-patterns.md#layout--responsive)). -- [ ] **Responsive:** branch on `useIsMobile()`; re-shell on mobile (bottom bar/drawer, cards, collapse) rather than scaling down (Constraint 3, [layout & responsive](./references/design-patterns.md#layout--responsive)). -- [ ] **Color** entirely from tokens (`bg-card` / `text-foreground` / `border-border` / `text-primary` / `bg-primary-background` …), no literals, verified on both themes (Constraint 1–2, [tokens](./references/design-tokens.md) & [theming](#theming)). -- [ ] **Components** reuse first — search existing pages for a composed block before building; use `src/pages/components/ui/` primitives; extract a shared component when a block repeats; variants via CVA, classes via `cn()`, icons via `lucide-react` (Constraint 6, [components](./references/design-components.md)). +- [ ] **Responsive:** branch on `useIsMobile()`; re-shell on mobile (bottom bar/drawer, cards, collapse) rather than scaling down ([layout & responsive](./references/design-patterns.md#layout--responsive)). +- [ ] **Color** entirely from tokens (`bg-card` / `text-foreground` / `border-border` / `text-primary` / `bg-primary-background` …), no literals, verified on both themes ([tokens](./references/design-tokens.md) & [theming](#theming)). +- [ ] **Components** reuse first — search existing pages for a composed block before building; use `src/pages/components/ui/` primitives; extract a shared component when a block repeats ([components](./references/design-components.md)). - [ ] **Hierarchy** orders the most important info first; decision pages go identity → permissions → code (Principle 1). - [ ] **State:** loading / empty / error / success / in-progress all covered, never silent ([state patterns](./references/design-patterns.md#state-patterns)). -- [ ] **Motion** restrained (`150–250ms`, `ease-out`), hover/focus via pseudo-classes, enter/leave via `data-state`, reuse existing utilities ([motion](./references/design-patterns.md#motion)). +- [ ] **Motion** restrained (`150–250ms`, `ease-out`), enter/leave via `data-state`, reuse existing utilities ([motion](./references/design-patterns.md#motion)). - [ ] **Depth** uses the elevation ladder (resting/raised/overlay, [elevation](./references/design-tokens.md#elevation-shadows)) and the z-index ladder (`z-10` chrome / `z-50` floating, [layering](./references/design-patterns.md#layering-z-index)) — no `shadow-2xl`, no magic `z-[…]`. - [ ] **Accessibility:** AA contrast on both themes; meaning never color-only; custom controls keyboard-reachable with a visible focus ring; `aria-label` on icon buttons; ≥ ~44px mobile tap targets; reduced-motion-safe ([accessibility](./references/design-patterns.md#accessibility)). - [ ] **Copy** defaults to sentence-case English + i18n; verbs on buttons; specific errors ([writing & microcopy](./references/design-patterns.md#writing--microcopy)), and flexes for long locales ([layout & responsive](./references/design-patterns.md#layout--responsive)); see [`develop.md`](./develop.md) and [`translation.md`](./translation.md). diff --git a/docs/develop.md b/docs/develop.md index a3dfaccf1..601d6ab01 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -11,7 +11,7 @@ ```bash pnpm install # install deps (preinstall enforces pnpm) pnpm run dev # dev build (source maps); load dist/ext as unpacked extension -pnpm run dev:noMap # dev build w/o source maps (incognito) +pnpm run dev:noMap # dev build w/o source maps pnpm run build # production Rspack build pnpm run pack # package the extension (requires dist/scriptcat.pem) diff --git a/docs/pull-request.md b/docs/pull-request.md index 2280e9662..354ec2023 100644 --- a/docs/pull-request.md +++ b/docs/pull-request.md @@ -132,17 +132,6 @@ Activate only the rows touched by the actual change; mixed changes use their uni | Persistence/migration/release | Compatibility and data scope, ordering/irreversibility, rollback/restore path, and rehearsal or invariant evidence where safe | | Async/concurrency/stateful UI | Duplicate in-flight work, stale or late results, cancellation/retry, cleanup, and identity or generation ordering where applicable | -## Review-oriented content - -For non-trivial changes, make the description useful for review: - -- `背景` explains the problem, compatibility gap, or maintenance need. -- `本次改动` summarizes user-visible behavior and important implementation changes. -- `实现考虑` records design decisions, invariants, lifecycle behavior, races, or compatibility choices. -- `已知限制` records unsupported cases, explicit scope boundaries, and follow-up work. -- `建议审查重点` lists concrete behaviors or risks reviewers should verify. -- `验证` lists exact commands and concise results, including known warnings or why a check was not run. - ## Documentation-only PRs For a PR that only changes Markdown, `验证` should reflect what a doc change actually needs, not an unrelated diff --git a/docs/references/architecture-agent.md b/docs/references/architecture-agent.md index 8defc00b4..f46812a0b 100644 --- a/docs/references/architecture-agent.md +++ b/docs/references/architecture-agent.md @@ -98,6 +98,34 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma attachments), `AgentTaskRunRepo` (task run history), `SkillRepo` (skill `.md`/script bundles). - `MCPServerRepo` (`Repo`) — MCP server configs. +## Userscript resource ownership + +The `CAT.agent.*` APIs are granted per script, but a grant alone does not decide which persisted resources that +script can access. The service-worker GM handlers take the caller identity from `request.script.uuid` and pass it +to the Agent services; they do not use a caller-supplied `scriptUuid` as the authority. + +- **Conversations** created by a script persist `ownerScriptUuid`. Script reads, chats, attaches, and mutations + check that owner. UI and legacy conversations without an owner remain available to the extension UI but are not + visible to script callers. Ephemeral chats are not persisted conversations. +- **Tasks** created by a script persist `ownerScriptUuid`; script list/get/update/delete/enable/run/history + operations are scoped to that owner. For compatibility, a legacy event task without an owner remains visible + only to the script named by `sourceScriptUuid`. +- **DOM monitors** are scoped to the script UUID supplied by the service-worker GM handler and to the tab. A + script caller cannot peek, stop, or replace a monitor owned by another script. +- **Attachments** live in the shared OPFS workspace and do not carry owner metadata themselves. Before + `CAT.agent.opfs.readAttachment` returns a file, `AgentChatRepo` verifies that a persisted message references + it from a conversation owned by the calling script. A guessed ID or a reference borrowed from another script's + conversation is insufficient. + +The checks are implemented in [`gm_agent.ts`](../../src/app/service/service_worker/gm_api/gm_agent.ts), +[`gm_agent_dom.ts`](../../src/app/service/service_worker/gm_api/gm_agent_dom.ts), +[`gm_agent_task.ts`](../../src/app/service/service_worker/gm_api/gm_agent_task.ts), +[`chat_service.ts`](../../src/app/service/agent/service_worker/chat_service.ts), +[`task_service.ts`](../../src/app/service/agent/service_worker/task_service.ts), +[`background_session_manager.ts`](../../src/app/service/agent/service_worker/background_session_manager.ts), +[`opfs_service.ts`](../../src/app/service/agent/service_worker/opfs_service.ts), and +[`dom_cdp.ts`](../../src/app/service/agent/service_worker/dom_cdp.ts). + ## Page / offscreen / sandbox delegation and permission boundaries - **Content (`src/app/service/content/gm_api/cat_agent.ts`)** exposes the `CAT.agent.*` API to user scripts — @@ -120,7 +148,8 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma uses CDP; a background (non-active) tab tries CDP first and falls back to `chrome.tabs.captureVisibleTab` on failure; an active tab with no selector uses `chrome.tabs.captureVisibleTab` directly. - **Tab monitoring** (`startMonitor`/`stopMonitor`/`peekMonitor`) is unconditionally CDP-based — there is no - non-CDP path for it at all. + non-CDP path for it at all. A monitor is scoped to its tab and initiating script; other scripts cannot + inspect, stop, or replace it. CDP attaches the debugger to a tab and carries the extra permission/user-visible-banner implications that come with `chrome.debugger`; how often that applies depends on which action you're looking at, not a single diff --git a/docs/references/architecture-build.md b/docs/references/architecture-build.md index 7d7cf3054..965bf1640 100644 --- a/docs/references/architecture-build.md +++ b/docs/references/architecture-build.md @@ -9,7 +9,7 @@ ``` context bundles : service_worker · offscreen · sandbox · content · inject · scripting shared : common (pre-React bootstrap, e.g. early theme init — see src/pages/common.ts) -UI pages (React): popup · options · install · batchupdate · confirm · import +UI pages (React): popup · options · install · batchupdate · confirm · external_access_confirm · import workers : editor.worker · ts.worker · json.worker (Monaco) · linter.worker ``` @@ -19,8 +19,8 @@ Output goes to `dist/ext/src/[name].js` (cleaned each build). Notable behavior: - **Path aliases** mirror `tsconfig.json`: `@App → src`, `@Packages → packages` (the `@Tests → tests` alias is test-only — defined in `vitest.config.ts` / `tsconfig.json`, not in the Rspack build). -- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`, needed - for incognito); prod minifies with SWC + Lightning CSS and drops debug. +- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`); prod minifies + with SWC + Lightning CSS and drops debug. - **Code splitting** pulls big libs into named `lib_*` chunks (react, monaco, radix-ui, dnd-kit, eslint, message), but **never splits** `service_worker`, `content`, `inject`, `scripting`, or the workers — MV3 requires those to be single self-contained files. @@ -78,7 +78,7 @@ MV3 officially supports Firefox, so `PACK_FIREFOX` is `true` by default and the | Package | Purpose | |---|---| | [`message`](../../packages/message) | The cross-context RPC + pub/sub layer (see [Message Passing](../architecture.md#message-passing)). Ships its own mocks. | -| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup — WebDAV, cloud drives (OneDrive, Google Drive, Dropbox, Baidu, S3), and zip archives. | +| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup; see the [package README](../../packages/filesystem/README.md) for providers and Zip behavior. | | [`cloudscript`](../../packages/cloudscript) | Cloud-script integration. | | [`eslint`](../../packages/eslint) | The ESLint config + globals shipped to the in-editor linter for userscripts (`CAT_*`, `GM_*`, `CATRetryError`, …). | | [`chrome-extension-mock`](../../packages/chrome-extension-mock) | A mock `chrome.*` + message bus for Vitest. | diff --git a/docs/references/architecture-data.md b/docs/references/architecture-data.md index a6534f85a..482074a70 100644 --- a/docs/references/architecture-data.md +++ b/docs/references/architecture-data.md @@ -40,8 +40,8 @@ Design notes: - **Cache:** `enableCache()` switches reads/writes to a process-local cache that mirrors storage — used for hot collections (scripts) to avoid repeated async reads. A subclass that overrides `joinKey` can hash keys (e.g. resources keyed by URL via a UUID-v5 namespace). -- **Storage errors are logged, not thrown** — `chrome.runtime.lastError` is checked and reads continue, since - a transient storage hiccup should not crash the worker. +- **Storage errors reject their promises.** The storage callback paths check `chrome.runtime.lastError` and reject; + `Repo` does not log the error and continue. ### Repository inventory @@ -57,6 +57,8 @@ Names ending in `DAO` don't all share one base class — check which backend bef | `PermissionDAO` | [`permission.ts`](../../src/app/repo/permission.ts) | `Permission` | Composite key `::` | | `SubscribeDAO` | [`subscribe.ts`](../../src/app/repo/subscribe.ts) | `Subscribe` | Keyed by feed URL | | `FaviconDAO`, `LocalStorageDAO`, `ExportDAO`, `TempStorageDAO` | `src/app/repo/*.ts` | misc | Same `Repo` pattern | +| `ExternalAccessOperationDAO` | [`external_access.ts`](../../src/app/repo/external_access.ts) | `ExternalAccessOperation` | External-access operation records | +| `NetworkRuleStateDAO` | [`network_rule.ts`](../../src/app/repo/network_rule.ts) | `NetworkRuleState` | Declarative network-rule state | | `AgentModelRepo` | [`agent_model.ts`](../../src/app/repo/agent_model.ts) | `AgentModelConfig` | Agent model configs — small, no indexed query need | | `AgentTaskRepo` | [`agent_task.ts`](../../src/app/repo/agent_task.ts) | `AgentTask` | Scheduled agent task definitions | | `MCPServerRepo` | [`mcp_server_repo.ts`](../../src/app/repo/mcp_server_repo.ts) | `MCPServerConfig` | MCP server configs | diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 717ea0f12..26f373f5a 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -24,21 +24,29 @@ go through a controlled context object instead of the page's real globals: Key points: - `with(arguments[0]||this.$)` makes every bare identifier resolve against the GM context first. The context is - a `Proxy` that intercepts reads, so the script sees `unsafeWindow`, the granted `GM_*` functions, and a - controlled view of globals — not the raw page scope. + a descriptor-based pseudo-window that projects `unsafeWindow`, the granted `GM_*` functions, and a controlled + view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. - Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code can't shadow them by declaring variables of the same name. -- `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow - function would capture the wrong `this`). +- The wrapper installs the body as a temporary method and removes it in the same expression. This preserves the + userscript `this` without resolving mutable page `call`, `apply`, or `bind` properties. ### Path A — Page scripts → `chrome.userScripts` -Normal userscripts run in the page. The SW builds a `RegisteredUserScript` from the script's `@match`/`@include` -patterns and registers the compiled payload (the `scripting` bundle) with `chrome.userScripts.register`, in the -`MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair +The SW compiles enabled userscripts and registers each payload through `chrome.userScripts` with its match, world, +and run-time settings. It also registers the `inject.js` and `content.js` runners there for the `MAIN` and +`USER_SCRIPT` paths. Separately, `scripting.js` is registered through `chrome.scripting` as a document-start +content script that supplies the page bridge. At document time the content/inject pair ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM -context. +context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over +`ExtensionMessage` after a bootstrap-token handoff. The MAIN `inject` path uses a native extension port for GM RPC +when available; `PageMessage` carries page-visible bootstrap/fallback traffic, MAIN event/value updates, the +whitelisted `external.Scriptcat` API, and the MAIN GM RPC fallback through the `scripting` bundle. That fallback +is checked against the current `PageRpcRegistry` execution handle and grant before it is forwarded to the service +worker. `CustomEventMessage` carries the content bootstrap handoff and synchronous DOM references. Neither +page-visible bridge establishes an authenticated extension origin, so consumers must validate its payloads before +acting on them. ### Path B — Background scripts → Offscreen → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 8282aae48..9850cdc57 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -7,12 +7,15 @@ across contexts to a privileged handler, then streams the result back. The imple - **Content side** ([`src/app/service/content/gm_api/`](../../src/app/service/content/gm_api)) — what runs *near* the userscript. Synchronous-feeling APIs (`GM_getValue`, `GM_log`) and the client half of async ones - (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the messaging plumbing. + (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the request facade. `USER_SCRIPT` calls use + the native extension channel; the DOM helper remains a narrow synchronous `CustomEventMessage` path. - **Service-worker side** ([`src/app/service/service_worker/gm_api/`](../../src/app/service/service_worker/gm_api)) — the privileged half: permission verification, cross-origin requests, DNR rule building. - **Offscreen side** ([`src/app/service/offscreen/gm_api.ts`](../../src/app/service/offscreen/gm_api.ts)) — DOM-dependent operations for background scripts (page-context XHR, `window.open`, clipboard). -- **Values** flow through `ValueService` and are broadcast so every tab running the same script sees updates. +- **Values** flow through `ValueService`. MAIN updates use the scripting broadcast, while USER_SCRIPT updates are + delivered over the native per-document callback port so privileged packets do not cross the page-observable DOM + channel. ### Registration: the `@GMContext.API` decorator @@ -82,4 +85,6 @@ traditional GM API: `@GMContext.API` on the content side [`compat-grant.js`](../../packages/eslint/compat-grant.js). What differs is the naming and transport shape — the grant is dotted (`CAT.agent.conversation`) and bound with `follow:` rather than `alias:`, the SW handlers set `dotAlias: false`, and conversation chat streams over `connect()` instead of `sendMessage`. Copy -the nearest existing `CAT.agent.*` method rather than a `GM_*` one. +the nearest existing `CAT.agent.*` method rather than a `GM_*` one. The service-worker handlers derive the script +identity from `request.script.uuid`, then the Agent services enforce persisted resource ownership; see +[`architecture-agent.md`](./architecture-agent.md#userscript-resource-ownership) for the scope and legacy rules. diff --git a/docs/references/design-components.md b/docs/references/design-components.md index 57416b3d9..372754d5e 100644 --- a/docs/references/design-components.md +++ b/docs/references/design-components.md @@ -2,7 +2,7 @@ ## Component palette & usage -The shadcn primitives live in [`src/pages/components/ui/`](../../src/pages/components/ui/) — `new-york` style, CSS variables enabled, no class prefix (`components.json`). Icons are always `lucide-react`; class merging is always `cn()` ([`src/pkg/utils/cn.ts`](../../src/pkg/utils/cn.ts)); variants are always CVA — these are the [`develop.md` § UI](../develop.md#ui) hard rules, not repeated here. This section is "what exists and how to choose." +The shadcn primitives live in [`src/pages/components/ui/`](../../src/pages/components/ui/) — `new-york` style, CSS variables enabled, no class prefix (`components.json`). Follow the [`develop.md` § UI](../develop.md#ui) for implementation rules; this section covers what exists and how to choose. ### Primitives & shared composites diff --git a/docs/references/design-patterns.md b/docs/references/design-patterns.md index de63e35e3..28dea9b35 100644 --- a/docs/references/design-patterns.md +++ b/docs/references/design-patterns.md @@ -147,7 +147,7 @@ A loading state is not one thing — and a centered spinner is the *last* resort Practical rules: -- **Never freeze and never wait silently.** A region that is loading must show a skeleton, spinner, or bar — never a blank or stale frame with no signal (Constraint 8). +- **Never freeze and never wait silently.** A region that is loading must show a skeleton, spinner, or bar — never a blank or stale frame with no signal ([Core Constraints](../design.md#core-constraints-non-negotiable)). - **Don't fake determinism.** Use the determinate progress bar only when the percent/bytes are actually known; otherwise use an indeterminate fill or a skeleton. - **One indicator per wait.** Don't stack a full-page spinner over content that is already skeletoned, or two bars for one fetch. - **The spinner is always `Loader2` + `animate-spin`** (`text-primary` when it should read as active), sized to context — `size-3.5`/`size-4` inline, `size-12` full-page ([motion](#motion)). @@ -175,7 +175,7 @@ Consistent words are part of a consistent UI. ### Interactive states -[Core Constraints](../design.md#core-constraints-non-negotiable) covers hover/focus (CSS pseudo-classes, never React state). For completeness every interactive control also needs: +[UI guidelines](../develop.md#ui) cover hover/focus (CSS pseudo-classes, never React state). For completeness every interactive control also needs: - **Disabled:** the shadcn primitives already apply `disabled:opacity-50 disabled:pointer-events-none` — reuse them; don't hand-roll a greyed-out look. A disabled control still needs a reason nearby (helper text/tooltip) if it's non-obvious. - **Active / pressed:** rely on the primitive's built-in `active:`; add `active:` utilities only for custom controls. diff --git a/docs/references/design-tokens.md b/docs/references/design-tokens.md index 5b9025099..c8c16679e 100644 --- a/docs/references/design-tokens.md +++ b/docs/references/design-tokens.md @@ -7,7 +7,8 @@ **Usage:** - Background `bg-`, text `text-`, border `border-`, focus ring `ring-ring`. - Opacity modifiers compose directly: `bg-primary-background/90` (solid primary hover), `ring-destructive/20`, `bg-input/30`. -- **Never hard-code a color value** — see Constraint 1 and [`develop.md` § UI](../develop.md#ui). For dark-only tweaks use the `dark:` variant. +- **Never hard-code a color value** — see [`develop.md` § UI](../develop.md#ui). For dark-only tweaks use the `dark:` variant. +- Use one token per semantic color concept. Reuse an existing token; add one only for a new concept with light and dark values, and document its role here. ### Base surfaces & text diff --git a/docs/references/develop-testing.md b/docs/references/develop-testing.md index fab471b13..322a17f39 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -334,16 +334,7 @@ before/after in one environment with the JSON-report method below. pay for a full accessibility-tree `*ByRole` scan when the role itself is not the behavior under test. - Accessibility coverage must not be weakened for speed. When role/ARIA derivation is the contract, assert the resulting `role` / `aria-*` attribute directly (or use the semantic query in a small, focused component test). -- Choose the narrowest async primitive that matches the production boundary: - - If an event handler calls the observed mock synchronously, assert immediately; `waitFor` only adds polling. - - For an element that appears after an effect or request, use `findBy*` instead of wrapping `screen.getBy*` in - `waitFor`. - - When a resolved Promise drives React state, locate the control first, trigger it inside one - `await act(async () => ...)`, then assert directly. Do not put a `findBy*` query inside `act`. - - Keep `waitFor` for genuinely open-ended async boundaries (deferred effects, externally controlled Promises, - Portal mounting). Keep its callback cheap and scoped, and combine related assertions into one polling loop. -- Avoid real sleeps in unit tests. Use fake timers for timer behavior; a short real delay is acceptable only when - the delay itself is the regression guard (for example, proving a rejected load does not start a runaway loop). +- Select waits according to the [asynchronous observation rules](#observation-rules-for-asynchronous-tests). - Match test concurrency to the workload: - Use `describe.concurrent()` / `it.concurrent()` only when cases can make useful progress without blocking the same worker. Synchronous CPU-heavy work such as parsing, encoding, compression, and large fixture loops still diff --git a/docs/references/terminology-ko-KR.md b/docs/references/terminology-ko-KR.md index c0b8f3e42..90220dc0b 100644 --- a/docs/references/terminology-ko-KR.md +++ b/docs/references/terminology-ko-KR.md @@ -72,7 +72,7 @@ | 개념 | 사용할 수 있는 표현 | 선택 기준 | 예시 key | | --- | --- | --- | --- | -| source | `출처`, `설치 출처`, `구독 출처`, `소스 코드` | origin/provenance는 `출처`, code는 `소스 코드`를 사용합니다. | `source`, `col_source`, `prompt.source` | +| source | `출처`, `설치 출처`, `구독 출처`, `소스 코드` | origin/provenance는 `출처`, code는 `소스 코드`를 사용합니다. | `source`, `col_source` | | local / cloud | `로컬` / `클라우드` | 데이터 위치, 백업 위치, 동기화 대상을 설명합니다. | `local`, `cloud`, `backup_to` | | storage | `저장소`, `저장 공간` | 기능 이름과 짧은 레이블은 `저장소`, 공간을 설명하는 문장은 `저장 공간`을 사용할 수 있습니다. | `script_storage`, `storage_error` | | panel / console | `패널` / `콘솔` | ScriptCat 조작 UI는 `패널`, 개발자 도구 출력은 `콘솔`을 사용합니다. | `background_script_description`, `build_success_message` | @@ -149,7 +149,7 @@ | `인증` / 권한 허용 | authorization을 authentication으로 오역할 수 있음 | 권한 결정은 `권한 허용`·`권한 요청`, 계정 검증만 `인증` | `auth_duration`, `loading_confirm` | | 일반 스크립트 / 유저스크립트 | 제품 유형과 일반 생태계 용어가 섞일 수 있음 | 제품 유형은 `일반 스크립트`, 일반 개념은 `유저스크립트` | `create_user_script`, `thisIsAUserScript` | | 예약 스크립트 / crontab script | 유형 이름과 문법 이름이 섞일 수 있음 | 유형은 `예약 스크립트`, 문법은 `cron 표현식` | `only_background_scheduled_can_run` | -| `소스` / `출처` | origin과 source code가 혼동될 수 있음 | origin은 `출처`, code는 `소스 코드` | `common:source`, `prompt.source` | +| `소스` / `출처` | origin과 source code가 혼동될 수 있음 | origin은 `출처`, code는 `소스 코드` | `common:source`, `editor:source` | | `Skill` / `스킬` | 동일한 기능 이름이 혼용될 수 있음 | 일반 UI는 `스킬`, 정확한 식별자는 원문 유지 | `import_skill`, `skills_title` | | 브라우저 탭 | bare `전체`가 무엇을 뜻하는지 불명확할 수 있음 | `모든 탭`, `일반 탭`, `시크릿 탭` | `script_run_env.*` | | clear / reset | 데이터 비우기와 기본값 초기화가 혼동될 수 있음 | `비우기`·`지우기`와 `초기화`를 구분 | `clear_success`, `reset` | diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 08f8f23f5..5674ac347 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,7 +15,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 7752b86b1..59719c021 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index 221fccba9..712d5394e 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -23,7 +23,7 @@ // @grant GM.cookie // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index f57875162..cd63c5d02 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -27,7 +27,7 @@ // @grant GM.setValue // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 32d1666b4..cc93a66e1 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,7 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 842e850de..35fa6ebdd 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,7 +6,7 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // ==/UserScript== (async function () { diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index 24076c706..a8e23984c 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -9,7 +9,7 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-idle // ==/UserScript== diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index cfab9e8de..d3e3dc990 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -5,7 +5,7 @@ // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) // @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect mockhttp.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 7f1814f48..1076d038b 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_REDIRECT_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 5acfbe5ad..2a5c10075 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 1c67c9432..02e43e753 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index a3991b3b4..abeb8accc 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -6,7 +6,7 @@ ## 引入 ```js -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js ``` E2E 运行时会把该框架 URL 重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 4cc6f0e67..0478c5951 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { beforeEach, describe as vdescribe, expect as vexpect, it as vit, vi } from "vitest"; const SCTEST_REQUIRE_URL = - "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js"; + "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js"; async function loadSCTest() { delete globalThis.SCTest; diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js index 4d07999ca..7ff5d7e81 100644 --- a/example/tests/sandbox_compatibility_test.js +++ b/example/tests/sandbox_compatibility_test.js @@ -15,7 +15,7 @@ // @grant GM.setValue // @grant GM.deleteValue // @grant window.onurlchange -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @inject-into content // ==/UserScript== diff --git a/example/tests/sandbox_function_test.js b/example/tests/sandbox_function_test.js index 81ada25fc..54e067bc6 100644 --- a/example/tests/sandbox_function_test.js +++ b/example/tests/sandbox_function_test.js @@ -19,7 +19,7 @@ // @grant window.close // @grant window.focus // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-end // ==/UserScript== diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 9afa5fcc9..1f7ec1620 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -6,7 +6,7 @@ // @author ScriptCat // @match https://content-security-policy.com/?unwrap_e2e_test // @grant GM_setValue -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index fc2ca51d1..66f791c6a 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -8,7 +8,7 @@ // @exclude /test_\w+_excluded/ // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 08a5eb23b..7a0893f88 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,7 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @run-at document-end // @noframes diff --git a/packages/chrome-extension-mock/README.md b/packages/chrome-extension-mock/README.md index 8e691efdc..691f78bb8 100644 --- a/packages/chrome-extension-mock/README.md +++ b/packages/chrome-extension-mock/README.md @@ -1,3 +1,5 @@ # mock一个chrome扩展环境 -> 只针对自己的项目做了一些简单的封装,如果有需要可以自己修改 +`@Packages/chrome-extension-mock` 的默认导出 `chromeMock` 为测试提供 `chrome.*` API mock。仓库测试在 +[`tests/vitest.setup.ts`](../../tests/vitest.setup.ts) 中将其注册为全局 `chrome` 并调用 `init()`,以重置下载、权限和 +WebRequest mock 的状态。 diff --git a/packages/message/README.md b/packages/message/README.md index 7e15272ea..44f6fa01e 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -1,12 +1,13 @@ # 消息 -跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层。按调用形态选择传输方式: +跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层,也包含与 +`scripting` 页面桥接辅助脚本的消息。按调用形态选择传输方式: - **单次 request/reply**(调用一次拿一次结果,例如大多数 GM API、扩展页面对 service_worker 的一次性调用)—— 使用 `sendMessage`(`Server`/`Group`/`Client` 的 RPC 封装)。 - **流式/进度/长响应,或需要持续双向交换**(例如需要分块返回大响应的 GM API、需要多次调用/多次结果的场景)—— 使用 `connect()`(`MessageConnect`)建立持久连接。 -- **广播**(service_worker/offscreen 触发的状态变化需要通知所有页面)——使用 `MessageQueue` 的 +- **广播**(service_worker/offscreen 的状态变化需要通知已实例化 `MessageQueue` 并订阅对应 topic 的上下文)——使用 `MessageQueue` 的 `publish`/`subscribe`,而不是上面两种点对点方式。 Service Worker → Offscreen 在 Chrome 与 Firefox 上走不同路径(Chrome 使用 @@ -19,3 +20,6 @@ document),细节见 - service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。 - service_worker 会在空闲后进入不活动状态;与它建立的 `connect()` 长连接会在此时中断,因此需要长连接的场景要考虑 重连/状态恢复,而不是假定连接一直存活——这不是禁止在 service_worker 上使用 `connect`,只是需要为其生命周期设计容错。 +- USER_SCRIPT content 和 MAIN inject 优先使用 `ExtensionMessage` 原生扩展通道;服务端区分浏览器提供的 USER_SCRIPT 来源,并把 MAIN 或专用监听器不可用时的普通端口绑定到文档 bootstrap token。 +- `Server("serviceWorker")` 对浏览器标记的 `userScript` 来源仅允许 `connect()` 使用 `runtime/registerUserScript` 或 `runtime/gmApi`,仅允许 `sendMessage()` 使用 `runtime/gmApi` 或 `runtime/reconnectUserScript`;普通 extension 端口不带该来源标记,USER_SCRIPT / MAIN 的注册回退路径会在 `runtime/registerUserScript` 握手中校验文档 bootstrap token。 +- `CustomEventMessage` 和 `PageMessage` 是页面可见的桥:前者承载 content bootstrap 交接与同步 DOM 节点引用,后者承载 MAIN bootstrap/fallback、事件/值更新、白名单 `external.Scriptcat` API,以及经 `scripting` 中转的 GM RPC fallback。它们不提供已认证的扩展来源;`PageMessage` 的 GM RPC 在转发前必须通过 `PageRpcRegistry` 的执行句柄与 grant 校验。 diff --git a/packages/message/common.ts b/packages/message/common.ts index 009fd8a51..33b3e6757 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -8,9 +8,14 @@ export const CustomEventClone = CustomEvent; const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; // 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败 -export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone); -export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); -export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +export const pageDispatchEvent = bindNative(performanceClone.dispatchEvent, performanceClone); +export const pageAddEventListener = bindNative(performanceClone.addEventListener, performanceClone); +export const pageRemoveEventListener = bindNative(performanceClone.removeEventListener, performanceClone); const detailClone = typeof cloneInto === "function" ? cloneInto : null; export const pageDispatchCustomEvent = (eventType: string, detail: T) => { if (detailClone && detail) detail = detailClone(detail, performanceClone); diff --git a/packages/message/custom_event_message.test.ts b/packages/message/custom_event_message.test.ts index 190d7f3cb..290e79a5c 100644 --- a/packages/message/custom_event_message.test.ts +++ b/packages/message/custom_event_message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CustomEventMessage } from "./custom_event_message"; import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common"; @@ -17,6 +17,30 @@ function createMessagePair() { } describe("CustomEventMessage relatedTarget lifecycle", () => { + it("ignores accessor envelopes without executing their getters", () => { + const receiver = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, true, ""); + const received = vi.fn(); + receiver.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "custom-event-message-test/hostile" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => receiver.messageHandle(envelope as any, { postMessage: vi.fn() })).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + it("stores a received target on the receiving message until it is consumed", () => { const { sender, receiver } = createMessagePair(); const target = document.createElement("div"); diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..dda181001 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -1,6 +1,11 @@ import type { Message, MessageConnect, RuntimeMessageSender, TMessage } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { type PostMessage, type WindowMessageBody, WindowMessageConnect } from "./window_message"; +import { + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, + WindowMessageConnect, +} from "./window_message"; import EventEmitter from "eventemitter3"; import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { @@ -78,6 +83,9 @@ export class CustomEventMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts new file mode 100644 index 000000000..a547f7eb3 --- /dev/null +++ b/packages/message/extension_message.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; + +describe("ExtensionMessage USER_SCRIPT compatibility", () => { + it("reports a failed dedicated listener registration so USER_SCRIPT can use the regular-port fallback", () => { + const runtime = chrome.runtime as unknown as { + onUserScriptConnect?: { addListener: (callback: (...args: any[]) => void) => void }; + onUserScriptMessage?: { addListener: (callback: (...args: any[]) => void) => void }; + messageListener?: Array<(message: any, sender: any, sendResponse: (response: any) => void) => void>; + connectListener?: Array<(port: chrome.runtime.Port) => void>; + }; + const originalConnect = runtime.onUserScriptConnect; + const originalMessage = runtime.onUserScriptMessage; + const initialMessageListenerCount = runtime.messageListener?.length ?? 0; + const initialConnectListenerCount = runtime.connectListener?.length ?? 0; + try { + runtime.onUserScriptConnect = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + runtime.onUserScriptMessage = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + const message = new ExtensionMessage(true); + message.onConnect(() => undefined); + message.onMessage(() => undefined); + + const response = vi.fn(); + const listeners = runtime.messageListener ?? []; + listeners.at(-1)?.({ type: "userScripts.LISTEN_CONNECTIONS" }, {}, response); + + expect(response).toHaveBeenCalledWith(false); + } finally { + if (runtime.messageListener) runtime.messageListener.length = initialMessageListenerCount; + if (runtime.connectListener) runtime.connectListener.length = initialConnectListenerCount; + runtime.onUserScriptConnect = originalConnect; + runtime.onUserScriptMessage = originalMessage; + } + }); + + it("does not require unavailable runtime event listeners", () => { + const runtime = chrome.runtime as unknown as { + onConnect?: typeof chrome.runtime.onConnect; + onMessage?: typeof chrome.runtime.onMessage; + }; + const onConnect = runtime.onConnect; + const onMessage = runtime.onMessage; + + try { + runtime.onConnect = undefined; + runtime.onMessage = undefined; + const message = new ExtensionMessage(); + + expect(() => message.onConnect(() => undefined)).not.toThrow(); + expect(() => message.onMessage(() => undefined)).not.toThrow(); + } finally { + runtime.onConnect = onConnect; + runtime.onMessage = onMessage; + } + }); + + it("keeps native sendMessage and connect bindings after runtime mutation", async () => { + const runtime = chrome.runtime as unknown as { + sendMessage: unknown; + connect: unknown; + }; + const sendMessage = runtime.sendMessage; + const connect = runtime.connect; + const message = new ExtensionMessage(); + + try { + runtime.sendMessage = () => { + throw new Error("patched sendMessage"); + }; + runtime.connect = () => { + throw new Error("patched connect"); + }; + + await expect(message.sendMessage({ action: "test" })).resolves.toMatchObject({ success: true }); + await expect(message.connect({ action: "test" })).resolves.toBeDefined(); + } finally { + runtime.sendMessage = sendMessage; + runtime.connect = connect; + } + }); + + it("keeps a native port postMessage binding after port mutation", () => { + const nativePostMessage = vi.fn(); + const port = { + postMessage: nativePostMessage, + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() }, + disconnect: vi.fn(), + } as unknown as chrome.runtime.Port; + const connection = new ExtensionMessageConnect(port); + + port.postMessage = vi.fn(); + connection.sendMessage({ action: "native" }); + + expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" }); + connection.disconnect(true); + }); + + it("preserves an explicit main-frame target when frameId is zero", async () => { + const sendMessage = vi + .spyOn(chrome.tabs, "sendMessage") + .mockImplementation((_tabId, _message, optionsOrCallback, callback) => { + const responseCallback = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; + responseCallback?.({ success: true }); + return Promise.resolve({ success: true }); + }); + try { + await new ExtensionContentMessageSend(7, { frameId: 0 }).sendMessage({ action: "private" }); + + expect(sendMessage).toHaveBeenCalledWith(7, { action: "private" }, { frameId: 0 }, expect.any(Function)); + } finally { + sendMessage.mockRestore(); + } + }); +}); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..4d4646705 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,24 +1,48 @@ import EventEmitter from "eventemitter3"; -import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; +import type { + Message, + MessageConnect, + MessageSend, + RuntimeMessageSender, + MessageOrigin, + TMessage, + TMessageCommAction, +} from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; const listenerMgr = new EventEmitter(); // 单一管理器 +// 这些引用必须在页面或 USER_SCRIPT 世界有机会改写 chrome.runtime 方法前捕获, +// 否则消息边界会再次查找页面可变的属性。 +const runtimeApi = typeof chrome === "undefined" ? undefined : chrome.runtime; +const nativeRuntimeConnect = + typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; +const nativeRuntimeSendMessage = + typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; +export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { + private userScriptConnectionListenerReady = false; + private userScriptMessageListenerReady = false; + constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { return new Promise((resolve) => { - const con = chrome.runtime.connect(); - con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + if (!nativeRuntimeConnect) throw new Error("chrome.runtime.connect is unavailable"); + const con = nativeRuntimeConnect(); + const connection = new ExtensionMessageConnect(con); + connection.sendMessage(data); + resolve(connection); }); } // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - chrome.runtime.sendMessage(data, (resp: T) => { + if (!nativeRuntimeSendMessage) { + throw new Error("chrome.runtime.sendMessage is unavailable"); + } + nativeRuntimeSendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.sendMessage:", lastError); @@ -38,23 +62,25 @@ export class ExtensionMessage implements Message { }; onConnect(callback: (data: TMessage, con: MessageConnect) => void) { - chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { - let myPort: chrome.runtime.Port | null = port; - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); - // 消息API发生错误因此不继续执行 - } - const handler = (msg: TMessage) => { - const port = myPort; - if (port !== null) { - myPort = null; - port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + if (typeof chrome.runtime?.onConnect?.addListener === "function") { + chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { + let myPort: chrome.runtime.Port | null = port; + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); + // 消息API发生错误因此不继续执行 } - }; - myPort.onMessage.addListener(handler); - }); + const handler = (msg: TMessage) => { + const port = myPort; + if (port !== null) { + myPort = null; + port.onMessage.removeListener(handler); + callback(msg, new ExtensionMessageConnect(port, "extension")); + } + }; + myPort.onMessage.addListener(handler); + }); + } if (this.backgroundPrimary) { let addUserScriptConnectionListener: (() => void) | null = () => { @@ -71,20 +97,23 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "userScript")); } }; myPort.onMessage.addListener(handler); }); addUserScriptConnectionListener = null; + this.userScriptConnectionListenerReady = true; } catch { - // do nothing + this.userScriptConnectionListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptConnect 的监听 this.tryEnableUserScriptConnectionListener = () => { if (typeof chrome.runtime.onUserScriptConnect?.addListener === "function") { addUserScriptConnectionListener && addUserScriptConnectionListener(); + } else { + this.userScriptConnectionListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -97,32 +126,35 @@ export class ExtensionMessage implements Message { callback: ( data: TMessageCommAction, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void ): void { - chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); - // 消息API发生错误因此不继续执行 - return false; - } - if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { - if ( - typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && - typeof chrome.runtime.onUserScriptMessage?.addListener === "function" - ) { - this.tryEnableUserScriptConnectionListener(); - this.tryEnableUserScriptMessageListener(); - sendResponse(true); - } else { - sendResponse(false); + if (typeof chrome.runtime?.onMessage?.addListener === "function") { + chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); + // 消息API发生错误因此不继续执行 + return false; } - return false; - } - if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); - }); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + if ( + typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && + typeof chrome.runtime.onUserScriptMessage?.addListener === "function" + ) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + } else { + sendResponse(false); + } + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "extension"); + }); + } if (this.backgroundPrimary) { let addUserScriptMessageListener: (() => void) | null = () => { @@ -130,23 +162,32 @@ export class ExtensionMessage implements Message { // 监听用户脚本的消息 chrome.runtime.onUserScriptMessage.addListener((msg: TMessage, sender, sendResponse) => { const lastError = chrome.runtime.lastError; - if (typeof msg.action !== "string") return; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.onUserScriptMessage:", lastError); // 消息API发生错误因此不继续执行 return false; } - return callback(msg, sendResponse, sender); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; + this.userScriptMessageListenerReady = true; } catch { - // do nothing + this.userScriptMessageListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptMessage 的监听 this.tryEnableUserScriptMessageListener = () => { if (typeof chrome.runtime.onUserScriptMessage?.addListener === "function") { addUserScriptMessageListener && addUserScriptMessageListener(); + } else { + this.userScriptMessageListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -158,10 +199,18 @@ export class ExtensionMessage implements Message { export class ExtensionMessageConnect implements MessageConnect { private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一 private con: chrome.runtime.Port | null; + private readonly postMessage: (data: TMessage) => void; private isSelfDisconnected = false; - constructor(con: chrome.runtime.Port) { + constructor( + con: chrome.runtime.Port, + // 来源只记录浏览器原生通道的来源,供服务端区分 USER_SCRIPT 与扩展内部消息。 + private readonly origin: "extension" | "userScript" = "extension" + ) { this.con = con; // 强引用 + if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + // Port 的原型可能被页面改写;后续发送固定使用构造时取得的绑定方法。 + this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); }; @@ -188,7 +237,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } - this.con.postMessage(data); + this.postMessage(data); } onMessage(callback: (data: TMessage) => void) { @@ -229,6 +278,10 @@ export class ExtensionMessageConnect implements MessageConnect { } return this.con; } + + getOrigin(): "extension" | "userScript" { + return this.origin; + } } export class ExtensionContentMessageSend implements MessageSend { @@ -242,7 +295,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { - if (!this.options?.documentId && !this.options?.frameId) { + if (this.options?.documentId === undefined && this.options?.frameId === undefined) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { const lastError = chrome.runtime.lastError; @@ -269,7 +322,7 @@ export class ExtensionContentMessageSend implements MessageSend { return new Promise((resolve) => { const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + resolve(new ExtensionMessageConnect(con, "extension")); }); } } diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts new file mode 100644 index 000000000..0479243e8 --- /dev/null +++ b/packages/message/page_message.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PageMessage } from "./page_message"; + +type FakeWindow = Window & { + handlers: Set<(event: MessageEvent) => void>; +}; + +const createWindow = (): FakeWindow => { + const handlers = new Set<(event: MessageEvent) => void>(); + const target = { + handlers, + addEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.add(handler); + }), + removeEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.delete(handler); + }), + postMessage: vi.fn((data: unknown) => { + queueMicrotask(() => { + for (const handler of handlers) handler({ source: target, data } as unknown as MessageEvent); + }); + }), + } as unknown as FakeWindow; + return target; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PageMessage", () => { + it("routes structured-clone messages only to the opposite role", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn((_data, sendResponse) => sendResponse({ code: 0, data: "pong" })); + inject.onMessage(received); + + const response = await scripting.sendMessage({ action: "inject/ping", data: "ping" }); + + expect(response).toEqual({ code: 0, data: "pong" }); + expect(received).toHaveBeenCalledWith( + { action: "inject/ping", data: "ping" }, + expect.any(Function), + expect.any(Object) + ); + expect(target.postMessage).toHaveBeenCalledTimes(2); + + scripting.dispose(); + inject.dispose(); + }); + + it("supports scoped connections and removes its listener on dispose", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onConnect((_data, connection) => connection.onMessage(received)); + + const connection = await scripting.connect({ action: "inject/connect" }); + connection.sendMessage({ action: "inject/message", data: 1 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(received).toHaveBeenCalledWith({ action: "inject/message", data: 1 }); + const handlerCount = target.handlers.size; + scripting.dispose(); + expect(target.handlers.size).toBe(handlerCount - 1); + inject.dispose(); + }); + + it("ignores envelopes with accessor fields without executing the accessor", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope: Record = { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores proxy envelopes whose own-key inspection is hostile", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope = new Proxy( + { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("keeps page connections working when the page patches Function.prototype.bind", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const originalBind = Function.prototype.bind; + let connection: Awaited> | undefined; + + try { + let connectionPromise: ReturnType | undefined; + try { + Function.prototype.bind = (() => { + throw new Error("patched bind"); + }) as typeof Function.prototype.bind; + connectionPromise = scripting.connect({ action: "inject/connect" }); + } finally { + Function.prototype.bind = originalBind; + } + + connection = await connectionPromise!; + connection.sendMessage({ action: "inject/message" }); + + expect(target.postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "connectMessage", data: { action: "inject/message" } }), + "*" + ); + } finally { + connection?.disconnect(true); + scripting.dispose(); + } + }); +}); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts new file mode 100644 index 000000000..011f9cbcd --- /dev/null +++ b/packages/message/page_message.ts @@ -0,0 +1,270 @@ +import EventEmitter from "eventemitter3"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; + +export type PageMessageRole = "scripting" | "inject"; + +type PageMessageType = "sendMessage" | "respMessage" | "connect" | "disconnect" | "connectMessage"; + +type PageMessageBody = { + readonly channel: string; + readonly source: PageMessageRole; + readonly target: PageMessageRole; + readonly messageId: string; + readonly type: PageMessageType; + readonly data: TMessage | null; +}; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +const listenerMgr = new EventEmitter(); + +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + +const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + try { + if (nativeReflectOwnKeys(value).length !== 6) return undefined; + + const channel = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const source = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const target = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !channel || + !("value" in channel) || + !source || + !("value" in source) || + !target || + !("value" in target) || + !messageId || + !("value" in messageId) || + !type || + !("value" in type) || + !data || + !("value" in data) + ) { + return undefined; + } + + const messageType = type.value; + if ( + typeof channel.value !== "string" || + (source.value !== "scripting" && source.value !== "inject") || + (target.value !== "scripting" && target.value !== "inject") || + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { + channel: channel.value, + source: source.value, + target: target.value, + messageId: messageId.value, + type: messageType, + data: data.value, + } as PageMessageBody; + } catch { + return undefined; + } +}; + +const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); + +class PageMessageConnect implements MessageConnect { + private readonly listenerId = uuidv4(); + private target: (() => void) | null; + private isSelfDisconnected = false; + + constructor( + private readonly messageId: string, + private readonly targetRole: PageMessageRole, + private readonly send: ( + target: PageMessageRole, + body: Omit + ) => void, + private readonly EE: EventEmitter + ) { + const handler = (message: TMessage) => { + listenerMgr.emit(`onMessage:${this.listenerId}`, message); + }; + const cleanup = () => { + if (!this.target) return; + this.target = null; + listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`); + this.EE.removeAllListeners(`connectMessage:${this.messageId}`); + this.EE.removeAllListeners(`disconnect:${this.messageId}`); + listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected); + listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`); + listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`); + }; + this.target = cleanup; + this.EE.addListener(`connectMessage:${this.messageId}`, handler); + this.EE.addListener(`disconnect:${this.messageId}`, cleanup); + listenerMgr.once(`cleanup:${this.listenerId}`, cleanup); + } + + sendMessage(data: TMessage): void { + if (!this.target) throw new Error("Attempted to sendMessage on a disconnected page channel."); + this.send(this.targetRole, { + messageId: this.messageId, + type: "connectMessage", + data, + }); + } + + onMessage(callback: (data: TMessage) => void): void { + if (!this.target) throw new Error("onMessage on a disconnected page channel."); + listenerMgr.addListener(`onMessage:${this.listenerId}`, callback); + } + + disconnect(ignoreAlreadyDisconnected = false): void { + if (!this.target) { + if (ignoreAlreadyDisconnected) return; + throw new Error("Attempted to disconnect a disconnected page channel."); + } + this.isSelfDisconnected = true; + this.send(this.targetRole, { + messageId: this.messageId, + type: "disconnect", + data: null, + }); + listenerMgr.emit(`cleanup:${this.listenerId}`); + } + + onDisconnect(callback: (isSelfDisconnected: boolean) => void): void { + if (!this.target) throw new Error("onDisconnect on a disconnected page channel."); + listenerMgr.once(`onDisconnect:${this.listenerId}`, callback); + } +} + +/** + * 页面异步 RPC 专用通道。 + * + * role 与 channel 只负责传输路由;调用方仍必须验证每个请求,并把权限绑定到隔离执行记录。 + */ +export class PageMessage implements Message { + readonly EE = new EventEmitter(); + private readonly postMessage: (message: unknown, targetOrigin: string) => void; + private readonly messageHandler: (event: MessageEvent) => void; + private readonly targetRole: PageMessageRole; + private sendEnvelopeBound: + | ((target: PageMessageRole, body: Omit) => void) + | undefined; + + constructor( + private readonly channel: string, + private readonly role: PageMessageRole, + private readonly sourceWindow: Window = window + ) { + if (typeof sourceWindow.postMessage !== "function") throw new TypeError("window.postMessage is unavailable"); + this.postMessage = bindNative(sourceWindow.postMessage, sourceWindow); + this.targetRole = otherRole(role); + this.messageHandler = (event: MessageEvent) => { + if (event.source !== null && event.source !== sourceWindow) return; + const body = parsePageMessageBody(event.data); + if (!body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole) { + return; + } + this.messageHandle(body); + }; + sourceWindow.addEventListener("message", this.messageHandler); + } + + private sendEnvelope(target: PageMessageRole, body: Omit): void { + this.postMessage( + { + channel: this.channel, + source: this.role, + target, + ...body, + } satisfies PageMessageBody, + "*" + ); + } + + private getSendEnvelopeBound() { + return (this.sendEnvelopeBound ??= bindNative(this.sendEnvelope, this)); + } + + private messageHandle(body: PageMessageBody): void { + if (body.type === "sendMessage") { + this.EE.emit( + "message", + body.data, + (response: TMessage) => { + this.sendEnvelope(body.source, { + messageId: body.messageId, + type: "respMessage", + data: response, + }); + }, + {} as RuntimeMessageSender + ); + } else if (body.type === "respMessage") { + this.EE.emit(`response:${body.messageId}`, body); + } else if (body.type === "connect") { + this.EE.emit( + "connect", + body.data, + new PageMessageConnect(body.messageId, body.source, this.getSendEnvelopeBound(), this.EE) + ); + } else if (body.type === "disconnect") { + this.EE.emit(`disconnect:${body.messageId}`); + } else if (body.type === "connectMessage") { + this.EE.emit(`connectMessage:${body.messageId}`, body.data); + } + } + + onConnect(callback: OnConnectCallback): void { + this.EE.addListener("connect", callback); + } + + onMessage(callback: OnMessageCallback): void { + this.EE.addListener("message", callback); + } + + connect(data: TMessage): Promise { + const messageId = uuidv4(); + this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); + return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.getSendEnvelopeBound(), this.EE)); + } + + sendMessage(data: TMessage): Promise { + return new Promise((resolve) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + this.EE.addListener(eventId, (body: PageMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }); + this.sendEnvelope(this.targetRole, { messageId, type: "sendMessage", data }); + }); + } + + dispose(): void { + this.sourceWindow.removeEventListener("message", this.messageHandler); + this.EE.removeAllListeners(); + } +} diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..ac6959811 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; -import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; +import { forwardMessage, GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -35,6 +35,87 @@ afterEach(() => { }); describe("Server", () => { + it("ignores message envelopes with accessor actions without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile", handler); + const message: Record = { data: {} }; + let accessed = false; + Object.defineProperty(message, "action", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("ignores message envelopes with accessor data without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile-data", handler); + const message: Record = { action: "api/on-hostile-data" }; + let accessed = false; + Object.defineProperty(message, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("应该在消息和长连接转发中都应用参数转换", async () => { + const transformed: unknown[] = []; + const targetFlag = `${uuidv4()}::target`; + const targetInbound = new CustomEventMessage(targetFlag, true); + const targetOutbound = new CustomEventMessage(targetFlag, false); + const targetServer = new Server("service", targetInbound); + targetServer.on("stream", (params) => { + transformed.push(params); + return "connected"; + }); + targetServer.on("call", (params) => { + transformed.push(params); + return "called"; + }); + + const sourceFlag = `${uuidv4()}::source`; + const sourceInbound = new CustomEventMessage(sourceFlag, true); + const sourceOutbound = new CustomEventMessage(sourceFlag, false); + const sourceServer = new Server("source", sourceInbound); + const targetSender = { + sendMessage: (data: any) => targetOutbound.sendMessage(data), + connect: (data: any) => targetOutbound.connect(data), + }; + forwardMessage("service", "stream", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + forwardMessage("service", "call", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + + const stream = await sourceOutbound.connect({ action: "source/stream", data: { value: 1 } }); + const response = await sourceOutbound.sendMessage({ action: "source/call", data: { value: 2 } }); + + expect(response.data).toBe("called"); + expect(transformed).toEqual([ + { value: 1, transformed: true }, + { value: 2, transformed: true }, + ]); + stream.disconnect(true); + }); + describe("基本功能测试 1", () => { it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -489,6 +570,36 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + it("应该保留有效的零标签页和窗口编号", () => { + let capturedSender: IGetSender; + + server.on("on-zero-ids", (_params, sender) => { + capturedSender = sender; + }); + + const mockSender: RuntimeMessageSender = { + tab: { id: 0, windowId: 0 }, + frameId: 0, + } as RuntimeMessageSender; + + (server as any).messageHandle("on-zero-ids", {}, vi.fn(), mockSender); + + expect(capturedSender!.getExtMessageSender()).toMatchObject({ tabId: 0, windowId: 0, frameId: 0 }); + }); + + it("应该把扩展消息来源传给 SenderRuntime", () => { + let capturedOrigin: string | undefined; + server.on("on-origin", (_params, sender) => { + capturedOrigin = sender.getConnectOrigin?.(); + }); + + const sendResponse = vi.fn(); + const mockSender = { tab: { id: 123 } } as RuntimeMessageSender; + (server as any).messageHandle("on-origin", {}, sendResponse, mockSender, "userScript"); + + expect(capturedOrigin).toBe("userScript"); + }); + it.concurrent("应该为没有 tab 的 sender 返回 -1 tabId", async () => { let capturedSender: IGetSender; @@ -534,6 +645,59 @@ describe("Server", () => { }); }); + describe("USER_SCRIPT action boundary", () => { + it("rejects privileged service worker actions before dispatch", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn(); + serviceWorkerServer.on("script/getAllScripts", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle("script/getAllScripts", {}, sendResponse, sender, "userScript"); + + expect(handler).not.toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ code: -1, message: "userScript action is not allowed" }); + }); + + it("allows only the USER_SCRIPT GM API message", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue("ok"); + serviceWorkerServer.on("runtime/gmApi", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/gmApi", + { api: "GM_log" }, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith({ api: "GM_log" }, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: "ok" }); + }); + + it("allows the native USER_SCRIPT reconnect request", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue({ bootstrapToken: "next-token" }); + serviceWorkerServer.on("runtime/reconnectUserScript", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/reconnectUserScript", + undefined, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith(undefined, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: { bootstrapToken: "next-token" } }); + }); + }); + describe("Connect 功能测试", () => { it("应该能够处理连接消息", async () => { const mockHandler = vi.fn(); diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..485fb9014 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -1,9 +1,41 @@ -import type { RuntimeMessageSender, MessageConnect, ExtMessageSender, Message, TMessage, MessageSend } from "./types"; +import type { + RuntimeMessageSender, + MessageConnect, + ExtMessageSender, + Message, + MessageOrigin, + TMessage, + MessageSend, +} from "./types"; import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +// 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +type ParsedServerMessage = { action: string; data?: unknown }; + +const parseServerMessage = (value: unknown): ParsedServerMessage | undefined => { + if (value === null || typeof value !== "object") return undefined; + try { + const actionDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "action"); + if (!actionDescriptor || !("value" in actionDescriptor) || typeof actionDescriptor.value !== "string") { + return undefined; + } + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (dataDescriptor && !("value" in dataDescriptor)) return undefined; + return { action: actionDescriptor.value, data: dataDescriptor?.value }; + } catch { + return undefined; + } +}; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -15,6 +47,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; + getConnectOrigin?(): MessageOrigin | undefined; } export class SenderConnect { @@ -47,8 +80,8 @@ export class SenderConnect { if (this.sender instanceof ExtensionMessageConnect) { const con = this.sender.getPort(); return { - windowId: con.sender?.tab?.windowId || -1, // -1表示后台脚本 - tabId: con.sender?.tab?.id || -1, // -1表示后台脚本 + windowId: con.sender?.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: con.sender?.tab?.id ?? -1, // -1表示后台脚本 frameId: con.sender?.frameId, documentId: con.sender?.documentId, }; @@ -65,11 +98,18 @@ export class SenderConnect { getConnect(): MessageConnect { return this.sender; } + + getConnectOrigin(): "extension" | "userScript" | undefined { + return this.sender instanceof ExtensionMessageConnect ? this.sender.getOrigin() : undefined; + } } export class SenderRuntime { private readonly mType; - constructor(private sender: RuntimeMessageSender) { + constructor( + private sender: RuntimeMessageSender, + private readonly origin?: MessageOrigin + ) { this.mType = GetSenderType.RUNTIME; } @@ -97,8 +137,8 @@ export class SenderRuntime { }; } return { - windowId: sender.tab?.windowId || -1, // -1表示后台脚本 - tabId: sender.tab?.id || -1, // -1表示后台脚本 + windowId: sender.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: sender.tab?.id ?? -1, // -1表示后台脚本 frameId: sender.frameId, documentId: sender.documentId, }; @@ -107,6 +147,10 @@ export class SenderRuntime { getConnect(): undefined { return undefined; } + + getConnectOrigin(): MessageOrigin | undefined { + return this.origin; + } } type ApiFunction = (params: any, con: IGetSender) => Promise | any | void; @@ -132,7 +176,7 @@ export class Server { private logger = LoggerCore.getInstance().logger({ service: "messageServer" }); constructor( - prefix: string, + private readonly prefix: string, msgReceiver: Message | Message[], private enableConnect: boolean = true ) { @@ -140,10 +184,11 @@ export class Server { if (this.enableConnect) { msgReceiverList.forEach((msg) => { msg.onConnect((msg: TMessage, con: MessageConnect) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onConnect", { msg }); - if (msg.action?.startsWith(prefix)) { - return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con); + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onConnect", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.connectHandle(parsed.action.slice(this.prefix.length + 1), parsed.data, con); } return false; }); @@ -151,11 +196,18 @@ export class Server { } msgReceiverList.forEach((msg) => { - msg.onMessage((msg: TMessage, sendResponse, sender) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(prefix)) { - return this.messageHandle(msg.action.slice(prefix.length + 1), msg.data, sendResponse, sender); + msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onMessage", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.messageHandle( + parsed.action.slice(this.prefix.length + 1), + parsed.data, + sendResponse, + sender, + origin + ); } }); return false; @@ -171,9 +223,15 @@ export class Server { } private connectHandle(msg: string, params: any, con: MessageConnect) { + const sender = new SenderConnect(con); + if (!this.isUserScriptActionAllowed(msg, sender.getConnectOrigin(), true)) { + con.sendMessage({ code: -1, message: "userScript action is not allowed" }); + con.disconnect(true); + return true; + } const func = this.apiFunctionMap.get(msg); if (func) { - const ret = func(params, new SenderConnect(con)); + const ret = func(params, sender); if (ret) { if (ret instanceof Promise) { ret @@ -197,12 +255,18 @@ export class Server { action: string, params: any, sendResponse: (response: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) { + if (!this.isUserScriptActionAllowed(action, origin, false)) { + sendResponse({ code: -1, message: "userScript action is not allowed" }); + this.logger.warn("userScript action rejected", { action }); + return; + } const func = this.apiFunctionMap.get(action); if (func) { try { - const ret = func(params, new SenderRuntime(sender)); + const ret = func(params, new SenderRuntime(sender, origin)); if (ret instanceof Promise) { ret .then((data) => { @@ -229,6 +293,14 @@ export class Server { this.logger.error("no such api", { action: action }); } } + + private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + // USER_SCRIPT 只应取得注册握手、断线重连和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 + if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; + return isConnect + ? action === "runtime/registerUserScript" || action === "runtime/gmApi" + : action === "runtime/gmApi" || action === "runtime/reconnectUserScript"; + } } export class Group { @@ -293,22 +365,23 @@ export function forwardMessage( path: string, receiverFrom: Server, senderTo: MessageSend, - middleware?: ApiFunctionSync + middleware?: ApiFunctionSync, + transform?: (params: any, con: IGetSender) => any ) { const handler = async (params: any, fromCon: IGetSender): Promise => { const fromConnect: MessageConnect | undefined = fromCon.getConnect(); if (fromConnect) { const toCon: MessageConnect = await connect(senderTo, `${prefix}/${path}`, params); - fromConnect.onMessage(toCon.sendMessage.bind(toCon)); - toCon.onMessage(fromConnect.sendMessage.bind(fromConnect)); - fromConnect.onDisconnect(toCon.disconnect.bind(toCon)); - toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect)); + fromConnect.onMessage(bindNative(toCon.sendMessage, toCon)); + toCon.onMessage(bindNative(fromConnect.sendMessage, fromConnect)); + fromConnect.onDisconnect(bindNative(toCon.disconnect, toCon)); + toCon.onDisconnect(bindNative(fromConnect.disconnect, fromConnect)); return undefined; } else { return sendMessage(senderTo, prefix + "/" + path, params); } }; - receiverFrom.on(path, (params, sender) => { + const processTransformed = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -324,5 +397,15 @@ export function forwardMessage( } } return handler(params, sender); - }); + }; + const process = transform + ? (params: any, sender: IGetSender) => { + // 转换先于中间件和转发执行,使跨世界输入只在一个受控位置完成校验/复制。 + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + } + : processTransformed; + receiverFrom.on(path, process); } diff --git a/packages/message/types.ts b/packages/message/types.ts index 1b323b8de..7d710f50b 100644 --- a/packages/message/types.ts +++ b/packages/message/types.ts @@ -28,12 +28,14 @@ export type TMessageCommCode = { export type TMessage = TMessagQueueUnit | TMessageCommAction | TMessageCommCode; export type RuntimeMessageSender = chrome.runtime.MessageSender; +export type MessageOrigin = "extension" | "userScript"; export type OnConnectCallback = (data: TMessage, con: MessageConnect) => void; export type OnMessageCallback = ( data: TMessage, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void; export interface Message { diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 00be4f8d7..0262c8800 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -210,6 +210,63 @@ describe("WindowMessage.connect", () => { }); }); +describe("WindowMessage envelope validation", () => { + it("ignores accessor envelopes without executing their getters", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "offscreen/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + + it("ignores proxy envelopes whose own-key inspection throws", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope = new Proxy( + { messageId: "hostile", type: "sendMessage", data: { action: "offscreen/ping" } }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + }); +}); + // 单测重点:target 支持传入惰性求值函数,避免在 Firefox sandbox iframe 尚处于初始 about:blank // 阶段就缓存 contentWindow 快照——导航到真正的 sandbox 页面后,浏览器是否仍保证该快照与 // 事件的 e.source 全等属于实现细节,不可依赖;每次发送/比对都应重新读取当前值。 diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..f844100bc 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -32,6 +32,40 @@ export type WindowMessageBody = { data: T | null; // 消息数据 }; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + +export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + try { + if (nativeReflectOwnKeys(value).length !== 3) return undefined; + + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (!messageId || !("value" in messageId) || !type || !("value" in type) || !data || !("value" in data)) { + return undefined; + } + + const messageType = type.value; + if ( + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { messageId: messageId.value, type: messageType, data: data.value } as WindowMessageBody; + } catch { + return undefined; + } +}; + export class WindowMessage implements Message { EE = new EventEmitter(); @@ -78,6 +112,9 @@ export class WindowMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 @@ -257,6 +294,9 @@ export class ServiceWorkerMessageSend implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage" && source) { // 接收到来自offscreen的请求消息 @@ -358,6 +398,9 @@ export class ServiceWorkerClientMessage implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 只处理响应类消息,请求类消息由WindowMessage处理 if (data.type === "sendMessage" && source) { this.EE.emit( diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..3848a05aa 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,9 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + // 每次构建都生成独立标记,脚本包装器只接受扩展内部传入的完整性密钥。 + "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, + "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, }), new rspack.CopyRspackPlugin({ diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts index 95b0dc1ce..a3fc1da32 100644 --- a/src/app/repo/agent_chat.test.ts +++ b/src/app/repo/agent_chat.test.ts @@ -157,6 +157,44 @@ describe("AgentChatRepo 附件存储", () => { expect(result).toBeInstanceOf(Blob); }); + it("附件读取权限只授予拥有引用该附件的脚本会话", async () => { + const conversation = await repo.createConversation({ + id: "conv-script-attachment", + ownerScriptUuid: "script-a", + title: "Script", + modelId: "m1", + createtime: 1, + updatetime: 1, + }); + await repo.saveMessages( + conversation.id, + [ + { + id: "message-script-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "script-image", mimeType: "image/png" }], + ownedAttachmentIds: ["script-image"], + createtime: 1, + }, + { + id: "message-borrowed-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "borrowed-image", mimeType: "image/png" }], + createtime: 2, + }, + ], + undefined, + { generation: conversation.generation! } + ); + + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-a")).resolves.toBe(true); + await expect(repo.isAttachmentAccessibleToScript("borrowed-image", "script-a")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-b")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("unreferenced", "script-a")).resolves.toBe(false); + }); + it("getAttachment 不存在的附件应返回 null", async () => { const result = await repo.getAttachment("nonexistent"); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts index 88fbaf1ff..5643f89da 100644 --- a/src/app/repo/agent_chat.ts +++ b/src/app/repo/agent_chat.ts @@ -500,6 +500,20 @@ export class AgentChatRepo extends OPFSRepo { } } + // 用户脚本只能读取自己拥有的会话消息声明过的附件;附件文件本身不携带 owner 元数据, + // 因此必须以持久化消息中的所有权字段作为授权依据,不能仅凭可猜测的附件 ID 或借用引用放行。 + async isAttachmentAccessibleToScript(id: string, scriptUuid: string): Promise { + if (!id || !scriptUuid) return false; + for (const conversation of await this.listConversations()) { + if (conversation.ownerScriptUuid !== scriptUuid) continue; + const snapshot = await this.getMessageSnapshot(conversation.id, conversation.generation); + if (collectMessageAttachmentIds(snapshot.messages, isLegacyGeneration(conversation.generation)).has(id)) { + return true; + } + } + return false; + } + // 删除单个附件(同时清理新旧路径) async deleteAttachment(id: string): Promise { // 新路径: agents/workspace/uploads/{id} diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..d2065f773 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -3,6 +3,7 @@ import type { Resource, ResourceType } from "./resource"; import type { SCMetadata } from "./metadata"; import type { GMInfoEnv } from "../service/content/types"; import type { URLRuleEntry } from "@App/pkg/utils/url_matcher"; +import type { ScriptEnvTag } from "@Packages/message/consts"; // 脚本模型 export type SCRIPT_TYPE = 1 | 2 | 3; @@ -110,6 +111,12 @@ export interface ScriptRunResource extends Script { resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + /** 页面执行环境绑定的能力句柄。 */ + executionHandle?: string; + /** 执行脚本所在的页面环境。 */ + executionEnvTag?: ScriptEnvTag; + /** 页面执行绑定使用的值更新关联标识。 */ + executionRunFlag?: string; } /** @@ -151,6 +158,10 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; + /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ + userScriptBootstrapToken?: string; + /** 一次性令牌,供 MAIN world 的 inject 环境请求私有 bootstrap。 */ + userScriptInjectBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index c2f197d5e..4f2ee5cc0 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -22,6 +22,8 @@ export type MessageContent = string | ContentBlock[]; export type Conversation = { id: string; + /** ScriptCat API owner; absent on conversations created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of an ID. Filled when legacy records are loaded. */ generation?: string; /** Optimistic-concurrency version. Filled when legacy records are loaded. */ @@ -640,6 +642,8 @@ export type MCPApiRequest = /** 定时任务基础字段(两种模式共用) */ type AgentTaskBase = { id: string; + /** ScriptCat API owner; absent on tasks created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of the task ID. */ generation?: string; /** Optimistic-concurrency version. */ @@ -733,5 +737,6 @@ export type ConversationApiRequest = generation?: string; messageIds: string[]; preserveAttachmentIds?: string[]; + scriptUuid?: string; } - | { action: "delete"; conversationId: string; generation: string; revision?: number }; + | { action: "delete"; conversationId: string; generation: string; revision?: number; scriptUuid?: string }; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 4ef1420f7..507e694f9 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -352,8 +352,8 @@ export class AgentService { } // 处理定时任务 API 请求,供 GMApi 调用 - async handleAgentTaskApi(params: AgentTaskApiRequest) { - return this.agentTaskService.handleAgentTask(params); + async handleAgentTaskApi(params: AgentTaskApiRequest, ownerScriptUuid?: string) { + return this.agentTaskService.handleAgentTask(params, ownerScriptUuid); } // 处理 CAT.agent.model API 请求,委托给 AgentModelService @@ -386,7 +386,7 @@ export class AgentService { // 附加到后台运行会话,供 GMApi 调用 async handleAttachToConversationFromGmApi( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid: string }, sender: IGetSender ) { return this.handleAttachToConversation(params, sender); @@ -399,7 +399,7 @@ export class AgentService { // 附加到后台运行中的会话(委托给 BackgroundSessionManager) private async handleAttachToConversation( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid?: string }, sender: IGetSender ) { return this.bgSessionManager.handleAttach(params, sender); diff --git a/src/app/service/agent/service_worker/background_session_manager.test.ts b/src/app/service/agent/service_worker/background_session_manager.test.ts new file mode 100644 index 000000000..bab0570ca --- /dev/null +++ b/src/app/service/agent/service_worker/background_session_manager.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { BackgroundSessionManager, type RunningConversation } from "./background_session_manager"; + +function createSender() { + const sentMessages: any[] = []; + const connection = { + sendMessage: (message: any) => sentMessages.push(message), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + return { + sender: { + isType: (type: any) => type === 1, + getConnect: () => connection, + } as any, + sentMessages, + }; +} + +describe("BackgroundSessionManager script ownership", () => { + it("does not attach a script to another script's running conversation", async () => { + const manager = new BackgroundSessionManager(); + const rc: RunningConversation = { + conversationId: "conv-owned", + generation: "gen-a", + ownerScriptUuid: "script-a", + abortController: new AbortController(), + listeners: new Set(), + streamingState: { content: "secret", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running" as const, + }; + manager.set(rc.conversationId, rc); + const { sender, sentMessages } = createSender(); + + await manager.handleAttach( + { conversationId: rc.conversationId, generation: rc.generation, scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages).toContainEqual({ action: "event", data: { type: "sync", tasks: [], status: "done" } }); + expect(rc.listeners.size).toBe(0); + }); +}); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts index 1378502fa..a2c385e23 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -10,6 +10,8 @@ export type ListenerEntry = { // 后台运行会话状态 export type RunningConversation = { conversationId: string; + /** ScriptCat API owner; absent for conversations started by the extension UI. */ + ownerScriptUuid?: string; // 该次运行绑定的会话 generation;attach() 的调用方必须持有同一 generation 才允许附加, // 否则会静默观察到删除重建后无关的新一代会话 generation: string; @@ -191,7 +193,10 @@ export class BackgroundSessionManager { } // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) - async handleAttach(params: { conversationId: string; generation?: string }, sender: IGetSender): Promise { + async handleAttach( + params: { conversationId: string; generation?: string; scriptUuid?: string }, + sender: IGetSender + ): Promise { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("attachToConversation requires connect mode"); } @@ -209,6 +214,13 @@ export class BackgroundSessionManager { return; } + // Script callers may observe only the running conversation owned by the same script. + // Missing owners are legacy/UI records and therefore fail closed for scripts. + if (params.scriptUuid !== undefined && rc.ownerScriptUuid !== params.scriptUuid) { + sendEvent({ type: "sync", tasks: [], status: "done" }); + return; + } + // 调用方持有的 generation 与实际运行中的会话不一致:会话已被删除重建, // 不能让旧一代的调用方附加到无关的新一代会话上 if (params.generation !== undefined && rc.generation !== params.generation) { diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 97ff823b6..7a2f80468 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createTestService, makeSkillRecord, makeSkillScriptRecord, makeTextResponse } from "./test-helpers"; +import { + createMockSender, + createTestService, + makeSkillRecord, + makeSkillScriptRecord, + makeTextResponse, +} from "./test-helpers"; // ---- handleConversationChat skipSaveUserMessage(重新生成 bug 修复验证)---- @@ -206,6 +212,83 @@ describe("handleConversationChat skipSaveUserMessage", () => { }); }); +describe("CAT.agent.conversation owner isolation", () => { + it("creates a persisted conversation bound to the requesting script", async () => { + const { service, mockRepo } = createTestService(); + + await (service as any).handleConversationApi({ + action: "create", + options: { model: "test-openai" }, + scriptUuid: "script-a", + }); + + expect(mockRepo.createConversation).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + + it("does not expose an owned or legacy conversation to another script", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + { id: "legacy", title: "Legacy", modelId: "test-openai" }, + ]); + + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-b" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "legacy", scriptUuid: "script-a" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-a" }) + ).resolves.toMatchObject({ id: "owned" }); + }); + + it("rejects every script mutation before it reaches message or conversation storage", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const requests = [ + { action: "getMessages", conversationId: "owned" }, + { action: "save", conversationId: "owned" }, + { action: "clearMessages", conversationId: "owned" }, + { action: "deleteMessages", conversationId: "owned", messageIds: [] }, + { action: "delete", conversationId: "owned", generation: "gen" }, + ]; + + for (const request of requests) { + await expect((service as any).handleConversationApi({ ...request, scriptUuid: "script-b" })).rejects.toThrow( + "Conversation not found" + ); + } + + expect(mockRepo.getMessageSnapshot).not.toHaveBeenCalled(); + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + expect(mockRepo.deleteConversation).not.toHaveBeenCalled(); + }); + + it("rejects a foreign script's chat before loading history or calling the model", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await (service as any).handleConversationChat( + { conversationId: "owned", message: "secret", scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages.map((message) => message.data)).toContainEqual( + expect.objectContaining({ type: "error", message: "Conversation not found" }) + ); + expect(mockRepo.getMessages).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + describe("userscript 会话工具隔离", () => { it("携带 scriptUuid 时不注册无法交互的 ask_user 工具", async () => { const { service } = createTestService(); @@ -394,6 +477,7 @@ describe("handleConversationChat 场景补充", () => { id: "conv-1", title: "Test", modelId: "test-openai", + ownerScriptUuid: "script-1", generation: "gen-b", createtime: Date.now(), updatetime: Date.now(), diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 89f79bd28..738246641 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -269,8 +269,10 @@ export class ChatService { case "create": return this.createConversation(params); case "get": - return this.getConversation(params.id); + return this.getConversation(params.id, params.scriptUuid); case "getMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // params.generation 提供时,与当前存储不一致(会话已被删除重建)则拒绝而非返回无关一代的消息; // 未提供 generation 时保留旧行为:会话不存在则返回空数组 try { @@ -281,15 +283,19 @@ export class ChatService { } case "save": { // 对话已经在 chat 过程中持久化,这里确保元数据也保存;仍需校验调用方持有的 generation - if (params.generation !== undefined) { - const conv = await this.getConversation(params.conversationId); - if (!conv || conv.generation !== params.generation) { - throw new Error("Conversation generation mismatch"); - } + const conv = + params.scriptUuid !== undefined || params.generation !== undefined + ? await this.getConversation(params.conversationId, params.scriptUuid) + : undefined; + if (params.scriptUuid !== undefined && !conv) throw new Error("Conversation not found"); + if (params.generation !== undefined && (!conv || conv.generation !== params.generation)) { + throw new Error("Conversation generation mismatch"); } return true; } case "clearMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // 会话正在等待脚本工具结果时,这个 clear 很可能来自该工具 handler 内部的 // await conv.clear():chat 持有会话队列锁等待 toolResults,clear 排队等锁, // 相互等待成死锁。对这个窗口显式拒绝(fail fast);其余时刻仍与 chat/compact @@ -317,6 +323,8 @@ export class ChatService { return true; }); case "deleteMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { const snapshot = await this.chatRepo.getMessageSnapshot(params.conversationId, params.generation); const ids = new Set(params.messageIds); @@ -333,6 +341,8 @@ export class ChatService { return true; }); case "delete": { + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); this.abortAdmittedChats(params.conversationId); this.bgSessionManager.stop(params.conversationId); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { @@ -352,6 +362,7 @@ export class ChatService { const model = await this.modelService.getModel(params.options.model); const conv: Conversation = { id: params.options.id || uuidv4(), + ownerScriptUuid: params.scriptUuid, title: "New Chat", modelId: model.id, system: params.options.system, @@ -362,10 +373,10 @@ export class ChatService { return this.chatRepo.createConversation(conv); } - private async getConversation(id: string): Promise { + private async getConversation(id: string, scriptUuid?: string): Promise { const conversations = await this.chatRepo.listConversations(); const conversation = conversations.find((item) => item.id === id); - if (!conversation) return null; + if (!conversation || (scriptUuid !== undefined && conversation.ownerScriptUuid !== scriptUuid)) return null; return { ...conversation, generation: conversation.generation || `legacy:${conversation.id}`, @@ -373,6 +384,12 @@ export class ChatService { }; } + private async requireConversationAccess(id: string, scriptUuid: string): Promise { + const conversation = await this.getConversation(id, scriptUuid); + if (!conversation) throw new Error("Conversation not found"); + return conversation; + } + // 统一的流式 conversation chat(UI 和脚本 API 共用) // 同一 conversationId 的 chat / compact(compact 复用本方法的 params.compact 分支)都必须与 // clearMessages 串行执行,避免并发读改写互相覆盖对方的持久化写入。 @@ -402,6 +419,21 @@ export class ChatService { // 后台模式:非 ephemeral、非 compact 时可用 const isBackground = params.background === true && !params.ephemeral && !params.compact; + // Script callers must prove ownership before entering the queue or touching a connection. + // Legacy/UI conversations have no owner and therefore fail closed for scripts. + if (!params.ephemeral && params.scriptUuid !== undefined) { + const conversation = await this.getConversation(params.conversationId, params.scriptUuid); + if (!conversation) { + try { + msgConn.sendMessage({ action: "event", data: { type: "error", message: "Conversation not found" } }); + } catch { + // 端口已断开,无需通知 + } + await releaseProvisionalUserAttachments(); + return; + } + } + if (!params.ephemeral && this.conversationsAwaitingScriptTools.has(params.conversationId)) { try { msgConn.sendMessage({ @@ -619,7 +651,7 @@ export class ChatService { if (isBackground) { // 后台会话必须先确认调用方持有的 generation 与当前存储一致,否则一次删除重建后的 // 陈旧调用会静默附加到无关的新一代会话上 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { await releaseProvisionalUserAttachments(); sendEventDirect({ type: "error", message: "Conversation not found" }); @@ -637,6 +669,7 @@ export class ChatService { rc = { conversationId: params.conversationId, generation: conv.generation!, + ownerScriptUuid: conv.ownerScriptUuid, abortController, listeners: new Set(), streamingState: { content: "", thinking: "", toolCalls: [] }, @@ -741,7 +774,7 @@ export class ChatService { } // 获取对话和模型 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; @@ -924,7 +957,7 @@ export class ChatService { abortController: AbortController ): Promise { const startTime = Date.now(); - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; diff --git a/src/app/service/agent/service_worker/dom.test.ts b/src/app/service/agent/service_worker/dom.test.ts index a0cf6829d..9d9938a24 100644 --- a/src/app/service/agent/service_worker/dom.test.ts +++ b/src/app/service/agent/service_worker/dom.test.ts @@ -406,6 +406,14 @@ describe("AgentDomService", () => { }); }); + describe("monitor", () => { + it("应拒绝在浏览器内部页面启动监控", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "chrome://settings", status: "complete", discarded: false }); + + await expect(service.startMonitor(1)).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + }); + describe("resolveTabId", () => { it("应在 tab 被 discard 时自动 reload", async () => { mockTabsGet.mockResolvedValueOnce({ diff --git a/src/app/service/agent/service_worker/dom.ts b/src/app/service/agent/service_worker/dom.ts index df0f6d951..7a1ac953e 100644 --- a/src/app/service/agent/service_worker/dom.ts +++ b/src/app/service/agent/service_worker/dom.ts @@ -264,18 +264,20 @@ export class AgentDomService { } // 启动页面监控(CDP:dialog 自动处理 + MutationObserver) - async startMonitor(tabId: number): Promise { - return cdpStartMonitor(tabId); + async startMonitor(tabId: number, scriptUuid?: string): Promise { + const tab = await chrome.tabs.get(tabId); + assertDomUrlAllowed(tab.url || ""); + return cdpStartMonitor(tabId, scriptUuid); } // 停止监控并返回收集的结果 - async stopMonitor(tabId: number): Promise { - return cdpStopMonitor(tabId); + async stopMonitor(tabId: number, scriptUuid?: string): Promise { + return cdpStopMonitor(tabId, scriptUuid); } // 查询当前 monitor 状态(不停止监控) - peekMonitor(tabId: number): MonitorStatus { - return cdpPeekMonitor(tabId); + peekMonitor(tabId: number, scriptUuid?: string): MonitorStatus { + return cdpPeekMonitor(tabId, scriptUuid); } // 处理 GM API 请求路由 @@ -300,11 +302,11 @@ export class AgentDomService { case "executeScript": return this.executeScript(request.code, request.options); case "startMonitor": - return this.startMonitor(request.tabId); + return this.startMonitor(request.tabId, request.scriptUuid); case "stopMonitor": - return this.stopMonitor(request.tabId); + return this.stopMonitor(request.tabId, request.scriptUuid); case "peekMonitor": - return this.peekMonitor(request.tabId); + return this.peekMonitor(request.tabId, request.scriptUuid); default: throw new Error(`Unknown DOM action: ${(request as any).action}`); } diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index f09cfa183..c7f97ddec 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -17,7 +17,7 @@ vi.stubGlobal("chrome", { tabs: { get: mockTabsGet }, }); -import { cdpClick } from "./dom_cdp"; +import { cdpClick, cdpPeekMonitor, cdpStartMonitor, cdpStopMonitor } from "./dom_cdp"; afterAll(() => { vi.stubGlobal("chrome", savedChrome); @@ -91,4 +91,40 @@ describe("agent_dom_cdp", () => { }); await expect(cdpClick(999, "#nonexistent")).rejects.toThrow(/Element not found/); }); + + it("页面监控只能由创建它的脚本重新启动", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(999, "script-a"); + + await expect(cdpStartMonitor(999, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(999, "script-a"); + }); + + it("并发重启同一标签页的监控不会泄漏旧监听器", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await Promise.all([cdpStartMonitor(997, "script-a"), cdpStartMonitor(997, "script-a")]); + await cdpStopMonitor(997, "script-a"); + + expect(mockAttach).toHaveBeenCalledTimes(2); + expect(mockDetach).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.addListener as ReturnType).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.removeListener as ReturnType).toHaveBeenCalledTimes(2); + }); + + it("页面监控的结果不能被其他脚本读取或停止", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(998, "script-a"); + + expect(cdpPeekMonitor(998, "script-b")).toEqual({ hasChanges: false, dialogCount: 0, nodeCount: 0 }); + await expect(cdpStopMonitor(998, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(998, "script-a"); + }); }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index f05980868..26d08229d 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -16,12 +16,32 @@ type CapturedNode = { }; type MonitorSession = { + ownerScriptUuid?: string; dialogs: Array<{ type: string; message: string }>; capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 listener: MonitorEventListener; }; const activeMonitors = new Map(); +const monitorOperationQueues = new Map>(); + +async function withMonitorOperation(tabId: number, operation: () => Promise): Promise { + const previous = monitorOperationQueues.get(tabId) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + monitorOperationQueues.set(tabId, current); + await previous; + try { + return await operation(); + } finally { + release(); + if (monitorOperationQueues.get(tabId) === current) { + monitorOperationQueues.delete(tabId); + } + } +} // 生命周期管理:attach → 执行 → detach // 如果该 tabId 已有活跃的 monitor(已 attach),则复用连接,不做 attach/detach @@ -239,10 +259,18 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number): Promise { +export function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => startMonitor(tabId, ownerScriptUuid)); +} + +async function startMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 - if (activeMonitors.has(tabId)) { - await cdpStopMonitor(tabId); + const current = activeMonitors.get(tabId); + if (current) { + if (current.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } + await stopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -292,13 +320,16 @@ export async function cdpStartMonitor(tabId: number): Promise { }; chrome.debugger.onEvent.addListener(listener); - activeMonitors.set(tabId, { dialogs, capturedNodes, listener }); + activeMonitors.set(tabId, { ownerScriptUuid, dialogs, capturedNodes, listener }); } // 轻量查询当前 monitor 状态(不停止监控) -export function cdpPeekMonitor(tabId: number): { hasChanges: boolean; dialogCount: number; nodeCount: number } { +export function cdpPeekMonitor( + tabId: number, + ownerScriptUuid?: string +): { hasChanges: boolean; dialogCount: number; nodeCount: number } { const monitor = activeMonitors.get(tabId); - if (!monitor) { + if (!monitor || monitor.ownerScriptUuid !== ownerScriptUuid) { return { hasChanges: false, dialogCount: 0, nodeCount: 0 }; } const dialogCount = monitor.dialogs.length; @@ -315,8 +346,15 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number): Promise { +export function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => stopMonitor(tabId, ownerScriptUuid)); +} + +async function stopMonitor(tabId: number, ownerScriptUuid?: string): Promise { const monitor = activeMonitors.get(tabId); + if (monitor && monitor.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } const result: MonitorResult = { dialogs: monitor?.dialogs || [], addedNodes: [], diff --git a/src/app/service/agent/service_worker/opfs.test.ts b/src/app/service/agent/service_worker/opfs.test.ts index 9f2377f0b..e48a94a29 100644 --- a/src/app/service/agent/service_worker/opfs.test.ts +++ b/src/app/service/agent/service_worker/opfs.test.ts @@ -176,6 +176,24 @@ describe("handleOPFSApi", () => { expect(mockRepo.getAttachment).toHaveBeenCalledWith("att-123"); }); + it("readAttachment 不得读取其他脚本未拥有的附件", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.isAttachmentAccessibleToScript.mockResolvedValue(false); + mockRepo.getAttachment = vi.fn().mockResolvedValue(new Blob(["secret"], { type: "image/png" })); + + await expect( + service.handleOPFSApi( + { + action: "readAttachment", + id: "att-private", + scriptUuid: "script-b", + }, + mockOPFSSender + ) + ).rejects.toThrow("Attachment access denied: att-private"); + expect(mockRepo.getAttachment).not.toHaveBeenCalled(); + }); + it("readAttachment 附件不存在时应抛出错误", async () => { const { service, mockRepo } = createTestService(); mockRepo.getAttachment = vi.fn().mockResolvedValue(null); diff --git a/src/app/service/agent/service_worker/opfs_service.ts b/src/app/service/agent/service_worker/opfs_service.ts index ac3a51d30..c082e5331 100644 --- a/src/app/service/agent/service_worker/opfs_service.ts +++ b/src/app/service/agent/service_worker/opfs_service.ts @@ -59,6 +59,9 @@ export class AgentOPFSService { return { path: safePath2, content: textContent, size: file2.size }; } case "readAttachment": { + if (!(await repo.isAttachmentAccessibleToScript(request.id, request.scriptUuid))) { + throw new Error(`Attachment access denied: ${request.id}`); + } const blob = await repo.getAttachment(request.id); if (!blob) { throw new Error(`Attachment not found: ${request.id}`); diff --git a/src/app/service/agent/service_worker/task_service.test.ts b/src/app/service/agent/service_worker/task_service.test.ts index 58e62a272..5c120b528 100644 --- a/src/app/service/agent/service_worker/task_service.test.ts +++ b/src/app/service/agent/service_worker/task_service.test.ts @@ -128,6 +128,7 @@ describe("AgentTaskService 任务生命周期", () => { function createMutationService() { const current = { id: "task-cas", + ownerScriptUuid: "script-a", generation: "generation-current", revision: 3, name: "current", @@ -142,6 +143,7 @@ describe("AgentTaskService 任务生命周期", () => { } as const; const taskRepo = { getTask: vi.fn().mockResolvedValue(current), + listTasks: vi.fn().mockResolvedValue([current]), createTask: vi.fn(async (candidate: any) => candidate), saveTask: vi.fn(async (candidate: any) => { if (candidate.generation !== current.generation || candidate.revision !== current.revision) { @@ -195,6 +197,55 @@ describe("AgentTaskService 任务生命周期", () => { expect(taskRepo.saveTask).toHaveBeenCalledWith(expect.objectContaining({ revision: 2 })); }); + it("脚本创建的任务只允许同一脚本读取和修改", async () => { + const { service, taskRepo, scheduler, current } = createMutationService(); + const other = { ...current, id: "task-other", ownerScriptUuid: "script-b" }; + taskRepo.listTasks.mockResolvedValue([current, other]); + + await expect(service.handleAgentTask({ action: "list" }, "script-a")).resolves.toEqual([current]); + await expect(service.handleAgentTask({ action: "get", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + await expect( + service.handleAgentTask( + { + action: "update", + id: current.id, + generation: current.generation, + revision: current.revision, + task: { name: "forged edit" }, + }, + "script-b" + ) + ).rejects.toThrow("Task not found"); + await expect(service.handleAgentTask({ action: "runNow", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + expect(scheduler.executeTask).not.toHaveBeenCalled(); + }); + + it("脚本创建的任务绑定创建者身份而不是请求体伪造的身份", async () => { + const { service, taskRepo } = createMutationService(); + + await service.handleAgentTask( + { + action: "create", + task: { + name: "owned task", + mode: "internal", + crontab: "0 9 * * *", + prompt: "hello", + enabled: true, + notify: false, + ownerScriptUuid: "script-b", + }, + } as any, + "script-a" + ); + + expect(taskRepo.createTask).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + it("delete 应先取消活动执行并使用客户端版本删除", async () => { const { service, taskRepo, scheduler } = createMutationService(); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 425787c20..184354286 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -265,17 +265,38 @@ export class AgentTaskService { }; } + private taskBelongsTo(task: AgentTask, ownerScriptUuid: string): boolean { + return ( + task.ownerScriptUuid === ownerScriptUuid || + (task.ownerScriptUuid === undefined && task.mode === "event" && task.sourceScriptUuid === ownerScriptUuid) + ); + } + + private assertTaskAccess(task: AgentTask | undefined, ownerScriptUuid: string | undefined): AgentTask { + if (!task || (ownerScriptUuid !== undefined && !this.taskBelongsTo(task, ownerScriptUuid))) { + throw new Error("Task not found"); + } + return task; + } + // 处理定时任务 CRUD 及 run 操作 - async handleAgentTask(params: AgentTaskApiRequest): Promise { + async handleAgentTask(params: AgentTaskApiRequest, ownerScriptUuid?: string): Promise { switch (params.action) { - case "list": - return this.taskRepo.listTasks(); - case "get": - return this.taskRepo.getTask(params.id); + case "list": { + const tasks = await this.taskRepo.listTasks(); + return ownerScriptUuid === undefined + ? tasks + : tasks.filter((task) => this.taskBelongsTo(task, ownerScriptUuid)); + } + case "get": { + const task = await this.taskRepo.getTask(params.id); + return this.assertTaskAccess(task, ownerScriptUuid); + } case "create": { const now = Date.now(); const task = { ...params.task, + ownerScriptUuid, id: uuidv4(), createtime: now, updatetime: now, @@ -300,11 +321,11 @@ export class AgentTaskService { return this.taskRepo.createTask(task); } case "update": { - const existing = await this.taskRepo.getTask(params.id); - if (!existing) throw new Error("Task not found"); + const existing = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...existing, ...params.task, + ownerScriptUuid: existing.ownerScriptUuid ?? ownerScriptUuid, id: params.id, generation: params.generation, revision: params.revision, @@ -332,17 +353,17 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "delete": { + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 先中止正在运行的执行,再清理元数据/运行记录:cancelTask 是同步的 abort(),必须最先 // 发生,否则被删除的任务会在 removeTask(含 run-history 清理)完成前继续调用 LLM/工具/ // 产生外部副作用;若 removeTask 之后才 cancel,一旦 removeTask 因清理失败而抛出, // cancelTask 根本不会被调用,执行也就永远不会被中止 - this.taskScheduler?.cancelTask(params.id); + this.taskScheduler?.cancelTask(task.id); await this.taskRepo.removeTask(params.id, params.generation, params.revision); return true; } case "enable": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...task, enabled: params.enabled, @@ -361,19 +382,22 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "runNow": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 不 await,立即返回 const now = Date.now(); const claimScheduled = Boolean(task.enabled && task.nextruntime && task.nextruntime <= now); this.taskScheduler?.executeTask(task, claimScheduled, now).catch(() => {}); return true; } - case "listRuns": + case "listRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); return this.taskRunRepo.listRuns(params.taskId, params.limit); - case "clearRuns": + } + case "clearRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); await this.taskRunRepo.clearRuns(params.taskId); return true; + } default: throw new Error(`Unknown agentTask action: ${(params as any).action}`); } diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 3944c5e4a..38422e813 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -90,6 +90,7 @@ export function createTestService() { getTasks: vi.fn().mockResolvedValue([]), getTaskSnapshot: vi.fn().mockResolvedValue({ generation: "test-generation", revision: 0, tasks: [] }), saveTasks: vi.fn().mockResolvedValue(undefined), + isAttachmentAccessibleToScript: vi.fn().mockResolvedValue(true), getAttachment: vi.fn().mockResolvedValue(null), saveAttachment: vi.fn().mockResolvedValue(0), deleteAttachment: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 058e59c95..b727499c9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; +import { GMContextApiGet } from "./gm_api/gm_context"; import { trimScriptInfo } from "./utils"; +import { Native } from "./global"; type AnyRecord = Record; @@ -304,6 +306,155 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("does not expose broker state on the script-facing context", () => { + const context = createTestContext(["GM_getValue"]); + + expect(context).not.toHaveProperty("message"); + expect(context).not.toHaveProperty("scriptRes"); + expect(context).not.toHaveProperty("valueChangeListener"); + expect(context).not.toHaveProperty("EE"); + expect(context).not.toHaveProperty("grantSet"); + }); + + it("does not let page prototype pollution hide granted APIs", () => { + const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, "GM_getValue"); + try { + Object.defineProperty(Object.prototype, "GM_getValue", { + configurable: true, + value: true, + }); + + const context = createTestContext(["GM_getValue"]); + + expect(context.GM_getValue).toBeTypeOf("function"); + } finally { + if (descriptor) Object.defineProperty(Object.prototype, "GM_getValue", descriptor); + else Reflect.deleteProperty(Object.prototype, "GM_getValue"); + } + }); + + it("creates collection instances from frozen captured-method subclasses", () => { + const set = new Native.Set(["grant"]); + const map = new Native.Map(); + const weakMap = new Native.WeakMap(); + + expect(set).toBeInstanceOf(Native.Set); + expect(map).toBeInstanceOf(Native.Map); + expect(weakMap).toBeInstanceOf(Native.WeakMap); + expect(Object.hasOwn(Object.getPrototypeOf(set), "add")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(map), "get")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(weakMap), "get")).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(set))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(map))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(weakMap))).toBe(true); + }); + + it("keeps grant construction on captured Set and iterator intrinsics", () => { + const NativeSet = Set; + const nativeArrayIsArray = Array.isArray; + const nativeArrayIterator = Array.prototype[Symbol.iterator]; + const nativeSetIterator = Set.prototype[Symbol.iterator]; + const grants = new NativeSet(); + NativeSet.prototype.add.call(grants, "GM_getValue"); + const poisonedIterator = function () { + let first = true; + return { + next() { + if (!first) return { value: undefined, done: true }; + first = false; + return { value: "GM_cookie", done: false }; + }, + }; + }; + try { + Array.isArray = (() => false) as unknown as typeof Array.isArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = class PoisonedSet { + constructor() { + throw new Error("page replaced Set"); + } + } as unknown as typeof Set; + + const arrayBackedSet = new Native.Set(["GM_getValue"]); + expect(arrayBackedSet.has("GM_getValue")).toBe(true); + + const context = createContext( + createScriptInfo({ grant: ["GM_getValue"] }), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + grants + ); + + expect(context.GM_getValue).toBeTypeOf("function"); + expect(context.GM_cookie).toBeUndefined(); + } finally { + Array.isArray = nativeArrayIsArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: nativeArrayIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: nativeSetIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = NativeSet; + } + }); + + it("uses the service-worker execution run flag for value acknowledgments", async () => { + const script = { + ...createScriptInfo({ grant: ["GM_setValue"] }), + executionRunFlag: "canonical-run", + } as TScriptInfo; + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: "bar" }), + }; + const context = createContext( + script, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + message as any, + undefined as any, + new Set(["GM_setValue"]) + ); + + context.GM_setValue("foo", "next"); + expect(message.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ runFlag: "canonical-run" }), + }) + ); + }); + + it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { + const apiValues = GMContextApiGet("GM_getValue")!; + const originalApi = apiValues[0].api; + const replacement = function (_ctx: unknown, key: string, fallback?: unknown) { + return fallback; + }; + Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); + apiValues[0].api = replacement; + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("key", "fallback")).toBe("fallback"); + } finally { + apiValues[0].api = originalApi; + } + }); + + it("uses captured object operations when page code replaces assign and keys", () => { + const assign = vi.spyOn(Object, "assign").mockImplementation(() => { + throw new Error("page replacement"); + }); + const keys = vi.spyOn(Object, "keys").mockImplementation(() => { + throw new Error("page replacement"); + }); + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("foo", "fallback")).toBe("bar"); + } finally { + assign.mockRestore(); + keys.mockRestore(); + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; @@ -355,9 +506,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); expect(context.not_exist).toBeUndefined(); - expect(context.grantSet.has("not_exist")).toBe(false); - expect(context.grantSet.has("GM_getValue")).toBe(true); - expect(context.grantSet.has("GM.getValue")).toBe(true); }); it.each(["GM.cookie", "GM_cookie"] as const)("雙向注入 cookie API:輸入 %s 時兩種公開形狀都可用", (grant) => { @@ -371,8 +519,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.set).toBeTypeOf("function"); expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); - expect(context.grantSet.has("GM.cookie")).toBe(true); - expect(context.grantSet.has("GM_cookie")).toBe(true); }); it("將 window grant 留在 context.window,投影時才暴露到 sandbox", () => { @@ -399,8 +545,7 @@ describe("createContext: capability and lifecycle contract", () => { await Promise.resolve(); expect(loaded).toBe(false); - const loadScriptResolve = (context as unknown as AnyRecord).loadScriptResolve as () => void; - loadScriptResolve(); + context.resolveLoadScript(); await loadedPromise; expect(loaded).toBe(true); }); @@ -439,20 +584,31 @@ describe("createContext: capability and lifecycle contract", () => { update("remote-1", "next", 7); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); - const contextValues = context as unknown as AnyRecord; - const runFlag = contextValues.runFlag; context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - expect(contextValues.runFlag).not.toBe(runFlag); - expect(contextValues.runFlag).toContain("(invalid)"); - expect(contextValues.message).toBeNull(); - expect(contextValues.scriptRes).toBeNull(); update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); }); + + it("事件回调收到独立快照,不能改写传输中的事件数据", () => { + const context = createTestContext(["CAT.agent.task"]); + let observed: { nested: { value: number } } | undefined; + const received = vi.fn((data: { nested: { value: number } }) => { + observed = { nested: { value: data.nested.value } }; + data.nested.value = 99; + }); + + context.CAT.agent.task.addListener("task-a", received); + const eventData = { nested: { value: 1 } }; + context.emitEvent("agentTask", "task-a", eventData); + + expect(received).toHaveBeenCalledTimes(1); + expect(observed).toEqual({ nested: { value: 1 } }); + expect(eventData).toEqual({ nested: { value: 1 } }); + }); }); describe.sequential("createProxyContext: module default split roots", () => { @@ -904,6 +1060,25 @@ describe("createProxyContext: deterministic realm contract", () => { expect(third).toHaveBeenCalledTimes(1); }); + it("事件 callback 的 call 屬性被頁面改寫時仍保留 sandbox this", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + Object.defineProperty(handler, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + + sandbox.onload = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + + expect(handler).toHaveBeenCalledTimes(1); + }); + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { const fixture = createSplitRealmRoots(); const sandbox = createProxyContext(Object.create(null), fixture.roots); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 44d4ccf20..75333db34 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -6,11 +6,56 @@ import { GMContextApiGet, protect } from "./gm_api/gm_context"; import { getGrantCandidates } from "./gm_api/grant"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; -import { createGMBase } from "./gm_api/gm_api"; +import { createGMBase, type IGM_Base } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; +import { nativeCall, Native } from "./global"; + +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + // 由闭包提供上下文,脚本侧只传 API 自身的参数。 + /* eslint-disable prefer-rest-params -- 以固定参数转发保留调用参数数量,避免每次调用创建 rest 数组。 */ + const capability = function (this: unknown) { + switch (arguments.length) { + case 0: + return api(receiver); + case 1: + return api(receiver, arguments[0]); + case 2: + return api(receiver, arguments[0], arguments[1]); + case 3: + return api(receiver, arguments[0], arguments[1], arguments[2]); + case 4: + return api(receiver, arguments[0], arguments[1], arguments[2], arguments[3]); + default: { + const args = new Array(arguments.length + 1); + args[0] = receiver; + for (let i = 0; i < arguments.length; i += 1) args[i + 1] = arguments[i]; + return Native.reflectApply(api, undefined, args); + } + } + }; + /* eslint-enable prefer-rest-params */ + Native.objectDefineProperty(capability, "name", { + configurable: true, + value: api.name, + }); + Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); + return capability; +}; // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 +export type ScriptContext = IGM_Base & { + [key: string]: any; + setExecutionRunFlag(runFlag: string): void; + resolveLoadScript(): void; +}; + +type InternalScriptContext = IGM_Base & { + [key: string]: any; + runFlag: string; + loadScriptResolve?: () => void; +}; + // 构建沙盒上下文 export const createContext = ( scriptRes: TScriptInfo, @@ -20,6 +65,8 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + // 复制授权集合并使用捕获的 Set 实现,避免页面改写迭代器后影响 API 注入。 + const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -32,7 +79,7 @@ export const createContext = ( }); } let invalid = false; - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GMInfo; const context = createGMBase({ prefix: envPrefix, @@ -41,12 +88,12 @@ export const createContext = ( scriptRes, valueChangeListener, EE, - runFlag: uuidv4(), + runFlag: scriptRes.executionRunFlag || uuidv4(), eventId: 10000, GM: GM, GM_info: GMInfo, - window: Object.create(null), - grantSet: new Set(), + window: Native.objectCreate(null), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -64,48 +111,91 @@ export const createContext = ( isInvalidContext() { return invalid; }, + }) as unknown as InternalScriptContext; + const publicContext = Native.objectCreate(null) as ScriptContext; + publicContext.GM = GM; + publicContext.GM_info = GMInfo; + publicContext.window = Native.objectCreate(null); + publicContext.unsafeWindow = window; + + // 生命周期方法只供隔离执行器使用,不进入脚本可枚举的 facade。 + Native.objectDefineProperty(publicContext, "valueUpdate", { + configurable: false, + enumerable: false, + value: (data: any) => context.valueUpdate(data), + }); + Native.objectDefineProperty(publicContext, "emitEvent", { + configurable: false, + enumerable: false, + value: (event: string, eventId: string, data: any) => context.emitEvent(event, eventId, data), + }); + Native.objectDefineProperty(publicContext, "setInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.setInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "isInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.isInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "setExecutionRunFlag", { + configurable: false, + enumerable: false, + value: (runFlag: string) => { + context.runFlag = runFlag; + }, + }); + Native.objectDefineProperty(publicContext, "resolveLoadScript", { + configurable: false, + enumerable: false, + value: () => context.loadScriptResolve?.(), }); - const grantedAPIs: { [key: string]: any } = Object.create(null); + + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) grantSet.add(grant); - for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); + for (let i = 0; i < s.length; i += 1) { + const { fnKey, api, param } = s[i]; + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { - for (const grant of depend) { - __methodInject__(grant); - } + for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); } } return true; }; - for (const grant of scriptGrants) { - for (const candidate of getGrantCandidates(grant)) { + // 只能调用捕获的 forEach;此处不依赖页面提供的 Set iterator。 + scriptGrantSet.forEach((grant) => { + const candidates = getGrantCandidates(String(grant)); + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; __methodInject__(candidate); } - } + }); // 兼容GM.Cookie.* - for (const fnKey of Object.keys(grantedAPIs)) { + const grantedKeys = Native.objectKeys(grantedAPIs); + for (let i = 0; i < grantedKeys.length; i += 1) { + const fnKey = grantedKeys[i]; const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; - let g = context; + let g = publicContext; let s = ""; for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; s += `${i ? "." : ""}${part}`; - g = g[part] || (g[part] = grantedAPIs[s] || Object.create(null)); + g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } - context.unsafeWindow = window; - if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { - context.onurlchange = null; + if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { + publicContext.onurlchange = null; attachNavigateHandler(window as any); } - return context; + return publicContext; }; const noEval = false; @@ -161,11 +251,13 @@ const getAllPropertyDescriptors = ( callback: (key: string | symbol, descriptor: PropertyDescriptor) => void ) => { while (obj && obj !== Object) { - const descs = Object.getOwnPropertyDescriptors(obj); - for (const key of Reflect.ownKeys(descs)) { + const descs = Native.objectGetOwnPropertyDescriptors(obj); + const keys = Native.reflectOwnKeys(descs); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; callback(key, descs[key as keyof typeof descs]); } - obj = Object.getPrototypeOf(obj); + obj = Native.objectGetPrototypeOf(obj); } }; @@ -178,21 +270,19 @@ const isConstructorOrInterface = (value: unknown) => { }; // 避免 host/Xray function 的 .bind lookup 不可靠 -const bindFn = Function.prototype.bind; - const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: bindFn.call(descriptor.value, receiver), + value: Native.bind(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, - set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, + get: descriptor.get ? Native.bind(descriptor.get, receiver) : undefined, + set: descriptor.set ? Native.bind(descriptor.set, receiver) : undefined, }; }; @@ -209,25 +299,27 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 - const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); // overriddenDescs 将以物件 OwnPropertyDescriptor 方式进行物件属性修改。 // 覆盖原有的 OwnPropertyDescriptor 定义或父类的 PropertyDescriptor 定义。 - const overriddenDescs: DescriptorMap = Object.create(null); + const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Set(); + const eventKeys = new Native.Set(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 - const protoBaseDescs: DescriptorMap = Object.create(null); + const protoBaseDescs: DescriptorMap = Native.objectCreate(null); const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 - const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Object.keys(descriptors)) { + const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); + const keys = Native.objectKeys(descriptors); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; const desc = descriptors[key]; if (descsCache.has(key)) continue; descsCache.add(key); // realm own descriptors take precedence over host descriptors @@ -273,7 +365,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } return; @@ -325,13 +417,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { + ? Native.objectCreate(null, { ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), + ...Native.objectGetOwnPropertyDescriptors(PseudoWindowPrototype), ...initOwnDescs, ...overriddenDescs, }) - : Object.create(Object.getPrototypeOf(realmGlobal), { + : Native.objectCreate(Native.objectGetPrototypeOf(realmGlobal), { ...initOwnDescs, ...overriddenDescs, }); @@ -342,7 +434,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 -const initConsoleDescs = Object.getOwnPropertyDescriptors(console); +const initConsoleDescs = Native.objectGetOwnPropertyDescriptors(console); const ConsolePrototype = Object.getPrototypeOf(console); type GMWorldContext = typeof globalThis & Record; @@ -359,12 +451,12 @@ export const createProxyContext = ( const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); - const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); + const ownDescs = Native.objectGetOwnPropertyDescriptors(sharedInitCopy); // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; - const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); - const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); + const hostAddEventListener = Native.bind(roots.hostWindow.addEventListener, roots.hostWindow); + const hostRemoveEventListener = Native.bind(roots.hostWindow.removeEventListener, roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -379,7 +471,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - fn.call(mySandbox, event); + nativeCall(fn, mySandbox, event); } }, }; @@ -411,7 +503,13 @@ export const createProxyContext = ( }; }; - for (const key of eventKeys) { + // 事件键只需传入沙盒属性;先用捕获的 forEach 转成数组,避免跨 realm 读取 iterator。 + const eventKeyList: string[] = []; + eventKeys.forEach((key) => { + eventKeyList[eventKeyList.length] = String(key); + }); + for (let i = 0; i < eventKeyList.length; i += 1) { + const key = eventKeyList[i]; const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], @@ -420,7 +518,9 @@ export const createProxyContext = ( } // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 - for (const key of ["window", "self", "globalThis"]) { + const sandboxAliases = ["window", "self", "globalThis"]; + for (let i = 0; i < sandboxAliases.length; i += 1) { + const key = sandboxAliases[i]; ownDescs[key] = { configurable: true, enumerable: true, @@ -429,9 +529,11 @@ export const createProxyContext = ( }, }; } - for (const key of ["top", "parent", "frames"]) { + const windowAliases = ["top", "parent", "frames"]; + for (let i = 0; i < windowAliases.length; i += 1) { + const key = windowAliases[i]; const descriptor = ownDescs[key]; - const hostValue = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const hostValue = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; ownDescs[key] = { @@ -439,7 +541,7 @@ export const createProxyContext = ( configurable: true, enumerable: descriptor?.enumerable ?? true, get() { - const value = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const value = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); return value === roots.hostWindow || value === roots.realmGlobal ? mySandbox : value; }, set: undefined, @@ -472,20 +574,21 @@ export const createProxyContext = ( get() { return currentValue; }, - set(nv) { - if (typeof nv !== "function") nv = null; - currentValue = nv; + set(nv: unknown) { + currentValue = typeof nv === "function" ? (nv as (this: GlobalEventHandlers, ev: UrlChangeEvent) => any) : null; return true; }, }; } // 把初始Copy加上特殊变量后,生成一份新Copy - mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & + mySandbox = Native.objectCreate(Native.objectGetPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 - for (const key of ["define", "module", "exports"]) { + const moduleKeys = ["define", "module", "exports"]; + for (let i = 0; i < moduleKeys.length; i += 1) { + const key = moduleKeys[i]; mySandbox[key] = undefined; } @@ -493,8 +596,10 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Object.keys(context)) { - if (key in protect || key === "window") continue; + const contextKeys = Native.objectKeys(context); + for (let i = 0; i < contextKeys.length; i += 1) { + const key = contextKeys[i]; + if (Native.objectHasOwn(protect, key) || key === "window") continue; mySandbox[key] = context[key]; // window以外 } @@ -517,11 +622,11 @@ export const createProxyContext = ( const handle = function (this: Window & Record, e: UrlChangeEvent) { this.onurlchange?.(e); } as EventListener; - (roots.hostWindow).addEventListener("urlchange", handle.bind(mySandbox), false); + (roots.hostWindow).addEventListener("urlchange", Native.bind(handle, mySandbox), false); } // 从网页 console 隔离出来的沙盒 console - mySandbox.console = Object.create(ConsolePrototype, initConsoleDescs); + mySandbox.console = Native.objectCreate(ConsolePrototype, initConsoleDescs); return mySandbox; }; diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..01d9e9299 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -68,6 +68,22 @@ describe.concurrent("GM_info", () => { expect(ret.GM_info.script.version).toEqual("1.0.0"); expect(ret._this).not.toEqual(global); }); + + it.concurrent("does not resolve a mutable script function call property", async () => { + const { exec } = makeExec("return this;"); + const scriptFunc = function (_token: string, context: unknown) { + return context; + } as ScriptFunc & { call?: unknown }; + Object.defineProperty(scriptFunc, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + exec.scriptFunc = scriptFunc; + + expect(await exec.exec()).toBe(exec.execContext); + }); }); describe.concurrent("unsafeWindow", () => { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..aca46a871 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -1,13 +1,16 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { createContext, createProxyContext } from "./create_context"; +import { createContext, createProxyContext, type ScriptContext } from "./create_context"; import type { GMInfoEnv, ScriptFunc } from "./types"; import { compileScript, isContextMenuScript } from "./utils"; import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; -import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import { Native } from "./global"; + +// 编译函数只在收到本次构建的密钥时执行,避免页面直接复用包装器。 +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; // 执行脚本,控制脚本执行与停止 export default class ExecScript { @@ -19,7 +22,7 @@ export default class ExecScript { // proxyContext: typeof globalThis; - sandboxContext?: IGM_Base & { [key: string]: any }; + sandboxContext?: ScriptContext; named?: { [key: string]: any }; @@ -48,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Set(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); @@ -57,14 +60,14 @@ export default class ExecScript { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GM_info; this.named = { GM, GM_info }; } else { // 构建脚本GM上下文 this.sandboxContext = createContext(scriptRes, GM_info, envPrefix, message, contentMsg, grantSet); if (globalInjection) { - Object.assign(this.sandboxContext, globalInjection); + Native.objectAssign(this.sandboxContext, globalInjection); } } } @@ -88,15 +91,32 @@ export default class ExecScript { this.logger.debug("script start"); const sandboxContext = this.sandboxContext; this.execContext = sandboxContext ? createProxyContext(sandboxContext) : global; // this.$ 只能执行一次 - return this.scriptFunc.call(this.execContext, this.named, this.scriptRes.name); + return this.scriptFunc(fnStrIntegrity, this.execContext, this.named, this.scriptRes.name); }; // 早期启动的脚本,处理GM API - updateEarlyScriptGMInfo(envInfo: GMInfoEnv) { + updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo) { + // 预注入事件可被页面观察,只携带空的用户值和配置;pageLoad 到达后再补回权威副本。 + this.scriptRes.value = scriptInfo.value; + this.scriptRes.config = scriptInfo.config; + this.scriptRes.metadata = scriptInfo.metadata; + this.scriptRes.resource = scriptInfo.resource; + this.scriptRes.requireCssResource = scriptInfo.requireCssResource; + } + if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 + this.scriptRes.executionHandle = scriptInfo.executionHandle; + this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; + this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; + if (this.sandboxContext && scriptInfo.executionRunFlag) { + this.sandboxContext.setExecutionRunFlag(scriptInfo.executionRunFlag); + } + } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve - this.sandboxContext["loadScriptResolve"]?.(); + this.sandboxContext.resolveLoadScript(); GM_info = this.execContext["GM_info"]; } else { GM_info = this.named?.GM_info; diff --git a/src/app/service/content/external.ts b/src/app/service/content/external.ts index 2ff7aa223..8aa255266 100644 --- a/src/app/service/content/external.ts +++ b/src/app/service/content/external.ts @@ -14,10 +14,12 @@ const isExternalWhitelisted = (hostname: string) => { }; // 生成暴露给页面的 Scriptcat 外部接口 -const createScriptcatExpose = (msg: Message) => { +const createScriptcatExpose = (msg: Message, messagePrefix: string) => { const scriptExpose: App.ExternalScriptCat = { isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { - sendMessage(msg, "scripting/script/isInstalled", { name, namespace }).then(callback); + sendMessage(msg, `${messagePrefix}/script/isInstalled`, { name, namespace }).then( + callback + ); }, }; return scriptExpose; @@ -63,7 +65,7 @@ const patchTampermonkeyIsInstalled = (external: any, scriptExpose: App.ExternalS }; // inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口 -export const onInjectPageLoaded = (msg: Message) => { +export const onInjectPageLoaded = (msg: Message, messagePrefix = "scripting") => { const hostname = window.location.hostname; // 不在白名单则不对外暴露接口 @@ -73,7 +75,7 @@ export const onInjectPageLoaded = (msg: Message) => { const external: External = (window.external || (window.external = {} as External)) as External; // 创建 Scriptcat 暴露对象 - const scriptExpose = createScriptcatExpose(msg); + const scriptExpose = createScriptcatExpose(msg, messagePrefix); // 尝试设置 external.Scriptcat safeSetExternal(external, "Scriptcat", scriptExpose); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 3b78911ff..12c16d2e3 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -1,36 +1,189 @@ // 避免在全局页面环境中,内置处理函数被篡改或重写 -const unsupportedAPI = () => { - throw "unsupportedAPI"; + +// 在页面或用户脚本替换调用内建函数前完成捕获。 +export const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +// structuredClone 不用 bind globalThis; nativeStructuredClone 在初期化時捕获。 +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeSetConstructor = Set; +const nativeSetAdd = Set.prototype.add; +const nativeSetHas = Set.prototype.has; +const nativeSetDelete = Set.prototype.delete; +const nativeSetClear = Set.prototype.clear; +const nativeSetForEach = Set.prototype.forEach; +const nativeSetValues = Set.prototype.values; +const nativeArrayIsArray = Array.isArray; +const nativeMapConstructor = Map; +const nativeMapGet = Map.prototype.get; +const nativeMapSet = Map.prototype.set; +const nativeMapHas = Map.prototype.has; +const nativeMapDelete = Map.prototype.delete; +const nativeMapClear = Map.prototype.clear; +const nativeMapForEach = Map.prototype.forEach; +const nativeWeakMapConstructor = WeakMap; +const nativeWeakMapGet = WeakMap.prototype.get; +const nativeWeakMapSet = WeakMap.prototype.set; +const nativeWeakMapHas = WeakMap.prototype.has; +const nativeWeakMapDelete = WeakMap.prototype.delete; +const nativeObjectFreeze = Object.freeze; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; +const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); + +// Keep the captured methods on private subclasses. Instances can then be created +// without reassigning every method, while the subclass prototypes remain outside +// the page's mutable built-in prototypes. +const NativeSetConstructor = class extends nativeSetConstructor { + constructor(values?: readonly T[] | Set | null) { + // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 + super(); + if (nativeArrayIsArray(values)) { + for (let i = 0; i < values.length; i += 1) this.add(values[i]); + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); + } + } }; +NativeSetConstructor.prototype.add = nativeSetAdd; +NativeSetConstructor.prototype.has = nativeSetHas; +NativeSetConstructor.prototype.delete = nativeSetDelete; +NativeSetConstructor.prototype.clear = nativeSetClear; +NativeSetConstructor.prototype.forEach = nativeSetForEach; +NativeSetConstructor.prototype.values = nativeSetValues; +nativeObjectFreeze(NativeSetConstructor.prototype); + +const NativeMapConstructor = class extends nativeMapConstructor {}; +NativeMapConstructor.prototype.get = nativeMapGet; +NativeMapConstructor.prototype.set = nativeMapSet; +NativeMapConstructor.prototype.has = nativeMapHas; +NativeMapConstructor.prototype.delete = nativeMapDelete; +NativeMapConstructor.prototype.clear = nativeMapClear; +NativeMapConstructor.prototype.forEach = nativeMapForEach; +nativeObjectFreeze(NativeMapConstructor.prototype); + +const NativeWeakMapConstructor = class extends nativeWeakMapConstructor {}; +NativeWeakMapConstructor.prototype.get = nativeWeakMapGet; +NativeWeakMapConstructor.prototype.set = nativeWeakMapSet; +NativeWeakMapConstructor.prototype.has = nativeWeakMapHas; +NativeWeakMapConstructor.prototype.delete = nativeWeakMapDelete; +nativeObjectFreeze(NativeWeakMapConstructor.prototype); + +const nativeFunctionApply = nativeReflectApply(nativeFunctionBind, Function.prototype.apply, [ + Function.prototype.apply, +]) as (fn: (...args: any[]) => any, receiver: any, args: any[]) => any; +const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.prototype.call, [ + Function.prototype.call, +]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; + +export const nativeApply = nativeFunctionApply; +export const nativeCall = nativeFunctionCall; +export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); export const Native = { - structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, - jsonStringify: JSON.stringify.bind(JSON), - jsonParse: JSON.parse.bind(JSON), - createElement: Document.prototype.createElement, - ownFragment: new DocumentFragment(), - objectCreate: Object.create.bind(Object), - objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), - objectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object), - objectGetPrototypeOf: Object.getPrototypeOf.bind(Object), + Set: NativeSetConstructor, + Map: NativeMapConstructor, + WeakMap: NativeWeakMapConstructor, + bind: nativeBind, + reflectApply: nativeReflectApply, + structuredClone: nativeStructuredClone, + jsonStringify: nativeBind(JSON.stringify, JSON), + jsonParse: nativeBind(JSON.parse, JSON), + createElement: nativeDocumentCreateElement, + ownFragment: nativeOwnFragment, + objectCreate: nativeBind(Object.create, Object), + objectAssign: nativeBind(Object.assign, Object), + arrayIsArray: nativeArrayIsArray, + objectKeys: nativeBind(Object.keys, Object), + objectHasOwn: nativeBind(Object.hasOwn, Object), + objectDefineProperty: nativeBind(Object.defineProperty, Object), + objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), + objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), + objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), + reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), + reflectGet: nativeBind(Reflect.get, Reflect), } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等) + // 非对象类型直接返回(包含 undefined、基本类型等);函数和 Symbol 不可跨边界传输。 // 接受参数:阵列、物件、null - if (typeof o !== "object") return o; + if (o === null || typeof o !== "object") { + return typeof o === "function" || typeof o === "symbol" ? undefined : o; + } - try { - // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); - } catch { - // 例如:被 Proxy 包装的对象(如 Vue 等框架处理过的 reactive 对象) - // structuredClone 可能会失败,忽略错误继续尝试其他方式 + // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 + const seen = new Native.WeakMap(); + const isDataOnly = (value: object): boolean => { + if (seen.has(value)) return true; + seen.set(value, true); + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + let valid = true; + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + if (valid && (!isDataOnlyValue(key) || !isDataOnlyValue(entry))) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Map,继续检查普通自有属性。 + } + try { + let valid = true; + nativeReflectApply(nativeSetForEach, value as Set, [ + (entry: unknown) => { + if (valid && !isDataOnlyValue(entry)) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Set,继续检查普通自有属性。 + } + + let keys: PropertyKey[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return false; + } + for (const key of keys) { + if (typeof key === "symbol") return false; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + } catch { + return false; + } + if (!descriptor || !("value" in descriptor)) return false; + if ( + typeof descriptor.value === "function" || + (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) + ) { + return false; + } + } + return true; + }; + const isDataOnlyValue = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return true; + return isDataOnly(value); + }; + if (!isDataOnly(o)) return undefined; + + if (nativeStructuredClone) { + try { + // 优先使用 structuredClone,支持大多数可克隆对象 + return nativeStructuredClone(o); + } catch { + // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 + return undefined; + } } try { - // 退而求其次,使用 JSON 序列化方式进行深拷贝 - // 仅适用于可被 JSON 表示的普通对象 + // 旧浏览器没有 structuredClone 时,只复制已验证的数据属性。 return Native.jsonParse(Native.jsonStringify(o)); } catch { // 序列化失败,忽略错误 diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 1ea74a921..18dfa728f 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -65,6 +65,33 @@ function createInstance( } describe("ConversationInstance 命令机制", () => { + it("不会把通用 GM 传输能力作为实例属性暴露", () => { + const { instance } = createInstance(); + const ownNames = Object.getOwnPropertyNames(instance); + + expect(ownNames).not.toContain("gmSendMessage"); + expect(ownNames).not.toContain("gmConnect"); + expect(ownNames).not.toContain("conv"); + expect(ownNames).not.toContain("scriptUuid"); + expect(ownNames).not.toContain("toolHandlers"); + expect(ownNames).not.toContain("toolDefs"); + expect(ownNames).not.toContain("messageHistory"); + }); + + it("does not let public mutation replace private conversation state", async () => { + const { instance } = createEphemeralInstance(); + const exposed = instance as unknown as Record; + exposed.messageHistory = [{ role: "user", content: "forged" }]; + exposed.toolHandlers = new Map([["forged", vi.fn()]]); + exposed.toolDefs = [{ name: "forged", description: "forged", parameters: {} }]; + + await instance.chat("real"); + + const messages = await instance.getMessages(); + expect(messages[0]).toMatchObject({ role: "user", content: "real" }); + expect(messages).not.toContainEqual({ role: "user", content: "forged" }); + }); + it("内置 /new 命令清空消息历史", async () => { const { instance, gmSendMessage } = createInstance(); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8f63e06cd..4c6911def 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -18,6 +18,7 @@ import type { MessageContent, } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { Native } from "../global"; export type ConversationStreamChunk = | StreamChunk @@ -81,27 +82,36 @@ function resolveToolCall( // 对话实例,暴露给用户脚本 // 导出供测试使用 +type ConversationPrivateState = { + conv: Conversation; + gmSendMessage: (api: string, params: any[]) => Promise; + gmConnect: (api: string, params: any[]) => Promise; + scriptUuid: string; + commandHandlers: Map; + cache?: boolean; + systemPrompt?: string; + background: boolean; +}; + export class ConversationInstance { - public toolHandlers: Map = new Map(); - public toolDefs: ToolDefinition[] = []; - private commandHandlers: Map = new Map(); - public ephemeral: boolean; - private cache?: boolean; - private systemPrompt?: string; - public messageHistory: Array<{ + // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 + #state: ConversationPrivateState; + + #toolHandlers: Map = new Map(); + #toolDefs: ToolDefinition[] = []; + #ephemeral: boolean; + #messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[]; }> = []; - private background: boolean; - constructor( - private conv: Conversation, - private gmSendMessage: (api: string, params: any[]) => Promise, - private gmConnect: (api: string, params: any[]) => Promise, - private scriptUuid: string, + conv: Conversation, + gmSendMessage: (api: string, params: any[]) => Promise, + gmConnect: (api: string, params: any[]) => Promise, + scriptUuid: string, initialTools?: ConversationCreateOptions["tools"], commands?: Record, ephemeral?: boolean, @@ -109,19 +119,27 @@ export class ConversationInstance { cache?: boolean, background?: boolean ) { - this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; + const state: ConversationPrivateState = { + conv, + gmSendMessage, + gmConnect, + scriptUuid, + commandHandlers: new Map(), + cache, + systemPrompt: system, + background: background || false, + }; + this.#state = state; + this.#ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { - this.toolHandlers.set(tool.name, tool.handler); - this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + this.#toolHandlers.set(tool.name, tool.handler); + this.#toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); } } // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { + state.commandHandlers.set("/new", async () => { await this.clear(); return "对话已清空"; }); @@ -129,21 +147,21 @@ export class ConversationInstance { // 用户传入的 commands 覆盖内置命令 if (commands) { for (const [name, handler] of Object.entries(commands)) { - this.commandHandlers.set(name, handler); + state.commandHandlers.set(name, handler); } } } get id() { - return this.conv.id; + return this.#state.conv.id; } get title() { - return this.conv.title; + return this.#state.conv.title; } get modelId() { - return this.conv.modelId; + return this.#state.conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -154,42 +172,43 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } // 通过 GM API connect 建立流式连接 const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); const reply = await this.processChat(conn, handlers); // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 - if (this.ephemeral) { - this.messageHistory.push({ role: "assistant", content: reply.content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "assistant", content: reply.content }); } return reply; @@ -221,39 +240,40 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 - if (this.ephemeral) { + if (this.#ephemeral) { return this.processStreamEphemeral(conn, handlers) as AsyncIterable; } @@ -274,7 +294,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = this.commandHandlers.get(parsed.name); + const handler = this.#state.commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -284,8 +304,8 @@ export class ConversationInstance { // 合并实例级别和调用级别的工具定义(调用级同名工具同时替换 schema 与 handler) protected mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); + const toolDefs: ToolDefinition[] = [...this.#toolDefs]; + const handlers = new Map(this.#toolHandlers); for (const tool of callTools || []) { const definition = { name: tool.name, @@ -302,11 +322,12 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { - if (this.ephemeral) { + const state = this.#state; + if (this.#ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ + return this.#messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, - conversationId: this.conv.id, + conversationId: state.conv.id, role: msg.role, content: msg.content, toolCallId: msg.toolCallId, @@ -314,12 +335,12 @@ export class ConversationInstance { createtime: Date.now(), })); } - const messages = await this.gmSendMessage("CAT_agentConversation", [ + const messages = await state.gmSendMessage("CAT_agentConversation", [ { action: "getMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); return messages || []; @@ -327,36 +348,39 @@ export class ConversationInstance { // 清空对话消息历史 async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; + if (this.#ephemeral) { + this.#messageHistory = []; return; } - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 持久化对话 async save(): Promise { - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "save", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const conn = await this.gmConnect("CAT_agentAttachToConversation", [ - { conversationId: this.conv.id, generation: this.conv.generation, scriptUuid: this.scriptUuid }, + const state = this.#state; + const conn = await state.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); return this.processStream(conn, new Map()); } @@ -381,15 +405,15 @@ export class ConversationInstance { const finalContent = buildContent(content, blocks); const round = ordered.map(cloneToolCall); aggregate.push(...round); - if (this.ephemeral && record && (content || blocks.length || round.length)) { - this.messageHistory.push({ + if (this.#ephemeral && record && (content || blocks.length || round.length)) { + this.#messageHistory.push({ role: "assistant", content: finalContent, toolCalls: round.length ? round : undefined, }); for (const toolCall of round) { if (toolCall.result !== undefined) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result, toolCallId: toolCall.id, @@ -775,13 +799,13 @@ export class ConversationInstance { result: JSON.stringify({ error: "Tool call cancelled: stream ended before it completed" }), }); }); - this.messageHistory.push({ + this.#messageHistory.push({ role: "assistant", content: buildContent(text, blocks), toolCalls: finalized.length ? finalized : undefined, }); for (const toolCall of finalized) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result!, toolCallId: toolCall.id, @@ -879,24 +903,25 @@ export class ConversationInstance { } } -// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; connect: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; } -// 构建 ConversationInstance,独立函数避免 this 绑定问题 -// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +// 构建 ConversationInstance,保留 GM_Base 的消息上下文。 function buildInstance( ctx: GMBaseContext, conv: Conversation, options?: ConversationCreateOptions ): ConversationInstance { + const sendMessage = Native.bind(ctx.sendMessage, ctx); + const connect = Native.bind(ctx.connect, ctx); return new ConversationInstance( conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), + sendMessage, + connect, ctx.scriptRes?.uuid || "", options?.tools, options?.commands, @@ -922,7 +947,10 @@ export default class CATAgentApi { // CAT.agent.conversation.create() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + public "CAT.agent.conversation.create"( + ctx: GMBaseContext, + options: ConversationCreateOptions = {} + ): Promise { return (async () => { if (options.ephemeral) { // ephemeral 模式:不发请求到 SW,直接在脚本端构造 @@ -934,26 +962,26 @@ export default class CATAgentApi { createtime: Date.now(), updatetime: Date.now(), }; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); } const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); })(); } // CAT.agent.conversation.get() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.get"(id: string): Promise { + public "CAT.agent.conversation.get"(ctx: GMBaseContext, id: string): Promise { return (async () => { - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation | null; if (!conv) return null; - return buildInstance(this as unknown as GMBaseContext, conv); + return buildInstance(ctx as unknown as GMBaseContext, conv); })(); } } diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts index 1208d39ed..914b16f37 100644 --- a/src/app/service/content/gm_api/cat_agent_dom.ts +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -23,7 +23,7 @@ import type { MonitorStatus, } from "@App/app/service/agent/core/types"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -37,96 +37,105 @@ export default class CATAgentDomApi { protected scriptRes?: any; @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.listTabs"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.listTabs"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "listTabs", scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.navigate"(ctx: GMBaseContext, url: string, options?: NavigateOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "navigate", url, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.readPage"(ctx: GMBaseContext, options?: ReadPageOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "readPage", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.screenshot"(ctx: GMBaseContext, options?: ScreenshotOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.click"( + ctx: GMBaseContext, + selector: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "click", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.fill"( + ctx: GMBaseContext, + selector: string, + value: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "fill", selector, value, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.scroll"( + ctx: GMBaseContext, + direction: ScrollDirection, + options?: ScrollOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "scroll", direction, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.waitFor"( + ctx: GMBaseContext, + selector: string, + options?: WaitForOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "waitFor", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.executeScript"(code: string, options?: ExecuteScriptOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.executeScript"( + ctx: GMBaseContext, + code: string, + options?: ExecuteScriptOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "executeScript", code, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.startMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.startMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "startMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.stopMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "stopMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.peekMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "peekMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); diff --git a/src/app/service/content/gm_api/cat_agent_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts index 614342ae4..f9746766f 100644 --- a/src/app/service/content/gm_api/cat_agent_model.test.ts +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -31,7 +31,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - const result = await listApi.api.call(ctx); + const result = await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -57,7 +57,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getApi = apis.find((a) => a.fnKey === "CAT.agent.model.get")!; - const result = await getApi.api.call(ctx, "m1"); + const result = await getApi.api(ctx, "m1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "get", id: "m1", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -75,7 +75,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getDefaultApi = apis.find((a) => a.fnKey === "CAT.agent.model.getDefault")!; - const result = await getDefaultApi.api.call(ctx); + const result = await getDefaultApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "getDefault", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -93,7 +93,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "" } as ModelApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts index 3cc53c8e8..fe3d7d5f2 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -1,7 +1,7 @@ import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,32 +23,28 @@ export default class CATAgentModelApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getDefault"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getDefault"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getSummary"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; diff --git a/src/app/service/content/gm_api/cat_agent_opfs.test.ts b/src/app/service/content/gm_api/cat_agent_opfs.test.ts index d189ccf6e..0b87bfea2 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.test.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.test.ts @@ -24,7 +24,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const writeApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.write")!; - const result = await writeApi.api.call(ctx, "hello.txt", "Hello"); + const result = await writeApi.api(ctx, "hello.txt", "Hello"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "write", path: "hello.txt", content: "Hello", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -38,7 +38,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "f.txt"); + const result = await readApi.api(ctx, "f.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "read", path: "f.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -54,14 +54,14 @@ describe.concurrent("CATAgentOPFSApi", () => { const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; // 不带 path - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "test-uuid" } as OPFSApiRequest, ]); // 带 path mockSendMessage.mockClear(); - await listApi.api.call(ctx, "sub"); + await listApi.api(ctx, "sub"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: "sub", scriptUuid: "test-uuid" } as OPFSApiRequest, ]); @@ -73,7 +73,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const deleteApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.delete")!; - const result = await deleteApi.api.call(ctx, "old.txt"); + const result = await deleteApi.api(ctx, "old.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "delete", path: "old.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -87,7 +87,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "" } as OPFSApiRequest, @@ -108,7 +108,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -126,7 +126,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -154,7 +154,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]); expect((result as any).data).toBe(testBlob); @@ -181,7 +181,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/456"]); expect((result as any).data).toBe(testBlob); diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts index 6355e5517..549919162 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -1,7 +1,7 @@ import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: any[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +17,11 @@ export default class CATAgentOPFSApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.write"(path: string, content: string | Blob): Promise<{ path: string; size: number }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.write"( + ctx: GMBaseContext, + path: string, + content: string | Blob + ): Promise<{ path: string; size: number }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "write", path, content, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ path: string; size: number }>; @@ -26,10 +29,10 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.read"( + ctx: GMBaseContext, path: string, format?: "text" | "blob" ): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -42,8 +45,10 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.list"(path?: string): Promise> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.list"( + ctx: GMBaseContext, + path?: string + ): Promise> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "list", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise>; @@ -51,9 +56,9 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.readAttachment"( + ctx: GMBaseContext, id: string ): Promise<{ id: string; data: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -66,8 +71,7 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.delete"(ctx: GMBaseContext, path: string): Promise<{ success: true }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ success: true }>; diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts index 8e9dd6314..be7715338 100644 --- a/src/app/service/content/gm_api/cat_agent_skills.ts +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -1,7 +1,7 @@ import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,16 +23,14 @@ export default class CATAgentSkillsApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.get"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.get"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "get", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -40,11 +38,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.install"( + ctx: GMBaseContext, skillMd: string, scripts?: Array<{ name: string; code: string }>, references?: Array<{ name: string; content: string }> ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "install", @@ -57,8 +55,7 @@ export default class CATAgentSkillsApi { } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.remove"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.remove"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -66,11 +63,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.call"( + ctx: GMBaseContext, skillName: string, scriptName: string, params?: Record ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "call", diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 70321ee91..4a0d9cd73 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -7,8 +7,9 @@ import type { EventAgentTask, } from "@App/app/service/agent/core/types"; import type EventEmitter from "eventemitter3"; +import { Native } from "../global"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +18,18 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; -// listener id → { eventName, callback } 映射,供 removeListener 使用 -const listenerMap = new Map void }>(); +type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; +const listenerMaps = new Native.WeakMap>(); +// 监听记录按 GM context 隔离;WeakMap 让脚本结束后不会因监听表反向持有 context。 + +const getListenerRecords = (owner: object): Map => { + let records = listenerMaps.get(owner); + if (!records) { + records = new Native.Map(); + listenerMaps.set(owner, records); + } + return records; +}; // CAT.agent.task API,注入到脚本上下文 export default class CATAgentTaskApi { @@ -33,11 +44,11 @@ export default class CATAgentTaskApi { @GMContext.API({ follow: "CAT.agent.task" }) public "CAT.agent.task.create"( + ctx: GMBaseContext, options: | Omit | Omit ): Promise { - const ctx = this as unknown as GMBaseContext; // event 模式:自动注入 sourceScriptUuid(脚本无需手动传入) const task = options.mode === "event" ? { ...options, sourceScriptUuid: ctx.scriptRes?.uuid || "" } : { ...options }; @@ -50,14 +61,12 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "list" } as AgentTaskApiRequest]) as Promise; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "get", id } as AgentTaskApiRequest]) as Promise< AgentTask | undefined >; @@ -66,8 +75,7 @@ export default class CATAgentTaskApi { // task 必须携带 get()/list() 返回的 generation/revision(乐观并发版本号), // 否则服务端无法区分"修改的是当前这个任务"还是"ID 被删除重建后的另一个任务" @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.update"(id: string, task: Partial): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.update"(ctx: GMBaseContext, id: string, task: Partial): Promise { if (task.generation === undefined || task.revision === undefined) { throw new Error( "CAT.agent.task.update: task must include the generation/revision returned by CAT.agent.task.get() or list() — spread the fetched task before applying changes." @@ -79,8 +87,11 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.remove"(id: string, task: Pick): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.remove"( + ctx: GMBaseContext, + id: string, + task: Pick + ): Promise { if (task?.generation === undefined || task?.revision === undefined) { throw new Error( "CAT.agent.task.remove: task must include the generation/revision returned by CAT.agent.task.get() or list()." @@ -92,16 +103,18 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.runNow"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.runNow"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "runNow", id } as AgentTaskApiRequest]) as Promise; } // 监听任务触发事件 // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.addListener"( + ctx: GMBaseContext, + taskId: string, + callback: (trigger: AgentTaskTrigger) => void + ): number { if (!ctx.EE) return 0; const listenerId = ++listenerCounter; @@ -112,20 +125,21 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + getListenerRecords(ctx).set(listenerId, { id: listenerId, eventName, callback: wrappedCallback }); return listenerId; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.removeListener"(listenerId: number): void { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.removeListener"(ctx: GMBaseContext, listenerId: number): void { if (!ctx.EE) return; - const entry = listenerMap.get(listenerId); + const records = getListenerRecords(ctx); + const entry = records.get(listenerId); if (entry) { + // 记录事件名和包装回调后可直接移除,不必扫描所有任务监听器。 + records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); - listenerMap.delete(listenerId); } } } diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index cf926aed6..47fc63e33 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -3,11 +3,12 @@ import ExecScript from "../exec_script"; import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; import type { GMInfoEnv, ScriptFunc } from "../types"; import { compileScript, compileScriptCode } from "../utils"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { ScriptRunResource } from "@App/app/repo/scripts"; import GMApi from "./gm_api"; +import { parseSerializedDocumentResponse } from "./gm_xhr"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -32,6 +33,133 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +describe("early-start page RPC", () => { + it("waits for the page binding before opening a long-lived connection", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const connection = {} as MessageConnect; + const connectMessage = vi.fn().mockResolvedValue(connection); + const script = { + ...scriptRes, + uuid: "early-start-script", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi("scripting", { connect: connectMessage } as unknown as Message, {} as Message, script); + Object.defineProperty(api, "loadScriptPromise", { configurable: true, value: ready, writable: true }); + + const pending = api.connect("GM_xmlhttpRequest", []); + expect(connectMessage).not.toHaveBeenCalled(); + + release(); + await expect(pending).resolves.toBe(connection); + expect(connectMessage).toHaveBeenCalledWith({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ + api: "GM_xmlhttpRequest", + handle: "page-binding", + version: 1, + requestId: expect.any(String), + }), + }); + }); + + it("uses the authoritative run flag for early-start async value acknowledgments", async () => { + const script = { + ...scriptRes, + uuid: "early-start-value-script", + metadata: { grant: ["GM.setValue"], "early-start": [""], "run-at": ["document-start"] }, + executionHandle: undefined, + executionEnvTag: undefined, + executionRunFlag: undefined, + } as ScriptLoadInfo; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: { sendMessage: mockSendMessage } as unknown as Message, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + + exec.scriptFunc = function (_token: string, context: any) { + return context.GM.setValue("a", 123); + } as unknown as ScriptFunc; + const result = exec.exec(); + await Promise.resolve(); + expect(mockSendMessage).not.toHaveBeenCalled(); + + exec.updateEarlyScriptGMInfo(envInfo, { + ...script, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "canonical-run", + }); + await Promise.resolve(); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + + const request = mockSendMessage.mock.calls[0][0].data; + exec.valueUpdate({ + id: request.params[0], + entries: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "canonical-run", tabId: -2 }, + valueUpdated: true, + }); + + await expect(result).resolves.toBeUndefined(); + }); +}); + +describe("CAT_fetchDocument", () => { + it("rebuilds documents from a data-only response instead of a relatedTarget reference", async () => { + const script = Object.assign({}, scriptRes, { + executionEnvTag: "it", + metadata: { grant: ["CAT_fetchDocument"] }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ + code: 0, + data: { + text: '
ok
', + contentType: "text/html", + }, + }); + const api = new GMApi("scripting", { sendMessage } as unknown as Message, {} as Message, script); + + const document = await api.CAT_fetchDocument(api, "https://example.test/document"); + + expect(document?.querySelector("main")?.getAttribute("data-source")).toBe("serialized"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ api: "CAT_fetchDocument", params: ["https://example.test/document", false] }), + }) + ); + }); + + it("does not execute accessors in a forged serialized response", () => { + const getter = vi.fn(() => "secret"); + const data = { contentType: "text/html" } as Record; + Object.defineProperty(data, "text", { configurable: true, enumerable: true, get: getter }); + + expect(parseSerializedDocumentResponse(data)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + + const proxy = new Proxy( + { text: "", contentType: "text/html" }, + { + getOwnPropertyDescriptor: () => { + throw new Error("proxy trap"); + }, + } + ); + expect(parseSerializedDocumentResponse(proxy)).toBeUndefined(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, @@ -59,10 +187,10 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const api = new GMApi("test", {} as Message, {} as Message, script); - expect(api.GM_getResourceText(name)).toBe("declared resource"); - expect(api.GM_getResourceURL(name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); - expect(await api["GM.getResourceText"](name)).toBe("declared resource"); - expect(await api["GM.getResourceUrl"](name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(api.GM_getResourceText(api, name)).toBe("declared resource"); + expect(api.GM_getResourceURL(api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(await api["GM.getResourceText"](api, name)).toBe("declared resource"); + expect(await api["GM.getResourceUrl"](api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); const legacyScript = { ...script, @@ -71,7 +199,7 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript); - expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource"); + expect(legacyApi.GM_getResourceText(legacyApi, name)).toBe("legacy resource"); }); }); @@ -117,23 +245,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret.GM_getValue?.name).toEqual("bound GM_getValue"); + expect(ret.GM_getValue?.name).toEqual("GM_getValue"); // getTab / getTabs / saveTab - expect(ret.GM_getTab?.name).toEqual("bound GM_getTab"); - expect(ret.GM_getTabs?.name).toEqual("bound GM_getTabs"); - expect(ret.GM_saveTab?.name).toEqual("bound GM_saveTab"); + expect(ret.GM_getTab?.name).toEqual("GM_getTab"); + expect(ret.GM_getTabs?.name).toEqual("GM_getTabs"); + expect(ret.GM_saveTab?.name).toEqual("GM_saveTab"); // cookie - expect(ret.GM_cookie?.name).toEqual("bound GM_cookie"); - expect(ret["GM_cookie.list"]?.name).toEqual("bound GM_cookie.list"); + expect(ret.GM_cookie?.name).toEqual("GM_cookie"); + expect(ret["GM_cookie.list"]?.name).toEqual("GM_cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -179,23 +307,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret["GM.getValue"]?.name).toEqual("bound GM.getValue"); + expect(ret["GM.getValue"]?.name).toEqual("GM.getValue"); // getTab / getTabs / saveTab - expect(ret["GM.getTab"]?.name).toEqual("bound GM.getTab"); - expect(ret["GM.getTabs"]?.name).toEqual("bound GM.getTabs"); - expect(ret["GM.saveTab"]?.name).toEqual("bound GM.saveTab"); + expect(ret["GM.getTab"]?.name).toEqual("GM.getTab"); + expect(ret["GM.getTabs"]?.name).toEqual("GM.getTabs"); + expect(ret["GM.saveTab"]?.name).toEqual("GM.saveTab"); // cookie - expect(ret["GM.cookie"]?.name).toEqual("bound GM.cookie"); - expect(ret["GM.cookie"]?.list?.name).toEqual("bound GM.cookie.list"); + expect(ret["GM.cookie"]?.name).toEqual("GM.cookie"); + expect(ret["GM.cookie"]?.list?.name).toEqual("GM.cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -470,6 +598,39 @@ describe.concurrent("GM_menu", () => { expect(await retPromise).toEqual(123); }); + it.concurrent("注册菜单不会执行选项 getter", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_registerMenuCommand"]; + script.code = ` + let getterCalls = 0; + const options = { accessKey: "s" }; + Object.defineProperty(options, "secret", { enumerable: true, get() { getterCalls += 1; return "forged"; } }); + GM_registerMenuCommand("safe", () => {}, options); + return getterCalls; + `; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { sendMessage: mockSendMessage } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script)); + + await expect(exec.exec()).resolves.toBe(0); + expect(mockSendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + api: "GM_registerMenuCommand", + params: [expect.any(String), "safe", expect.objectContaining({ accessKey: "s" })], + }), + }) + ); + expect(mockSendMessage.mock.calls[0][0].data.params[2]).not.toHaveProperty("secret"); + }); + it.concurrent("取消注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"]; @@ -591,6 +752,47 @@ describe.concurrent("GM_menu", () => { }); describe.concurrent("GM_value", () => { + it("stores __proto__ as a value key instead of changing the value store prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + expect(Object.prototype.hasOwnProperty.call(script.value, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(script.value)).toBe(Object.prototype); + expect(api.GM_getValue(api, "__proto__")).toEqual(stored); + expect(api.GM_getValue(api, "leaked")).toBeUndefined(); + }); + + it("returns __proto__ as an own key without changing the result prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_getValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + const selected = api.GM_getValues(api, ["__proto__"]); + const defaults = Object.create(null) as Record; + defaults.__proto__ = "fallback"; + const withDefaults = api.GM_getValues(api, defaults); + + expect(Object.getPrototypeOf(selected)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(selected, "__proto__")).toBe(true); + expect(selected.__proto__).toEqual(stored); + expect(Object.getPrototypeOf(withDefaults)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(withDefaults, "__proto__")).toBe(true); + expect(withDefaults.__proto__).toEqual(stored); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; @@ -659,7 +861,7 @@ describe.concurrent("GM_value", () => { action: "scripting/runtime/gmApi", data: { api: "GM_setValue", - params: [expect.any(String), "proxy-key", {}], // Proxy 会被转换为空对象 + params: [expect.any(String), "proxy-key"], // Proxy 无法通过 data-only clone,按删除处理 runFlag: expect.any(String), uuid: undefined, }, @@ -683,7 +885,7 @@ describe.concurrent("GM_value", () => { expect(ret).toEqual({ ret1: 123, ret2: 456, - ret3: {}, + ret3: undefined, ret4: undefined, }); }); @@ -846,6 +1048,107 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); + it("拒绝带 getter 的值,且不会在克隆时执行 getter", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = {} as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValue(api, "hostile", payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value.hostile).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "hostile"] }) }) + ); + }); + + it("GM_setValues skips accessor fields without invoking them", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = { valid: 1 } as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValues(api, payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value).toEqual({ valid: 1 }); + }); + + it("GM_setValues does not trust a hooked Array.prototype.push for transport", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, ["injected", encodeRValue("forged")]); + }; + + try { + api.GM_setValues(api, { valid: 1 }); + } finally { + Array.prototype.push = originalPush; + } + + expect(script.value).toEqual({ valid: 1 }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), [["valid", [0, 1]]]] }) }) + ); + }); + + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const executable = () => "secret"; + + api.GM_setValue(api, "executable", executable); + + expect(script.value.executable).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "executable"] }) }) + ); + }); + + it("拒绝 Symbol 值,避免把不可结构化克隆的数据写入本地存储", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, "symbol", Symbol("secret")); + + expect(script.value.symbol).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "symbol"] }) }) + ); + }); + + it("GM_setValues deletes existing falsy values when given undefined", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: { zero: 0, no: false, empty: "", nil: null }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValues(api, { zero: undefined, no: undefined, empty: undefined, nil: undefined }); + + expect(script.value).toEqual({}); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; @@ -939,7 +1242,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 // event id expect.stringMatching(/^.+::\d+$/), // the object payload - [["proxy-key", encodeRValue({})]], + [["proxy-key", encodeRValue(undefined)]], ], runFlag: expect.any(String), uuid: undefined, @@ -974,7 +1277,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(ret).toEqual({ ret1: { a: 123, b: 456, c: "789" }, ret2: { b: 456 }, - ret3: { "proxy-key": {} }, + ret3: { "proxy-key": undefined }, ret4: { window: undefined }, }); }); @@ -1137,6 +1440,9 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; + script.executionHandle = "page-binding"; + script.executionEnvTag = "it"; + script.executionRunFlag = "canonical-run"; script.code = ` return new Promise(resolve=>{ GM_addValueChangeListener("param1", (name, oldValue, newValue, remote)=>{ @@ -1165,7 +1471,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: script.executionRunFlag, tabId: -2 }, valueUpdated: true, }); const ret = await retPromise; @@ -1211,6 +1517,27 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); + + it.concurrent("value change listeners receive snapshots instead of the cached object", () => { + const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; + script.value = {}; + const api = new GMApi("test", {} as Message, {} as Message, script); + api.GM_addValueChangeListener(api, "snapshot", (_name, _oldValue, newValue) => { + const snapshot = newValue as { nested: { value: number } }; + snapshot.nested.value = 99; + }); + + api.valueUpdate({ + entries: [["snapshot", encodeRValue({ nested: { value: 1 } }), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "remote", tabId: -2 }, + valueUpdated: true, + }); + + expect(api.GM_getValue(api, "snapshot")).toEqual({ nested: { value: 1 } }); + }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM.getValue", "GM.setValue"]; @@ -1245,7 +1572,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["a", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: actualCall.data.runFlag, tabId: -2 }, valueUpdated: true, }); @@ -1254,6 +1581,45 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); +describe("GM_openInTab DTO", () => { + it("does not execute accessor options", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_openInTab"]; + const getter = vi.fn(() => "forged"); + const options = { active: true } as Record; + Object.defineProperty(options, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue(1); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_openInTab(api, "https://example.com", options as never); + + expect(getter).not.toHaveBeenCalled(); + const sentOptions = sendMessage.mock.calls[0][0].data.params[1]; + expect(sentOptions.active).toBe(true); + expect(Object.getOwnPropertyDescriptor(sentOptions, "secret")).toBeUndefined(); + }); +}); + +describe("GM_notification DTO", () => { + it("does not execute accessor details", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_notification"]; + const getter = vi.fn(() => "forged"); + const details = { text: "safe" } as Record; + Object.defineProperty(details, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue("notification-id"); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_notification(api, details as never); + await Promise.resolve(); + + expect(getter).not.toHaveBeenCalled(); + const sentDetails = sendMessage.mock.calls[0][0].data.params[0]; + expect(sentDetails.text).toBe("safe"); + expect(Object.getOwnPropertyDescriptor(sentDetails, "secret")).toBeUndefined(); + }); +}); + describe("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index d9c52c8e4..2e517987a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1,4 +1,4 @@ -import { customClone, Native } from "../global"; +import { customClone, nativeApply, Native } from "../global"; import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { @@ -9,9 +9,9 @@ import type { SWScriptMenuItemOption, TScriptMenuItemID, TScriptMenuItemKey, - MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; +import { uuidv4 } from "@App/pkg/utils/uuid"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -19,12 +19,13 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; +import { isExtensionBlobUrl } from "../page_rpc"; import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; -import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, parseSerializedDocumentResponse, toBlobURL } from "./gm_xhr"; // 导入 CAT Agent API 以触发装饰器注册 // 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 import CATAgentApi from "./cat_agent"; @@ -59,12 +60,47 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +const copyOwnEnumerableDataProperties = (value: object): Record => { + const result = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + result[key] = descriptor.value; + } + return result; +}; + +// 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 +const valueChangePromiseMap: Record void> = Object.create(null); + +const setOwnValue = (store: Record, key: string, value: any): void => { + Native.objectDefineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + +// 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 +const notificationTagMaps = new Native.WeakMap>(); + +const getNotificationTagMap = (owner: object): Map => { + let map = notificationTagMaps.get(owner); + if (!map) { + map = new Native.Map(); + notificationTagMaps.set(owner, map); + } + return map; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = new Set(); + execEnv.menuKeyRegistered = new Native.Set(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } @@ -115,7 +151,7 @@ class GM_Base implements IGM_Base { constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); - Object.assign(this, options); + Native.objectAssign(this, options); } @GMContext.protected() @@ -136,14 +172,37 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } + // USER_SCRIPT 自己的 realm 已有 DOM 与 fetch;这些辅助操作必须留在本地, + // 不能改走只有隔离 broker 才实现的内部 CAT service worker 请求。 + if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { + if (api === "CAT_fetchBlob") { + if (!isExtensionBlobUrl(params[0])) throw new Error("CAT_fetchBlob expects an extension blob URL"); + return fetch(params[0]).then((response) => response.blob()); + } + if (api === "CAT_createBlobUrl") { + if (typeof URL.createObjectURL !== "function") throw new Error("Blob URLs are unavailable in USER_SCRIPT"); + return URL.createObjectURL(params[0] as Blob); + } + } let ret; try { - ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + // 有页面句柄时走版本化 RPC;后台脚本和未迁移上下文继续使用旧请求形状。 + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, request); } catch (e: any) { if (`${e?.message || e}`.includes("Extension context invalidated.")) { this.setInvalidContext(); // 之后不再进行 sendMessage 跟 EE操作 @@ -157,14 +216,29 @@ class GM_Base implements IGM_Base { // 长连接使用,connect只用于接受消息,不发送消息 @GMContext.protected() - public connect(api: string, params: any[]) { + public async connect(api: string, params: any[]) { if (!this.message || !this.scriptRes) return new Promise(() => {}); - return connect(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + if (this.loadScriptPromise) { + await this.loadScriptPromise; + } + if (!this.message || !this.scriptRes) return new Promise(() => {}); + // 长连接也必须携带同一页面句柄,否则 broker 无法把连接绑定回脚本和文档。 + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @GMContext.protected() @@ -176,9 +250,9 @@ class GM_Base implements IGM_Base { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { - const fn = valueChangePromiseMap.get(id); + const fn = valueChangePromiseMap[id]; if (fn) { - valueChangePromiseMap.delete(id); + delete valueChangePromiseMap[id]; fn(); } } @@ -189,13 +263,16 @@ class GM_Base implements IGM_Base { const oldValue = decodeRValue(rTyped2); // 触发,并更新值 if (value === undefined) { - if (valueStore[key] !== undefined) { + if (Native.objectHasOwn(valueStore, key)) { delete valueStore[key]; } } else { - valueStore[key] = value; + setOwnValue(valueStore, key, value); } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 + const listenerValue = value && typeof value === "object" ? customClone(value) : value; + const listenerOldValue = oldValue && typeof oldValue === "object" ? customClone(oldValue) : oldValue; + this.valueChangeListener.execute(key, listenerOldValue, listenerValue, remote, sender.tabId); } } } @@ -204,17 +281,14 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; - this.EE.emit(`${event}:${eventId}`, data); + // 事件回调同样不能拿到 broker 内部对象的可变引用。 + const callbackData = data && typeof data === "object" ? customClone(data) : data; + this.EE.emit(`${event}:${eventId}`, callbackData); } } // GMApi 定义 外部用API函数。不使用@protected export default class GMApi extends GM_Base { - /** - * - */ - notificationTagMap?: Map; - constructor( public prefix: string, public message: Message, @@ -232,7 +306,6 @@ export default class GMApi extends GM_Base { scriptRes, valueChangeListener, EE, - notificationTagMap: new Map(), eventId: 0, setInvalidContext() { if (invalid) return; @@ -255,7 +328,7 @@ export default class GMApi extends GM_Base { static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; - const ret = a.scriptRes.value[key]; + const ret = Native.objectHasOwn(a.scriptRes.value, key) ? a.scriptRes.value[key] : undefined; if (ret !== undefined) { if (ret && typeof ret === "object") { return customClone(ret)!; @@ -267,15 +340,15 @@ export default class GMApi extends GM_Base { // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 @GMContext.API() - public GM_getValue(key: string, defaultValue?: any) { - return _GM_getValue(this, key, defaultValue); + public GM_getValue(ctx: GMApi, key: string, defaultValue?: any) { + return _GM_getValue(ctx, key, defaultValue); } @GMContext.API() - public "GM.getValue"(key: string, defaultValue?: any): Promise { + public "GM.getValue"(ctx: GMApi, key: string, defaultValue?: any): Promise { // 兼容GM.getValue return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); + const ret = _GM_getValue(ctx, key, defaultValue); resolve(ret); }); } @@ -290,18 +363,18 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } if (value === undefined) { delete a.scriptRes.value[key]; a.sendMessage("GM_setValue", [id, key]); } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { + // 对对象或函数值进行一次转化 + if (typeof value === "function" || typeof value === "symbol" || (value !== null && typeof value === "object")) { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + setOwnValue(a.scriptRes.value, key, value); if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -320,108 +393,122 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { + const valueEntries: [string, unknown][] = []; + const valueKeys = Native.reflectOwnKeys(values); + for (let index = 0; index < valueKeys.length; index += 1) { + const key = valueKeys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + valueEntries[valueEntries.length] = [key, descriptor.value]; + } + for (let index = 0; index < valueEntries.length; index += 1) { + const [key, value] = valueEntries[index]; let value_ = value; if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; } else { - // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { + // 对对象或函数值进行一次转化 + if ( + typeof value_ === "function" || + typeof value_ === "symbol" || + (value_ !== null && typeof value_ === "object") + ) { value_ = customClone(value_); } // customClone 可能返回 undefined - valueStore[key] = value_; + setOwnValue(valueStore, key, value_); } // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); + keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; } a.sendMessage("GM_setValues", [id, keyValuePairs]); return id; } @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); + public GM_setValue(ctx: GMApi, key: string, value: any) { + _GM_setValue(ctx, null, key, value); } @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { + public "GM.setValue"(ctx: GMApi, key: string, value: any): Promise { // Asynchronous wrapper for GM_setValue to support GM.setValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); + _GM_setValue(ctx, resolve, key, value); }); } @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); + public GM_deleteValue(ctx: GMApi, key: string): void { + _GM_setValue(ctx, null, key, undefined); } @GMContext.API() - public "GM.deleteValue"(key: string): Promise { + public "GM.deleteValue"(ctx: GMApi, key: string): Promise { // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); + _GM_setValue(ctx, resolve, key, undefined); }); } @GMContext.API() - public GM_listValues(): string[] { - if (!this.scriptRes) return []; - const keys = Object.keys(this.scriptRes.value); + public GM_listValues(ctx: GMApi): string[] { + if (!ctx.scriptRes) return []; + const keys = Native.objectKeys(ctx.scriptRes.value); return keys; } @GMContext.API() - public "GM.listValues"(): Promise { + public "GM.listValues"(ctx: GMApi): Promise { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); - const keys = Object.keys(this.scriptRes.value); + if (!ctx.scriptRes) return resolve([]); + const keys = Native.objectKeys(ctx.scriptRes.value); resolve(keys); }); } @GMContext.API() - public GM_setValues(values: TGMKeyValue) { + public GM_setValues(ctx: GMApi, values: TGMKeyValue) { if (!values || typeof values !== "object") { throw new Error("GM_setValues: values must be an object"); } - _GM_setValues(this, null, values); + _GM_setValues(ctx, null, values); } @GMContext.API() - public GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { - if (!this.scriptRes) return {}; + public GM_getValues(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined) { + if (!ctx.scriptRes) return {}; if (!keysOrDefaults) { // Returns all values - return customClone(this.scriptRes.value)!; + return customClone(ctx.scriptRes.value)!; } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { + const result: TGMKeyValue = Native.objectCreate(null); + if (Native.arrayIsArray(keysOrDefaults)) { // 键名数组 // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in this.scriptRes.value) { + if (Native.objectHasOwn(ctx.scriptRes.value, key)) { // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; + let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { value = customClone(value)!; } - result[key] = value; + setOwnValue(result, key, value); } } } else { // 对象 键: 默认值 // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) - for (const key of Object.keys(keysOrDefaults)) { + for (const key of Native.objectKeys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); + setOwnValue(result, key, _GM_getValue(ctx, key, defaultValue)); } } return result; @@ -429,29 +516,29 @@ export default class GMApi extends GM_Base { // Asynchronous wrapper for GM.getValues @GMContext.API({ depend: ["GM_getValues"] }) - public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.getValues"(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); + const ret = GMApi.prototype.GM_getValues(ctx, keysOrDefaults); resolve(ret); }); } @GMContext.API() - public "GM.setValues"(values: { [key: string]: any }): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.setValues"(ctx: GMApi, values: { [key: string]: any }): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { if (!values || typeof values !== "object") { throw new Error("GM.setValues: values must be an object"); } - _GM_setValues(this, resolve, values); + _GM_setValues(ctx, resolve, values); }); } @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; - if (!Array.isArray(keys)) { + public GM_deleteValues(ctx: GMApi, keys: string[]) { + if (!ctx.scriptRes) return; + if (!Native.arrayIsArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; } @@ -459,94 +546,111 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, null, req); + _GM_setValues(ctx, null, req); } // Asynchronous wrapper for GM.deleteValues @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); } else { const req = {} as Record; for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, resolve, req); + _GM_setValues(ctx, resolve, req); } }); } @GMContext.API() - public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { - if (!this.valueChangeListener) return 0; - return this.valueChangeListener.add(name, listener); + public GM_addValueChangeListener(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): number { + if (!ctx.valueChangeListener) return 0; + return ctx.valueChangeListener.add(name, listener); } @GMContext.API({ depend: ["GM_addValueChangeListener"] }) - public "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { + public "GM.addValueChangeListener"(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): Promise { return new Promise((resolve) => { - const ret = this.GM_addValueChangeListener(name, listener); + const ret = GMApi.prototype.GM_addValueChangeListener(ctx, name, listener); resolve(ret); }); } @GMContext.API() - public GM_removeValueChangeListener(listenerId: number): void { - if (!this.valueChangeListener) return; - this.valueChangeListener.remove(listenerId); + public GM_removeValueChangeListener(ctx: GMApi, listenerId: number): void { + if (!ctx.valueChangeListener) return; + ctx.valueChangeListener.remove(listenerId); } @GMContext.API({ depend: ["GM_removeValueChangeListener"] }) - public "GM.removeValueChangeListener"(listenerId: number): Promise { + public "GM.removeValueChangeListener"(ctx: GMApi, listenerId: number): Promise { return new Promise((resolve) => { - this.GM_removeValueChangeListener(listenerId); + GMApi.prototype.GM_removeValueChangeListener(ctx, listenerId); resolve(); }); } @GMContext.API() - public GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { - if (this.isInvalidContext()) return; + public GM_log( + ctx: GMApi, + message: string, + level: GMTypes.LoggerLevel = "info", + ...labels: GMTypes.LoggerLabel[] + ): void { + if (ctx.isInvalidContext()) return; if (typeof message !== "string") { message = Native.jsonStringify(message); } - this.sendMessage("GM_log", [`${message}`, `${level}`, labels]); + ctx.sendMessage("GM_log", [`${message}`, `${level}`, labels]); } @GMContext.API({ depend: ["GM_log"] }) public "GM.log"( + ctx: GMApi, message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[] ): Promise { return new Promise((resolve) => { - this.GM_log(message, level, ...labels); + GMApi.prototype.GM_log(ctx, message, level, ...labels); resolve(); }); } @GMContext.API() - public CAT_createBlobUrl(blob: Blob): Promise { - return Promise.resolve(toBlobURL(this, blob)); + public CAT_createBlobUrl(ctx: GMApi, blob: Blob): Promise { + return Promise.resolve(toBlobURL(ctx, blob)); } // 辅助GM_xml获取blob数据 @GMContext.API() - public CAT_fetchBlob(url: string): Promise { - return this.sendMessage("CAT_fetchBlob", [`${url}`]); + public CAT_fetchBlob(ctx: GMApi, url: string): Promise { + return ctx.sendMessage("CAT_fetchBlob", [`${url}`]); } @GMContext.API() - public async CAT_fetchDocument(url: string): Promise { + public async CAT_fetchDocument(ctx: GMApi, url: string): Promise { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 - if (this.isInvalidContext()) return undefined; + if (ctx.isInvalidContext()) return undefined; + + const isContentEnv = ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content; + if (isContentEnv) { + // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", url); + xhr.onloadend = () => resolve((xhr.response as Document | null) || undefined); + xhr.onerror = () => resolve(undefined); + xhr.send(); + }); + } - const message = this.message as CustomEventMessage | null; - const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; - return urlToDocumentInContentPage(this, url, isContentEnv); + return parseSerializedDocumentResponse(await ctx.sendMessage("CAT_fetchDocument", [`${url}`, isContentEnv])); } static _GM_cookie( @@ -583,36 +687,36 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public "GM.cookie"(action: string, details: GMTypes.CookieDetails) { + public "GM.cookie"(ctx: GMApi, action: string, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, action, details, (cookie, error) => { + _GM_cookie(ctx, action, details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.set"(details: GMTypes.CookieDetails) { + public "GM.cookie.set"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "set", details, (cookie, error) => { + _GM_cookie(ctx, "set", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.list"(details: GMTypes.CookieDetails) { + public "GM.cookie.list"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "list", details, (cookie, error) => { + _GM_cookie(ctx, "list", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.delete"(details: GMTypes.CookieDetails) { + public "GM.cookie.delete"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "delete", details, (cookie, error) => { + _GM_cookie(ctx, "delete", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); @@ -620,35 +724,39 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.set"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "set", details, done); + _GM_cookie(ctx, "set", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.list"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "list", details, done); + _GM_cookie(ctx, "list", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.delete"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "delete", details, done); + _GM_cookie(ctx, "delete", details, done); } @GMContext.API() public GM_cookie( + ctx: GMApi, action: string, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, action, details, done); + _GM_cookie(ctx, action, details, done); } // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 @@ -670,32 +778,43 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_registerMenuCommand( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): TScriptMenuItemID { - if (!this.EE) return -1; - execEnvInit(this); - this.regMenuCounter! += 1; + if (!ctx.EE) return -1; + execEnvInit(ctx); + ctx.regMenuCounter! += 1; // 兼容 GM_registerMenuCommand(name, options_or_accessKey) if (!options_or_accessKey && typeof listener === "object") { options_or_accessKey = listener; listener = undefined; } // 浅拷贝避免修改/共用参数 - const options: SWScriptMenuItemOption = ( - typeof options_or_accessKey === "string" - ? { accessKey: options_or_accessKey } - : options_or_accessKey - ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) - : {} - ) as ScriptMenuItemOption; + const optionObject = typeof options_or_accessKey === "object" && options_or_accessKey !== null; + let options: SWScriptMenuItemOption; + let optionId: string | number | undefined; + let optionIndividual: boolean | undefined; + if (typeof options_or_accessKey === "string") { + options = { accessKey: options_or_accessKey }; + } else if (optionObject) { + const safeOptions = copyOwnEnumerableDataProperties(options_or_accessKey as object); + optionId = safeOptions.id as string | number | undefined; + optionIndividual = safeOptions.individual as boolean | undefined; + // id不直接储存在options (id 影响 groupKey 操作) + safeOptions.id = undefined; + safeOptions.individual = undefined; + options = safeOptions as SWScriptMenuItemOption; + } else { + options = {}; + } const isSeparator = !listener && !name; - let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; + let isIndividual = optionObject ? optionIndividual : undefined; if (isIndividual === undefined && isSeparator) { isIndividual = true; } - options.mIndividualKey = isIndividual ? this.regMenuCounter : 0; + options.mIndividualKey = isIndividual ? ctx.regMenuCounter : 0; if (options.autoClose === undefined) { options.autoClose = true; } @@ -710,54 +829,57 @@ export default class GMApi extends GM_Base { } else { options.mSeparator = false; } - let providedId: string | number | undefined = - typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + let providedId: string | number | undefined = optionObject ? optionId : undefined; + if (providedId === undefined) providedId = ctx.menuIdCounter! += 1; // 如无指定,使用累计器id const ret = providedId! as TScriptMenuItemID; providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 - providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + providedId = `${ctx.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} // 检查之前有否注册 - if (menuKey && this.menuKeyRegistered!.has(menuKey)) { + if (menuKey && ctx.menuKeyRegistered!.has(menuKey)) { // 有注册过,先移除 listeners - this.EE.removeAllListeners("menuClick:" + menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); } else { // 没注册过,先记录一下 - this.menuKeyRegistered!.add(menuKey); + ctx.menuKeyRegistered!.add(menuKey); } if (listener) { // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 - this.EE.addListener("menuClick:" + menuKey, listener); + ctx.EE.addListener("menuClick:" + menuKey, listener); } // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) - this.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); + ctx.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); return ret; } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) public "GM.registerMenuCommand"( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): Promise { return new Promise((resolve) => { - const ret = this.GM_registerMenuCommand(name, listener, options_or_accessKey); + const ret = GMApi.prototype.GM_registerMenuCommand(ctx, name, listener, options_or_accessKey); resolve(ret); }); } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { - return this.GM_registerMenuCommand(...args); + public CAT_registerMenuInput( + ctx: GMApi, + ...args: [name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string] + ): TScriptMenuItemID { + return GMApi.prototype.GM_registerMenuCommand(ctx, ...args); } @GMContext.API() - public GM_addStyle(css: string): Element | undefined { - if (!this.message || !this.scriptRes) return; + public GM_addStyle(ctx: GMApi, css: string): Element | undefined { + if (!ctx.message || !ctx.scriptRes) return; if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); // 与content页的消息通讯实际是同步,此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [ @@ -772,24 +894,25 @@ export default class GMApi extends GM_Base { if (resp.code) { throw new Error(resp.message); } - return (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + return (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; } @GMContext.API({ depend: ["GM_addStyle"] }) - public "GM.addStyle"(css: string): Promise { + public "GM.addStyle"(ctx: GMApi, css: string): Promise { return new Promise((resolve) => { - const ret = this.GM_addStyle(css); + const ret = GMApi.prototype.GM_addStyle(ctx, css); resolve(ret); }); } @GMContext.API() public GM_addElement( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Element | undefined { - if (!this.message || !this.scriptRes) return; + if (!ctx.message || !ctx.scriptRes) return; // 与content页的消息通讯实际是同步, 此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise // 在content脚本执行的话,与直接 DOM 无异 @@ -799,7 +922,7 @@ export default class GMApi extends GM_Base { let parentNodeId: number | null; if (typeof parentNode !== "string") { - const id = (this.contentMsg).sendRelatedTarget(parentNode); + const id = (ctx.contentMsg).sendRelatedTarget(parentNode); parentNodeId = id; } else { parentNodeId = null; @@ -813,22 +936,30 @@ export default class GMApi extends GM_Base { } // 控制传送参数,避免参数出现 non-json-selizable - const attrsCT = {} as Record; - const setAttr = {} as Record; - for (const [key, value] of Object.entries(attrs as Record)) { - if (typeof value === "string" || typeof value === "number") { - // 数字不是标准的 attribute value type, 但常见于实际使用 - attrsCT[key] = value; - } else { - // property setter for non attribute (e.g. Function, Symbol, boolean, etc) - // Function, Symbol 无法跨环境传递 - setAttr[key] = value; + const attrsCT = Native.objectCreate(null) as Record; + const setAttr = Native.objectCreate(null) as Record; + if (attrs !== null) { + const keys = Native.reflectOwnKeys(attrs); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(attrs, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + const value = descriptor.value; + if (typeof value === "string" || typeof value === "number") { + // 数字不是标准的 attribute value type, 但常见于实际使用 + attrsCT[key] = value; + } else { + // property setter for non attribute (e.g. Function, Symbol, boolean, etc) + // Function, Symbol 无法跨环境传递 + setAttr[key] = value; + } } } // 使用contentMsg同步发送消息到content脚本,由content脚本创建元素并返回 // 不使用message,因为message是在scripting环境处理的,会因为扩展的 CSP 而无法操作 DOM - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [parentNodeId, tagName, attrsCT], @@ -838,7 +969,7 @@ export default class GMApi extends GM_Base { throw new Error(resp.message); } - const el = (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + const el = (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; // 设置属性 for (const [key, value] of Object.entries(setAttr)) { (el as any)[key] = value; @@ -850,34 +981,35 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_addElement"] }) public "GM.addElement"( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Promise { return new Promise((resolve) => { - const ret = this.GM_addElement(parentNode, tagName, attrs); + const ret = GMApi.prototype.GM_addElement(ctx, parentNode, tagName, attrs); resolve(ret); }); } @GMContext.API() - public GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { - if (!this.EE) return; - if (!this.contentEnvKey) { + public GM_unregisterMenuCommand(ctx: GMApi, menuId: TScriptMenuItemID): void { + if (!ctx.EE) return; + if (!ctx.contentEnvKey) { return; } let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 - menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - this.menuKeyRegistered!.delete(menuKey); - this.EE.removeAllListeners("menuClick:" + menuKey); + menuKey = `${ctx.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + ctx.menuKeyRegistered!.delete(menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); // 发送至 service worker 处理(唯一键) - this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); + ctx.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); } @GMContext.API({ depend: ["GM_unregisterMenuCommand"] }) - public "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { + public "GM.unregisterMenuCommand"(ctx: GMApi, menuId: TScriptMenuItemID): Promise { return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); resolve(); }); } @@ -885,21 +1017,21 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_unregisterMenuCommand"], }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); + public CAT_unregisterMenuInput(ctx: GMApi, menuId: TScriptMenuItemID): void { + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); } @GMContext.API() - public CAT_userConfig() { - return this.sendMessage("CAT_userConfig", []); + public CAT_userConfig(ctx: GMApi) { + return ctx.sendMessage("CAT_userConfig", []); } @GMContext.API({ depend: ["CAT_fetchBlob"], }) - public async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { + public async CAT_fileStorage(ctx: GMApi, action: "list" | "download" | "upload" | "delete" | "config", details: any) { if (action === "config") { - this.sendMessage("CAT_fileStorage", ["config"]); + ctx.sendMessage("CAT_fileStorage", ["config"]); return; } const sendDetails: CATType.CATFileStorageDetails = { @@ -909,44 +1041,42 @@ export default class GMApi extends GM_Base { file: details.file, }; if (action === "upload") { - const url = await toBlobURL(this, details.data); + const url = await toBlobURL(ctx, details.data); sendDetails.data = url; } - this.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then( - async (resp: { action: string; data: any }) => { - switch (resp.action) { - case "onload": { - if (action === "download") { - // 读取blob - const blob = await this.CAT_fetchBlob(resp.data); - details.onload && details.onload(blob); - } else { - details.onload && details.onload(resp.data); - } - break; + ctx.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then(async (resp: { action: string; data: any }) => { + switch (resp.action) { + case "onload": { + if (action === "download") { + // 读取blob + const blob = await GMApi.prototype.CAT_fetchBlob(ctx, resp.data); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); } - case "error": { - if (typeof resp.data.code === "undefined") { - details.onerror && details.onerror({ code: -1, message: resp.data.message }); - return; - } - details.onerror && details.onerror(resp.data); + break; + } + case "error": { + if (typeof resp.data.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: resp.data.message }); + return; } + details.onerror && details.onerror(resp.data); } } - ); + }); } // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = GM_xmlhttpRequest(this, details, false); + public GM_xmlhttpRequest(ctx: GMApi, details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(ctx, details, false); return { abort }; } @GMContext.API() - public "GM.xmlHttpRequest"(details: GMTypes.XHRDetails): Promise & GMRequestHandle { - const { retPromise, abort } = GM_xmlhttpRequest(this, details, true); + public "GM.xmlHttpRequest"(ctx: GMApi, details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(ctx, details, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1220,16 +1350,16 @@ export default class GMApi extends GM_Base { // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public GM_download(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { abort } = _GM_download(this, details as GMTypes.DownloadDetails, false); + const { abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, false); return { abort }; } @GMContext.API() - public "GM.download"(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public "GM.download"(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { retPromise, abort } = _GM_download(this, details as GMTypes.DownloadDetails, true); + const { retPromise, abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1243,7 +1373,7 @@ export default class GMApi extends GM_Base { onclick?: GMTypes.NotificationOnClick ): Promise { if (gmApi.isInvalidContext()) return Promise.resolve(); - const notificationTagMap: Map = gmApi.notificationTagMap || (gmApi.notificationTagMap = new Map()); + const notificationTagMap = getNotificationTagMap(gmApi); gmApi.eventId += 1; let data: GMTypes.NotificationDetails; if (typeof detail === "string") { @@ -1263,7 +1393,7 @@ export default class GMApi extends GM_Base { break; } } else { - data = Object.assign({}, detail); + data = copyOwnEnumerableDataProperties(detail) as GMTypes.NotificationDetails; data.ondone = data.ondone || ondone; } let click: GMTypes.NotificationOnClick; @@ -1288,7 +1418,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - create.apply({ id }, [id]); + nativeApply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1325,8 +1455,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); + click && nativeApply(click, { id }, [clickEvent]); + done && nativeApply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1339,7 +1469,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && done.apply({ id }, [resp.params.byUser]); + done && nativeApply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; @@ -1357,44 +1487,46 @@ export default class GMApi extends GM_Base { @GMContext.API() public async "GM.notification"( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): Promise { - return _GM_notification(this, detail, ondone, image, onclick); + return _GM_notification(ctx, detail, ondone, image, onclick); } @GMContext.API() public GM_notification( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): void { - _GM_notification(this, detail, ondone, image, onclick); + _GM_notification(ctx, detail, ondone, image, onclick); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeNotification" }) - public GM_closeNotification(id: string): void { - this.sendMessage("GM_closeNotification", [`${id}`]); + public GM_closeNotification(ctx: GMApi, id: string): void { + ctx.sendMessage("GM_closeNotification", [`${id}`]); } // ScriptCat 额外API @GMContext.API({ alias: "GM.updateNotification" }) - public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { - this.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); + public GM_updateNotification(ctx: GMApi, id: string, details: GMTypes.NotificationDetails): void { + ctx.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); } @GMContext.API({ depend: ["GM_closeInTab"] }) - public GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { - if (this.isInvalidContext()) return undefined; + public GM_openInTab(ctx: GMApi, url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { + if (ctx.isInvalidContext()) return undefined; let option = {} as GMTypes.OpenTabOptions; if (typeof param === "boolean") { option.active = !param; // Greasemonkey 3.x loadInBackground } else if (param) { - option = { ...param } as GMTypes.OpenTabOptions; + option = copyOwnEnumerableDataProperties(param) as GMTypes.OpenTabOptions; } if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { // TM 同时兼容 active 和 loadInBackground ( active 优先 ) @@ -1413,19 +1545,19 @@ export default class GMApi extends GM_Base { const ret: GMTypes.Tab = { close: () => { - tabid && this.GM_closeInTab(tabid); + tabid && GMApi.prototype.GM_closeInTab(ctx, tabid); }, closed: false, // 占位 onclose() {}, }; - this.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { - if (!this.EE) return; + ctx.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { + if (!ctx.EE) return; if (id) { tabid = id; - this.EE.addListener("GM_openInTab:" + id, (resp: any) => { - if (!this.EE) return; + ctx.EE.addListener("GM_openInTab:" + id, (resp: any) => { + if (!ctx.EE) return; switch (resp.event) { case "oncreate": tabid = resp.tabId; @@ -1433,7 +1565,7 @@ export default class GMApi extends GM_Base { case "onclose": ret.onclose && ret.onclose(); ret.closed = true; - this.EE.removeAllListeners("GM_openInTab:" + id); + ctx.EE.removeAllListeners("GM_openInTab:" + id); break; default: LoggerCore.logger().warn("GM_openInTab resp is error", { @@ -1452,74 +1584,78 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + public "GM.openInTab"( + ctx: GMApi, + url: string, + param?: GMTypes.OpenTabOptions | boolean + ): Promise { return new Promise((resolve) => { - const ret = this.GM_openInTab(url, param); + const ret = GMApi.prototype.GM_openInTab(ctx, url, param); resolve(ret); }); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeInTab" }) - public GM_closeInTab(tabid: string) { - if (this.isInvalidContext()) return; - return this.sendMessage("GM_closeInTab", [tabid]); + public GM_closeInTab(ctx: GMApi, tabid: string) { + if (ctx.isInvalidContext()) return; + return ctx.sendMessage("GM_closeInTab", [tabid]); } @GMContext.API() - public GM_getTab(callback: (tabData: object) => void) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTab", []).then((tabData) => { + public GM_getTab(ctx: GMApi, callback: (tabData: object) => void) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTab", []).then((tabData) => { callback(tabData ?? {}); }); } @GMContext.API({ depend: ["GM_getTab"] }) - public "GM.getTab"(): Promise { + public "GM.getTab"(ctx: GMApi): Promise { return new Promise((resolve) => { - this.GM_getTab((data) => { + GMApi.prototype.GM_getTab(ctx, (data) => { resolve(data); }); }); } @GMContext.API() - public GM_saveTab(tabData: object): void { - if (this.isInvalidContext()) return; + public GM_saveTab(ctx: GMApi, tabData: object): void { + if (ctx.isInvalidContext()) return; if (typeof tabData === "object") { tabData = customClone(tabData); } - this.sendMessage("GM_saveTab", [tabData]); + ctx.sendMessage("GM_saveTab", [tabData]); } @GMContext.API({ depend: ["GM_saveTab"] }) - public "GM.saveTab"(tabData: object): Promise { + public "GM.saveTab"(ctx: GMApi, tabData: object): Promise { return new Promise((resolve) => { - this.GM_saveTab(tabData); + GMApi.prototype.GM_saveTab(ctx, tabData); resolve(); }); } @GMContext.API() - public GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTabs", []).then((tabsData) => { + public GM_getTabs(ctx: GMApi, callback: (tabsData: { [key: number]: object }) => any) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTabs", []).then((tabsData) => { callback(tabsData); }); } @GMContext.API({ depend: ["GM_getTabs"] }) - public "GM.getTabs"(): Promise<{ [key: number]: object }> { + public "GM.getTabs"(ctx: GMApi): Promise<{ [key: number]: object }> { return new Promise<{ [key: number]: object }>((resolve) => { - this.GM_getTabs((tabsData) => { + GMApi.prototype.GM_getTabs(ctx, (tabsData) => { resolve(tabsData); }); }); } @GMContext.API() - public GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { - if (this.isInvalidContext()) return; + public GM_setClipboard(ctx: GMApi, data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { + if (ctx.isInvalidContext()) return; // 物件参数意义不明。日后再检视特殊处理 // 未支持 TM4.19+ application/octet-stream // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 @@ -1532,7 +1668,8 @@ export default class GMApi extends GM_Base { else if (mimetype === "html") mimetype = "text/html"; } data = `${data}`; // 强制 string type - this.sendMessage("GM_setClipboard", [data, mimetype]) + ctx + .sendMessage("GM_setClipboard", [data, mimetype]) .then(() => { if (typeof cb === "function") { cb(); @@ -1546,18 +1683,22 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_setClipboard"] }) - public "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { - if (this.isInvalidContext()) return new Promise(() => {}); + public "GM.setClipboard"( + ctx: GMApi, + data: string, + info?: string | { type?: string; mimetype?: string } + ): Promise { + if (ctx.isInvalidContext()) return new Promise(() => {}); return new Promise((resolve) => { - this.GM_setClipboard(data, info, () => { + GMApi.prototype.GM_setClipboard(ctx, data, info, () => { resolve(); }); }); } @GMContext.API() - public GM_getResourceText(name: string): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceText(ctx: GMApi, name: string): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { return r.content; } @@ -1565,17 +1706,17 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { + public "GM.getResourceText"(ctx: GMApi, name: string): Promise { // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText return new Promise((resolve) => { - const ret = this.GM_getResourceText(name); + const ret = GMApi.prototype.GM_getResourceText(ctx, name); resolve(ret); }); } @GMContext.API() - public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceURL(ctx: GMApi, name: string, isBlobUrl?: boolean): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { let base64 = r.base64; if (!base64) { @@ -1591,39 +1732,39 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceURL"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceUrl"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } @GMContext.API() - public "window.close"() { - return this.sendMessage("window.close", []); + public "window.close"(ctx: GMApi) { + return ctx.sendMessage("window.close", []); } @GMContext.API() - public "window.focus"() { - return this.sendMessage("window.focus", []); + public "window.focus"(ctx: GMApi) { + return ctx.sendMessage("window.focus", []); } @GMContext.protected() apiLoadPromise: Promise | undefined; @GMContext.API() - public CAT_scriptLoaded() { - return this.loadScriptPromise; + public CAT_scriptLoaded(ctx: GMApi) { + return ctx.loadScriptPromise; } } diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index f127a99b8..d8ebc4a40 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,6 +1,14 @@ import type { ApiParam, ApiValue } from "../types"; +import { Native } from "../global"; -const apis: Map = new Map(); +const apiRegistry: Record = Native.objectCreate(null); +const apis = { + get: (name: string) => apiRegistry[name], + set: (name: string, values: ApiValue[]) => { + apiRegistry[name] = values; + }, + keys: () => Native.objectKeys(apiRegistry), +}; export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 @@ -17,10 +25,10 @@ function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); if (!m) apis.set(grant, (m = [])); - m.push({ fnKey, api, param }); + m[m.length] = { fnKey, api, param }; } -export const protect: { [key: string]: any } = {}; +export const protect: { [key: string]: any } = Native.objectCreate(null); export default class GMContext { public static protected(value: any = undefined) { diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts new file mode 100644 index 000000000..16b095d12 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { GM_xmlhttpRequest } from "./gm_xhr"; + +initTestEnv(); + +describe("GM_xmlhttpRequest callback cleanup", () => { + it("settles and disconnects when an error callback throws", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const onload = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: () => { + throw new Error("user callback failed"); + }, + onload, + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + onMessage({ + code: 0, + action: "onload", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onload", + ok: false, + contentType: "text/plain", + }, + }); + onMessage({ + code: 0, + action: "onloadend", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onloadend", + ok: false, + contentType: "text/plain", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onload).not.toHaveBeenCalled(); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("synthesizes loadend when an error has no terminal broker event", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: vi.fn(), + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("aborts and releases the connection even without an onabort callback", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(connection.onMessage).toHaveBeenCalled()); + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("honors abort requested before the native connection is ready", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("settles the request when connection setup rejects", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(new Error("connection failed")), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("connection failed"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("settles the request when data encoding rejects before connection setup", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn(), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + data: Promise.reject(new Error("data failed")) as unknown as GMTypes.XHRDetails["data"], + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("data failed"); + expect(api.connect).not.toHaveBeenCalled(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("disconnects an established connection when listener setup throws", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connection = { + onMessage: vi.fn(() => { + throw new Error("listener setup failed"); + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("listener setup failed"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("does not execute accessor headers while preparing the request", async () => { + const getter = vi.fn(() => "forged"); + const headers = {} as Record; + Object.defineProperty(headers, "X-Hostile", { enumerable: true, configurable: true, get: getter }); + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest(api as any, { url: "https://example.com/data", headers }, false); + + await vi.waitFor(() => expect(api.connect).toHaveBeenCalled()); + expect(getter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(api.connect.mock.calls[0][1][0].headers, "X-Hostile")).toBeUndefined(); + request.abort(); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..42a2ce6e7 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,11 +1,11 @@ import { Native } from "../global"; -import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type GMApi from "./gm_api"; import { dataEncode } from "@App/pkg/utils/xhr/xhr_data"; import type { MessageConnect, TMessage } from "@Packages/message/types"; import { base64ToUint8, concatUint8 } from "@App/pkg/utils/datatype"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; const ChunkResponseCode = { NONE: 0, @@ -112,10 +112,33 @@ export const convObjectToURL = async (object: string | URL | Blob | File | undef return url; }; -export const urlToDocumentInContentPage = async (a: GMApi, url: string, isContent: boolean) => { - // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) - const nodeId = await a.sendMessage("CAT_fetchDocument", [`${url}`, isContent]); - return (a.message).getAndDelRelatedTarget(nodeId) as Document; +export type SerializedDocumentResponse = { + text: string; + contentType: string; +}; + +const readDataProperty = (value: object, key: string): unknown => { + try { + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +}; + +export const parseSerializedDocumentResponse = (value: unknown): Document | undefined => { + if (value === null || typeof value !== "object") return undefined; + const text = readDataProperty(value, "text"); + const contentType = readDataProperty(value, "contentType"); + if (typeof text !== "string" || typeof contentType !== "string") return undefined; + + const mime = getMimeType(contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + try { + return new DOMParser().parseFromString(text, parseType); + } catch { + return undefined; + } }; const getMimeType = (contentType: string) => { @@ -126,9 +149,25 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); - -const retStateFnMap = new WeakMap, RetStateFnRecord>(); +const docParseTypes = new Native.Set([ + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); + +const retStateFnMap = new Native.WeakMap(); + +const invokeXHRCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { + if (!callback) return; + try { + callback(value); + } catch (error) { + // 用户回调异常只记录,不得拒绝内部消息队列或打断请求收尾。 + LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); + } +}; interface RetStateFnRecord { getResponseText(): string | undefined; @@ -172,14 +211,15 @@ export function GM_xmlhttpRequest( isDownload: boolean = false ) { let reqDone = false; + let abortRequested = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, abort: () => {}, }; } - let retPromiseResolve: (value: unknown) => void | undefined; - let retPromiseReject: (reason?: any) => void | undefined; + let retPromiseResolve: ((value: unknown) => void) | undefined; + let retPromiseReject: ((reason?: any) => void) | undefined; const retPromise = requirePromise ? new Promise((resolve, reject) => { retPromiseResolve = resolve; @@ -189,11 +229,20 @@ export function GM_xmlhttpRequest( const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; const dataPromise = dataEncode(details.data); const headers = details.headers; + let requestHeaders: Record | undefined; + let requestCookie = details.cookie; if (headers) { - for (const key of Object.keys(headers)) { + requestHeaders = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(headers); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(headers, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; if (key.toLowerCase() === "cookie") { - details.cookie = headers[key]; - delete headers[key]; + requestCookie = descriptor.value as string; + } else { + requestHeaders[key] = descriptor.value as string; } } } @@ -206,8 +255,8 @@ export function GM_xmlhttpRequest( method: details.method, timeout: details.timeout, url: "", - headers: details.headers, - cookie: details.cookie, + headers: requestHeaders, + cookie: requestCookie, responseType: details.responseType, overrideMimeType: details.overrideMimeType, anonymous: details.anonymous, @@ -260,19 +309,30 @@ export function GM_xmlhttpRequest( } } // 发送信息 - let connectMessage: Promise; - if (isDownload) { - // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download - // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 - const method = param.method === "POST" ? "POST" : "GET"; - const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; - connectMessage = a.connect("GM_download", [downloadParam]); - } else { - // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest - connectMessage = a.connect("GM_xmlhttpRequest", [param]); + try { + let connectMessage: Promise; + if (isDownload) { + // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download + // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 + const method = param.method === "POST" ? "POST" : "GET"; + const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; + connectMessage = a.connect("GM_download", [downloadParam]); + } else { + // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest + connectMessage = a.connect("GM_xmlhttpRequest", [param]); + } + param = null; // GC + connect = await connectMessage; + } catch (error) { + param = null; + const message = error instanceof Error ? error.message : `${error}`; + reqDone = true; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + return; } - param = null; // GC - connect = await connectMessage; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC @@ -501,6 +561,7 @@ export function GM_xmlhttpRequest( }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; + let loadCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -509,24 +570,35 @@ export function GM_xmlhttpRequest( finalResultBuffers = null; finalResultText = null; const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrResponse); if (errorOccur === null) { retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); } refCleanup?.(); + invokeXHRCallback("onloadend", details.onloadend, xhrResponse); } }; + const scheduleSyntheticLoadEnd = () => { + // abort/error/timeout 可能没有 broker 的 onloadend,补发一次以释放连接和引用。 + Promise.resolve({ + error: "loadend", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg).then(doLoadEnd); + }; doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + // 先标记完成再调用用户代码;回调抛错也不能留下未收尾的连接。 + invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 - Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } doAbort = null; }; @@ -557,22 +629,16 @@ export function GM_xmlhttpRequest( }); if (!reqDone) { errorOccur = message; - details.onerror?.({ + reqDone = true; + invokeXHRCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); - reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理 - Promise.resolve({ - error: "loadend", - responseHeaders: "", - readyState: 0, - status: 0, - statusText: "", - } as TXhrCallBackArg).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } return; } @@ -646,14 +712,16 @@ export function GM_xmlhttpRequest( break; } case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + if (loadCalled || reqDone) break; + loadCalled = true; + invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -665,7 +733,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - details.onprogress?.(res); + invokeXHRCallback("onprogress", details.onprogress, res); } break; } @@ -678,14 +746,15 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + invokeXHRCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } @@ -694,10 +763,14 @@ export function GM_xmlhttpRequest( if (!reqDone) { data.error ||= "Unknown Error"; errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; - // 不要进行 refCleanup !要等待最后的 onloadend - // refCleanup?.(); + invokeXHRCallback( + "onerror", + details.onerror, + (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError + ); + // 错误消息可能没有对应的 onloadend,补发一次以收尾并释放连接。 + scheduleSyntheticLoadEnd(); } break; case "onabort": @@ -716,16 +789,37 @@ export function GM_xmlhttpRequest( }; connect?.onMessage((msgData) => onMessageHandler?.(msgData)); - })(); + if (abortRequested && !reqDone) { + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg); + } + })().catch((error) => { + const pendingConnection = connect; + connect = null; + pendingConnection?.disconnect(true); + if (reqDone) return; + reqDone = true; + const message = error instanceof Error ? error.message : `${error}`; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + }); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, abort: () => { + abortRequested = true; if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + if (doAbort && !reqDone) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ diff --git a/src/app/service/content/gm_api/grant.ts b/src/app/service/content/gm_api/grant.ts index ab1e91c39..579e03112 100644 --- a/src/app/service/content/gm_api/grant.ts +++ b/src/app/service/content/gm_api/grant.ts @@ -1,9 +1,13 @@ +const nativeReflectApply = Reflect.apply; +const nativeStringStartsWith = String.prototype.startsWith; +const nativeStringSlice = String.prototype.slice; + export function getGrantCandidates(grant: string): string[] { - if (grant.startsWith("GM.")) { - return [grant, `GM_${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM."])) { + return [grant, `GM_${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } - if (grant.startsWith("GM_")) { - return [grant, `GM.${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM_"])) { + return [grant, `GM.${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } return [grant]; } diff --git a/src/app/service/content/gm_api/navigation_handle.test.ts b/src/app/service/content/gm_api/navigation_handle.test.ts index 29d7dfbdd..5058be5d6 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -94,6 +94,23 @@ describe("attachNavigateHandler", () => { expect(ev.url).toBe("https://example.com/new"); }); + it("dispatchEvent 的 bind 屬性被改寫時仍能派發事件", async () => { + const mock = createMockWin("https://example.com/"); + Object.defineProperty(mock.win.dispatchEvent, "bind", { + configurable: true, + value: () => { + throw new Error("poisoned bind"); + }, + }); + + attachNavigateHandler(mock.win); + mock.fireNavigate("https://example.com/new"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + }); + it("URL 未变化时不应派发事件", async () => { const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); diff --git a/src/app/service/content/gm_api/navigation_handle.ts b/src/app/service/content/gm_api/navigation_handle.ts index a536f6a70..d05beebe5 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -19,7 +19,7 @@ const getPropGetter = (obj: T, key: keyof T) => { // 避免直接 obj[key] 读取。或会被 hack for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) { const pd = Native.objectGetOwnPropertyDescriptor(t, key); - if (pd) return pd.get?.bind(obj); + if (pd) return pd.get ? Native.bind(pd.get, obj) : undefined; } }; @@ -33,7 +33,7 @@ export const attachNavigateHandler = (win: Window & { navigation: EventTarget }) // 以 location.href 判断避免 replaceState/pushState 重复执行重复触发 const loc = win.location; const getUrl = getPropGetter(loc, "href"); - const dispatch = win.dispatchEvent.bind(win); + const dispatch = Native.bind(win.dispatchEvent, win); let lastUrl = getUrl?.(); let callSeq = 0; const handler = async (ev: Event): Promise => { diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index 722111552..b3368382a 100644 --- a/src/app/service/content/gm_api/related_target_lifecycle.test.ts +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ScriptEnvTag } from "@Packages/message/consts"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; @@ -50,19 +50,19 @@ describe("relatedTarget lifecycle across content runtime callers", () => { const parent = document.createElement("section"); try { - const style = api.GM_addStyle("body { color: red; }"); + const style = api.GM_addStyle(api, "body { color: red; }"); expect(style?.tagName).toBe("STYLE"); expect(style?.textContent).toBe("body { color: red; }"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const child = api.GM_addElement(parent, "span", { id: "child" }); + const child = api.GM_addElement(api, parent, "span", { id: "child" }); expect(child?.parentNode).toBe(parent); expect(child?.id).toBe("child"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const root = api.GM_addElement("div", { id: "root" }); + const root = api.GM_addElement(api, "div", { id: "root" }); expect(root?.tagName).toBe("DIV"); expect(root?.id).toBe("root"); expect(sender.relatedTarget).toHaveProperty("size", 0); @@ -72,4 +72,21 @@ describe("relatedTarget lifecycle across content runtime callers", () => { receiver.relatedTarget.clear(); } }); + + it("skips accessor attributes without executing their getter", () => { + const { api, sender, receiver } = createApiWithContentRuntime(); + const getter = vi.fn(() => "forged"); + const attrs = { id: "safe" } as Record; + Object.defineProperty(attrs, "secret", { enumerable: true, configurable: true, get: getter }); + + try { + const element = api.GM_addElement(api, "div", attrs); + expect(element?.id).toBe("safe"); + expect(element).not.toHaveProperty("secret"); + expect(getter).not.toHaveBeenCalled(); + } finally { + sender.relatedTarget.clear(); + receiver.relatedTarget.clear(); + } + }); }); diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index cc4de0f9e..ef9aa23ed 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -2,46 +2,44 @@ // 删除会较慢但执行会较快 export class ListenerManager void> { private counterId = 0; - private readonly listeners = new Map>(); + private readonly listeners: Array<{ key: string; id: number; handler: T }> = []; public add(key: string, handler: T): number { const id = ++this.counterId; - let listenrMap = this.listeners.get(key); - if (!listenrMap) { - this.listeners.set(key, (listenrMap = new Map())); - } - listenrMap.set(id, handler); + this.listeners[this.listeners.length] = { key, id, handler }; return id; } public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { - const handlers = this.listeners.get(key); - if (handlers) { - for (const handler of handlers.values()) { - handler?.(key, ...args); + // handler 可能在执行期间移除自身;按当前下标复查 id,避免跳过紧邻监听器。 + for (let i = 0; i < this.listeners.length; ) { + const listener = this.listeners[i]; + if (listener?.key !== key) { + i += 1; + continue; } + const listenerId = listener.id; + listener.handler?.(key, ...args); + if (this.listeners[i]?.id === listenerId) i += 1; } } public remove(id: number | string): boolean { const idNum = +id || 0; if (idNum > 0) { - for (const [key, handlers] of this.listeners) { - if (handlers.delete(idNum)) { - if (handlers.size === 0) { - this.listeners.delete(key); - } - return true; + for (let i = 0; i < this.listeners.length; i += 1) { + if (this.listeners[i]?.id !== idNum) continue; + for (let j = i + 1; j < this.listeners.length; j += 1) { + this.listeners[j - 1] = this.listeners[j]; } + this.listeners.length -= 1; + return true; } } return false; } public clear(): void { - for (const [_key, handlers] of this.listeners) { - handlers.clear(); - } - this.listeners.clear(); + this.listeners.length = 0; } } diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts new file mode 100644 index 000000000..53a948ae2 --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMainWorldPageLoadGate } from "./main_world_page_load_gate"; + +describe("createMainWorldPageLoadGate", () => { + it("does not deliver the page-visible payload while the native channel is opening", async () => { + let resolveNative!: (connected: boolean) => void; + const openNativeChannel = vi.fn( + () => + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onPageLoad({ source: "page" }); + gate.onBootstrap("bootstrap-token"); + + expect(openNativeChannel).toHaveBeenCalledWith("bootstrap-token"); + expect(receivePageLoad).not.toHaveBeenCalled(); + + resolveNative(true); + await Promise.resolve(); + expect(receivePageLoad).not.toHaveBeenCalled(); + + gate.onPageLoad({ source: "replay" }); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); + + it("releases one queued payload only when native transport is unavailable", async () => { + const receivePageLoad = vi.fn(); + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad, requestFallbackPageLoad); + const first = { source: "page" }; + const second = { source: "page-after-fallback" }; + + gate.onPageLoad(first); + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(receivePageLoad).toHaveBeenCalledWith(first); + expect(requestFallbackPageLoad).toHaveBeenCalledOnce(); + + gate.onPageLoad(second); + expect(receivePageLoad).toHaveBeenLastCalledWith(second); + expect(receivePageLoad).toHaveBeenCalledTimes(2); + }); + + it("does not request a page-visible fallback after native transport succeeds", async () => { + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => true, vi.fn(), requestFallbackPageLoad); + + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(requestFallbackPageLoad).not.toHaveBeenCalled(); + }); + + it("does not reopen or fall back after the native channel has been selected", async () => { + const receivePageLoad = vi.fn(); + const openNativeChannel = vi.fn(async () => true); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onBootstrap("first-token"); + await Promise.resolve(); + gate.onBootstrap("second-token"); + gate.onPageLoad({ source: "replay" }); + + expect(openNativeChannel).toHaveBeenCalledOnce(); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts new file mode 100644 index 000000000..35652ab7e --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -0,0 +1,44 @@ +type MainWorldPageLoadGateState = "waiting" | "opening" | "native" | "fallback"; + +export type MainWorldPageLoadGate = { + onBootstrap: (bootstrapToken: string) => void; + onPageLoad: (data: unknown) => void; +}; + +export const createMainWorldPageLoadGate = ( + openNativeChannel: (bootstrapToken: string) => Promise, + receivePageLoad: (data: unknown) => void, + requestFallbackPageLoad: () => void = () => undefined +): MainWorldPageLoadGate => { + let state: MainWorldPageLoadGateState = "waiting"; + let pendingPageLoad: unknown; + let hasPendingPageLoad = false; + + const finishOpening = (connected: boolean): void => { + if (state !== "opening") return; + state = connected ? "native" : "fallback"; + if (state === "fallback") requestFallbackPageLoad(); + if (state === "fallback" && hasPendingPageLoad) { + receivePageLoad(pendingPageLoad); + } + pendingPageLoad = undefined; + hasPendingPageLoad = false; + }; + + return { + onBootstrap(bootstrapToken) { + if (state !== "waiting") return; + state = "opening"; + void openNativeChannel(bootstrapToken).then(finishOpening, () => finishOpening(false)); + }, + onPageLoad(data) { + if (state === "fallback") { + receivePageLoad(data); + return; + } + if (state === "native") return; + pendingPageLoad = data; + hasPendingPageLoad = true; + }, + }; +}; diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts new file mode 100644 index 000000000..40cb5c58e --- /dev/null +++ b/src/app/service/content/page_rpc.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it, vi } from "vitest"; +import { Blob as NodeBlob } from "node:buffer"; +import { + getPageRpcAllowedAPIs, + setPageRpcExtensionOrigin, + isExtensionBlobUrl, + PageRpcError, + PageRpcRegistry, + validatePageGMRequest, +} from "./page_rpc"; + +describe("page GM RPC", () => { + it("expands only the helper operations reachable from an explicit public grant", () => { + const allowed = getPageRpcAllowedAPIs(["CAT.agent.opfs", "GM_xmlhttpRequest"]); + + expect(allowed).toEqual( + expect.arrayContaining([ + "CAT.agent.opfs", + "CAT_agentOPFS", + "CAT_fetchBlob", + "GM_xmlhttpRequest", + "GM.xmlhttpRequest", + ]) + ); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + expect(allowed).not.toContain("CAT_agentSkills"); + }); + + it("includes APIs exposed through the same dependency graph as the script context", () => { + const allowed = getPageRpcAllowedAPIs(["GM.openInTab"]); + + expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); + }); + + it("includes storage APIs used by delete wrappers", () => { + expect(getPageRpcAllowedAPIs(["GM_deleteValue"])).toEqual( + expect.arrayContaining(["GM_deleteValue", "GM_setValue"]) + ); + expect(getPageRpcAllowedAPIs(["GM.deleteValues"])).toEqual( + expect.arrayContaining(["GM.deleteValues", "GM_setValues"]) + ); + }); + + it("includes the nested cookie methods exposed by both cookie grant spellings", () => { + expect(getPageRpcAllowedAPIs(["GM.cookie"])).toEqual( + expect.arrayContaining(["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"]) + ); + expect(getPageRpcAllowedAPIs(["GM_cookie"])).toEqual( + expect.arrayContaining(["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"]) + ); + }); + + it("does not create a GM capability set for a none grant", () => { + expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); + }); + + it("honors a none grant when Array.prototype.some is hooked", () => { + const originalSome = Array.prototype.some; + Array.prototype.some = (() => false) as typeof Array.prototype.some; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["none", "GM_getValue"]); + } finally { + Array.prototype.some = originalSome; + } + + expect(allowed!).toEqual([]); + }); + + it("does not let a hooked String.prototype.slice enlarge grant aliases", () => { + const originalSlice = String.prototype.slice; + String.prototype.slice = (() => "xmlhttpRequest") as typeof String.prototype.slice; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM.getValue"]); + } finally { + String.prototype.slice = originalSlice; + } + + expect(allowed!).toContain("GM_getValue"); + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("ignores inherited capability-map properties for unknown grant names", () => { + expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); + }); + + it("does not let a hooked Array.prototype.push enlarge the capability result", () => { + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, "GM_xmlhttpRequest"); + }; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM_getValue"]); + } finally { + Array.prototype.push = originalPush; + } + + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { + const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); + + expect(allowed).toContain("GM_xmlhttpRequest"); + expect(allowed).not.toContain("CAT_fetchBlob"); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + }); + + it("rejects direct internal fetch helpers from a GM XHR binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", getPageRpcAllowedAPIs(["GM_xmlhttpRequest"])); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "fetch", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ) + ).toThrow("API is not granted"); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "document", + handle, + api: "CAT_fetchDocument", + params: ["https://example.com/file", false], + }, + registry + ) + ).toThrow("API is not granted"); + }); + + it("accepts a request for the active execution binding and clones parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); + const params = { nested: { value: 1 } }; + + const request = validatePageGMRequest( + { + version: 1, + requestId: "request-a", + handle, + api: "GM_getValue", + params: [params], + }, + registry + ); + + expect(request).toEqual({ + version: 1, + requestId: "request-a", + handle, + api: "GM_getValue", + params: [params], + uuid: "script-a", + envTag: "it", + runFlag: "canonical-run", + }); + expect(request.params[0]).not.toBe(params); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-a", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); + }); + + it("rejects an unknown or stale execution binding and supplies canonical identity", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "a", + handle: "missing", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + + registry.revoke(handle); + expect(() => + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow(PageRpcError); + + const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect( + validatePageGMRequest( + { version: 1, requestId: "c", handle: activeHandle, api: "GM_getValue", params: [] }, + registry + ) + ).toMatchObject({ + uuid: "script-a", + envTag: "it", + }); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "d", handle: activeHandle, uuid: "script-b", api: "GM_getValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects APIs outside the binding and packets with accessors or unsupported values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const accessorRequest = { + version: 1, + requestId: "a", + handle, + api: "GM_getValue", + params: [], + }; + Object.defineProperty(accessorRequest, "api", { get: () => "GM_getValue" }); + + expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); + expect(() => + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_setValue", params: [] }, registry) + ).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle, + api: "GM_getValue", + params: [() => undefined], + }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects accessors nested in collection RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "collection", handle, api: "GM_getValue", params: [new Map([["nested", nested]])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "set", handle, api: "GM_getValue", params: [new Set([nested])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("does not execute a Symbol.toStringTag accessor while validating RPC values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "Blob"); + const nested = Object.create(null) as Record; + Object.defineProperty(nested, Symbol.toStringTag, { configurable: true, get: getter }); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "tag", handle, api: "GM_getValue", params: [nested] }, registry) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("keeps validation on captured intrinsics after page prototype hooks", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const ownKeysSpy = vi.spyOn(Reflect, "ownKeys").mockImplementation(() => { + throw new Error("page hook"); + }); + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor").mockImplementation(() => { + throw new Error("page hook"); + }); + + let result: ReturnType | undefined; + try { + result = validatePageGMRequest( + { version: 1, requestId: "hooked", handle, api: "GM_getValue", params: [] }, + registry + ); + } finally { + ownKeysSpy.mockRestore(); + descriptorSpy.mockRestore(); + } + expect(result).toMatchObject({ uuid: "script-a", envTag: "it" }); + }); + + it("rejects malformed parameters for privileged helper operations", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) + ).toThrow("CAT_fetchBlob expects an extension blob URL"); + + expect(isExtensionBlobUrl("https://example.com/file")).toBe(false); + const extensionBlobUrl = `blob:${chrome.runtime.getURL("/").replace(/\/$/, "")}/internal`; + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + + expect( + validatePageGMRequest( + { version: 1, requestId: "b", handle, api: "CAT_fetchBlob", params: [extensionBlobUrl] }, + registry + ).params + ).toEqual([extensionBlobUrl]); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); + }); + + it("requires a Blob for CAT_createBlobUrl after parameter cloning", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_createBlobUrl"]); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "object", handle, api: "CAT_createBlobUrl", params: [{}] }, + registry + ) + ).toThrow("CAT_createBlobUrl expects one Blob value"); + + const blob = new NodeBlob(["payload"], { type: "text/plain" }); + expect(Object.prototype.toString.call(blob)).toBe("[object Blob]"); + expect(Object.prototype.toString.call(structuredClone(blob))).toBe("[object Blob]"); + const request = validatePageGMRequest( + { version: 1, requestId: "blob", handle, api: "CAT_createBlobUrl", params: [blob] }, + registry + ); + expect(Object.prototype.toString.call(request.params[0])).toBe("[object Blob]"); + expect(request.params[0]).not.toBe(blob); + }); + + it("validates extension blobs in USER_SCRIPT when runtime.getURL is unavailable", () => { + const runtime = chrome.runtime as unknown as { getURL?: typeof chrome.runtime.getURL }; + const getURL = runtime.getURL; + const extensionBlobUrl = `blob:chrome-extension://${chrome.runtime.id}/internal`; + try { + runtime.getURL = undefined; + setPageRpcExtensionOrigin({ protocol: "chrome-extension:", hostname: chrome.runtime.id, port: "" }); + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + } finally { + runtime.getURL = getURL; + setPageRpcExtensionOrigin(undefined); + } + }); + + it("accepts a unique request when replay state is full while still rejecting replay", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const binding = registry.resolve(handle, "GM_getValue"); + + // 请求 ID 必须严格只消费一次,与集合已保存的条目数量无关。 + // 直接模拟满集合,避免 CI 为构造状态发送数千个请求。 + Object.defineProperty(binding.requestIds, "size", { configurable: true, value: 4096 }); + binding.requestIds.add("request-0"); + + expect( + validatePageGMRequest({ version: 1, requestId: "request-4097", handle, api: "GM_getValue", params: [] }, registry) + ).toMatchObject({ requestId: "request-4097" }); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); + }); +}); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts new file mode 100644 index 000000000..f17eec44b --- /dev/null +++ b/src/app/service/content/page_rpc.ts @@ -0,0 +1,439 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { ScriptEnvTag } from "@Packages/message/consts"; +import { getGrantCandidates } from "./gm_api/grant"; +import { Native, nativeReflectApply } from "./global"; + +export const PAGE_RPC_VERSION = 1 as const; +const MAX_REQUEST_ID_LENGTH = 256; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeObjectToString = Object.prototype.toString; +const nativeMapForEach = Map.prototype.forEach; +const nativeSetForEach = Set.prototype.forEach; +const EXTENSION_PROTOCOLS = new Native.Set(["chrome-extension:", "moz-extension:"]); +const nativeReflectOwnKeys = Native.reflectOwnKeys; +const nativeObjectGetOwnPropertyDescriptor = Native.objectGetOwnPropertyDescriptor; +const nativeArrayIsArray = Array.isArray; +const nativeURL = URL; +const nativeBlob = typeof Blob === "function" ? Blob : undefined; +const nativeStringSlice = String.prototype.slice; + +export type ExtensionOrigin = Pick; + +export const getExtensionOrigin = (): ExtensionOrigin | undefined => { + if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; + try { + const url = new nativeURL(chrome.runtime.getURL("/")); + if (!EXTENSION_PROTOCOLS.has(url.protocol) || !url.hostname) return undefined; + return { protocol: url.protocol, hostname: url.hostname, port: url.port }; + } catch { + // Ignore malformed runtime metadata and reject the URL below. + } + return undefined; +}; + +// USER_SCRIPT 的 blob URL 必须回指当前扩展 origin,origin 由隔离 context 提供并缓存。 +let configuredExtensionOrigin: ExtensionOrigin | undefined; + +export const setPageRpcExtensionOrigin = (value: unknown): void => { + if (value === null || typeof value !== "object") { + configuredExtensionOrigin = undefined; + return; + } + try { + const read = (key: keyof ExtensionOrigin): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + }; + const protocol = read("protocol"); + const hostname = read("hostname"); + const port = read("port"); + if ( + (protocol !== "chrome-extension:" && protocol !== "moz-extension:") || + typeof hostname !== "string" || + hostname.length === 0 || + typeof port !== "string" + ) { + configuredExtensionOrigin = undefined; + return; + } + configuredExtensionOrigin = { protocol, hostname, port }; + } catch { + configuredExtensionOrigin = undefined; + } +}; + +export const isExtensionBlobUrl = (value: unknown): value is string => { + if (typeof value !== "string") return false; + const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); + if (!extensionOrigin) return false; + try { + const url = new nativeURL(value); + if (url.protocol !== "blob:") return false; + const creatorOrigin = new nativeURL(nativeReflectApply(nativeStringSlice, value, ["blob:".length])); + return ( + creatorOrigin.protocol === extensionOrigin.protocol && + creatorOrigin.hostname === extensionOrigin.hostname && + creatorOrigin.port === extensionOrigin.port + ); + } catch { + return false; + } +}; + +export type PageExecutionBinding = { + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly allowedAPIs: ReadonlySet; + readonly runFlag: string; + active: boolean; + requestIds: Set; +}; + +export type PageGMRequest = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; + /** Canonical identity filled by the isolated broker after handle resolution. */ + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly runFlag: string; +}; + +/** MAIN world 脚本可提交的不可信数据包。 */ +export type PageGMRequestPacket = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; +}; + +const INTERNAL_APIS_BY_GRANT: Readonly> = { + "CAT.agent.conversation": ["CAT_agentConversation", "CAT_agentConversationChat", "CAT_agentAttachToConversation"], + "CAT.agent.dom": ["CAT_agentDom"], + "CAT.agent.model": ["CAT_agentModel"], + "CAT.agent.opfs": ["CAT_agentOPFS", "CAT_fetchBlob"], + "CAT.agent.skills": ["CAT_agentSkills"], + "CAT.agent.task": ["CAT_agentTask"], + CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], +}; + +// ScriptingRuntime 不加载 GM 实现模块,因此在此镜像一份精简依赖图。 +const API_DEPENDENCIES: Readonly> = { + "GM.getValues": ["GM_getValues"], + "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], + GM_cookie: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"], + "GM.setValue": ["GM_setValue"], + "GM.setValues": ["GM_setValues"], + "GM.listValues": ["GM_listValues"], + "GM.download": ["GM_download"], + "GM.notification": ["GM_notification"], + "GM.addValueChangeListener": ["GM_addValueChangeListener"], + "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], + "GM.log": ["GM_log"], + "GM.deleteValue": ["GM_setValue"], + GM_deleteValue: ["GM_setValue"], + "GM.deleteValues": ["GM_setValues"], + GM_deleteValues: ["GM_setValues"], + "GM.registerMenuCommand": ["GM_registerMenuCommand"], + CAT_registerMenuInput: ["GM_registerMenuCommand"], + "GM.addStyle": ["GM_addStyle"], + "GM.addElement": ["GM_addElement"], + "GM.unregisterMenuCommand": ["GM_unregisterMenuCommand"], + CAT_unregisterMenuInput: ["GM_unregisterMenuCommand"], + CAT_fileStorage: ["CAT_fetchBlob"], + "GM.openInTab": ["GM_openInTab", "GM_closeInTab"], + "GM.getTab": ["GM_getTab"], + "GM.saveTab": ["GM_saveTab"], + "GM.getTabs": ["GM_getTabs"], + "GM.setClipboard": ["GM_setClipboard"], + "GM.getResourceText": ["GM_getResourceText"], + "GM.getResourceURL": ["GM_getResourceURL"], + "GM.getResourceUrl": ["GM_getResourceURL"], +}; + +export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + for (let index = 0; index < grants.length; index += 1) { + if (grants[index] === "none") return []; + } + const allowed = new Native.Set(); + const visited = new Native.Set(); + const visitGrant = (grant: string): void => { + const candidates = getGrantCandidates(grant); + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (visited.has(candidate)) continue; + visited.add(candidate); + allowed.add(candidate); + if (Native.objectHasOwn(INTERNAL_APIS_BY_GRANT, candidate)) { + const internalAPIs = INTERNAL_APIS_BY_GRANT[candidate]; + for (let index = 0; index < internalAPIs.length; index += 1) allowed.add(internalAPIs[index]); + } + if (Native.objectHasOwn(API_DEPENDENCIES, candidate)) { + const dependencies = API_DEPENDENCIES[candidate]; + for (let index = 0; index < dependencies.length; index += 1) visitGrant(dependencies[index]); + } + } + }; + for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); + const result: string[] = []; + allowed.forEach((value) => { + result[result.length] = value; + }); + return result; +}; + +export class PageRpcError extends Error { + constructor(message: string) { + super(message); + this.name = "PageRpcError"; + } +} + +const ownData = (value: object, key: PropertyKey): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); + } + return descriptor.value; +}; + +const isBlobLike = (value: object): boolean => { + let current: object | null = value; + while (current !== null) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(current, Symbol.toStringTag); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + if (descriptor) { + if (!("value" in descriptor)) throw new PageRpcError("page RPC values cannot contain accessor properties"); + return descriptor.value === "Blob"; + } + try { + current = Native.objectGetPrototypeOf(current); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + } + return false; +}; + +const assertDataOnly = (value: unknown, seen: Set): void => { + // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + // Blob 的内部槽由浏览器管理;只检查可由页面添加的字符串属性,忽略其内部 symbol 属性。 + if (nativeBlob && (value instanceof nativeBlob || isBlobLike(value))) { + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "string") assertDataOnly(ownData(value, key), seen); + } + return; + } + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + assertDataOnly(key, seen); + assertDataOnly(entry, seen); + }, + ]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Map,继续检查普通自有属性。 + } + try { + nativeReflectApply(nativeSetForEach, value as Set, [(entry: unknown) => assertDataOnly(entry, seen)]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Set,继续检查普通自有属性。 + } + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "symbol") throw new PageRpcError("page RPC values cannot contain symbol properties"); + const child = ownData(value, key); + assertDataOnly(child, seen); + } +}; + +const cloneParams = (params: unknown): readonly unknown[] => { + // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 + if (!nativeArrayIsArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new Native.Set()); + if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); + try { + return nativeStructuredClone(params) as readonly unknown[]; + } catch { + throw new PageRpcError("page RPC params are not cloneable"); + } +}; + +const validateOperationParams = (api: string, params: readonly unknown[]): void => { + switch (api) { + case "CAT_fetchBlob": + if (params.length !== 1 || !isExtensionBlobUrl(params[0])) { + throw new PageRpcError("CAT_fetchBlob expects an extension blob URL"); + } + return; + case "CAT_createBlobUrl": + if ( + params.length !== 1 || + !nativeBlob || + (!(params[0] instanceof nativeBlob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + ) { + throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); + } + return; + case "CAT_fetchDocument": + if (params.length !== 2 || typeof params[0] !== "string" || typeof params[1] !== "boolean") { + throw new PageRpcError("CAT_fetchDocument expects a URL and content flag"); + } + return; + case "CAT_agentOPFS": + if ( + params.length !== 1 || + params[0] === null || + typeof params[0] !== "object" || + nativeArrayIsArray(params[0]) || + typeof (params[0] as { action?: unknown }).action !== "string" + ) { + throw new PageRpcError("CAT_agentOPFS expects an operation object"); + } + return; + default: + return; + } +}; + +export class PageRpcRegistry { + private readonly bindings = new Native.Map(); + + register( + uuid: string, + envTag: ScriptEnvTag, + allowedAPIs: readonly string[], + handle = uuidv4(), + runFlag = uuidv4() + ): string { + if (!uuid || !handle || this.bindings.has(handle)) { + throw new PageRpcError("invalid page execution binding"); + } + this.bindings.set(handle, { + handle, + uuid, + envTag, + allowedAPIs: new Native.Set(allowedAPIs), + runFlag, + active: true, + requestIds: new Native.Set(), + }); + return handle; + } + + revoke(handle: string): void { + this.bindings.delete(handle); + } + + revokeAll(): void { + this.bindings.clear(); + } + + resolve(handle: string, api: string): PageExecutionBinding { + const binding = this.bindings.get(handle); + if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); + if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); + return binding; + } + + consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + // requestId 在每个绑定内只接受一次;绑定销毁时一并释放,避免重放而不截断长时间运行的脚本。 + if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); + binding.requestIds.add(requestId); + } +} + +const REQUEST_KEYS = ["version", "requestId", "handle", "api", "params"] as const; + +export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry): PageGMRequest => { + if (value === null || typeof value !== "object") throw new PageRpcError("page RPC request must be an object"); + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC request cannot be inspected"); + } + if (keys.length !== REQUEST_KEYS.length) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let knownKey = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < REQUEST_KEYS.length; expectedIndex += 1) { + const expected = REQUEST_KEYS[expectedIndex]; + if (expected === key) { + knownKey = true; + break; + } + } + } + if (!knownKey) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + } + + const version = ownData(value, "version"); + const requestId = ownData(value, "requestId"); + const handle = ownData(value, "handle"); + const api = ownData(value, "api"); + const params = ownData(value, "params"); + + if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); + if (typeof requestId !== "string" || !requestId || requestId.length > MAX_REQUEST_ID_LENGTH) { + throw new PageRpcError("page RPC requestId is invalid"); + } + if (typeof handle !== "string" || typeof api !== "string") { + throw new PageRpcError("page RPC identity fields are invalid"); + } + + const binding = registry.resolve(handle, api); + // resolve 同时执行句柄、授权和活跃状态检查;不要把页面传来的 api 直接转发给后端。 + const clonedParams = cloneParams(params); + validateOperationParams(api, clonedParams); + registry.consumeRequestId(binding, requestId); + return { + version: PAGE_RPC_VERSION, + requestId, + handle, + api, + params: clonedParams, + uuid: binding.uuid, + envTag: binding.envTag, + runFlag: binding.runFlag, + }; +}; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a4fe6c02a..65ecfc42a 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -2,10 +2,30 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; +import { + compilePreInjectScript, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, +} from "./utils"; +import { DefinedFlags } from "../service_worker/runtime.consts"; +import { pageDispatchEvent } from "@Packages/message/common"; const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + +beforeEach(() => { + if (!Object.prototype.hasOwnProperty.call(window, preInjectScriptDocumentIdKey)) { + Object.defineProperty(window, preInjectScriptDocumentIdKey, { + configurable: false, + writable: false, + value: "script-executor-test-document", + }); + } +}); function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { @@ -31,6 +51,322 @@ function makeScript(overrides: Partial { + it("uses the configured transport prefix for USER_SCRIPT GM calls", () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); + const script = makeScript({ metadata: { grant: ["GM_log"] } }); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message, "serviceWorker"); + + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: (_token: string, context: any) => context.GM_log("transport prefix"), + }); + + expect(sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/gmApi", + data: expect.objectContaining({ api: "GM_log" }), + }); + }); + + it("does not resolve page-patchable Map methods for execution bookkeeping", () => { + const originalSet = Map.prototype.set; + const originalGet = Map.prototype.get; + const originalValues = Map.prototype.values; + const receivers: Map[] = []; + Map.prototype.set = function (key, value) { + receivers.push(this); + return originalSet.call(this, key, value); + }; + Map.prototype.get = function (key) { + receivers.push(this); + return originalGet.call(this, key); + }; + Map.prototype.values = function () { + receivers.push(this); + return originalValues.call(this); + }; + try { + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: makeScript(), + scriptFlag: "executor-test-flag", + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + expect(receivers).toHaveLength(0); + } finally { + Map.prototype.set = originalSet; + Map.prototype.get = originalGet; + Map.prototype.values = originalValues; + } + }); + + it("attaches the page execution binding when an early-start script is reconciled", () => { + const initial = makeScript({ metadata: { "early-start": [""], "run-at": ["document-start"] } }); + const executor = new ScriptExecutor({} as Message, {} as Message); + + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const exec = ( + executor as unknown as { + execScripts: Map< + string, + { + scriptRes: TScriptInfo; + updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; + } + >; + } + ).execScripts.get(initial.uuid)!; + expect(exec.scriptRes.executionHandle).toBeUndefined(); + + exec.updateEarlyScriptGMInfo(initEnvInfo, { + ...initial, + value: { secret: "authoritative-value" }, + config: { + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }, + executionHandle: "page-binding", + executionEnvTag: "it", + }); + + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + expect(exec.scriptRes.config).toEqual({ + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }); + }); + + it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { + const script = makeScript({ flag: "executor-counterfeit-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attackerTarget = vi.fn(); + const attacker = new Proxy(attackerTarget, { + getOwnPropertyDescriptor(target, property) { + if (property === fnStrIntegrity) { + return { configurable: true, enumerable: false, value: true, writable: true }; + } + return Object.getOwnPropertyDescriptor(target, property); + }, + }); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attackerTarget).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects a counterfeit early-start wrapper before execution", () => { + const script = makeScript({ flag: "executor-counterfeit-early-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attacker = vi.fn(); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = attacker; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(attacker).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects early metadata that retargets the flag or carries a page binding", () => { + const script = makeScript({ flag: "#-executor-test-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const wrongUuid = vi.fn(); + const bound = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(wrongUuid, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, uuid: "other-script" }), + }); + Object.defineProperty(bound, fnStrIntegrity, { value: true }); + Object.defineProperty(bound, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, executionHandle: "other-binding" }), + }); + + try { + pageWindow[script.flag] = wrongUuid; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(wrongUuid).not.toHaveBeenCalled(); + + pageWindow[script.flag] = bound; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(bound).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects same-UUID early metadata mutations", () => { + const script = makeScript({ + uuid: "executor-early-authenticated-uuid", + flag: "#-executor-early-authenticated-uuid", + metadata: { grant: ["GM_getValue", "GM_getResourceText"], resource: ["canonical https://example.com/canonical"] }, + resource: { + canonical: { + url: "https://example.com/canonical", + content: "canonical", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const performance = { dispatchEvent: vi.fn(() => false), addEventListener: vi.fn() }; + const generated = new Function("window", "performance", "CustomEvent", compilePreInjectScript(script, "")); + + try { + generated(pageWindow, performance, CustomEvent); + const forged = { + ...script, + metadata: { grant: ["GM_setValue"] }, + resource: { forged: { content: "forged", contentType: "text/plain" } }, + } as TScriptInfo; + + executor.checkEarlyStartScript("it", initEnvInfo); + const hostileDetail = {}; + const flagGetter = vi.fn(() => script.flag); + Object.defineProperty(hostileDetail, "scriptFlag", { get: flagGetter }); + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: hostileDetail, + cancelable: true, + }) + ); + expect(flagGetter).not.toHaveBeenCalled(); + + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: { scriptFlag: script.flag, scriptInfo: forged }, + cancelable: true, + }) + ); + + const exec = ( + executor as unknown as { + execScripts: Map; + } + ).execScripts.get(script.uuid); + expect(exec?.scriptRes.metadata).toEqual(script.metadata); + expect(exec?.scriptRes.resource).toEqual({ + canonical: { base64: "", content: "canonical", contentType: "text/plain" }, + }); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("accepts an early-start wrapper after a same-document URL change", () => { + const script = makeScript({ uuid: "executor-early-document-uuid", flag: "#-executor-early-document-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + const initialUrl = window.location.href; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: initialUrl }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + + try { + window.history.pushState({}, "", `${initialUrl}#same-document-change`); + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + window.history.replaceState({}, "", initialUrl); + delete pageWindow[script.flag]; + } + }); + + it("accepts the immutable early manifest through the wrapper name fallback", () => { + const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("continues loading later scripts after reconciling an early-start entry", () => { + const early = makeScript({ + uuid: "early-script", + flag: "executor-early-batch", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const later = makeScript({ uuid: "later-script", flag: "executor-later-batch" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: early, + scriptFlag: early.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map void }>; + }; + internal.earlyScriptFlags.add(early.flag); + const updateEarlyScriptGMInfo = vi.spyOn(internal.execScripts.get(early.uuid)!, "updateEarlyScriptGMInfo"); + const genuine = vi.fn(); + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + const pageWindow = window as unknown as Record; + + try { + executor.startScripts([early, later], initEnvInfo); + pageWindow[later.flag] = genuine; + + expect(updateEarlyScriptGMInfo).toHaveBeenCalledWith(initEnvInfo, early); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, later.name); + } finally { + delete pageWindow[later.flag]; + } + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 7bbcd9bb3..377e22a67 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,13 +3,23 @@ import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; -import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; -import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import { + addStyleSheet, + definePropertyListener, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, + waitBody, +} from "./utils"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { localizeObject } from "./global"; +import { localizeObject, Native } from "./global"; + +// 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -30,33 +40,32 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - earlyScriptFlag: Set = new Set(); - execScriptMap: Map = new Map(); + private readonly earlyScriptFlags = new Native.Set(); + private readonly execScripts = new Native.Map(); constructor( private msg: Message, - private contentMsg: Message // 用于 content <-> content/inject 通讯 + private contentMsg: Message, // 用于 content <-> content/inject 通讯 + private readonly envPrefix = "scripting" ) {} emitEvent(data: EmitEventRequest) { // 转发给脚本 - const exec = this.execScriptMap.get(data.uuid); - if (exec) { - exec.emitEvent(data.event, data.eventId, data.data); - } + this.execScripts.get(data.uuid)?.emitEvent(data.event, data.eventId, data.data); } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + this.execScripts.forEach((exec) => { + if (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName) { + exec.valueUpdate(data); } - } + }); } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + const pageWindow = window as unknown as Record; const loadExec = (script: TScriptInfo, scriptFunc: any) => { this.execScriptEntry({ scriptLoadInfo: script, @@ -66,22 +75,39 @@ export class ScriptExecutor { }); }; // 监听脚本加载 - scripts.forEach((script) => { + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - if (this.earlyScriptFlag.has(flag)) { - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.flag === flag) { + if (this.earlyScriptFlags.has(flag)) { + let updated = false; + this.execScripts.forEach((exec) => { + if (!updated && exec.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 - val.updateEarlyScriptGMInfo(envInfo); - return; + exec.updateEarlyScriptGMInfo(envInfo, script); + updated = true; } - } + }); + if (updated) continue; } - definePropertyListener(window, flag, (val: ScriptFunc) => { - loadExec(script, val); - }); - }); + const listenForScript = () => { + definePropertyListener(window, flag, (val: ScriptFunc) => { + // 只有扩展生成且不可改写的完整性标记才算有效挂载,页面自建同名函数必须忽略。 + const descriptor = + typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(pageWindow, flag); + if (mountDescriptor?.configurable) { + delete pageWindow[flag]; + listenForScript(); + } + return; + } + loadExec(script, val); + }); + }; + listenForScript(); + } } checkEarlyStartScript(scriptEnvTag: ScriptEnvTag, envInfo: GMInfoEnv) { @@ -91,33 +117,18 @@ export class ScriptExecutor { // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 const scriptLoadCompleteHandler: EventListener = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { - scriptFlag: string; - scriptInfo: ScriptLoadInfo; - }; - const scriptFlag = detail?.scriptFlag; - if (typeof scriptFlag === "string") { - ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 - // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (detail.scriptInfo.scriptUrlPatterns) { - // 以 REGEX 情况为例 - // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 - // (All @include = false -> 除去) - // 注:如果 @include 混合了 regex 跟 一般的,即使 regex 的 @include 不匹对当前网址,但匹对了一般 @include 也视为有效 - // 相反如果 @include 混合了 regex 跟 一般的,regex 的 @include 匹对了即可 - // "@exclude /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围不会扩大,然后在 earlyScript 把符合 REGEX 的匹配除去 - // (Any @exclude = true -> 除去) - // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) - try { - if (isUrlExcluded(window.location.href, detail.scriptInfo.scriptUrlPatterns)) { - // 「多余的匹配」-> 略过注入 - return; - } - } catch (e) { - console.warn("Unexpected match error", e); - } - } - this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); + let scriptFlag: unknown; + try { + const detail = (ev as CustomEvent).detail; + if (!detail || typeof detail !== "object") return; + const flagDescriptor = Native.objectGetOwnPropertyDescriptor(detail, "scriptFlag"); + if (!flagDescriptor || !("value" in flagDescriptor)) return; + scriptFlag = flagDescriptor.value; + } catch { + return; + } + if (typeof scriptFlag === "string" && !this.earlyScriptFlags.has(scriptFlag)) { + if (this.execEarlyScript(scriptFlag, envInfo)) ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -127,15 +138,87 @@ export class ScriptExecutor { pageDispatchEvent(ev); } - execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { - const scriptFunc = (window as any)[flag] as ScriptFunc; + execEarlyScript(flag: string, envInfo: GMInfoEnv) { + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; + // 事件在页面可见,只用预注入函数上的不可改写清单作为脚本资料来源。 + const scriptInfoDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) + : undefined; + if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + // The wrapper is installed on this document's window. Same-document history changes must not invalidate it; + // a full navigation creates a new window and cannot retain the old function. + const documentUrlDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) + : undefined; + if ( + !documentUrlDescriptor || + documentUrlDescriptor.configurable || + documentUrlDescriptor.writable || + typeof documentUrlDescriptor.value !== "string" + ) { + return; + } + const documentIdDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentIdKey) + : undefined; + const currentDocumentIdDescriptor = Native.objectGetOwnPropertyDescriptor(window, preInjectScriptDocumentIdKey); + if ( + !documentIdDescriptor || + documentIdDescriptor.configurable || + documentIdDescriptor.writable || + typeof documentIdDescriptor.value !== "string" || + !currentDocumentIdDescriptor || + currentDocumentIdDescriptor.configurable || + currentDocumentIdDescriptor.writable || + currentDocumentIdDescriptor.value !== documentIdDescriptor.value + ) { + return; + } + const scriptInfoJSON = + typeof scriptInfoDescriptor?.value === "string" + ? scriptInfoDescriptor.value + : typeof scriptFunc.name === "string" + ? scriptFunc.name + : undefined; + if (typeof scriptInfoJSON !== "string") return; + let scriptInfo: TScriptInfo | undefined; + try { + scriptInfo = Native.jsonParse(scriptInfoJSON) as TScriptInfo | undefined; + } catch { + return; + } + if (!scriptInfo || scriptInfo.flag !== flag) return; + const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + if (expectedUuid && scriptInfo.uuid !== expectedUuid) return; + if ( + scriptInfo.executionHandle !== undefined || + scriptInfo.executionEnvTag !== undefined || + scriptInfo.executionRunFlag !== undefined + ) { + return; + } + // MV3 对正则匹配会放宽注入范围,必须用编译器绑定的模式在当前页面再确认一次。 + if (scriptInfo.scriptUrlPatterns) { + try { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) return; + } catch (e) { + console.warn("Unexpected match error", e); + } + } this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlag.add(flag); + this.earlyScriptFlags.add(flag); + return true; } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -144,18 +227,20 @@ export class ScriptExecutor { const scriptLoadInfo = localizeObject(scriptEntry.scriptLoadInfo); const execScript = new ExecScript(scriptLoadInfo, { - envPrefix: "scripting", + envPrefix: this.envPrefix, message: this.msg, contentMsg: this.contentMsg, code: scriptFunc, envInfo, }); - this.execScriptMap.set(scriptLoadInfo.uuid, execScript); + this.execScripts.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css if (metadata["require-css"] && resource) { - for (const val of metadata["require-css"]) { + const requireCss = metadata["require-css"]; + for (let i = 0; i < requireCss.length; i += 1) { + const val = requireCss[i]; const res = resource[val]; if (res) { addStyleSheet(res.content); diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts new file mode 100644 index 000000000..3945f3772 --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message } from "@Packages/message/types"; +import type { Server } from "@Packages/message/server"; +import type { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { ScriptRuntime } from "./script_runtime"; +import type { ScriptExecutor } from "./script_executor"; + +describe("ScriptRuntime DOM bridge", () => { + it("rejects accessor attributes without executing their getters", () => { + let handler: ((data: any) => unknown) | undefined; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, {} as CustomEventMessage); + + const getter = vi.fn(() => "secret"); + const attrs = {} as Record; + Object.defineProperty(attrs, "id", { configurable: true, enumerable: true, get: getter }); + + expect(handler?.({ params: [null, "div", attrs] })).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it("creates an element only from the cloned flat attribute payload", () => { + let handler: ((data: any) => unknown) | undefined; + const domMessage = { + getAndDelRelatedTarget: vi.fn(), + sendRelatedTarget: vi.fn(() => 1), + } as unknown as CustomEventMessage; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, domMessage); + + const result = handler?.({ params: [null, "div", { id: "safe", textContent: "hello" }] }); + + expect(result).toBe(1); + expect(domMessage.sendRelatedTarget).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + const element = (domMessage.sendRelatedTarget as any).mock.calls[0][0] as HTMLDivElement; + expect(element.id).toBe("safe"); + expect(element.textContent).toBe("hello"); + }); +}); + +describe("ScriptRuntime inject page bootstrap", () => { + const makeServer = () => { + const handlers = new Map unknown>(); + const server = { + on: vi.fn((name: string, callback: (data: unknown) => unknown) => { + handlers.set(name, callback); + }), + } as unknown as Server; + return { handlers, server }; + }; + + const makeExecutor = () => ({ + checkEarlyStartScript: vi.fn(), + startScripts: vi.fn(), + emitEvent: vi.fn(), + valueUpdate: vi.fn(), + }); + + const makePageLoad = () => ({ + scripts: [ + { + uuid: "inject-script", + name: "Inject script", + flag: "inject-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }); + + it("rejects pageLoad payloads with accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects pageLoad payloads whose own-key enumeration throws", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = new Proxy(makePageLoad(), { + ownKeys() { + throw new Error("hostile enumeration"); + }, + }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects callback DTO accessors before entering the script context", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const getter = vi.fn(() => eventData.data); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in collection callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Map([["nested", nested]]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Set([nested]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("clones valid callback and value-update DTOs before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const valueData = { + uuid: "script", + storageName: "script", + entries: [["key", [0, { value: 1 }], [2]]], + sender: { runFlag: "run", tabId: 3 }, + valueUpdated: true, + }; + + handlers.get("runtime/emitEvent")?.(eventData); + handlers.get("runtime/valueUpdate")?.(valueData); + + expect(executor.emitEvent).toHaveBeenCalledOnce(); + expect(executor.valueUpdate).toHaveBeenCalledOnce(); + expect(executor.emitEvent.mock.calls[0][0]).not.toBe(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).not.toBe(valueData); + expect(executor.emitEvent.mock.calls[0][0]).toEqual(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).toEqual(valueData); + }); + + it("rejects inject scripts without the current execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + Object.defineProperty(pageLoad.scripts[0], "executionHandle", { configurable: true, value: undefined }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("starts scripts only after validating and cloning the execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + const [scripts, envInfo] = executor.startScripts.mock.calls[0]; + expect(scripts).not.toBe(pageLoad.scripts); + expect(scripts[0]).toMatchObject({ + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }); + expect(envInfo).toEqual(pageLoad.envInfo); + }); + + it("does not execute the same native bootstrap twice after a USER_SCRIPT reconnect", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const first = makePageLoad(); + const replay = makePageLoad(); + handlers.get("pageLoad")?.(first); + handlers.get("pageLoad")?.(replay); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + }); + + it("keeps the content pageLoad path on the native payload", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { scripts: [], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false } }; + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledWith(pageLoad.scripts, pageLoad.envInfo); + }); + + it("rejects content pageLoad accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { + scripts: [ + { + uuid: "content-script", + name: "Content script", + flag: "content-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "content-binding", + executionEnvTag: "ct", + executionRunFlag: "content-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }; + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects content callback DTO accessors before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const eventPayload = eventData.data; + const getter = vi.fn(() => eventPayload); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..72faa7d27 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,8 +8,212 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; +import { RuntimeClient } from "../service_worker/client"; +import { customClone, Native } from "./global"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./page_rpc"; + +const MAX_EXECUTION_TOKEN_LENGTH = 256; + +// Inject pageLoad crosses the page-visible bridge, so only a cloned DTO with a current broker binding may reach the executor. +const isRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Native.arrayIsArray(value)) return false; + const prototype = Native.objectGetPrototypeOf(value); + return prototype === null || Native.objectGetPrototypeOf(prototype) === null; +}; + +const isStringArray = (value: unknown): value is string[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== "string") return false; + } + return true; +}; + +const isExecutionToken = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_EXECUTION_TOKEN_LENGTH; + +const isPageResourceMap = (value: unknown): boolean => { + if (!isRecord(value)) return false; + const keys = Native.objectKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + const resource = value[key]; + if (!isRecord(resource) || typeof resource.content !== "string" || typeof resource.contentType !== "string") { + return false; + } + if (resource.base64 !== undefined && typeof resource.base64 !== "string") return false; + } + return true; +}; + +const isPageScriptInfo = (value: unknown, envTag: "it" | "ct"): value is TScriptInfo => { + if (!isRecord(value)) return false; + if ( + typeof value.uuid !== "string" || + value.uuid.length === 0 || + typeof value.name !== "string" || + typeof value.flag !== "string" || + value.flag.length === 0 || + typeof value.code !== "string" || + !isRecord(value.metadata) || + !isRecord(value.value) || + !isPageResourceMap(value.resource) || + (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || + !isExecutionToken(value.executionHandle) || + value.executionEnvTag !== envTag || + !isExecutionToken(value.executionRunFlag) + ) { + return false; + } + const metadataKeys = Native.objectKeys(value.metadata); + for (let index = 0; index < metadataKeys.length; index += 1) { + const key = metadataKeys[index]; + if (!isStringArray(value.metadata[key])) return false; + } + return true; +}; + +const hasOnlyKeys = (value: Record, required: readonly string[], optional: readonly string[] = []) => { + const keys = Native.objectKeys(value); + for (let index = 0; index < required.length; index += 1) { + if (!Native.objectHasOwn(value, required[index])) return false; + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + for (let keyIndex = 0; keyIndex < required.length; keyIndex += 1) { + if (required[keyIndex] === key) { + known = true; + break; + } + } + if (!known) { + for (let keyIndex = 0; keyIndex < optional.length; keyIndex += 1) { + if (optional[keyIndex] === key) { + known = true; + break; + } + } + } + if (!known) return false; + } + return true; +}; + +const isEncodedValue = (value: unknown): boolean => { + if (!Native.arrayIsArray(value)) return false; + if (value.length === 1) return value[0] === 1 || value[0] === 2; + return value.length === 2 && value[0] === 0; +}; + +const cloneInjectValueUpdate = (data: unknown): ValueUpdateDataEncoded | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["entries", "uuid", "storageName", "sender", "valueUpdated"], ["id"]) || + (cloned.id !== undefined && (typeof cloned.id !== "string" || cloned.id.length > MAX_EXECUTION_TOKEN_LENGTH)) || + typeof cloned.uuid !== "string" || + typeof cloned.storageName !== "string" || + typeof cloned.valueUpdated !== "boolean" || + !isRecord(cloned.sender) || + !hasOnlyKeys(cloned.sender, ["runFlag"], ["tabId"]) || + typeof cloned.sender.runFlag !== "string" || + (cloned.sender.tabId !== undefined && typeof cloned.sender.tabId !== "number") || + !Native.arrayIsArray(cloned.entries) + ) { + return undefined; + } + for (let index = 0; index < cloned.entries.length; index += 1) { + const entry = cloned.entries[index]; + if ( + !Native.arrayIsArray(entry) || + entry.length !== 3 || + typeof entry[0] !== "string" || + !isEncodedValue(entry[1]) || + !isEncodedValue(entry[2]) + ) { + return undefined; + } + } + return cloned as unknown as ValueUpdateDataEncoded; +}; + +const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["uuid", "event", "eventId"], ["data"]) || + typeof cloned.uuid !== "string" || + typeof cloned.event !== "string" || + typeof cloned.eventId !== "string" + ) { + return undefined; + } + return cloned as unknown as EmitEventRequest; +}; + +type InjectPageLoadData = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + reconnectToken?: string; +}; + +type PageLoadData = InjectPageLoadData & { + extensionOrigin?: ExtensionOrigin; +}; + +const isExtensionOrigin = (value: unknown): value is ExtensionOrigin => { + if (!isRecord(value) || !hasOnlyKeys(value, ["protocol", "hostname", "port"])) return false; + return ( + (value.protocol === "chrome-extension:" || value.protocol === "moz-extension:") && + typeof value.hostname === "string" && + value.hostname.length > 0 && + typeof value.port === "string" + ); +}; + +const clonePageLoad = ( + data: unknown, + envTag: "it" | "ct", + allowEmpty: boolean, + allowExtensionOrigin: boolean +): PageLoadData | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys( + cloned, + ["scripts", "envInfo"], + ["reconnectToken", ...(allowExtensionOrigin ? ["extensionOrigin"] : [])] + ) || + (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) + ) + return undefined; + if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || (!allowEmpty && cloned.scripts.length === 0)) return undefined; + for (let index = 0; index < cloned.scripts.length; index += 1) { + if (!isPageScriptInfo(cloned.scripts[index], envTag)) return undefined; + } + if (!isRecord(cloned.envInfo)) return undefined; + if (cloned.envInfo.sandboxMode !== "raw" || typeof cloned.envInfo.isIncognito !== "boolean") { + return undefined; + } + if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; + if (cloned.extensionOrigin !== undefined && !isExtensionOrigin(cloned.extensionOrigin)) return undefined; + return { + scripts: cloned.scripts, + envInfo: cloned.envInfo as unknown as GMInfoEnv, + reconnectToken: cloned.reconnectToken as string | undefined, + extensionOrigin: cloned.extensionOrigin as ExtensionOrigin | undefined, + }; +}; + +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => clonePageLoad(data, "it", false, false); export class ScriptRuntime { + // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 + private readonly startedScriptKeys = new Native.Set(); + constructor( private readonly scripEnvTag: ScriptEnvTag, private readonly server: Server, @@ -19,11 +223,24 @@ export class ScriptRuntime { ) {} // content环境的特殊初始化 - contentInit() { - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { - const [parentNodeId, tagName, tmpAttr] = data.params; + contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { + domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + const safeData = customClone(data) as typeof data | undefined; + if (!safeData || !Array.isArray(safeData.params) || safeData.params.length !== 3) return undefined; + const [parentNodeId, tagName, tmpAttr] = safeData.params; + + // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 + if ( + (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || + typeof tagName !== "string" || + tagName.length === 0 || + tagName.length > 128 || + (tmpAttr !== null && (typeof tmpAttr !== "object" || Array.isArray(tmpAttr))) + ) { + return undefined; + } - const msg = this.msg as CustomEventMessage; + const msg = domMsg; // 取回 parentNode(如果存在) let parentNode: Node | undefined; @@ -33,7 +250,16 @@ export class ScriptRuntime { // 创建元素并设置属性 const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; + const attr: Record = Object.create(null); + if (tmpAttr) { + for (const key of Object.keys(tmpAttr)) { + const descriptor = Object.getOwnPropertyDescriptor(tmpAttr, key); + if (!descriptor || !("value" in descriptor)) return undefined; + const value = descriptor.value; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined; + attr[key] = String(value); + } + } let textContent = ""; if (attr.textContent) { textContent = attr.textContent; @@ -54,19 +280,30 @@ export class ScriptRuntime { }); } + async loadPage(beforeStart?: (scripts: TScriptInfo[]) => void | Promise) { + const client = new RuntimeClient(this.msg); + const result = await client.pageLoad(this.scripEnvTag); + if (!result.ok) return; + const scripts = this.scripEnvTag === "ct" ? result.contentScriptList : result.injectScriptList; + if (scripts.length) { + await beforeStart?.(scripts); + this.startScripts(scripts, result.envInfo); + } + } + init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { - // 转发给脚本 - this.scriptExecutor.emitEvent(data); + this.receiveEmitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { - this.scriptExecutor.valueUpdate(data); + this.receiveValueUpdate(data); }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 - this.startScripts(data.scripts, data.envInfo); + this.receivePageLoad(data); }); + // Older MAIN worlds may receive a forward-compatible native bootstrap token but cannot open a runtime port. + this.server.on("bootstrap", () => undefined); // 用于 early-start 的扩充参数 const { inIncognitoContext } = this.extensionEnv || {}; @@ -78,10 +315,48 @@ export class ScriptRuntime { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { - this.scriptExecutor.startScripts(scripts, envInfo); + if (scripts.length === 0) { + this.scriptExecutor.startScripts(scripts, envInfo); + return; + } + const freshScripts: TScriptInfo[] = []; + for (let index = 0; index < scripts.length; index += 1) { + const script = scripts[index]; + const key = script.executionHandle || `${this.scripEnvTag}:${script.uuid}`; + if (this.startedScriptKeys.has(key)) continue; + this.startedScriptKeys.add(key); + freshScripts.push(script); + } + if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); + } + + receivePageLoad(data: unknown): string | undefined { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return undefined; + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + const safeData = clonePageLoad(data, "ct", true, true); + if (!safeData) return undefined; + setPageRpcExtensionOrigin(safeData.extensionOrigin); + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + + receiveEmitEvent(data: unknown): void { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + } + + receiveValueUpdate(data: unknown): void { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); } - externalMessage() { - onInjectPageLoaded(this.msg); + externalMessage(messagePrefix = "scripting", message: Message = this.msg) { + onInjectPageLoaded(message, messagePrefix); } } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts new file mode 100644 index 000000000..2b75f8fe1 --- /dev/null +++ b/src/app/service/content/scripting.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { MessageSend } from "@Packages/message/types"; +import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import type { Server } from "@Packages/message/server"; +import { RuntimeClient } from "../service_worker/client"; +import ScriptingRuntime, { serializeDocumentResponse } from "./scripting"; + +const makeSender = () => ({ + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), + connect: vi.fn(), +}); + +const makeScript = (uuid: string): TScriptInfo => + ({ + uuid, + metadata: { grant: ["GM_getValue"] }, + resource: {}, + value: {}, + flag: `${uuid}-flag`, + code: "", + }) as unknown as TScriptInfo; + +describe("ScriptingRuntime page bootstrap", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requests the combined page list so USER_SCRIPT content receives its bootstrap", async () => { + const pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [makeScript("inject-script")], + contentScriptList: [makeScript("content-script")], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + userScriptBootstrapToken: "bootstrap-token", + userScriptInjectBootstrapToken: "inject-bootstrap-token", + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown) => unknown) => handlers.set(action, handler)), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pageLoad).toHaveBeenCalledWith("it"); + expect(senderToContent.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ + bootstrapToken: "bootstrap-token", + extensionOrigin: { + protocol: "chrome-extension:", + hostname: chrome.runtime.id, + port: "", + }, + }), + }) + ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); + expect(senderToInject.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "inject/pageLoad" }) + ); + + handlers.get("pageLoadFallback")?.({}); + await Promise.resolve(); + expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { + const document = new DOMParser().parseFromString("
ok
", "text/html"); + expect(serializeDocumentResponse(document, "text/html")).toEqual({ + text: expect.stringContaining("
ok
"), + contentType: "text/html", + }); + }); +}); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 7286d82b0..4baddadb8 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,11 +2,15 @@ import { Client, sendMessage } from "@Packages/message/client"; import { type CustomEventMessage } from "@Packages/message/custom_event_message"; import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; +import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { SerializedDocumentResponse } from "./gm_api/gm_xhr"; import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import type { ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { PAGE: 1, @@ -16,6 +20,18 @@ const PageOrContent = { type PageOrContent = ValueOf; +export const serializeDocumentResponse = ( + response: Document | null, + contentType: string +): SerializedDocumentResponse | undefined => { + if (!response) return undefined; + try { + return { text: new XMLSerializer().serializeToString(response), contentType }; + } catch { + return undefined; + } +}; + // For Firefox, StorageArea.setAccessLevel is not implemented. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754 // const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session; @@ -23,7 +39,12 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { - private activeStorageNames: Map | null = null; + // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 + private activeStorageNames = new Map(); + // MAIN world 的完整脚本资料只在原生通道失败时才走页面桥;原生成功时由 service worker 直接投递。 + private fallbackInjectPageLoad?: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }; + // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 + private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 private readonly extServer: Server, @@ -34,7 +55,7 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: CustomEventMessage + private readonly senderToInject: MessageSend ) {} // 广播消息给 content 和 inject @@ -51,12 +72,18 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/emitEvent", data); + // USER_SCRIPT 的私有回调通过原生扩展端口投递。 + return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/valueUpdate", data); + // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 + return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); + }); + this.server.on("pageLoadFallback", () => { + const pageLoad = this.fallbackInjectPageLoad; + if (!pageLoad) return undefined; + this.fallbackInjectPageLoad = undefined; + return new Client(this.senderToInject, "inject").do("pageLoad", pageLoad); }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); @@ -74,13 +101,10 @@ export default class ScriptingRuntime { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; - const activeOn = - this.activeStorageNames === null - ? PageOrContent.PAGE_AND_CONTENT - : this.activeStorageNames.get(sendData.storageName); + const activeOn = this.activeStorageNames.get(sendData.storageName); if (activeOn) { // 转发给 content 和 inject - this.broadcastToPage("runtime/valueUpdate", sendData, activeOn); + this.broadcastToPage("runtime/valueUpdate", sendData, (activeOn & PageOrContent.PAGE) as PageOrContent); } } }); @@ -91,7 +115,7 @@ export default class ScriptingRuntime { "runtime/gmApi", this.server, this.senderToExt, - (data: { api: string; params: any; uuid: string }) => { + (data: { api: string; params: any }) => { // 拦截关注的 API,未命中则返回 false 交由默认转发处理 switch (data.api) { case "CAT_createBlobUrl": { @@ -111,18 +135,19 @@ export default class ScriptingRuntime { return false; // 继续转发到 SW } case "CAT_fetchDocument": { - const [url, isContent] = data.params; - // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.senderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; - xhr.open("GET", url); - xhr.onloadend = function () { - const nodeId = msg!.sendRelatedTarget(this.response); - resolve(nodeId); - msg = null; + xhr.open("GET", data.params[0]); + xhr.onloadend = () => { + resolve( + serializeDocumentResponse( + xhr.response as Document | null, + xhr.getResponseHeader("Content-Type") || "" + ) + ); }; + xhr.onerror = () => resolve(undefined); xhr.send(); }); } @@ -142,6 +167,21 @@ export default class ScriptingRuntime { break; } return false; + }, + (data) => { + // 所有来自页面的 GM RPC 都在转发前完成字段、句柄、授权和参数复制检查。 + const request = validatePageGMRequest(data, this.pageRpc); + return { + uuid: request.uuid, + api: request.api, + params: request.params, + runFlag: request.runFlag, + executionHandle: request.handle, + version: 1 as const, + requestId: request.requestId, + handle: request.handle, + envTag: request.envTag, + }; } ); } @@ -157,29 +197,44 @@ export default class ScriptingRuntime { }); } // 向service_worker请求脚本列表及环境信息 - client.pageLoad().then((o) => { + client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, contentScriptList, envInfo } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken, userScriptInjectBootstrapToken } = o; + // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 + this.pageRpc.revokeAll(); + const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => + scripts.map((script) => { + const allowedAPIs = getPageRpcAllowedAPIs(script.metadata.grant || []); + const executionRunFlag = script.executionRunFlag || uuidv4(); + const executionHandle = + script.executionHandle || + this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); + if (script.executionHandle) { + // service worker 已签发的句柄要在本页 registry 中恢复,保持跨 context 身份一致。 + this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); + } + return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; + }); + const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); const pairs = {} as Record; - for (const script of injectScriptList) { + for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of contentScriptList) { - pairs[getStorageName(script)] |= PageOrContent.CONTENT; - } this.activeStorageNames = new Map(Object.entries(pairs)); - // 向页面 发送脚本列表及环境信息 - if (contentScriptList.length) { + if (typeof userScriptBootstrapToken === "string" && userScriptBootstrapToken.length > 0) { const contentClient = new Client(this.senderToContent, "content"); - // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); + contentClient.do("pageLoad", { + bootstrapToken: userScriptBootstrapToken, + envInfo, + extensionOrigin: getExtensionOrigin(), + }); } - if (injectScriptList.length) { + if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + this.fallbackInjectPageLoad = { scripts: preparedInjectScriptList, envInfo }; const injectClient = new Client(this.senderToInject, "inject"); - // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); + injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); } }); } diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -1,6 +1,6 @@ import type { REncoded } from "@App/pkg/utils/message_value"; -export type ScriptFunc = (named: { [key: string]: any } | undefined, scriptName: string) => any; +export type ScriptFunc = (s: string, ctx: any, named: { [key: string]: any } | undefined, scriptName: string) => any; // exec_script.ts diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts new file mode 100644 index 000000000..d68ef656d --- /dev/null +++ b/src/app/service/content/user_script_connection.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./user_script_connection"; + +const makeConnection = (): MessageConnect => ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), +}); + +describe("connectUserScriptChannel", () => { + it("enables the native listener before opening the USER_SCRIPT port", async () => { + const connection = makeConnection(); + const order: string[] = []; + const message = { + sendMessage: vi.fn(async (packet: TMessage) => { + order.push(`send:${(packet as { type?: string }).type}`); + return true; + }), + connect: vi.fn(async (packet: TMessage) => { + order.push(`connect:${packet.action}`); + return connection; + }), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); + expect(connection.onMessage).toHaveBeenCalledOnce(); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("uses the constrained extension transport for the MAIN world port", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "inject-bootstrap", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "inject-bootstrap", transport: "extension" }, + }); + }); + + it("returns no channel when the browser cannot enable any runtime port", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockRejectedValue(new Error("runtime.connect is unavailable")), + } as unknown as Message; + + await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("uses a constrained extension-port fallback when dedicated listeners are unavailable", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("uses the extension fallback when listener capability probing has no response", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("reports remote disconnects so the caller can reconnect natively", async () => { + const connection = makeConnection(); + const onDisconnect = vi.fn(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), onDisconnect); + + expect(connection.onDisconnect).toHaveBeenCalledOnce(); + const disconnectHandler = (connection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + expect(onDisconnect).toHaveBeenCalledWith(false); + }); + + it("accepts only a valid native reconnect token response", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: { bootstrapToken: "next-token" } }), + } as unknown as Message; + + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBe("next-token"); + expect(message.sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken: "current-token" }, + }); + + (message.sendMessage as ReturnType).mockResolvedValue({ code: 0, data: {} }); + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBeUndefined(); + }); +}); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts new file mode 100644 index 000000000..61bf2933a --- /dev/null +++ b/src/app/service/content/user_script_connection.ts @@ -0,0 +1,53 @@ +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; + +type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; +type UserScriptDisconnectHandler = (isSelfDisconnected: boolean) => void; + +type UserScriptReconnectResponse = { + code?: unknown; + data?: unknown; +}; +type UserScriptWorld = "USER_SCRIPT" | "MAIN"; + +/** + * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, + * 并发执行两步会丢失首个连接。 + */ +export async function connectUserScriptChannel( + message: Message, + bootstrapToken: string, + onPacket: UserScriptPacketHandler, + onDisconnect?: UserScriptDisconnectHandler, + world: UserScriptWorld = "USER_SCRIPT" +): Promise { + const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + const useExtensionFallback = world === "MAIN" || enabled !== true; + let connection: MessageConnect; + try { + // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 + connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: useExtensionFallback ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + }); + } catch (error) { + if (!useExtensionFallback) throw error; + return undefined; + } + connection.onMessage((packet) => onPacket(connection, packet)); + if (onDisconnect) connection.onDisconnect(onDisconnect); + connection.sendMessage({ action: "userScript/bootstrap" }); + return connection; +} + +export async function requestUserScriptReconnect( + message: Message, + reconnectToken: string +): Promise { + const response = await message.sendMessage({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken }, + }); + if (response?.code !== 0 || response.data === null || typeof response.data !== "object") return undefined; + const token = (response.data as { bootstrapToken?: unknown }).bootstrapToken; + return typeof token === "string" && token.length > 0 && token.length <= 256 ? token : undefined; +} diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..3cac0cfba 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,16 +3,38 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import type { ScriptFunc } from "./types"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +const znRand = process.env.SC_ZN_RAND!; + +type GeneratedWindow = Record; + +function executeGeneratedScript( + code: string, + targetWindow: GeneratedWindow, + testPerformance: Pick = globalThis.performance +) { + const execute = new Function("window", "performance", "CustomEvent", code) as ( + window: GeneratedWindow, + performance: Pick, + customEvent: typeof CustomEvent + ) => void; + execute(targetWindow, testPerformance, globalThis.CustomEvent); +} + // 设置 console mock 来避免测试输出污染 vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -60,7 +82,8 @@ describe("utils", () => { expect(result).toContain("try {"); expect(result).toContain("} catch (e) {"); expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + expect(result).toContain("return this[arguments[0]](...((delete this[arguments[0]]),[]));"); }); it.concurrent("应该处理自定义脚本代码参数", () => { @@ -481,6 +504,19 @@ describe("utils", () => { contentType: "text/plain", }); }); + + it("copies public values and metadata before crossing the page boundary", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.value = { nested: { count: 1 } }; + script.metadata.grant!.push("GM_setValue"); + + const trimmed = trimScriptInfo(script); + (trimmed.value.nested as { count: number }).count = 9; + trimmed.metadata.grant!.push("GM_deleteValue"); + + expect(script.value.nested).toEqual({ count: 1 }); + expect(script.metadata.grant).toEqual(["GM_getValue", "GM_setValue"]); + }); }); describe("compileScript", () => { @@ -495,7 +531,7 @@ describe("utils", () => { const code = "return arguments[0].value + arguments[1];"; const func: ScriptFunc = compileScript(code); - const result = func({ value: 10 }, "test-script"); + const result = func(fnStrIntegrity, {}, { value: 10 }, "test-script"); expect(result).toBe("10test-script"); }); @@ -511,8 +547,8 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result1 = func({ value: 5, multiply: 3 }, "test"); - const result2 = func({ value: 5 }, "fallback"); + const result1 = func(fnStrIntegrity, {}, { value: 5, multiply: 3 }, "test"); + const result2 = func(fnStrIntegrity, {}, { value: 5 }, "fallback"); expect(result1).toBe(15); expect(result2).toBe("fallback"); @@ -526,7 +562,7 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result = await func({ value: 5 }, "async-test"); + const result = await func(fnStrIntegrity, {}, { value: 5 }, "async-test"); expect(result).toBe(10); }); @@ -535,7 +571,13 @@ describe("utils", () => { const code = "throw new Error('Test error');"; const func: ScriptFunc = compileScript(code); - expect(() => func({}, "error-test")).toThrow("Test error"); + expect(() => func(fnStrIntegrity, {}, {}, "error-test")).toThrow("Test error"); + }); + + it.concurrent("完整性标记不匹配时不应执行脚本", () => { + const func: ScriptFunc = compileScript("throw new Error('should not run');"); + + expect(func("invalid", {}, {}, "blocked")).toBeUndefined(); }); }); @@ -559,13 +601,48 @@ describe("utils", () => { ...overrides, }); + it("生成的腳本包裝不依賴被 require 內容改寫的 Function.prototype 调用方法", async () => { + const script = createMockScript({ + code: "return this;", + resource: { + library: { + url: "https://example.com/library.js", + content: + "Function.prototype.call = Function.prototype.apply = Function.prototype.bind = () => { throw new Error('poisoned invocation'); };", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: Date.now(), + }, + }, + metadata: { require: ["library"] }, + }); + const func = compileScript(compileScriptCode(script)); + const originalCall = Function.prototype.call; + const originalApply = Function.prototype.apply; + const originalBind = Function.prototype.bind; + let result: unknown; + try { + result = await func(fnStrIntegrity, globalThis, {}, script.name); + } finally { + Function.prototype.call = originalCall; + Function.prototype.apply = originalApply; + Function.prototype.bind = originalBind; + } + expect(result).toBe(globalThis); + }); + it.concurrent("应该生成基本的注入脚本代码", () => { const script = createMockScript(); const scriptCode = "console.log('injected');"; const result = compileInjectScript(script, scriptCode); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('injected');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('injected');}));` + ); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -577,7 +654,7 @@ describe("utils", () => { expect(result).toContain(`try{delete window['inject-test-flag']}catch(e){}`); expect(result).toContain("console.log('with auto delete');"); expect(result).toBe( - `window['inject-test-flag'] = function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}` + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}));` ); }); @@ -588,7 +665,64 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); expect(result).not.toContain("try{delete window"); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('without auto delete');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('without auto delete');}));` + ); + }); + + it.concurrent("生成的注入脚本应在运行时传递上下文和参数,并清理临时挂载", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + const context = {}; + const named = { value: 42 }; + + executeGeneratedScript( + compileInjectScript( + script, + "return { thisValue: this, args: Array.from(arguments), contextKeys: Reflect.ownKeys(this) };" + ), + targetWindow + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + contextKeys: [], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + }); + + it.concurrent("生成的注入脚本应拒绝错误的完整性标记", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "throw new Error('should not run');"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated("invalid", {}, {}, "blocked")).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本应按选项自动删除挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';", true), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本默认应保留挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -613,7 +747,143 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); - expect(result).toContain(`window['flag-with-special-chars_123']`); + expect(result).toContain(`'flag-with-special-chars_123'`); + }); + }); + + describe("compilePreInjectScript", () => { + it.concurrent("生成的预注入脚本应可执行并发出脚本加载事件", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-test-uuid", + name: "Pre Inject Test Script", + namespace: "pre.inject.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-test-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript( + compilePreInjectScript(script, "return { thisValue: this, args: Array.from(arguments) };"), + targetWindow, + testPerformance + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptInfoKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentUrlKey)).toMatchObject({ + configurable: false, + writable: false, + value: window.location.href, + }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentIdKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); + const context = {}; + const named = { value: 42 }; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); + expect(testPerformance.addEventListener).not.toHaveBeenCalled(); + }); + + it.concurrent("does not expose stored values or user config in the observable preload event", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-private-uuid", + name: "Pre Inject Private Script", + namespace: "pre.inject.private", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: { secret: "stored-value" }, + config: { private: { secret: { title: "Private", description: "", index: 0, default: "secret" } } }, + flag: "pre-inject-private-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + let detail: Record | undefined; + const testPerformance = { + dispatchEvent: vi.fn((event: Event) => { + detail = (event as CustomEvent).detail; + return false; + }), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); + + expect(detail).toEqual({ scriptFlag: script.flag }); + }); + + it.concurrent("does not mount a regex-excluded early-start script", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-excluded-uuid", + name: "Pre Inject Excluded Script", + namespace: "pre.inject.excluded", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-excluded-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + scriptUrlPatterns: [ + { + ruleType: RuleType.REGEX_INCLUDE, + ruleContent: ["allowed", ""], + ruleTag: "include", + patternString: "/allowed/", + }, + ], + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), targetWindow, testPerformance); + + expect(targetWindow[script.flag]).toBeUndefined(); + expect(testPerformance.dispatchEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..7caa009c8 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,6 +7,19 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +import { customClone } from "./global"; + +const cloneTransportValue = (value: any) => { + // USER_SCRIPT 只能接收数据副本;共享 customClone 的 data-only 检查,避免 getter/Proxy 进入页面资料。 + return customClone(value); +}; + +// 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; +const znRand = process.env.SC_ZN_RAND; +export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; +export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; +export const preInjectScriptDocumentIdKey = `${lnStrIntegrity}:documentId`; export type CompileScriptCodeResource = { name: string; @@ -141,7 +154,7 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) // arguments = [named: Object, scriptName: string] // 使用sandboxContext时,arguments[0]为undefined, this.$则为一次性Proxy变量,用于全域拦截context // 非沙盒环境时,先读取 arguments[0],因此不会读取页面环境的 this.$ - // 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ] + // 临时方法调用保留 userscript 的 this,避免在页面解析可变的 call/apply/bind。 if (resource.isContextMenu) { // 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册 @@ -151,9 +164,9 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) const joinedCode = [ "with(arguments[0]||this.$){", `${preCode}`, - "return(async function(){", + "this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){", `${code}`, - "}).call(this);}", + "};return this[arguments[0]](...((delete this[arguments[0]]),[]));}", ] .filter(Boolean) .join("\n"); @@ -161,9 +174,51 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = ( + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => { + // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 + const infoProperty = + scriptInfoJSON === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ + documentUrlExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });${ + documentIdExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentIdKey}', { value: ${documentIdExpression} });` + }` + }`; + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; +}; + +// 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 +const mountCodeFunction = ( + flag: string, + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression, documentIdExpression)})`; + +const ZFunction = Function; + // 通过脚本代码编译脚本函数 export function compileScript(code: string): ScriptFunc { - return new Function(code); + const fn = new ZFunction(code); + const k = lnStrIntegrity; + const y = `${znRand}` + Math.random(); + return (t: any, u: any, ...args: any[]) => { + if (t === k) { + u[y] = fn; + return u[y](...(delete u[y], args)); + } + }; } /** @@ -186,7 +241,7 @@ export function compileInjectScriptByFlag( autoDeleteMountFunction: boolean = false ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)};`; } /** @@ -216,7 +271,18 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { } // --- 处理 resource --- // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; + const metadata = Object.fromEntries( + Object.entries(script.metadata).map(([key, values]) => [key, Array.isArray(values) ? [...values] : values]) + ); + const scriptInfo = { + ...script, + metadata, + value: cloneTransportValue(script.value) ?? {}, + config: script.config === undefined ? undefined : cloneTransportValue(script.config), + resource, + requireCssResource, + code: "", + } as TScriptInfo; // 删除其他不需要注入的 script 信息 delete scriptInfo.originalMetadata; delete scriptInfo.selfMetadata; @@ -232,10 +298,25 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.runStatus; // 前台脚本不用 delete scriptInfo.type; // 脚本类型总是普通脚本 delete scriptInfo.status; // 脚本状态总是启用 + delete scriptInfo.executionHandle; + delete scriptInfo.executionEnvTag; + // 这些绑定令牌只在隔离 broker 内有效,不能随脚本资料暴露给页面或 USER_SCRIPT。 + delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; }; +/** + * 预注入事件会经过页面可观察的 performance 通道;不要把用户值或配置放进它的 detail。 + * 资源仍需在脚本最早执行时可用,后续 pageLoad 会补回权威的值与配置。 + */ +export const trimPreInjectScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { + const scriptInfo = trimScriptInfo(script); + scriptInfo.value = {}; + scriptInfo.config = undefined; + return scriptInfo; +}; + /** * 将脚本函数编译为预注入脚本代码 */ @@ -247,16 +328,28 @@ export function compilePreInjectScript( const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject; const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化 const flag = `${script.flag}`; - const scriptInfo = trimScriptInfo(script); + const scriptInfo = trimPreInjectScriptInfo(script); const scriptInfoJSON = `${JSON.stringify(scriptInfo)}`; + const scriptUrlPatterns = script.scriptUrlPatterns?.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); + const urlCondition = scriptUrlPatterns + ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) + : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; + const documentIdExpression = `(()=>{const k='${preInjectScriptDocumentIdKey}',d=Object.getOwnPropertyDescriptor(window,k);if(d&&'value'in d&&typeof d.value==='string')return d.value;const v=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2);Object.defineProperty(window,k,{configurable:false,writable:false,value:v});return v})()`; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; -{ - let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, - c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + return `{ + let mounted = false, + f = () => { + if (!(${urlCondition})) return false; + if (!mounted) { + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href", documentIdExpression)}; + mounted = true; + } + const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, + c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; + return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); + }, needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } @@ -337,16 +430,32 @@ export const getScriptFlag = (uuid: string) => { // 监听属性设置 export function definePropertyListener(obj: any, prop: string, listener: (val: T) => void) { - if (obj[prop] !== undefined) { - listener(obj[prop]); - delete obj[prop]; + const sameProperty = (left: PropertyDescriptor | undefined, right: PropertyDescriptor | undefined) => + left?.configurable === right?.configurable && + left?.enumerable === right?.enumerable && + left?.value === right?.value && + left?.get === right?.get && + left?.set === right?.set; + const current = obj[prop]; + if (current !== undefined) { + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + listener(current); + // 页面可能在回调里替换属性;只有描述符仍是原来的才可以清理自身监听器。 + if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { + delete obj[prop]; + } return; } + const setter = (val: T) => { + listener(val); + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + // 不删除页面后来安装的 setter,只删除本函数仍拥有的那一个。 + if (descriptor?.configurable && descriptor.set === setter) { + delete obj[prop]; + } + }; Object.defineProperty(obj, prop, { configurable: true, - set: (val: any) => { - delete obj[prop]; // 删除 property setter - listener(val); - }, + set: setter, }); } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index b58a27912..1fff7b223 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -348,8 +348,9 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } - pageLoad(): Promise { - return this.doThrow("pageLoad"); + // envTag 让 service worker 区分主世界请求与 content-world bootstrap,分别签发/回收句柄。 + pageLoad(envTag?: "it" | "ct"): Promise { + return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } /** bfcache 还原上报:只告知本页仍在运行,不请求脚本 */ diff --git a/src/app/service/service_worker/gm_api/gm_agent.ts b/src/app/service/service_worker/gm_api/gm_agent.ts index d6b5bc59b..d6dcfc89f 100644 --- a/src/app/service/service_worker/gm_api/gm_agent.ts +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -38,7 +38,7 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationApi(request.params[0]); + return this.agentService.handleConversationApi({ ...request.params[0], scriptUuid: request.script.uuid }); } @PermissionVerify.API({ @@ -50,7 +50,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationChatFromGmApi(request.params[0], sender); + return this.agentService.handleConversationChatFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } @PermissionVerify.API({ @@ -62,7 +65,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAttachToConversationFromGmApi(request.params[0], sender); + return this.agentService.handleAttachToConversationFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_dom.ts b/src/app/service/service_worker/gm_api/gm_agent_dom.ts index c9e087265..043f22247 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_dom.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_dom.ts @@ -36,7 +36,7 @@ class GMAgentDomApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleDomApi(request.params[0]); + return this.agentService.handleDomApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_model.ts b/src/app/service/service_worker/gm_api/gm_agent_model.ts index 87eb9474b..ce4cc36de 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_model.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_model.ts @@ -38,7 +38,7 @@ class GMAgentModelApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleModelApi(request.params[0]); + return this.agentService.handleModelApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts index 70d993688..9122bfeff 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts @@ -50,7 +50,7 @@ class GMAgentOPFSApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleOPFSApi(request.params[0], sender); + return this.agentService.handleOPFSApi({ ...request.params[0], scriptUuid: request.script.uuid }, sender); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts index 47b961793..90c8e33a0 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_skills.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_skills.ts @@ -50,7 +50,7 @@ class GMAgentSkillsApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleSkillsApi(request.params[0]); + return this.agentService.handleSkillsApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_task.ts b/src/app/service/service_worker/gm_api/gm_agent_task.ts index 7de5a2880..1d942c05c 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_task.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_task.ts @@ -36,7 +36,7 @@ class GMAgentTaskApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAgentTaskApi(request.params[0]); + return this.agentService.handleAgentTaskApi(request.params[0], request.script.uuid); } } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 63b937997..dc1d00fff 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -9,6 +9,11 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import GMAgentApi from "./gm_agent"; +import GMAgentDomApi from "./gm_agent_dom"; +import GMAgentModelApi from "./gm_agent_model"; +import GMAgentOPFSApi from "./gm_agent_opfs"; +import GMAgentSkillsApi from "./gm_agent_skills"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -124,6 +129,281 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +describe("CAT.agent.conversation identity binding", () => { + it("overrides a forged payload owner with the authenticated script", async () => { + const handleConversationApi = vi.fn().mockResolvedValue(null); + const api = { agentService: { handleConversationApi } } as unknown as GMApi; + const request = { + params: [{ action: "get", id: "conv-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + + await GMAgentApi.prototype.CAT_agentConversation.call(api, request, makeSender()); + + expect(handleConversationApi).toHaveBeenCalledWith({ + action: "get", + id: "conv-1", + scriptUuid: "script-authenticated", + }); + }); + + it("binds streaming chat and background attach to the authenticated script", async () => { + const handleConversationChatFromGmApi = vi.fn().mockResolvedValue(undefined); + const handleAttachToConversationFromGmApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleConversationChatFromGmApi, handleAttachToConversationFromGmApi }, + } as unknown as GMApi; + const chatRequest = { + params: [{ conversationId: "conv-1", message: "hi", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const attachRequest = { + params: [{ conversationId: "conv-1", generation: "gen-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const sender = makeSender(); + + await GMAgentApi.prototype.CAT_agentConversationChat.call(api, chatRequest, sender); + await GMAgentApi.prototype.CAT_agentAttachToConversation.call(api, attachRequest, sender); + + expect(handleConversationChatFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + expect(handleAttachToConversationFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + }); +}); + +describe("CAT agent identity binding", () => { + it("overrides forged nested scriptUuid values for every agent service boundary", async () => { + const handleDomApi = vi.fn().mockResolvedValue(undefined); + const handleModelApi = vi.fn().mockResolvedValue(undefined); + const handleSkillsApi = vi.fn().mockResolvedValue(undefined); + const handleOPFSApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleDomApi, handleModelApi, handleSkillsApi, handleOPFSApi }, + } as unknown as GMApi; + const script = { uuid: "script-authenticated" }; + const sender = makeSender(); + + await GMAgentDomApi.prototype.CAT_agentDom.call( + api, + { params: [{ action: "listTabs", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentModelApi.prototype.CAT_agentModel.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentSkillsApi.prototype.CAT_agentSkills.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentOPFSApi.prototype.CAT_agentOPFS.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + + expect(handleDomApi).toHaveBeenCalledWith({ action: "listTabs", scriptUuid: "script-authenticated" }); + expect(handleModelApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleSkillsApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleOPFSApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }, sender); + }); +}); + +describe("page execution binding gate", () => { + it("rejects a page-originated request that has no binding handle", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest({ uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged" }, sender) + ).rejects.toThrow("page execution binding is required"); + }); + + it("rejects an unknown page binding before parsing or invoking a GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const resolveBinding = vi.fn().mockReturnValue(undefined); + Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, value: resolveBinding }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest( + { uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged", executionHandle: "missing" }, + sender + ) + ).rejects.toThrow("page execution binding is invalid"); + expect(resolveBinding).toHaveBeenCalledTimes(1); + }); + + it("rejects a page API that is outside the binding capability set", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const parseRequest = vi.fn(); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_getTab"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1, + }, + sender + ) + ).rejects.toThrow("API is not granted to this execution"); + expect(parseRequest).not.toHaveBeenCalled(); + expect(binding.requestIds.size).toBe(0); + }); + + it("rejects a replayed page request id before invoking the GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const request = { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1 as const, + envTag: "ct" as const, + }; + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page execution binding is invalid"); + expect(binding.requestIds).toHaveLength(0); + + const validRequest = { ...request, envTag: "it" as const }; + await expect(api.handlerRequest(validRequest, sender)).resolves.toBe(true); + await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); + }); + + it("accepts a unique request when replay state is full while still rejecting replay", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const requestIds = new Set(["request-0"]); + // 直接模拟满集合,验证唯一 ID 仍可用且重放仍被拒绝,避免 CI 发送数千个请求。 + Object.defineProperty(requestIds, "size", { configurable: true, value: 4096 }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestIds, + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-4097", + version: 1, + }, + sender + ) + ).resolves.toBe(true); + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-0", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId was already used"); + }); +}); + describe("window.focus", () => { it("应同时激活标签页并将其所在窗口置于前台", async () => { const tabsUpdate = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 853ebc3aa..eb8619c32 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -33,6 +33,7 @@ import type { MessageRequest, NotificationMessageOption, GMApiRequest, + ServiceWorkerExecutionBinding, } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; @@ -361,7 +362,11 @@ export default class GMApi { private msgSender: MessageSend, private mq: IMessageQueue, private value: ValueService, - private gmExternalDependencies: IGMExternalDependencies + private gmExternalDependencies: IGMExternalDependencies, + private readonly resolvePageExecutionBinding?: ( + handle: string, + sender: IGetSender + ) => ServiceWorkerExecutionBinding | undefined ) { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } @@ -374,6 +379,37 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); + const source = sender.getSender(); + const isPageRequest = typeof source?.tab?.id === "number"; + if (isPageRequest && !data.executionHandle) { + throw new Error("page execution binding is required"); + } + if (data.executionHandle) { + if (data.version !== undefined && data.version !== 1) { + throw new Error("unsupported page execution binding version"); + } + if (data.handle !== undefined && data.handle !== data.executionHandle) { + throw new Error("page execution binding is invalid"); + } + const binding = this.resolvePageExecutionBinding?.(data.executionHandle, sender); + if (!binding || (data.uuid && data.uuid !== binding.uuid)) { + throw new Error("page execution binding is invalid"); + } + if (!binding.allowedAPIs.has(data.api)) { + throw new Error("API is not granted to this execution"); + } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } + if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { + throw new Error("page RPC requestId is invalid"); + } + if (binding.requestIds.has(data.requestId)) { + throw new Error("page RPC requestId was already used"); + } + binding.requestIds.add(data.requestId); + data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; + } const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); @@ -598,7 +634,7 @@ export default class GMApi { const keyValuePairs = [[key, encodeRValue(value)]] as TKeyValuePair[]; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -611,7 +647,7 @@ export default class GMApi { const [id, keyValuePairs] = request.params; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -1133,7 +1169,7 @@ export default class GMApi { key, name, options, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); @@ -1146,7 +1182,7 @@ export default class GMApi { this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, key, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index dbe139672..a393a6aba 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -484,6 +484,7 @@ export default class ServiceWorkerManager { // 无视错误 } onTabRemoved(tabId); + runtime.revokePageBindingsForTab(tabId); }); } } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index fc272def4..4a07fbe65 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -20,7 +20,7 @@ import type { ResourceService } from "./resource"; import type { ScriptDAO } from "@App/app/repo/scripts"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import type { MessageConnect, TMessage } from "@Packages/message/types"; -import { obtainBlackList } from "@App/pkg/utils/utils"; +import { getStorageName, obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -1077,6 +1077,16 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }, }); + it("拒绝 USER_SCRIPT 来源直接请求页面脚本清单", async () => { + const { runtime } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + + const result = await runtime.pageLoad(undefined, new SenderRuntime(createSender(false), "userScript")); + + expect(result).toEqual({ ok: false }); + expect(getScriptsForTab).not.toHaveBeenCalled(); + }); + it.each([ ["普通", false], ["隐身", true], @@ -1094,6 +1104,64 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("preserves tab ID zero for page matching and BFCache reporting", async () => { + const { runtime, mockGroup } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(null); + const sender = new SenderRuntime({ + ...createSender(false), + tab: { ...(createSender(false).tab as chrome.tabs.Tab), id: 0 } as chrome.tabs.Tab, + }); + + await runtime.pageLoad(undefined, sender); + await runtime.pageShow(undefined, sender); + + expect(getScriptsForTab).toHaveBeenCalledWith({ + url: "https://www.example.com/page", + tabId: 0, + frameId: 0, + incognito: false, + }); + expect(mockGroup.emit).toHaveBeenCalledWith("popupPageRestored", { + tabId: 0, + frameId: 0, + url: "https://www.example.com/page", + }); + }); + + it("discards an older same-frame pageLoad response that resolves after a newer one", async () => { + const { runtime } = _createRuntimeContext(); + const firstScript = _createScriptRunResource(_createMockScript({ uuid: "first-page-load" })); + const secondScript = _createScriptRunResource(_createMockScript({ uuid: "second-page-load" })); + const loadResult = (script: ScriptRunResource) => + ({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + }) as unknown as Awaited>; + let resolveFirst!: (result: Awaited>) => void; + let resolveSecond!: (result: Awaited>) => void; + vi.spyOn(runtime, "getScriptsForTab") + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const firstLoad = runtime.pageLoad(undefined, sender); + const secondLoad = runtime.pageLoad(undefined, sender); + resolveSecond(loadResult(secondScript)); + const second = await secondLoad; + expect(second.ok).toBe(true); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + + resolveFirst(loadResult(firstScript)); + await expect(firstLoad).resolves.toEqual({ ok: false }); + expect(runtime.resolvePageExecutionBinding(secondHandle!, sender)).toBeDefined(); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { @@ -1109,6 +1177,572 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { url: "https://www.example.com/page", }); }); + + it("为每个页面文档签发绑定,并拒绝跨标签页、跨 frame 和旧文档复用", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sender = new SenderRuntime(rawSender); + const first = await runtime.pageLoad(undefined, sender); + const firstHandle = first.ok ? first.injectScriptList[0].executionHandle : undefined; + const firstRunFlag = first.ok ? first.injectScriptList[0].executionRunFlag : undefined; + expect(firstHandle).toEqual(expect.any(String)); + expect(firstRunFlag).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toMatchObject({ + uuid: "bound-script", + envTag: "it", + tabId: 41, + frameId: 0, + documentId: "doc-a", + }); + + const otherTab = new SenderRuntime({ ...rawSender, tab: { ...rawSender.tab, id: 42 } as chrome.tabs.Tab }); + const otherFrame = new SenderRuntime({ ...rawSender, frameId: 1 }); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherTab)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherFrame)).toBeUndefined(); + + const secondSender = new SenderRuntime({ ...rawSender, documentId: "doc-b" }); + const second = await runtime.pageLoad(undefined, secondSender); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + const secondRunFlag = second.ok ? second.injectScriptList[0].executionRunFlag : undefined; + expect(secondHandle).toEqual(expect.any(String)); + expect(secondRunFlag).toEqual(expect.any(String)); + expect(secondHandle).not.toBe(firstHandle); + expect(secondRunFlag).not.toBe(firstRunFlag); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); + + runtime.revokePageBindingsForTab(41); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); + }); + + it("新文档加载时撤销上一文档的执行绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "navigation-bound-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const firstRawSender = { + url: "https://www.example.com/first", + frameId: 0, + documentId: "doc-first", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const firstSender = new SenderRuntime(firstRawSender); + const firstLoad = await runtime.pageLoad(undefined, firstSender); + expect(firstLoad.ok).toBe(true); + if (!firstLoad.ok) return; + const firstHandle = firstLoad.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeDefined(); + + const secondRawSender = { ...firstRawSender, url: "https://www.example.com/second", documentId: "doc-second" }; + const secondSender = new SenderRuntime(secondRawSender); + const secondLoad = await runtime.pageLoad(undefined, secondSender); + expect(secondLoad.ok).toBe(true); + if (!secondLoad.ok) return; + + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeUndefined(); + }); + + it("rejects a stale URL when the browser omits documentId", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const initialSender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const load = await runtime.pageLoad(undefined, initialSender); + expect(load.ok).toBe(true); + if (!load.ok) return; + const handle = load.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, initialSender)).toBeDefined(); + + const navigatedSender = new SenderRuntime({ + url: "https://www.example.com/next", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + expect(runtime.resolvePageExecutionBinding(handle!, navigatedSender)).toBeUndefined(); + }); + + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const injectHandle = first.injectScriptList[0].executionHandle; + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + expect(contentLoad.injectScriptList).toEqual([]); + expect(contentLoad.contentScriptList[0].executionHandle).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); + }); + + it("isolated scripting 的 pageLoad 会撤销上一轮 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + const contentHandle = contentLoad.contentScriptList[0].executionHandle; + const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); + + expect(injectLoad.ok).toBe(true); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeUndefined(); + }); + + it("没有匹配脚本时也会撤销当前页面的旧绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "stale-script" })); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + getScriptsForTab.mockResolvedValueOnce({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + getScriptsForTab.mockResolvedValueOnce(null); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const handle = first.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeDefined(); + + await runtime.pageLoad(undefined, sender); + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeUndefined(); + }); +}); + +describe("USER_SCRIPT native callbacks", () => { + it("rejects bootstrap and reconnect tokens from a different URL when documentId is missing", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-user-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const originalSender = { + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const connection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const bootstrapSender = { + getType: () => 3, + isType: (type: number) => type === 3, + getSender: () => originalSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0 }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(originalSender)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(bootstrapToken).toEqual(expect.any(String)); + + const navigatedSender = { + ...bootstrapSender, + getSender: () => ({ ...originalSender, url: "https://www.example.com/next" }), + }; + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, navigatedSender)).toBe(false); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + ...navigatedSender, + getType: () => 4, + isType: (type: number) => type === 4, + getConnect: () => undefined, + } + ) + ).toBeUndefined(); + }); + + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "inject-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + const contentScript = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [contentScript], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-main", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-main" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + expect(pageLoad.ok && pageLoad.userScriptInjectBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe( + false + ); + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, connectionSender)).toBe(true); + + const contentConnection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const contentSender = { ...connectionSender, getConnect: () => contentConnection }; + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, + contentSender + ) + ).toBe(true); + expect((runtime as any).userScriptConnections.size).toBe(2); + + const bootstrapHandler = (connection.onMessage as ReturnType).mock.calls[0]?.[0] as + | ((packet: TMessage) => void) + | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + sendMessage.mockClear(); + (runtime as any).sendUserScriptMessage(undefined, "runtime/emitEvent", { + uuid: "inject-script", + event: "click", + eventId: "1", + }); + expect(sendMessage).toHaveBeenCalledWith({ + action: "inject/runtime/emitEvent", + data: { uuid: "inject-script", event: "click", eventId: "1" }, + }); + }); + + it("queues USER_SCRIPT value updates until a reconnect finishes its bootstrap", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "queued-content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const makeConnection = () => + ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }) as unknown as MessageConnect; + const firstConnection = makeConnection(); + const sender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => firstConnection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect(contentBootstrapToken).toEqual(expect.any(String)); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, sender) + ).toBe(true); + + const update = { + uuid: script.uuid, + storageName: getStorageName(script), + entries: [["beforeReconnect", [0, "new"], [0, "old"]]], + sender: { runFlag: "remote", tabId: 42 }, + valueUpdated: true, + }; + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", update); + expect(firstConnection.sendMessage).not.toHaveBeenCalled(); + + const firstBootstrapHandler = (firstConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + firstBootstrapHandler({ action: "userScript/bootstrap" }); + expect(firstConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(firstConnection.sendMessage).toHaveBeenLastCalledWith({ + action: "content/runtime/valueUpdate", + data: update, + }); + + const disconnectHandler = (firstConnection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnect", [0, "next"], [0, "old-next"]]], + }); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnectAgain", [0, "latest"], [0, "old-latest"]]], + }); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: contentBootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + + const secondConnection = makeConnection(); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: reconnect?.bootstrapToken }, + { ...sender, getConnect: () => secondConnection } + ) + ).toBe(true); + const secondBootstrapHandler = (secondConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + secondBootstrapHandler({ action: "userScript/bootstrap" }); + + expect(secondConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(secondConnection.sendMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "content/runtime/valueUpdate", + data: expect.objectContaining({ + entries: [ + ["afterReconnect", [0, "next"], [0, "old-next"]], + ["afterReconnectAgain", [0, "latest"], [0, "old-latest"]], + ], + }), + }) + ); + }); + + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const onMessage = vi.fn(); + const connection = { + onMessage, + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; + const handles = contentBindings.map(({ handle }) => handle); + expect(handles).toHaveLength(1); + expect(pageLoad.ok && pageLoad.userScriptBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + connectionSender + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(true); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); + const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "other-script", + storageName: getStorageName(script), + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "content-script", + storageName: "unrelated-storage", + }); + expect(sendMessage).not.toHaveBeenCalled(); + + const reconnect = runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "extension" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + expect((runtime as any).userScriptBootstraps.size).toBe(1); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ) + ).toBeUndefined(); + + (runtime as any).revokePageBindingsForScript("content-script"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect((runtime as any).userScriptConnections.size).toBe(0); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index d44ef0215..d0233c5b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1,7 +1,13 @@ -import type { EmitEventRequest, ScriptLoadInfo, ScriptMatchInfo, ScriptMenu } from "./types"; +import type { + EmitEventRequest, + ScriptLoadInfo, + ScriptMatchInfo, + ScriptMenu, + ServiceWorkerExecutionBinding, +} from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; -import type { Group, IGetSender } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; +import { GetSenderType, type Group, type IGetSender } from "@Packages/message/server"; +import type { ExtMessageSender, MessageConnect, MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo } from "@App/app/repo/scripts"; import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; @@ -34,6 +40,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; import { sendMessage } from "@Packages/message/client"; import type { CompileScriptCodeResource } from "../content/utils"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, type ExtensionOrigin } from "../content/page_rpc"; import { compileInjectScriptByFlag, compileScriptCodeByResource, @@ -60,6 +67,7 @@ import { CompiledResourceDAO, CompiledResourceNamespace } from "@App/app/repo/re import { setOnTabURLChanged } from "./url_monitor"; import { scriptToMenu, type TPopupPageLoadInfo, type TPopupPageRestoreInfo } from "./popup_scriptmenu"; import { getExtensionUserAgentData } from "../extension/extension_env"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 @@ -123,6 +131,22 @@ export type TScriptsForTab = { scriptmenus: ScriptMenu[]; } | null; +type UserScriptSession = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken: string; + envTag: "it" | "ct"; + url: string; + tabId: number; + frameId?: number; + documentId?: string; + transport: "userScript" | "extension"; + // 断线窗口内按 storageName 合并值更新,重连握手完成后再投递。 + pendingValueUpdates: Map; +}; +type UserScriptBootstrap = Omit; + const bgScriptStorageNames = new Set(); // For Firefox, StorageArea.setAccessLevel is not implemented. @@ -134,11 +158,450 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 + private readonly pageExecutionBindings = new Map(); + // 原生 page/content 端口只保留各自签发的句柄,回调发送前再按该集合过滤一次。 + private readonly userScriptConnections = new Map< + string, + { + connection: MessageConnect; + handles: Set; + envTag: "it" | "ct"; + tabId: number; + frameId?: number; + documentId?: string; + ready: boolean; + } + >(); + private readonly userScriptBootstraps = new Map(); + // 连接断开后保留当前文档的已验证资料与待投递值更新,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + private readonly userScriptSessions = new Map(); + // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. + private readonly pageLoadSequences = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; } + private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { + // pageLoad 是文档切换信号;按 tab/frame 退休旧句柄,避免旧文档继续使用上一页的权限。 + const source = sender.getSender(); + const tabId = source?.tab?.id; + const frameId = source?.frameId; + for (const [handle, binding] of this.pageExecutionBindings) { + if ( + binding.tabId === tabId && + binding.frameId === frameId && + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) + ) { + this.pageExecutionBindings.delete(handle); + } + } + if (envTag === "it") { + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId && entry.frameId === frameId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); + } + } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId && session.frameId === frameId) this.userScriptSessions.delete(key); + } + } + if (envTag !== "ct") { + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId && bootstrap.frameId === frameId) this.userScriptBootstraps.delete(token); + } + } + } + + revokePageBindingsForTab(tabId: number): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); + } + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); + } + } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); + } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId) this.userScriptSessions.delete(key); + } + const prefix = `${tabId}:`; + for (const key of this.pageLoadSequences.keys()) { + if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); + } + } + + private beginPageLoadSequence(sender: IGetSender, envTag: "it" | "ct" | undefined): [string, number] | undefined { + const tabId = sender.getSender()?.tab?.id; + if (typeof tabId !== "number") return undefined; + const key = `${tabId}:${sender.getSender()?.frameId ?? -1}:${envTag ?? "it"}`; + const sequence = (this.pageLoadSequences.get(key) ?? 0) + 1; + this.pageLoadSequences.set(key, sequence); + return [key, sequence]; + } + + private userScriptConnectionKey( + tabId: number, + frameId: number | undefined, + documentId: string | undefined, + envTag: "it" | "ct" + ): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; + } + + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks; fallback ports remain token-bound. */ + registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + if (data === null || typeof data !== "object") return false; + const handshake = data as { world?: unknown; bootstrapToken?: unknown; transport?: unknown }; + const origin = sender.getConnectOrigin?.(); + const isExtensionFallback = origin === "extension" && handshake.transport === "extension"; + if (origin === "userScript" ? handshake.transport !== undefined : !isExtensionFallback) return false; + if ( + Object.keys(data).length !== (isExtensionFallback ? 3 : 2) || + typeof handshake.bootstrapToken !== "string" || + handshake.bootstrapToken.length === 0 || + handshake.bootstrapToken.length > 256 + ) { + return false; + } + const source = sender.getSender(); + const connection = sender.getConnect(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number" || !connection) return false; + const bootstrap = this.userScriptBootstraps.get(handshake.bootstrapToken); + if ( + !bootstrap || + bootstrap.tabId !== tabId || + bootstrap.frameId !== source.frameId || + bootstrap.documentId !== source.documentId || + (bootstrap.documentId === undefined && + (typeof source.url !== "string" || source.url.length === 0 || bootstrap.url !== source.url)) + ) { + return false; + } + // bootstrap 令牌决定唯一可消费这些句柄的 world,调用方不能借握手字段改投其他环境。 + const expectedWorld = bootstrap.envTag === "it" ? "MAIN" : "USER_SCRIPT"; + if (handshake.world !== expectedWorld) return false; + const handles = new Set(); + for (const script of bootstrap.scripts) { + const handle = script.executionHandle; + if (typeof handle !== "string" || handle.length === 0 || handle.length > 256) return false; + const binding = this.pageExecutionBindings.get(handle); + if ( + !binding || + binding.envTag !== bootstrap.envTag || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + return false; + } + handles.add(handle); + } + if (handles.size === 0) return false; + const frameId = source.frameId; + const documentId = source.documentId; + const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); + const session = { ...bootstrap, transport: isExtensionFallback ? ("extension" as const) : ("userScript" as const) }; + this.userScriptSessions.set(key, session); + this.userScriptBootstraps.delete(handshake.bootstrapToken); + const previous = this.userScriptConnections.get(key); + if (previous) previous.connection.disconnect(true); + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId, ready: false }; + this.userScriptConnections.set(key, entry); + connection.onDisconnect(() => { + if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); + }); + let bootstrapped = false; + connection.onMessage((packet) => { + if ( + bootstrapped || + packet === null || + typeof packet !== "object" || + Object.keys(packet).length !== 1 || + packet.action !== "userScript/bootstrap" + ) { + return; + } + bootstrapped = true; + try { + const pageLoadData = { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + reconnectToken: bootstrap.reconnectToken, + ...(bootstrap.envTag === "ct" ? { extensionOrigin: bootstrap.extensionOrigin } : {}), + }; + connection.sendMessage({ + action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, + data: pageLoadData, + }); + entry.ready = true; + this.flushPendingUserScriptValueUpdates(key, entry); + } catch { + this.userScriptConnections.delete(key); + } + }); + return true; + } + + reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { + if (!sender.isType(GetSenderType.RUNTIME)) { + return undefined; + } + if ( + data === null || + typeof data !== "object" || + Object.keys(data).length !== 1 || + typeof (data as { reconnectToken?: unknown }).reconnectToken !== "string" || + (data as { reconnectToken: string }).reconnectToken.length === 0 || + (data as { reconnectToken: string }).reconnectToken.length > 256 + ) { + return undefined; + } + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number") return undefined; + let key: string | undefined; + let session: UserScriptSession | undefined; + for (const [candidateKey, candidateSession] of this.userScriptSessions) { + if ( + candidateSession.tabId === tabId && + candidateSession.frameId === source.frameId && + candidateSession.documentId === source.documentId && + (candidateSession.documentId !== undefined || + (typeof source.url === "string" && source.url.length > 0 && candidateSession.url === source.url)) && + candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken + ) { + key = candidateKey; + session = candidateSession; + break; + } + } + if (!key || !session) return undefined; + if (sender.getConnectOrigin?.() !== session.transport) return undefined; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + !binding || + binding.envTag !== session.envTag || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + this.userScriptSessions.delete(key); + return undefined; + } + } + const bootstrapToken = uuidv4(); + const nextSession = { ...session, reconnectToken: uuidv4() }; + for (const [token, bootstrap] of this.userScriptBootstraps) { + if ( + bootstrap.tabId === session.tabId && + bootstrap.frameId === session.frameId && + bootstrap.documentId === session.documentId + ) { + this.userScriptBootstraps.delete(token); + } + } + this.userScriptSessions.set(key, nextSession); + this.userScriptBootstraps.set(bootstrapToken, nextSession); + return { bootstrapToken }; + } + + private queuePendingUserScriptValueUpdate(key: string, data: ValueUpdateDataEncoded): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + const previous = session.pendingValueUpdates.get(data.storageName); + if (!previous) { + session.pendingValueUpdates.set(data.storageName, data); + return; + } + const entries: ValueUpdateDataEncoded["entries"] = previous.entries.map((entry) => [entry[0], entry[1], entry[2]]); + const entryIndexes = new Map(); + for (let index = 0; index < entries.length; index += 1) entryIndexes.set(entries[index][0], index); + for (const entry of data.entries) { + const index = entryIndexes.get(entry[0]); + if (index === undefined) { + entryIndexes.set(entry[0], entries.length); + entries.push([entry[0], entry[1], entry[2]]); + } else { + entries[index] = [entry[0], entry[1], entries[index][2]]; + } + } + session.pendingValueUpdates.set(data.storageName, { + ...data, + entries, + valueUpdated: previous.valueUpdated || data.valueUpdated, + }); + } + + private flushPendingUserScriptValueUpdates( + key: string, + entry: { connection: MessageConnect; envTag: "it" | "ct" } + ): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + for (const [storageName, data] of session.pendingValueUpdates) { + entry.connection.sendMessage({ + action: `${entry.envTag === "it" ? "inject" : "content"}/runtime/valueUpdate`, + data, + }); + session.pendingValueUpdates.delete(storageName); + } + } + + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + const dataRecord = + typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; + const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; + const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + const valueUpdate = + action === "runtime/valueUpdate" && typeof dataRecord?.storageName === "string" + ? (data as ValueUpdateDataEncoded) + : undefined; + // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 + for (const [key, entry] of this.userScriptConnections) { + if ( + to && + (entry.tabId !== to.tabId || + (to.frameId !== undefined && entry.frameId !== to.frameId) || + (to.documentId !== undefined && entry.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } + if (!bindingMatches) continue; + if (!entry.ready) { + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + continue; + } + try { + entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); + } catch { + this.userScriptConnections.delete(key); + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + if (!valueUpdate) return; + for (const [key, session] of this.userScriptSessions) { + if (this.userScriptConnections.has(key)) continue; + if ( + to && + (session.tabId !== to.tabId || + (to.frameId !== undefined && session.frameId !== to.frameId) || + (to.documentId !== undefined && session.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } + if (bindingMatches) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + + private revokePageBindingsForScript(uuid: string): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); + } + for (const [key, entry] of this.userScriptConnections) { + // 脚本撤销后同步裁剪句柄集;没有任何有效句柄的端口必须关闭,避免残留授权接收器。 + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if (!binding || binding.uuid === uuid) entry.handles.delete(handle); + } + if (entry.handles.size === 0) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); + } + for (const [key, session] of this.userScriptSessions) { + if (session.scripts.some((script) => script.uuid === uuid)) this.userScriptSessions.delete(key); + } + } + + private issuePageBinding( + uuid: string, + envTag: "it" | "ct", + storageName: string, + allowedAPIs: readonly string[], + sender: IGetSender + ): ServiceWorkerExecutionBinding { + const source = sender.getSender(); + const tabId = source?.tab?.id; + const url = source?.url; + if (typeof tabId !== "number" || typeof url !== "string" || url.length === 0) { + throw new Error("page execution binding requires a tab and URL"); + } + // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 + const handle = uuidv4(); + const binding = { + handle, + uuid, + envTag, + runFlag: uuidv4(), + url, + tabId, + frameId: source?.frameId, + documentId: source?.documentId, + storageName, + allowedAPIs: new Set(allowedAPIs), + requestIds: new Set(), + } satisfies ServiceWorkerExecutionBinding; + this.pageExecutionBindings.set(handle, binding); + return binding; + } + + resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { + const binding = this.pageExecutionBindings.get(handle); + const source = sender.getSender(); + if ( + !binding || + !source?.tab || + source.tab.id !== binding.tabId || + source.frameId !== binding.frameId || + (binding.documentId === undefined && source.url !== binding.url) + ) + return undefined; + if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; + return binding; + } + private readonly disabledMatcherTaskKey = `runtime_disabled_matcher:${Math.random()}`; private disabledMatcher: UrlMatch | null = null; private disabledMatcherVersion = 0; @@ -478,6 +941,8 @@ export class RuntimeService { sendData, }, }); + // USER_SCRIPT 看不到 scripting world 的页面广播,改经原生扩展连接投递同一份编码 DTO。 + this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 if (bgScriptStorageNames.has(sendData.storageName)) { @@ -527,7 +992,8 @@ export class RuntimeService { this.msgSender, this.mq, this.value, - new GMExternalDependencies(this) + new GMExternalDependencies(this), + this.resolvePageExecutionBinding.bind(this) ); permission.init(); this.gmApi.start(); @@ -536,6 +1002,8 @@ export class RuntimeService { this.group.on("runScript", this.runScript.bind(this)); this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); + this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); + this.group.on("reconnectUserScript", this.reconnectUserScript.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -548,6 +1016,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; for (const { uuid, enable } of data) { + this.revokePageBindingsForScript(uuid); const script = await this.scriptDAO.get(uuid); if (!script) { this.logger.error("script enable failed, script not found", { @@ -582,6 +1051,7 @@ export class RuntimeService { // 监听脚本安装 this.mq.subscribe("installScript", async (data) => { const uuid = data.script.uuid; + this.revokePageBindingsForScript(uuid); this.invalidateDisabledMatcher(); this.deleteScriptRuntimeCache(uuid); @@ -620,6 +1090,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; this.updateSorter((next) => { for (const { uuid } of data) { + this.revokePageBindingsForScript(uuid); unregisterUuids.push(uuid); this.deleteScriptRuntimeCache(uuid); this.deleteScriptSort(next, uuid); @@ -844,6 +1315,12 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { + this.pageExecutionBindings.clear(); + this.userScriptSessions.clear(); + for (const [key, entry] of this.userScriptConnections) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1160,6 +1637,7 @@ export class RuntimeService { // 如果是-1, 代表给offscreen发送消息 return sendMessage(this.msgSender, "offscreen/runtime/emitEvent", req); } + this.sendUserScriptMessage(to, "runtime/emitEvent", req); return sendMessage( new ExtensionContentMessageSend(to.tabId, { documentId: to.documentId, @@ -1266,17 +1744,26 @@ export class RuntimeService { } } - async pageLoad(_: any, sender: IGetSender): Promise { + async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + // USER_SCRIPT 只能通过一次性 bootstrap 获取 content-world 资料,不能自行请求 pageLoad。 + if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; + const pageLoadSequence = this.beginPageLoadSequence(sender, data?.envTag); const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + if (pageLoadSequence && this.pageLoadSequences.get(pageLoadSequence[0]) !== pageLoadSequence[1]) { + return { ok: false }; + } + + // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 + this.revokePageBindings(sender, data?.envTag); this.mq.emit("popupPageLoadUpdate", { tabId: tabId, @@ -1286,12 +1773,55 @@ export class RuntimeService { }); if (res) { + const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => + scripts.map((script) => { + const binding = this.issuePageBinding( + script.uuid, + envTag, + getStorageName(script), + getPageRpcAllowedAPIs(script.metadata.grant || []), + sender + ); + return { + ...script, + executionHandle: binding.handle, + executionEnvTag: envTag, + executionRunFlag: binding.runFlag, + }; + }); + const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); + const contentScriptList = prepareScripts(res.contentScriptList, "ct"); + let userScriptBootstrapToken: string | undefined; + let userScriptInjectBootstrapToken: string | undefined; + if (data?.envTag === "it") { + const createBootstrap = (scripts: TScriptInfo[], envTag: "it" | "ct"): string | undefined => { + if (scripts.length === 0) return undefined; + const token = uuidv4(); + this.userScriptBootstraps.set(token, { + scripts, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + reconnectToken: token, + envTag, + url, + tabId, + frameId, + documentId: chromeSender.documentId, + pendingValueUpdates: new Map(), + }); + return token; + }; + userScriptInjectBootstrapToken = createBootstrap(injectScriptList, "it"); + userScriptBootstrapToken = createBootstrap(contentScriptList, "ct"); + } // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: res.injectScriptList, - contentScriptList: res.contentScriptList, + injectScriptList, + contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, + userScriptBootstrapToken, + userScriptInjectBootstrapToken, }; } else { // 没有脚本资料,不需要加载 @@ -1308,7 +1838,7 @@ export class RuntimeService { const url = chromeSender?.url; if (!url) return; this.mq.emit("popupPageRestored", { - tabId: chromeSender.tab?.id || -1, + tabId: chromeSender.tab?.id ?? -1, frameId: chromeSender.frameId, url, }); diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5197fa039..00397d794 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -46,6 +46,31 @@ export type MessageRequest = { api: string; runFlag: string; params: T; + /** 页面执行环境绑定的能力句柄;后台脚本不携带此字段。 */ + executionHandle?: string; + /** 页面 GM RPC 的版本和请求关联字段。 */ + version?: 1; + requestId?: string; + handle?: string; + envTag?: "it" | "ct"; +}; + +export type ServiceWorkerExecutionBinding = { + handle: string; + uuid: string; + envTag: "it" | "ct"; + runFlag: string; + /** The URL observed when this execution binding was issued. */ + url: string; + tabId: number; + frameId?: number; + documentId?: string; + /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ + storageName: string; + /** 隔离 GM API broker 为本次页面执行接受的能力名称。 */ + allowedAPIs: ReadonlySet; + /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ + requestIds: Set; }; export type GMApiRequest = MessageRequest & { diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 94c31b627..f856b1954 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { isBase64, parseUrlSRI, @@ -6,14 +6,16 @@ import { selfMetadataUpdate, getUserScriptRegister, compileInjectionCode, + parseScriptLoadInfo, shouldAutoOpenChangelog, scriptURLPatternResults, } from "./utils"; -import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; +import type { SCMetadata, ScriptLoadInfo, Script } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns, RuleTypeBit } from "@App/pkg/utils/url_matcher"; +import { compilePreInjectScript } from "../content/utils"; describe.concurrent("parseUrlSRI", () => { it.concurrent("should parse URL SRI", () => { @@ -311,7 +313,7 @@ describe.concurrent("getUserScriptRegister", () => { }); describe.concurrent("compileInjectionCode", () => { - const createMockScriptRes = (overrides: Partial = {}): ScriptRunResource => ({ + const createMockScriptRes = (overrides: Partial = {}): ScriptLoadInfo => ({ uuid: "test-uuid", name: "Test Script", namespace: "test.namespace", @@ -327,6 +329,8 @@ describe.concurrent("compileInjectionCode", () => { resource: {}, metadata: {}, originalMetadata: {}, + metadataStr: "", + userConfigStr: "", ...overrides, }); @@ -354,9 +358,32 @@ describe.concurrent("compileInjectionCode", () => { // 包含沙箱封装 expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); - // 使用 compileInjectScript 包裹(window[flag] = function(){...}) - expect(result).toContain("window['#-test-uuid']"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + // 使用 compileInjectScript 包裹并挂载脚本标志 + expect(result).toContain("window, '#-test-uuid'"); + }); + + it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { + const scriptRes = createMockScriptRes({ + metadata: { "early-start": [""], "run-at": ["document-start"] }, + scriptUrlPatterns: extractUrlPatterns(["@include /example\\.com/"]), + }); + const result = compilePreInjectScript(parseScriptLoadInfo(scriptRes, scriptRes.scriptUrlPatterns ?? []), "", false); + const dispatchEvent = vi.fn(() => true); + const performance = { dispatchEvent, addEventListener: vi.fn() }; + const customEvent = class { + constructor( + readonly type: string, + readonly init: unknown + ) {} + }; + const run = new Function("window", "performance", "CustomEvent", "location", result); + + run(Object.create(null), performance, customEvent, { href: "https://other.example/" }); + expect(dispatchEvent).not.toHaveBeenCalled(); + + run(Object.create(null), performance, customEvent, { href: "https://example.com/" }); + expect(dispatchEvent).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..a73e0a486 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -100,6 +100,51 @@ describe("ValueService - setValue 方法测试", () => { vi.restoreAllMocks(); }); + it("persists __proto__ as an own value key without polluting inherited values", async () => { + const mockScript = createMockScript(); + const stored = { leaked: "secret" }; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + await valueService.setValues({ + uuid: mockScript.uuid, + keyValuePairs: [["__proto__", encodeRValue(stored)]], + valueSender: createMockValueSender(), + isReplace: false, + }); + + const savedData = vi.mocked(mockValueDAO.save).mock.calls[0][1].data; + expect(Object.prototype.hasOwnProperty.call(savedData, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(savedData)).toBe(Object.prototype); + expect(savedData.__proto__).toEqual(stored); + expect((savedData as Record).leaked).toBeUndefined(); + }); + + it("does not let a bound config key change the returned value object's prototype", async () => { + const mockScript = createMockScript({ + config: { + settings: { + setting: { + bind: "$__proto__", + default: { polluted: true }, + index: 0, + }, + }, + } as any, + }); + const stored = {}; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue({ data: stored } as any); + + const values = await valueService.getScriptValue(mockScript); + + expect(Object.getPrototypeOf(values)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(values, "__proto__")).toBe(true); + expect(values.__proto__).toBeUndefined(); + expect((values as Record).polluted).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..fd741d9c8 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -15,6 +15,15 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { decodeRValue, R_UNDEFINED, encodeRValue } from "@App/pkg/utils/message_value"; +const setOwnValue = (store: Record, key: string, value: any): void => { + Object.defineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + export type TSetValuesParams = { uuid: string; id?: string; @@ -41,10 +50,12 @@ export class ValueService { } async getScriptValueDetails(script: Script) { - let data: { [key: string]: any } = {}; + const data: { [key: string]: any } = Object.create(null); const ret = await this.valueDAO.get(getStorageName(script)); if (ret) { - data = ret.data; + for (const key of Object.keys(ret.data)) { + setOwnValue(data, key, ret.data[key]); + } } const newValues = data; // 和userconfig组装 @@ -62,10 +73,13 @@ export class ValueService { // 动态变量 if (tab[key].bind) { const bindKey = tab[key].bind!.substring(1); - newValues[bindKey] = data[bindKey] === undefined ? undefined : data[bindKey]; + setOwnValue(newValues, bindKey, data[bindKey] === undefined ? undefined : data[bindKey]); } - newValues[`${tabKey}.${key}`] = - data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`]; + setOwnValue( + newValues, + `${tabKey}.${key}`, + data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`] + ); } } } @@ -107,7 +121,7 @@ export class ValueService { for (const [key, rTyped1] of keyValuePairs) { const value = decodeRValue(rTyped1); if (value !== undefined) { - dataModel[key] = value; + setOwnValue(dataModel, key, value); entries.push([key, rTyped1, R_UNDEFINED]); } } @@ -134,7 +148,7 @@ export class ValueService { if (value === undefined) { delete dataModel[key]; } else { - dataModel[key] = value; + setOwnValue(dataModel, key, value); } const rTyped2 = encodeRValue(oldValue); entries.push([key, rTyped1, rTyped2]); diff --git a/src/content.ts b/src/content.ts index 094f345ac..3f68af55c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,20 +1,28 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { GMInfoEnv } from "./app/service/content/types"; +import type { ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + // USER_SCRIPT 使用浏览器原生扩展通道;DOM 通道只保留同步元素辅助 API, + // 因为节点引用必须留在当前 content realm。 + const msg: Message = new ExtensionMessage(false); + const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); // 初始化日志组件 const logger = new LoggerCore({ @@ -26,8 +34,46 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("content start"); const server = new Server("content", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, scriptEnvTag)); + const domServer = new Server("content", domMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.contentInit(); + runtime.contentInit(domServer, domMsg); + let reconnecting = false; + let reconnectToken: string | undefined; + const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "content/pageLoad") { + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "content/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "content/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + const openUserScriptChannel = async (bootstrapToken: string): Promise => { + try { + await connectUserScriptChannel(msg, bootstrapToken, handleUserScriptPacket, (isSelfDisconnected) => { + if (isSelfDisconnected || reconnecting) return; + if (!reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(msg, reconnectToken) + .then((nextToken) => (nextToken ? openUserScriptChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }); + } catch (error) { + logger.logger().debug("USER_SCRIPT channel failed", { error: String(error) }); + } + }; + domServer.on( + "pageLoad", + (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openUserScriptChannel(data.bootstrapToken); + } + ); runtime.init(); }); diff --git a/src/inject.ts b/src/inject.ts index 0d13290da..ec44e611c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,24 +1,36 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; +import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; +import { Client } from "@Packages/message/client"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; +import { createMainWorldPageLoadGate } from "./app/service/content/main_world_page_load_gate"; const messageFlag = process.env.SC_RANDOM_KEY!; +const NATIVE_BOOTSTRAP_TIMEOUT_MS = 1000; + getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const pageMsg: Message = new PageMessage(eventFlag, "inject"); + const nativeMsg: Message = new ExtensionMessage(false); + // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 + const canUseNativeChannel = hasNativeRuntimeChannel; + const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), + writer: new MessageWriter(msg, canUseNativeChannel ? "serviceWorker/logger" : "scripting/logger"), consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 labels: { env: "inject", href: window.location.href }, }); @@ -26,10 +38,123 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("inject start"); const server = new Server("inject", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, ScriptEnvTag.content)); + const scriptExecutor = new ScriptExecutor( + msg, + new CustomEventMessage(eventFlag, true, ScriptEnvTag.content), + canUseNativeChannel ? "serviceWorker" : "scripting" + ); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.init(); + const pageServer = canUseNativeChannel ? new Server("inject", pageMsg) : undefined; + let reconnecting = false; + let openingNative = false; + let nativeConnection: MessageConnect | undefined; + let pendingNativeReady: + | { + resolve: (connected: boolean) => void; + timer: ReturnType; + } + | undefined; + let reconnectToken: string | undefined; + + const settleNativeReady = (connected: boolean): void => { + const pending = pendingNativeReady; + if (!pending) return; + pendingNativeReady = undefined; + clearTimeout(pending.timer); + pending.resolve(connected); + }; + + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "inject/pageLoad") { + if (!pendingNativeReady) return; + nativeConnection = _connection; + settleNativeReady(true); + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "inject/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "inject/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return Boolean(nativeConnection); + openingNative = true; + return new Promise((resolve) => { + const timer = setTimeout(() => { + const connection = nativeConnection; + nativeConnection = undefined; + settleNativeReady(false); + try { + connection?.disconnect(true); + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel cleanup failed", { error: String(error) }); + } + }, NATIVE_BOOTSTRAP_TIMEOUT_MS); + pendingNativeReady = { resolve, timer }; + + void connectUserScriptChannel( + nativeMsg, + bootstrapToken, + handleNativePacket, + (isSelfDisconnected) => { + nativeConnection = undefined; + settleNativeReady(false); + if (isSelfDisconnected || reconnecting || !reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(nativeMsg, reconnectToken) + .then((nextToken) => (nextToken ? openNativeChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("MAIN USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }, + "MAIN" + ) + .then((connection) => { + if (!connection) { + settleNativeReady(false); + return; + } + if (pendingNativeReady || nativeConnection === connection) { + nativeConnection = connection; + return; + } + connection.disconnect(true); + }) + .catch((error) => { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + settleNativeReady(false); + }) + .finally(() => { + openingNative = false; + }); + }); + }; + + if (pageServer) { + const pageLoadGate = createMainWorldPageLoadGate( + openNativeChannel, + (data) => runtime.receivePageLoad(data), + () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + } + ); + pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + pageLoadGate.onBootstrap(data.bootstrapToken); + }); + pageServer.on("pageLoad", pageLoadGate.onPageLoad); + } + runtime.init(); + if (!pageServer) { + // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 + server.on("bootstrap", () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + }); + } // inject环境,直接判断白名单,注入对外接口 - runtime.externalMessage(); + runtime.externalMessage("scripting", pageMsg); }); diff --git a/src/scripting.ts b/src/scripting.ts index fa943e7ba..0d14a8eaa 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -3,6 +3,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import type { Message } from "@Packages/message/types"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { ScriptEnvTag } from "@Packages/message/consts"; import { Server } from "@Packages/message/server"; import ScriptingRuntime from "./app/service/content/scripting"; @@ -24,7 +25,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { logger.logger().debug("scripting start"); const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); - const injectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); + const injectMsg = new PageMessage(eventFlag, "scripting"); const server = new Server("scripting", [contentMsg, injectMsg]); diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index e1840b447..ef407a2c9 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -146,7 +146,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, onload: (res) => { resolve(true); @@ -169,7 +169,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, onload: (res) => { @@ -209,7 +209,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "blob", onload: (res) => { @@ -250,7 +250,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, responseType: "blob", url: "https://mock-xmlhttprequest.test/", @@ -288,7 +288,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "json", onload: (res) => { @@ -319,7 +319,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, responseType: "json", @@ -346,7 +346,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("get", () => { return new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com", onreadystatechange: (resp) => { if (resp.readyState === 4 && resp.status === 200) { @@ -361,7 +361,7 @@ describe.concurrent("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it.concurrent("json", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://example.com/json", method: "GET", responseType: "json", @@ -375,7 +375,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/", method: "GET", responseType: "json", @@ -389,7 +389,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("header", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/header", method: "GET", headers: { @@ -409,7 +409,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("404", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/notexist", method: "GET", onload: (resp) => { @@ -441,7 +441,7 @@ describe("GM download", () => { const onprogress = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_download({ + gmApi.GM_download(gmApi, { url: "https://download.test/", name: "example.txt", onprogress: onprogress, diff --git a/vitest.config.ts b/vitest.config.ts index dcb067830..26ac0d507 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -45,6 +45,8 @@ const sharedTest = { env: { VI_TESTING: "true", SC_RANDOM_KEY: "005a7deb-3a6e-4337-83ea-b9626c02ea38", + SC_RANDOM_FNKEY: "843078d2-403b-4ec0-a6e0-358488e135ec", + SC_ZN_RAND: "4622da29-026c-47d1-a8f8-ee52bad37129", }, };