diff --git a/.gitignore b/.gitignore index b3638ee02..7b39feee6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,10 +35,12 @@ yarn.lock .claude -CLAUDE.md .omc test-results playwright-report +# 一次性功能验证脚本(见 docs/VERIFICATION.md),不提交、不进 CI +e2e/scratch/ + superpowers diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..441c321e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# Repository Guidelines + +This file provides guidance to AI coding agents (Claude Code, etc.) when working with code in this repository. + +> **Note:** This is the single source of truth. `CLAUDE.md` only contains `@AGENTS.md` and re-imports this file — do not add content to `CLAUDE.md`; put all guidance here in `AGENTS.md`. + +> **Before writing any code, read [`docs/DEVELOP.md`](docs/DEVELOP.md)** — the development spec (commands, +> project structure, coding style, UI & theme rules, testing mechanics, i18n, and the commit/PR workflow). This +> file keeps only the non-negotiable engineering principles and the architecture map; the concrete "how" lives +> in `DEVELOP.md`, and deep internals in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +> **To manually verify a feature actually works, read [`docs/VERIFICATION.md`](docs/VERIFICATION.md)** — drive +> the real built extension end-to-end with one-shot throwaway scratch scripts (not the committed test suite). + +> **Before any translation/localization work, read [`docs/translation/README.md`](docs/translation/README.md)** — +> the single source of truth for translation. Whenever you add or change localized content +> (`src/locales//translation.json`, per-language docs, UI copy, or test snapshots), you must first read +> that guide and follow the matching `docs/translation/terminology-.md` if it exists. + +> **Before adding, editing, reorganizing, or reviewing any contributor doc (this file or `docs/*`), read +> [`docs/DOC-MAINTENANCE.md`](docs/DOC-MAINTENANCE.md)** — keep the doc set organized (links resolve, index +> current, no duplication) and every claim factually true against the current branch (*if you can't grep it on +> this branch, don't claim it*). + +> **Doc map:** [`docs/README.md`](docs/README.md) indexes every contributor doc (development, architecture, +> translation, contributing, localized READMEs). + +## Project Overview + +ScriptCat — Manifest V3 browser extension that runs Tampermonkey-compatible user scripts. TypeScript + React 18 + Rspack. Package manager is **pnpm** (preinstall enforces). + +## Engineering Principles + +These are non-negotiable and apply to every change, regardless of what `docs/DEVELOP.md` says about mechanics. + +- **Fix root causes, not symptoms — refactor over patch.** No `as any` / `// @ts-ignore` / try-catch swallow / defensive skips to make errors disappear. When the clean fix needs restructuring, refactor rather than bolt on a patch (宁愿重构也不要打补丁). If a test fails, fix the code, not the test. +- **Confirm before you fix.** Before touching a reported bug, reproduce it and confirm it actually exists — never fix from assumption. Then capture it in a failing test, then fix, **in that order** (确定 bug 存在 → 写测试 → 修复). +- **TDD/BDD first.** Write failing tests **before** implementation, using BDD-style Chinese `describe`/`it` titles. (Runner, mocks, and how to run tests are in `docs/DEVELOP.md`.) +- **SOLID, high cohesion & low coupling — applied to the existing extension points.** `Repo` for new entities, `Group.on(...)` for new messages. Inject `Group` / `IMessageQueue` / DAOs via constructor; don't `new` them inside methods. Depend on narrow interfaces (`IMessageQueue`, not `MessageQueue`). +- **Direct replacement over adapter sandwiches.** When swapping a backend/library, replace in place — no `interface Foo + LegacyImpl + NewImpl` unless both must coexist at runtime. +- **Scope discipline — stay in your lane.** Bug fix ≠ cleanup PR. Touch only the files the task requires; leave unrelated files untouched (不要动和任务不相干的文件). Don't add helpers, abstractions, validation, or backwards-compat shims you don't need today. Three similar lines beats a premature abstraction. +- **No dead code or `// removed` markers** — git remembers. Delete unused code outright. + +## Architecture + +> **Deep dive:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — the human-facing internals guide for +> contributors working on ScriptCat core: process model, message passing, service/data layers, GM API system, +> script execution, and the build pipeline, with "how to extend" recipes. The section below is the quick map. + +### Multi-Process Model + +5 isolated contexts communicating via message passing: + +``` +Service Worker (src/service_worker.ts) + ├── ExtensionMessage ──────────────→ Content Script (src/content.ts) + │ └── CustomEventMessage ──→ Inject Script (src/inject.ts) + └── ServiceWorkerMessageSend ──────→ Offscreen (src/offscreen.ts) (Chrome; Firefox uses EventPageOffscreenManager) + └── WindowMessage ──→ Sandbox (src/sandbox.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. + +- **Service Worker** — central hub: script CRUD, chrome APIs, permission verification, resource caching, message routing +- **Content** — bridges SW and inject script +- **Inject** — runs in page context with `unsafeWindow` +- **Offscreen** — DOM-capable background environment for background/scheduled scripts +- **Sandbox** — isolated execution via `with(arguments[0])`; cron scheduling + +Execution paths: page scripts → `chrome.userScripts`; background → SW → Offscreen → Sandbox; scheduled → cron in 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), `MessageQueue` (cross-context broadcast). + +### Service & Data Layers +- Services in `src/app/service//` — split by execution context. Constructor-injected `Group`, `IMessageQueue`, DAOs. +- DAOs in `src/app/repo/` extend `Repo` (chrome.storage + cache): `ScriptDAO`, `ValueDAO`, `ResourceDAO`, `PermissionDAO`, `SubscribeDAO`. +- **GM API** split across content / SW / offscreen, each a `GMApi` (content built on `GM_Base`; SW — permission verify, cross-origin; offscreen — DOM-dependent background-script APIs); values via `ValueService`. + +### Browser Extension APIs (MV3) +`chrome.userScripts` (page injection), Offscreen API (DOM in background), Declarative Net Request (intercepts `.user.js` URLs to trigger install flow). + +### Key Packages + +`message/` (with mocks), `filesystem/` (WebDAV + local), `cloudscript/`, `eslint/` (userscript lint config — `eslint-plugin-userscripts`-based `defaultConfig` for the in-app editor), `chrome-extension-mock/`. + +> The project's own custom ESLint rule `require-last-error-check` (enforces `chrome.runtime.lastError` handling) lives in `eslint-rules/` at the repo root and is wired in `eslint.config.mjs` — not in `packages/eslint/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76963f364..19b0c824b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,8 @@ Before submitting an issue, we recommend that you first check the [existing Issu ScriptCat is an evolving project. If you encounter problems during use and are confident that these issues are caused by ScriptCat, we welcome you to submit an Issue. When submitting, please include detailed reproduction steps and runtime environment information. +**Security vulnerabilities are an exception — do not open a public Issue.** Report them privately following our [Security Policy](SECURITY.md), so a fix can be prepared before the issue is disclosed. + ### Proposing New Features We welcome you to propose new feature suggestions in Issues. To help us better understand your needs, we recommend that you describe the feature in as much detail as possible and provide what you think might be a possible solution. diff --git a/SECURITY.md b/SECURITY.md index 034e84803..8d9d3fe22 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,56 @@ # Security Policy +ScriptCat is a Manifest V3 browser extension that runs user-provided scripts with broad +privileges, so we take security reports seriously. Thank you for helping keep users safe. + ## Supported Versions -Use this section to tell people about which versions of your project are -currently being supported with security updates. +ScriptCat is distributed through the Chrome Web Store, Firefox Add-ons, and Edge Add-ons and +auto-updates to the latest release. Security fixes ship in **new releases only**: + +| Version | Supported | +| --- | --- | +| Latest stable release | :white_check_mark: | +| Current beta channel | :white_check_mark: | +| Any older / superseded version | :x: — update to the latest | -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | +Because the stores auto-update, staying on the latest version is the most reliable mitigation. ## Reporting a Vulnerability -Use this section to tell people how to report a vulnerability. +**Please do not report security vulnerabilities through public GitHub Issues, Discord, the +Telegram group, or any other public channel** — doing so discloses the issue to attackers +before a fix is available. + +Instead, report it privately: + +1. **Preferred — GitHub private vulnerability reporting.** Open a private advisory at + (repo **Security** tab → + *Report a vulnerability*). The report is visible only to you and the maintainers. +2. **Fallback.** If you cannot use that, send a **private** direct message to a maintainer via + [Telegram](https://t.me/scriptscat) or [Discord](https://discord.gg/JF76nHCCM7) asking for a + private channel — do not post details in the public chat. + +Please include, as far as you can: + +- affected version(s), browser, and OS; +- the impact — what an attacker can actually do; +- step-by-step reproduction, plus a proof-of-concept userscript or page if applicable; +- any suggested fix or mitigation. + +### What to expect + +- We aim to acknowledge your report within a few days and to keep you updated as we investigate. +- We follow **coordinated disclosure**: we prepare a fix and agree a disclosure timeline with you + before any public write-up, and we credit you unless you ask to stay anonymous. + +## 中文上报指引 + +请**不要**通过公开的 GitHub Issue、Discord、Telegram 群或任何公开渠道提交安全漏洞——那会在修复发布前把问题暴露给攻击者。 + +请改用私密渠道: + +- **首选**:GitHub 私密漏洞上报——在 提交私密 advisory(仓库 **Security** 标签页 → *Report a vulnerability*),仅你与维护者可见。 +- **备选**:通过 [Telegram](https://t.me/scriptscat) 或 [Discord](https://discord.gg/JF76nHCCM7) **私信**维护者索取私密渠道,不要在公开群里贴细节。 -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. +请尽量附上:受影响版本 / 浏览器 / 操作系统、影响说明(攻击者能做什么)、复现步骤与 PoC、以及可能的修复建议。我们会尽快确认并与你协调披露时间,默认在公开前先修复,并为你署名(除非你希望匿名)。 diff --git a/docs/AI prompt.md b/docs/AI prompt.md deleted file mode 100644 index e63097343..000000000 --- a/docs/AI prompt.md +++ /dev/null @@ -1,51 +0,0 @@ -# AI Prompt - -我将在这里记录下开发过程中的AI提示词,让AI更好的助力项目发展(使用VSCode Github Copilot Agent模式) - -## 术语与本地化修改规则 - -以下要求适用于所有会新增或修改本地化内容的 AI 任务,包括 `src/locales//translation.json`、对应语言的文档、界面文案与测试快照: - -```md -凡是新增或修改某个语言地区(locale)的内容,必须先检查是否存在对应的术语规范文件 `docs/terminology-.md`;如果存在,必须读取并遵循该文件。例如,修改 Traditional Chinese / 繁体中文(zh-TW)时,必须遵循 `docs/terminology-zh-TW.md`。 - -- 遵循目标语言地区的自然表达和产品界面惯用语,不可仅做文字或字形的机械转换。 -- 对应术语规范文件中标注为固定保留的术语,或明确限定在当前同类 UI 场景中的修正规则,必须遵循。 -- 不要把某个界面文案的修正扩大成该词在所有上下文中的禁用规则;对于需要结合语境判断的项目,不得机械式全局替换,应根据功能语境与原文含义选择用词。 -- 如果目标 locale 尚无术语规范文件,应保持现有翻译风格,并避免擅自引入新的术语标准。 -- 保留 i18next placeholder、程序标识符、HTML/React 标记和既有功能行为。 -- 完成后检查本次修改的本地化内容,确认符合对应的术语规范文件(如存在)。 -``` - -## 单元测试 - -```md -### 角色 -你是一名专业的 TypeScript 测试工程师,精通 Vitest 测试框架和单元测试最佳实践。 - -### 任务 -请为我提供的 TypeScript 文件编写完整的单元测试套件,遵循以下规范: -1. **测试框架**:使用 Vitest -2. **文件命名**:`<原文件名>.test.ts` 格式,与原文件同级目录 -3. **测试覆盖**: - - 覆盖所有导出函数/类 - - 包含正向、负向和边界测试用例 - - 验证异步逻辑和错误处理 -4. **最佳实践**: - - 使用 `describe`/`it` 组织测试结构 - - 包含必要的 setup/teardown 逻辑 - - 使用 `vi.fn()`/`vi.mock()` 模拟外部依赖 - - 添加清晰的测试描述 - -### 输入格式 -请严格按此格式提供被测试代码,下面请为此文件编写单元测试 - -``` - -## 提取翻译 - -```md - -你是一个翻译专家,使用react-i18next做为翻译框架,我需要你帮助我翻译这个React文件中的中文,首先你需要提取文件中的中文部分,生成一个合适的key,使用蛇形命名,添加到 src/locales/zh-CN/translations.json 文件中,然后使用`useTranslation`替换原有中文,如果有参数你可以使用i18next的格式,不需要处理其他语言,不要做多余的事情 - -``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 000000000..3a4fdce8f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,579 @@ +# ScriptCat Architecture & Internals + +> **Audience.** Contributors who work on **ScriptCat itself** — the browser extension — not script authors. +> If you want to *write* userscripts, read [docs.scriptcat.org](https://docs.scriptcat.org/) instead. +> +> **Scope.** This document is the deep-dive companion to [`AGENTS.md`](../AGENTS.md) (terse contributor +> guide + conventions) and [`CONTRIBUTING.md`](../CONTRIBUTING.md) (setup + PR workflow). It explains *how the +> pieces fit together and why*: the multi-process model, message passing, the service/data layers, the GM API +> system, script execution, and the build pipeline. File references use repo-relative paths and are clickable. + +--- + +## Table of Contents + +1. [The Big Picture](#1-the-big-picture) +2. [The Five Contexts (Process Model)](#2-the-five-contexts-process-model) +3. [Message Passing](#3-message-passing) +4. [The Service Layer](#4-the-service-layer) +5. [The Data Layer (`Repo` + DAOs)](#5-the-data-layer-repot--daos) +6. [The GM API System](#6-the-gm-api-system) +7. [Script Execution](#7-script-execution) +8. [Build Pipeline & Manifest](#8-build-pipeline--manifest) +9. [Workspace Packages](#9-workspace-packages) +10. [Extending ScriptCat — Recipes](#10-extending-scriptcat--recipes) +11. [Testing the Internals](#11-testing-the-internals) + +--- + +## 1. The Big Picture + +ScriptCat is a **Manifest V3** browser extension that runs Tampermonkey-compatible userscripts, plus its own +**background** and **scheduled** script types that have no Tampermonkey equivalent. MV3 fragments an extension +into several sandboxed JavaScript realms that cannot share memory; ScriptCat therefore runs as a small +**distributed system** of cooperating contexts that talk over message channels. + +Three ideas explain almost everything in the codebase: + +- **Contexts are processes.** Each entry point (`service_worker`, `content`, `inject`, `offscreen`, `sandbox`) + is an isolated realm. They never share objects — only serializable messages. +- **One message layer, several transports.** [`packages/message`](../packages/message) abstracts + `chrome.runtime`, `postMessage`, and DOM `CustomEvent` behind a single RPC + pub/sub API, so services are + written against interfaces (`Server`/`Group`/`Client`/`IMessageQueue`), not raw browser APIs. +- **Services are constructor-injected.** Domain logic lives in services that receive their `Group`, + `IMessageQueue`, and DAOs through the constructor and register message handlers in an `init()` method. This + is what makes the system testable with the mock message bus. + +``` + ┌───────────────────────────────────────────────┐ + │ SERVICE WORKER │ + │ central hub: script CRUD, chrome.* APIs, │ + │ permission checks, resource cache, routing │ + │ Server("serviceWorker") + MessageQueue │ + └───────┬───────────────────────────────┬─────────┘ + ExtensionMessage │ │ ServiceWorkerMessageSend (Chrome) + (chrome.runtime) │ │ / EventPageOffscreenManager (Firefox) + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ CONTENT SCRIPT │ │ OFFSCREEN DOCUMENT │ + │ bridges SW ↔ inject │ │ DOM-capable background │ + │ Server("content") │ │ Server("offscreen") │ + └──────────┬───────────┘ └─────────────┬────────────┘ + CustomEventMessage WindowMessage + (DOM CustomEvent) (window.postMessage) + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ INJECT SCRIPT │ │ SANDBOX (iframe) │ + │ page realm, │ │ with(){} script eval, │ + │ unsafeWindow access │ │ cron scheduling │ + │ Server("inject") │ │ Server("sandbox") │ + └──────────────────────┘ └──────────────────────────┘ + + MessageQueue (pub/sub) broadcasts state changes across ALL contexts. +``` + +--- + +## 2. The Five Contexts (Process Model) + +Each context is a separate bundle (see [§8](#8-build-pipeline--manifest)) with its own entry file in `src/`. + +| 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")` | +| **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 [§7](#7-script-execution)). + +### Service-worker bootstrap + +[`src/service_worker.ts`](../src/service_worker.ts) is the canonical example of how a context wires itself up: + +```ts +const message = new ExtensionMessage(true); // backgroundPrimary = true +const server = new Server("serviceWorker", message); // RPC listener, action prefix "serviceWorker/" +const messageQueue = new MessageQueue(); // pub/sub broadcast bus + +const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; +if (hasOffscreenDocument) { + const offscreen = new ServiceWorkerMessageSend(); // Chrome: talk to the real offscreen document + new ServiceWorkerManager(server, messageQueue, offscreen).initManager(); + setupOffscreenDocument(); // chrome.offscreen.createDocument(...) +} else { + const offscreen = new EventPageOffscreenManager(message); // Firefox MV3: event page IS the DOM env + new ServiceWorkerManager(server, messageQueue, offscreen).initManager(); +} +``` + +### Chrome vs Firefox: the offscreen split + +This is the most important platform divergence to understand. MV3 service workers have **no DOM**, but +ScriptCat needs DOM for things like `DOMParser`, Blobs, and clipboard. Chrome solves this with the +**Offscreen API** (a hidden document). Firefox MV3 has no offscreen API, so its background **event page** +already has DOM and plays the offscreen role directly. + +- **Chrome:** SW → Offscreen uses [`ServiceWorkerMessageSend`](../packages/message/window_message.ts) (it + finds the offscreen client via `clients.matchAll()` and `postMessage`s it); Offscreen → SW replies over + `ExtensionMessage` (`chrome.runtime`). +- **Firefox:** [`EventPageOffscreenManager`](../src/app/service/offscreen/event_page_manager.ts) substitutes + for the offscreen document; `service_worker.ts` emits `preparationOffscreen` immediately because the DOM + environment is already live. + +Services never see this difference: they receive an `IOffscreenSend` and call `.init()`. + +--- + +## 3. Message Passing + +Everything cross-context flows through [`packages/message`](../packages/message). It provides **two +communication styles** over **several transports**. + +### 3.1 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) | +| `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) | All contexts (broadcast) | `chrome.runtime.sendMessage` + local `EventEmitter3` | +| `MockMessage` | [`mock_message.ts`](../packages/message/mock_message.ts) | Tests | in-memory `EventEmitter3` | + +All transports implement the small `Message` / `MessageSend` / `MessageConnect` interfaces in +[`types.ts`](../packages/message/types.ts), so higher layers don't care which one they got. + +### 3.2 Style 1 — Request/Reply RPC (`Server` / `Group` / `Client`) + +A request/reply call is identified by an **action string** like `"script/install"`. + +- [`Server`](../packages/message/server.ts) listens on a transport and routes by **action prefix**. The SW + creates `new Server("serviceWorker", message)`, so it only handles actions beginning with `serviceWorker/`. +- [`Group`](../packages/message/server.ts) namespaces handlers. `server.group("script")` returns a group whose + `.on("install", fn)` registers the full action `script/install`. Groups can nest and carry **middleware** + (`group.use(fn)`), which is how `RuntimeService` delays handling until initialization finishes. +- [`Client`](../packages/message/client.ts) is the caller side. It sends an action + params and awaits the + reply. + +Replies are wrapped in a uniform envelope: `{ code: 0, data }` on success or `{ code: -1, message }` on error, +so the calling side can reject the promise on failure. + +```ts +// SW side — register +class ValueService { + init(/* … */) { + this.group.on("getScriptValue", this.getScriptValue.bind(this)); + this.group.on("setScriptValues", this.setScriptValues.bind(this)); + } +} +// Caller side — invoke +const value = await client.do("value/getScriptValue", { uuid }); +``` + +### 3.3 Style 2 — Pub/Sub broadcast (`MessageQueue`) + +[`MessageQueue`](../packages/message/message_queue.ts) is a fire-and-forget bus for **state changes** that any +context may care about. `publish(topic, data)` both `chrome.runtime.sendMessage`s the payload to every context +*and* emits locally; `subscribe(topic, fn)` registers a listener and returns an unsubscribe function; `emit` is +local-only (no broadcast). `group(name)` namespaces topics and supports middleware. + +This is how data stays consistent without shared memory. For example, when a script is deleted the SW +`publish`es `deleteScripts`, and `ValueService` reacts by garbage-collecting orphaned values: + +```ts +this.mq.subscribe("deleteScripts", async (data) => { + for (const { storageName } of data) { + const stillUsed = await this.scriptDAO.find((_, s) => getStorageName(s) === storageName); + if (stillUsed.length === 0) await this.valueDAO.delete(storageName); + } +}); +``` + +**Rule of thumb:** use **RPC** when you need an answer; use **MessageQueue** to announce that something +changed. + +### 3.4 Testing the bus + +[`MockMessage`](../packages/message/mock_message.ts) implements the full `Message` interface with an in-memory +`EventEmitter3` and no browser APIs, so message-driven services can be unit-tested. Tests wire it up directly — +e.g. [`tests/utils.ts`](../tests/utils.ts) builds the SW/offscreen mock buses from it. The separate `chrome.*` +mock ([`@Packages/chrome-extension-mock`](../packages/chrome-extension-mock)) is what +[`tests/vitest.setup.ts`](../tests/vitest.setup.ts) registers globally. + +--- + +## 4. The Service Layer + +Services live under `src/app/service//` — **split by the context they run in** — and hold the domain +logic. They are deliberately "dumb plumbing on the outside, smart logic on the inside": construction is pure DI, +wiring happens once in a manager, and message handlers are registered in `init()`. + +``` +src/app/service/ +├── service_worker/ script.ts · value.ts · resource.ts · runtime.ts · popup.ts · subscribe.ts +│ synchronize.ts · system.ts · permission_verify.ts · clipboard.ts · download.ts +│ gm_api/ (SW-side GM handlers) · index.ts (ServiceWorkerManager) +├── content/ script_runtime.ts · script_executor.ts · exec_script.ts · create_context.ts · gm_api/ +├── offscreen/ background-script runner, gm_api, event_page_manager.ts +└── sandbox/ runtime.ts (background/scheduled eval + cron) +``` + +### 4.1 The DI pattern + +Every service takes its collaborators through the constructor — never `new`s them internally. A representative +signature ([`script.ts`](../src/app/service/service_worker/script.ts)): + +```ts +class ScriptService { + constructor( + private readonly systemConfig: SystemConfig, + private readonly group: Group, // RPC namespace for "script/*" + private readonly mq: IMessageQueue, // broadcast bus + private readonly valueService: ValueService, + private readonly resourceService: ResourceService, + private readonly scriptDAO: ScriptDAO // data access + ) {} + + init() { + this.group.on("getAllScripts", this.getAllScripts.bind(this)); + this.group.on("install", this.installScript.bind(this)); + this.group.on("enable", this.enableScript.bind(this)); + // … ~20 more handlers + } +} +``` + +Two consequences worth internalizing: + +- **Depend on narrow interfaces** (`IMessageQueue`, not `MessageQueue`) so tests can pass `MockMessage`. +- **No work in the constructor.** Handler registration and subscriptions belong in `init()`, called by the + manager after the whole graph is built (this avoids ordering hazards between mutually dependent services). + +### 4.2 Wiring: `ServiceWorkerManager` + +[`src/app/service/service_worker/index.ts`](../src/app/service/service_worker/index.ts) is the composition +root for the SW context. It builds DAOs, hands each service its own `group("name")` namespace and the shared +`mq`, then calls `init()`: + +```ts +const scriptDAO = new ScriptDAO(); +scriptDAO.enableCache(); + +const resource = new ResourceService(this.api.group("resource"), this.mq); resource.init(); +const value = new ValueService(this.api.group("value"), this.mq); +const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource, scriptDAO); +script.init(); +const runtime = new RuntimeService(systemConfig, this.api.group("runtime"), this.offscreenSend, this.mq, + value, script, resource, scriptDAO, localStorageDAO); runtime.init(); +const popup = new PopupService(this.api.group("popup"), this.mq, runtime, scriptDAO, systemConfig); popup.init(); +value.init(runtime, popup); // late-bound cross deps resolved after construction +// … synchronize, subscribe, system +``` + +The `group("name")` call is what gives each service its action prefix (`resource/*`, `value/*`, `script/*`, …) +on the single `serviceWorker` `Server`. Other contexts have their own managers (`OffscreenManager`, +`SandboxManager`, `ScriptRuntime` for content/inject) following the same shape. + +--- + +## 5. The Data Layer (`Repo` + DAOs) + +Persistence is a thin generic over `chrome.storage.local` with an optional in-memory cache, in +[`src/app/repo/repo.ts`](../src/app/repo/repo.ts). + +### 5.1 `Repo` + +```ts +export abstract class Repo { + useCache = false; + constructor(protected prefix: string) { + if (!prefix.endsWith(":")) this.prefix += ":"; // every key is ":" + } + enableCache() { this.useCache = true; } // load-once, serve from memory + protected joinKey(key: string) { return this.prefix + key; } + protected _save(key, val): Promise { /* cache or storage */ } + get(key): Promise; + gets(keys): Promise<(T | undefined)[]>; + getRecord(keys): Promise>>; + find(filter?): Promise; findOne(filter?): Promise; all(): Promise; + update(key, val): Promise; updates(keys, val); + delete(key): Promise; deletes(keys): Promise; +} +``` + +Design notes: + +- **Key scheme:** entities are stored under `":"` so a single `chrome.storage.local` namespace + holds every entity type without collisions. `find`/`all` scan the prefix. +- **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. + +### 5.2 The DAOs + +| DAO | File | Entity | Notes | +|---|---|---|---| +| `ScriptDAO` | [`scripts.ts`](../src/app/repo/scripts.ts) | `Script` (metadata) | Cached; companion `ScriptCodeDAO` stores source separately to keep metadata reads small; dedup via `searchExistingScript` | +| `ValueDAO` | [`value.ts`](../src/app/repo/value.ts) | `Value` (GM storage) | Keyed by storage name (per-script or shared `@storageName`) | +| `ResourceDAO` | [`resource.ts`](../src/app/repo/resource.ts) | `Resource` (`@require`/`@resource`) | Overrides `joinKey` to hash URLs; `CompiledResourceDAO` caches compiled deps with a version namespace | +| `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` | `src/app/repo/*.ts` | misc | Same `Repo` pattern | +| `LoggerDAO` | [`logger.ts`](../src/app/repo/logger.ts) | `Logger` | Extends `DAO` (Dexie/IndexedDB), **not** `Repo` — logs need indexed queries | + +### 5.3 Adding an entity is tiny + +```ts +export interface MyEntity { id: string; data: Record; createtime: number; } + +export class MyEntityDAO extends Repo { + constructor() { super("myentity"); } // → keys "myentity:" + save(e: MyEntity) { return this._save(e.id, e); } + findById(id: string) { return this.get(id); } +} +``` + +Then create it in the manager (`enableCache()` if hot), and expose operations via `group.on(...)`. + +--- + +## 6. The GM API System + +The `GM_*` / `GM.*` functions a userscript calls are not one function — each is a small client that forwards +across contexts to a privileged handler, then streams the result back. The implementation is split: + +- **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. +- **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. + +### 6.1 Registration: the `@GMContext.API` decorator + +APIs are declared with a decorator that maps a method to the `@grant` that unlocks it +([`gm_context.ts`](../src/app/service/content/gm_api/gm_context.ts)): + +```ts +function GMContextApiSet(grant, fnKey, api, param) { /* apis.get(grant).push({ fnKey, api, param }) */ } + +class GMContext { + static API(param: ApiParam = {}) { + return (target, propertyName, descriptor) => { + const follow = param.follow ?? propertyName; // the real @grant + GMContextApiSet(follow, propertyName, descriptor.value, param); + if (param.alias) GMContextApiSet(param.alias, param.alias, descriptor.value, param); // GM_x ↔ GM.x + }; + } +} +``` + +When a script context is built, ScriptCat reads the script's `@grant` list and installs exactly the matching +APIs onto the script's `GM` object. On the SW side a parallel `@PermissionVerify.API` decorator wraps handlers +with permission checks before they run. + +### 6.2 End-to-end: `GM_xmlhttpRequest` + +``` +userscript GM_xmlhttpRequest(details) // inject realm + └─ content/gm_api/gm_xhr.ts // client: resolve url/data, open a MessageConnect + └─ ExtensionMessage "GM_xmlhttpRequest" // → service worker + └─ service_worker/gm_api/gm_api.ts // @PermissionVerify.API gate + ├─ buildDNRRule(...) // declarativeNetRequest: spoof headers/referer + └─ fetch/XHR strategy // real cross-origin request + ◄─ streamed chunks over the MessageConnect + ◄─ response object reassembled → details.onload(resp) +``` + +The **persistent connection** (`MessageConnect`, opened via `connect()`) matters here: the response is streamed +back in chunks rather than returned by a single request/reply, which is how progress events and large bodies +work. + +### 6.3 Adding a GM API (sketch) + +1. Add the method to the content `GMApi` with `@GMContext.API({ alias: "GM.foo" })`; for sync APIs return + directly, for async ones forward via `sendMessage`/`connect`. +2. If it needs privilege (network, cookies, tabs), add the handler on the SW `GMApi` with + `@PermissionVerify.API(...)`. +3. If it needs DOM, route through the offscreen GM API instead. +4. Register the `@grant` so the linter and the context builder recognize it (see + [`packages/eslint`](../packages/eslint)). + +--- + +## 7. Script Execution + +There are three execution paths; all share one **compilation** step. + +### 7.1 Compilation — the `with(){}` sandbox wrapper + +[`src/app/service/content/utils.ts`](../src/app/service/content/utils.ts) wraps user code so that global lookups +go through a controlled context object instead of the page's real globals: + +```ts +// compileScriptCodeByResource(): the emitted wrapper +[ + "with(arguments[0]||this.$){", // arguments[0] = the GM context (sandbox) / this.$ = one-shot Proxy + preCode, // @require dependencies, concatenated + "return(async function(){", // async → user code may use top-level await + code, // the user's script body + "}).call(this);}", +].join("\n"); +// then wrapped in try/catch and compiled with `new Function(code)` +``` + +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. +- 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`). + +### 7.2 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 +([`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. + +### 7.3 Path B — Background scripts → Offscreen → Sandbox + +`@background` scripts have no page. The SW asks the Offscreen document to host them, and the Offscreen forwards +evaluation into the **Sandbox iframe** ([`src/app/service/sandbox/runtime.ts`](../src/app/service/sandbox/runtime.ts)). +The sandbox wraps execution in `BgExecScriptWarp`, which supplies managed `setTimeout`/`setInterval` and +`CATRetryError` semantics so long-lived scripts can be cleanly torn down and retried. + +### 7.4 Path C — Scheduled scripts → cron in Sandbox + +`@crontab` scripts are background scripts triggered by a schedule. The sandbox parses the cron expression with +the `cron` library and keeps a `Map`; each fire runs the same `BgExecScriptWarp` path as +background scripts, with a retry list for transient failures. + +--- + +## 8. Build Pipeline & Manifest + +### 8.1 Rspack + +[`rspack.config.ts`](../rspack.config.ts) emits one bundle per context/page. Entry points: + +``` +context bundles : service_worker · offscreen · sandbox · content · inject · scripting +UI pages (React): popup · options · install · batchupdate · confirm · import +workers : editor.worker · ts.worker (Monaco) · linter.worker +``` + +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. +- **Code splitting** pulls big libs into named `lib_*` chunks (react, monaco, arco, dnd-kit, eslint, message), + but **never splits** `service_worker`, `content`, `inject`, `scripting`, or the workers — MV3 requires those + to be single self-contained files. +- **`CopyRspackPlugin`** copies [`src/manifest.json`](../src/manifest.json) — its `transform` rewrites the beta + name (dev/beta) and, for react-tools builds, the CSP — and copies `_locales` and logos. The **version is not** + stamped here; that happens at pack time (see [§8.3](#83-packaging--pnpm-run-pack)). **`HtmlRspackPlugin`** + generates the page HTML shells. + +The dist layout: + +``` +dist/ext/ +├── manifest.json # version-stamped, browser-specialized at pack time +├── assets/ _locales/ +└── src/ + ├── service_worker.js content.js inject.js scripting.js offscreen.js sandbox.js + ├── popup.html/.js options.html/.js install.html/.js … + ├── offscreen.html sandbox.html + └── lib_*.js editor.worker.js ts.worker.js linter.worker.js +``` + +### 8.2 Manifest (MV3) + +[`src/manifest.json`](../src/manifest.json) highlights: + +- `background.service_worker` (Chrome) **and** `background.scripts` (Firefox fallback) point at the same bundle. +- `permissions` include `userScripts`, `declarativeNetRequest`, `offscreen`, `scripting`, `cookies`, + `webRequest`, `unlimitedStorage`, …; `optional_permissions` hold `background` + `userScripts`. +- `host_permissions: [""]`, `incognito: "split"`. +- `sandbox.pages` declares `src/sandbox.html`; `web_accessible_resources` exposes `install.html` so a + `.user.js` page can hand off to the install flow. + +### 8.3 Packaging — `pnpm run pack` + +[`scripts/pack.js`](../scripts/pack.js) drives release packaging: it derives the version (special-casing +alpha/beta into internal version codes), runs the production build, then **emits browser-specific manifests** — +the Chrome variant strips the Firefox `scripts`/CSP bits, while the Firefox variant drops `service_worker` and +`sandbox`, adds `browser_specific_settings` (Gecko ID, min Firefox 136), and filters Chrome-only permissions. +By default it writes the Chrome zip and a `.crx` signed with `dist/scriptcat.pem` (which you must supply +locally); the Firefox zip is gated behind the `PACK_FIREFOX` flag (`false` by default — testers flip it locally). + +--- + +## 9. Workspace Packages + +`pnpm` workspace packages under [`packages/`](../packages): + +| Package | Purpose | +|---|---| +| [`message`](../packages/message) | The cross-context RPC + pub/sub layer (this doc, [§3](#3-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. | +| [`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. | + +Project-local ESLint rules live in [`eslint-rules/`](../eslint-rules); the headline one, +`require-last-error-check`, enforces that `chrome.*` callbacks inspect `chrome.runtime.lastError` (wired in +[`eslint.config.mjs`](../eslint.config.mjs)). + +--- + +## 10. Extending ScriptCat — Recipes + +Map a change onto the existing extension points instead of inventing new structure: + +- **A new cross-context message (RPC).** Pick the owning service, add `this.group.on("myAction", handler)` in + its `init()`, and call it from the other context with a `Client`. No new transport, no new wiring. +- **A new broadcast event.** `this.mq.publish("myTopic", payload)` where state changes; `this.mq.subscribe(...)` + wherever it matters. Use this, not RPC, for "X changed" notifications. +- **A new persisted entity.** Subclass `Repo` ([§5.3](#53-adding-an-entity-is-tiny)), construct it in the + manager, expose ops via `group.on`. +- **A new service.** Constructor-inject `Group` + `IMessageQueue` + DAOs; register handlers in `init()`; + instantiate it in the relevant manager with its own `group("name")`. +- **A new GM API.** Decorate the method with `@GMContext.API` on the content side, add a privileged/offscreen + handler if needed, register the `@grant` ([§6.3](#63-adding-a-gm-api-sketch)). + +Follow the engineering principles in [`AGENTS.md`](../AGENTS.md): fix root causes (no `as any` / swallowed +errors), prefer direct replacement over adapter sandwiches, and keep scope tight — three similar lines beat a +premature abstraction. + +--- + +## 11. Testing the Internals + +- **Unit (Vitest + jsdom).** Co-locate `*.test.ts` next to source. `chrome.*` is mocked via + [`@Packages/chrome-extension-mock`](../packages/chrome-extension-mock) (`tests/vitest.setup.ts`); message-bus + behavior uses `MockMessage`. Run one file: `pnpm test -- --run path/to/file.test.ts`. +- **TDD first.** Write the failing test before the implementation. When a test fails, fix the code — don't edit + the test to pass. +- **E2E (Playwright).** `e2e/*.spec.ts`, one worker, real Chromium. `pnpm run test:e2e` (first run: + `pnpm run test:e2e:install`). +- **Before a PR:** lint + the relevant suite — owned by [`DEVELOP.md`](./DEVELOP.md) → *Testing*. + +The DI + interface design is what makes this tractable: because services receive `IMessageQueue` and DAOs by +constructor, a test builds a service with `MockMessage` and an in-memory DAO and exercises handlers directly, +with no browser. diff --git a/docs/CONTRIBUTING_RU.md b/docs/CONTRIBUTING_RU.md index 95463560c..1db40f4bc 100644 --- a/docs/CONTRIBUTING_RU.md +++ b/docs/CONTRIBUTING_RU.md @@ -12,6 +12,8 @@ ScriptCat — это постоянно развивающийся проект. Если вы столкнетесь с проблемами в процессе использования и уверены, что они вызваны ScriptCat, вы можете создать Issue. При создании, приложите подробные шаги для воспроизведения и информацию о среде выполнения. +**Уязвимости безопасности — исключение: не создавайте публичный Issue.** Сообщайте о них приватно согласно нашей [Политике безопасности](../SECURITY.md), чтобы исправление можно было подготовить до публичного раскрытия. + ### Предложение новых функций Мы приветствуем ваши предложения по новым функциям в Issues. Чтобы мы лучше поняли ваши потребности, максимально подробно опишите функцию и предложите возможные решения. diff --git a/docs/CONTRIBUTING_ZH.md b/docs/CONTRIBUTING_ZH.md index a6da68735..3eb0af5fc 100644 --- a/docs/CONTRIBUTING_ZH.md +++ b/docs/CONTRIBUTING_ZH.md @@ -12,6 +12,8 @@ ScriptCat 是一个不断发展的项目。如果你在使用过程中发现问题,并且确信这些问题是由 ScriptCat 引起的,欢迎提交 Issue。在提交时,请附带详细的复现步骤和运行环境信息。 +**安全漏洞是例外——请勿提交公开 Issue。** 请按照我们的[安全策略](../SECURITY.md)私密上报,以便在公开披露前先完成修复。 + ### 提出新功能 我们欢迎你在 Issue 中提出新的功能建议。为了让我们更好地理解你的需求,建议你尽可能详细地描述这个功能,并提供你认为可能的解决方案。 diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md new file mode 100644 index 000000000..08a25acf4 --- /dev/null +++ b/docs/DEVELOP.md @@ -0,0 +1,113 @@ +# ScriptCat Development Guide (开发规范) + +> **Read this before writing code.** [`AGENTS.md`](../AGENTS.md) holds the non-negotiable engineering +> principles (SOLID / high cohesion & low coupling, TDD/BDD-first, root-cause fixes, scope discipline) and the +> architecture quick-map — those are **not** repeated here. This file is the concrete development spec: the +> commands, structure, coding style, UI/theme rules, testing mechanics, i18n, and commit/PR workflow you follow +> while implementing. For deep internals see [`docs/ARCHITECTURE.md`](./ARCHITECTURE.md). + +## Commands + +```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 build # production Rspack build +pnpm run pack # package the extension (requires dist/scriptcat.pem) + +pnpm test # all tests (Vitest) +pnpm test -- --run path/to/file.test.ts # single test file +pnpm run coverage +pnpm run typecheck # tsc --noEmit + +pnpm run test:e2e:install # install Playwright Chromium (first run only) +pnpm run test:e2e # Playwright (e2e/*.spec.ts, 1 worker) +pnpm run lint # tsc --noEmit + eslint +pnpm run lint-fix # tsc --noEmit + eslint --fix (also applies Prettier via eslint-plugin-prettier) +``` + +No standalone `format` script — formatting is part of `lint-fix`. Husky pre-commit runs `pnpm run lint-fix` and re-stages the files it touched. + +After `pnpm run dev`, load `dist/ext` as an unpacked extension. The browser hot-reloads page changes, but edits to `manifest.json`, `service_worker`, `offscreen`, or `sandbox` require reloading the extension. + +## Project Structure & Module Organization + +Core entry points live in `src` (`service_worker.ts`, `content.ts`, `inject.ts`, `offscreen.ts`, `sandbox.ts`). UI pages are in `src/pages`, with shared UI in `src/pages/components` and state in `src/pages/store`. Reusable domain code is in `src/pkg`; app services are in `src/app`; templates are in `src/template`; assets and translations are in `src/assets` and `src/locales`. Workspace packages live in `packages`, including browser mocks and filesystem adapters. Unit tests are colocated as `*.test.ts`/`*.test.tsx` or placed in `tests`; E2E specs are in `e2e`. + +### Path Aliases + +`@App/* → src/*`, `@Packages/* → packages/*`, `@Tests/* → tests/*` + +## Coding Style & Naming Conventions + +Use strict TypeScript, React JSX runtime, 2-space indentation, semicolons, double quotes, trailing commas where valid, and a 120-column Prettier width. Prefer aliases from `tsconfig.json`: `@App/*`, `@Packages/*`, and `@Tests/*`. ESLint requires type-only imports, allows `_`-prefixed unused variables, warns on literal JSX text, and enforces `chrome.runtime.lastError` checks. Use `pnpm run lint-fix` for mechanical fixes. + +### Language Conventions + +- Comments in Simplified Chinese. +- Code-review responses in Chinese. +- UI default English (global users). +- Template literals: `${i}`, not `${i.toString()}`. + +## UI + +React 18 + Arco Design + UnoCSS + React Router. Pages in `src/pages/`. + +- Use `tw-` UnoCSS utilities; avoid inline `style={{}}`. +- **Hover/focus visuals → CSS pseudo-classes (`hover:`, `focus:`)**, not React state. State is for data/logic. +- **Theme** (light/dark/auto, persisted as `lightMode`, state in `src/pages/store/AppContext.tsx`) — every UI change must work in both themes: + - Arco vars (`var(--color-fill-1)`, `var(--color-text-1)`, `var(--color-border-2)`) — auto-adapt. + - UnoCSS `dark:tw-*` (configured `dark: "class"`). + - `body[arco-theme="dark"]` selector for custom CSS overrides. + - No hard-coded colors. + +## Testing + +> The **TDD/BDD-first principle** (write failing tests before implementation; fix code not tests) lives in +> [`AGENTS.md`](../AGENTS.md) → *Engineering Principles*. This section is the mechanics. + +Vitest + jsdom, 500ms timeout, isolation disabled. Chrome APIs mocked via `@Packages/chrome-extension-mock` (`tests/vitest.setup.ts`). `MockMessage` available for message-system tests. + +- Write failing tests **before** implementation; co-locate `*.test.ts`/`*.test.tsx` next to source (or place in `tests`). +- BDD-style Chinese `describe`/`it` titles. Use `describe.concurrent()` / `it.concurrent()` where independent. +- Single file: `pnpm test -- --run path/to/file.test.ts`. +- 避免冗余测试 — 如果调用方测试已充分覆盖,可省略被调函数的独立单测。 +- Playwright tests are `*.spec.ts` files in `e2e`; they run with one worker and retain failure artifacts. Run targeted tests while iterating, then run `pnpm run lint` plus the relevant full suite before a PR. + +> To **verify a change works end-to-end without growing the suite** — drive the real built extension with a +> throwaway scratch script — see [`VERIFICATION.md`](./VERIFICATION.md). That is lightweight verification, not +> the committed test suite owned by this section. + +## i18n + +i18next, 7 locales (`src/locales/`: en-US, zh-CN, zh-TW, ja-JP, de-DE, vi-VN, ru-RU); extension strings in `src/assets/_locales/`. ESLint `react/jsx-no-literals: warn` enforces translation. For localization, edit `src/locales//translation.json`; new locales must also be registered in `src/locales/locales.ts`. + +**Before translating, read [`docs/translation/README.md`](translation/README.md)** — the translation/localization guide (terminology rules + per-locale `terminology-.md` specs). + +## Security & Configuration Tips + +Do not commit secrets, local certificates, build output, coverage, Playwright reports, test results, or local `.env` changes. + +## Commit & Pull Request Guidelines + +Commits must be single-purpose and **start with a gitmoji emoji** — use the actual emoji character, not the `:code:` text form, for example `git commit -m "🐛 fix template matching"` or `git commit -m "✨ add script filter"`. The leading emoji drives release changelog grouping (see the `release` skill), so pick the one that matches the change: + +| Emoji | Use for | +|---|---| +| ✨ | New feature | +| 🐛 / 🚑 | Bug fix / urgent hotfix | +| ⚡️ | Performance improvement | +| ♻️ | Refactor / compatibility | +| 🎨 / 💄 | Code structure / UI & styling | +| 🔒 | Security | +| ⬆️ | Dependency bump | +| ✅ | Tests | +| 📄 | Docs | +| 🔧 / 👷 / 💚 | Config / CI / CI fix | +| 🔖 | Release / version bump | + +Work from a feature branch or fork and open PRs against `main`. Chinese PR titles are preferred for changelog generation. + +Use `.github/pull_request_template.md` (checklist + description + screenshots). Include a problem/solution summary, linked issues (`close #123` / `fix #123`), test results, and screenshots or recordings for UI changes. + +**Review policy**: review **all** modified files (including `.md`/`.json`); PR description is context only — judge from the diff. Verify every code path touched. diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md new file mode 100644 index 000000000..c1f434e15 --- /dev/null +++ b/docs/DOC-MAINTENANCE.md @@ -0,0 +1,112 @@ +# Documentation Maintenance & Fact-Check Guide + +> **Read this before adding, editing, reorganizing, or reviewing any contributor doc** (`AGENTS.md`, +> `docs/*`). It has two jobs: keep the doc set **organized** (links resolve, index current, no duplication) +> and keep every claim **factually true against the current branch**. + +## Why this exists + +The contributor docs describe a living codebase, so two failure modes recur: + +- **Stale facts** — a class is renamed, a count changes, a file moves; the doc keeps the old value. Real + examples caught in review: docs named the offscreen GM API `OffscreenGMApi` (no such class — it is `GMApi`), + and claimed "8 locales" when there were 7. +- **Branch leakage** — work that only lives on a feature branch gets documented as if it ships on `main`. + Example: the Agent Subsystem only lives on its feature branch and is not committed to `main`; describing it + in `main`'s quick-map misleads readers into expecting code that is not there. + +**Rule of thumb: if you can't `git grep` it in the committed code on this branch, don't claim it.** Verify with +git-aware commands (`git grep`, `git ls-files`, `git ls-tree`) — never a plain `rg`/`ls`, which also match +**untracked** files in your working tree, so feature-branch code sitting in your checkout but not committed to +`main` will masquerade as shipped (this is exactly how the Agent Subsystem above sneaks into a `main` doc). +Aspirational / feature-branch content belongs in that branch's docs, or is clearly marked as planned. + +## Doc set & responsibilities (don't duplicate — cross-link) + +| Doc | Owns | +| --- | --- | +| [`../AGENTS.md`](../AGENTS.md) | Engineering principles + architecture quick-map. Single source of truth; `CLAUDE.md` only `@import`s it. | +| [`DEVELOP.md`](./DEVELOP.md) | The concrete "how": commands, structure, style, testing, i18n, commit/PR. | +| [`VERIFICATION.md`](./VERIFICATION.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension. | +| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Deep internals: process model, message passing, service/data layers, GM API, execution, build. | +| [`translation/README.md`](./translation/README.md) | Translation / localization single source of truth. | +| [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules + fact-check / anti-drift discipline. | +| [`README.md`](./README.md) | The index that points to all of the above. | + +When you move a fact, move it to the doc that **owns** it and cross-link — never copy the same fact into two +places, or they drift apart. + +## Checklist 1 — Organization (every doc change) + +- [ ] Added / renamed / removed a doc → update the [`docs/README.md`](./README.md) index, the *Doc set & + responsibilities* table above, **and** every reference in `AGENTS.md` / `DEVELOP.md`. +- [ ] All relative links resolve (run the link check in *One-shot verification* below). +- [ ] No content that only exists on a feature branch is presented as current `main` — removed, or explicitly + marked "planned (branch `X`)". +- [ ] No fact is duplicated across docs; the owning doc holds it, the others link to it. + +## Checklist 2 — Fact-check (when a doc states something concrete) + +Verify **every** concrete claim against the code. Common claim types and how to check them: + +| Claim in docs | Verify with | +| --- | --- | +| Entry-point / context files exist | `git ls-files src/service_worker.ts src/content.ts src/inject.ts src/offscreen.ts src/sandbox.ts` | +| Workspace packages exist | `git ls-tree --name-only HEAD packages/` | +| A class / identifier exists **by that exact name** | `git grep "class \b" -- src packages` — a renamed class is the #1 source of drift | +| DAOs extend `Repo` | `git grep "class \w*DAO" -- src/app/repo` | +| Service file tree (ARCHITECTURE §4) | `git ls-tree --name-only -d HEAD src/app/service/`, then confirm each listed file | +| A constructor / function signature | open the file and compare param-by-param | +| A count ("N locales", "N tools", "5 contexts") | enumerate the canonical source, e.g. `git ls-tree --name-only -d HEAD src/locales/` **and** `src/locales/locales.ts` | +| Custom ESLint rule / config | `eslint.config.mjs` (project rules) **vs** `packages/eslint/linter-config.ts` (userscript lint config) — these are different things | +| Path aliases | `git grep "@App/\*\|@Packages/\*\|@Tests/\*" -- tsconfig.json` | + +Three traps worth calling out (all bit us before): + +- **Working tree ≠ committed.** A plain `rg`/`ls` also matches **untracked** files in your checkout, so + feature-branch code you have locally but haven't committed to `main` reads as if it ships — the exact + branch-leakage failure mode above. Verify with `git grep` / `git ls-files` / `git ls-tree` so only committed + code counts. +- **Same name, different thing.** `packages/eslint/` is the *userscript* lint config + (`eslint-plugin-userscripts`-based `defaultConfig` used by the in-app editor). The project's *own* custom rule + `require-last-error-check` lives in `eslint-rules/` at the repo root and is wired in `eslint.config.mjs`. Don't + conflate the two. +- **Counts drift silently.** Whenever a doc states a number, enumerate it from the canonical list — don't trust + the prose, and don't trust your memory. + +## One-shot verification + +Git-aware on purpose: every check reads **committed** code (`git ls-files` / `git ls-tree` / `git grep`), so +untracked feature-branch files in your checkout don't report as present. Run from the repo root and eyeball the +output against the docs: + +```bash +echo "== entry points ==" +for f in src/service_worker.ts src/content.ts src/inject.ts src/offscreen.ts src/sandbox.ts; do + git ls-files --error-unmatch "$f" >/dev/null 2>&1 && echo "ok $f" || echo "MISSING/untracked $f" +done +echo "== packages =="; git ls-tree --name-only HEAD packages/ +echo "== service contexts =="; git ls-tree --name-only -d HEAD src/app/service/ +echo "== DAOs =="; git grep -n "class \w*DAO" -- src/app/repo +echo "== locales (count + dirs) =="; git ls-tree --name-only -d HEAD src/locales/ | wc -l; git ls-tree --name-only -d HEAD src/locales/ +echo "== path aliases =="; git grep -n "@App/\*\|@Packages/\*\|@Tests/\*" -- tsconfig.json +echo "== eslint (project rule in eslint-rules/ vs userscript config in packages/eslint/ — don't conflate) ==" +git ls-files eslint-rules/; git grep -l "require-last-error-check" -- eslint.config.mjs; git ls-files packages/eslint/linter-config.ts +``` + +Link integrity — confirm every relative markdown link in the core docs resolves: + +```bash +for doc in AGENTS.md docs/README.md docs/DEVELOP.md docs/VERIFICATION.md docs/ARCHITECTURE.md docs/DOC-MAINTENANCE.md docs/translation/README.md; do + grep -oE '\]\(([^)]+)\)' "$doc" | sed -E 's/^\]\(|\)$//g' | grep -vE '^https?:|^#' | while read -r link; do + target="$(dirname "$doc")/${link%%#*}" + [ -e "$target" ] && echo "ok $doc → $link" || echo "BROKEN $doc → $link" + done +done +``` + +## When you find a discrepancy + +Fix the **doc** to match the code — the code on this branch is the source of truth. The exception: if the code +itself is wrong (a real bug), fix the code and say so in the PR. Either way, never silently drop a check you +couldn't satisfy — surface it in the PR description so a reviewer can confirm. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..653ea9e25 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# ScriptCat 文档索引 / Documentation Index + +本目录收录 ScriptCat **贡献者 / 维护者**面向的文档。想*编写*用户脚本的读者请改看 [docs.scriptcat.org](https://docs.scriptcat.org/)。 + +## 开发文档 / Development + +| 文档 | 说明 | +| --- | --- | +| [`../AGENTS.md`](../AGENTS.md) | 工程原则、架构速览、AI/贡献者约定的单一信息源(`CLAUDE.md` 仅导入它)。 | +| [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** | +| [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** | +| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 | +| [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | + +## 翻译 / Translation + +| 文档 | 说明 | +| --- | --- | +| [`translation/README.md`](./translation/README.md) | 翻译 / 本地化指南(单一信息源):术语与修改规则、工作流、提取翻译提示词。**翻译前先读。** | +| [`translation/README.md` 术语规范表](./translation/README.md#各语言术语规范--per-locale-terminology) | 各语言地区术语与界面文案规范 `terminology-.md`(en-US / zh-CN / zh-TW / ja-JP / ru-RU / de-DE / vi-VN)。 | + +## 贡献指南 / Contributing + +| 文档 | 语言 / Language | +| --- | --- | +| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | English(主文档) | +| [`CONTRIBUTING_ZH.md`](./CONTRIBUTING_ZH.md) | 简体中文 | +| [`CONTRIBUTING_RU.md`](./CONTRIBUTING_RU.md) | Русский | + +## 各语言 README / Localized README + +| 文档 | 语言 / Language | +| --- | --- | +| [`../README.md`](../README.md) | English(主文档) | +| [`README_zh-CN.md`](./README_zh-CN.md) | 简体中文 | +| [`README_zh-TW.md`](./README_zh-TW.md) | 繁體中文 | +| [`README_ja.md`](./README_ja.md) | 日本語 | +| [`README_RU.md`](./README_RU.md) | Русский | diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 000000000..49e0515e9 --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,193 @@ +# 功能验证指南 / Functional Verification Guide + +> **What this owns.** How to *confirm a change actually works* by driving the **real built extension** +> end-to-end — written so an AI coding tool (Claude, Codex, …) or a human can do it without inventing a +> workflow. This is deliberately **lightweight**: one-shot, throwaway scripts you run and discard. +> +> **What this is NOT.** It is *not* the test-suite reference. The mechanics of Vitest unit tests and the +> permanent Playwright E2E suite live in [`DEVELOP.md`](./DEVELOP.md) → *Testing*; the TDD-first principle and +> engineering rules live in [`../AGENTS.md`](../AGENTS.md). Read those for writing committed tests. + +## The one rule: verification ≠ growing the E2E suite + +The full E2E suite is **heavy** (two-phase browser launch, real network fetches, multi-minute timeouts). When +you only want to *check that a feature works*, do not pay that cost and do not leave anything behind: + +- ❌ **Never** run the whole suite (`pnpm run test:e2e`) just to verify one thing. +- ❌ **Never** add a permanent `e2e/*.spec.ts` as part of casual verification. +- ✅ Write a **throwaway scratch script** under `e2e/scratch/` (git-ignored), run it, then delete it. + +Promoting a scenario into the permanent suite is a *separate, deliberate* decision — only when it deserves +permanent regression coverage. That path is owned by [`DEVELOP.md`](./DEVELOP.md), not this guide. + +## Prerequisite gate (cheap signals first) + +Driving a browser is the *last* check, not the first. Confirm the cheap signals are green before you build: + +```bash +pnpm run typecheck # tsc --noEmit +pnpm test # Vitest unit tests (or target one file, see DEVELOP.md) +``` + +Green unit tests do **not** mean the feature works — they mean the units you tested behave. Cross-context wiring +(Service Worker ↔ Content ↔ Inject ↔ Offscreen ↔ Sandbox) and real Chrome APIs only exercise in a loaded +extension. That gap is exactly what this guide closes. + +## Step 1 — Build a loadable extension + +```bash +pnpm run dev # development build with source maps → writes dist/ext +# or: pnpm run build # production build, also → dist/ext +``` + +Load `dist/ext` as an unpacked extension (the scratch scripts below do this for you via +`--load-extension`). After a rebuild: + +- **Page-only edits** (React UI under `src/pages/`) hot-reload — just refresh the page. +- **Edits to `manifest.json`, `service_worker`, `offscreen`, or `sandbox`** require **reloading the extension** + (and a fresh launch in the scratch flow, since each run loads `dist/ext` freshly). + +## Step 2 — Write a scratch verification script + +Scratch scripts live in **`e2e/scratch/`** and reuse the existing harness, so you write almost no boilerplate: + +- `import { test, expect } from "../fixtures";` — gives you a `context` (with `dist/ext` loaded) and an + `extensionId`, with the first-use guide already dismissed. See [`e2e/fixtures.ts`](../e2e/fixtures.ts). +- `import { ... } from "../utils";` — page openers and a script installer. See [`e2e/utils.ts`](../e2e/utils.ts): + `openOptionsPage`, `openPopupPage`, `openInstallPage`, `openEditorPage`, `installScriptByCode`, + and a ready `sampleUserScript`. + +### Minimal template (drive a UI page) + +Save as e.g. `e2e/scratch/verify-options.spec.ts`: + +```ts +import { test, expect } from "../fixtures"; +import { openOptionsPage } from "../utils"; + +test("验证:选项页能打开并渲染脚本列表区", async ({ context, extensionId }) => { + const page = await openOptionsPage(context, extensionId); + + // 1) 驱动真实 UI——点击、填表、导航,按你要验证的功能来。 + // 2) 观察真实行为,用断言或日志给出结论(证据先于结论)。 + await expect(page.locator("body")).toBeVisible(); + console.log("[verify] options url =", page.url()); + + // 失败时留证据,便于排查。 + await page.screenshot({ path: "test-results/verify-options.png" }); +}); +``` + +### Run only your scratch script + +A dedicated config keeps scratch scripts **out of the main suite/CI** while still letting you run them: + +```bash +# run every script in e2e/scratch/ +pnpm exec playwright test --config playwright.scratch.config.ts + +# run one, filtering by test title (regex) — quote it +pnpm exec playwright test --config playwright.scratch.config.ts -g "选项页" +``` + +Why two configs: [`playwright.config.ts`](../playwright.config.ts) sets `testIgnore: ["**/scratch/**"]`, so +`pnpm run test:e2e` and CI **never** pick up scratch scripts; [`playwright.scratch.config.ts`](../playwright.scratch.config.ts) +points `testDir` at `e2e/scratch/` so you can run them on demand. When you are done verifying, **delete the +script** (the directory is git-ignored, so nothing leaks regardless). + +## Step 3 — Verify actual script *execution* (GM APIs, injection) + +The shared `e2e/fixtures.ts` is enough to drive extension pages, but to make a userscript **actually inject and +run in a page** you need two extra things, both already solved in [`e2e/gm-api.spec.ts`](../e2e/gm-api.spec.ts) — +copy that file's inline fixture and helpers into your scratch script rather than re-deriving them: + +1. **Enable the `userScripts` permission.** It is an *optional* MV3 permission (see `manifest.json` + `optional_permissions`). `gm-api.spec.ts` enables it with a **two-phase launch**: first launch toggles + `developerPrivate.updateExtensionConfiguration({ userScriptsAccess: true })`, then it relaunches the same + user-data dir with scripts enabled. +2. **Auto-approve permission prompts.** GM APIs that need a grant open a `confirm.html` page; + `gm-api.spec.ts`'s `autoApprovePermissions(context)` listens for it and clicks "permanent allow". + +### The in-page self-test pattern + +The canonical way to verify GM APIs / injection is a userscript that **runs assertions in the page and prints a +summary line**, which the harness parses from the console. The bundled scripts in +[`example/tests/`](../example/tests/) (e.g. `gm_api_sync_test.js`, `gm_api_async_test.js`, +`inject_content_test.js`, `sandbox_test.js`, `window_message_test.js`) do exactly this. The exact line varies by +script — what matters is that each emits a `通过`/`Passed` and a `失败`/`Failed` count the harness can parse: + +``` +总计: 12 | 通过: 12 | 失败: 0 # inject_content_test.js / sandbox_test.js (combined line) +总测试数: 12 / 通过: 12 / 失败: 0 # gm_api_sync_test.js / gm_api_async_test.js (counts on separate lines) +Total: 12 | Passed: 12 | Failed: 0 # window_message_test.js (English) +``` + +Collect and assert on it from your scratch script (the regex below matches all three layouts): + +```ts +const logs: string[] = []; +let passed = -1; +let failed = -1; +page.on("console", (msg) => { + const text = msg.text(); + logs.push(text); + const pass = text.match(/(通过|Passed)[::]\s*(\d+)/); + const fail = text.match(/(失败|Failed)[::]\s*(\d+)/); + if (pass) passed = parseInt(pass[2], 10); + if (fail) failed = parseInt(fail[2], 10); +}); +// ...navigate to the target page, then: +expect(failed, logs.join("\n")).toBe(0); +expect(passed).toBeGreaterThan(0); +``` + +To verify a *new* GM API or behavior, write a small self-test userscript in the same style (assert in-page, +print `通过`/`失败` counts) and install it with `installScriptByCode`. Keep the userscript inside your scratch +script or a git-ignored file — it is verification scaffolding, not a committed example. + +## Step 4 — When it fails: the five-context debug map + +A feature can break in any of ScriptCat's five isolated contexts. Match the symptom to where its logs live (deep +model in [`ARCHITECTURE.md`](./ARCHITECTURE.md)): + +| Symptom | Where to look | +| --- | --- | +| CRUD, permissions, routing, chrome API calls | **Service Worker** — `chrome://extensions` → ScriptCat → *Inspect views: service worker* | +| Script not injecting / GM bridge to page | **Content** + **Inject** — the **target page**'s DevTools console | +| Background / scheduled script, DOM-needing GM APIs | **Offscreen** — `dist/ext/src/offscreen.html` context | +| Cron scheduling, `with`-sandboxed execution | **Sandbox** — `dist/ext/src/sandbox.html` context | + +In a scratch script, capture the page console (`page.on("console", …)`) and take screenshots +(`await page.screenshot({ path: "test-results/…png" })`) so failures leave evidence. + +Key extension URLs (replace `` with `extensionId`): + +| Page | URL | +| --- | --- | +| Options / dashboard | `chrome-extension:///src/options.html` | +| Popup | `chrome-extension:///src/popup.html` | +| Script editor | `chrome-extension:///src/options.html#/script/editor` | +| Install flow | `chrome-extension:///src/install.html?url=` | + +## Step 5 — Report honestly + +Verification only counts if the result is reported as observed (this mirrors the engineering principle: evidence +before assertions). + +- If it works, say so and state *what you ran* and *what you observed* (the summary line, the screenshot, the + asserted value). +- If it fails or you could not verify a path, **say that plainly** with the console/output — do not soften it, + do not claim success you did not see. +- Never weaken an assertion or skip a check to make a scratch run "pass". + +## Maintaining this guide + +When the harness, scripts, or paths change, keep this doc true to the branch (see +[`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md)). Quick checks: + +```bash +ls e2e/fixtures.ts e2e/utils.ts e2e/gm-api.spec.ts playwright.scratch.config.ts +grep -n "testIgnore" playwright.config.ts +grep -n "e2e/scratch/" .gitignore +ls example/tests/ +``` diff --git a/docs/translation/README.md b/docs/translation/README.md new file mode 100644 index 000000000..9627d208f --- /dev/null +++ b/docs/translation/README.md @@ -0,0 +1,67 @@ +# 翻译与本地化指南 / Translation & Localization Guide + +> **开始任何翻译之前,先读本文件。** +> 凡是新增或修改本地化内容(`src/locales//translation.json`、对应语言的文档、界面文案、测试快照),都必须先阅读本指南,并遵循对应语言的术语规范文件。 + +本目录是 ScriptCat 翻译 / 本地化的单一信息源: + +- **本文件** —— 通用的术语与本地化修改规则、翻译工作流、提示词。 +- **`terminology-.md`** —— 各语言地区(locale)的术语与界面文案规范,由译者按目标语言的自然表达编写。 + +## 适用范围 + +以下要求适用于所有会新增或修改本地化内容的人类贡献者与 AI 任务。 + +## 术语与本地化修改规则 + +```md +凡是新增或修改某个语言地区(locale)的内容,必须先检查是否存在对应的术语规范文件 `docs/translation/terminology-.md`;如果存在,必须读取并遵循该文件。例如,修改 Traditional Chinese / 繁体中文(zh-TW)时,必须遵循 `docs/translation/terminology-zh-TW.md`。 + +- 遵循目标语言地区的自然表达和产品界面惯用语,不可仅做文字或字形的机械转换。 +- 对应术语规范文件中标注为固定保留的术语,或明确限定在当前同类 UI 场景中的修正规则,必须遵循。 +- 不要把某个界面文案的修正扩大成该词在所有上下文中的禁用规则;对于需要结合语境判断的项目,不得机械式全局替换,应根据功能语境与原文含义选择用词。 +- 如果目标 locale 尚无术语规范文件,应保持现有翻译风格,并避免擅自引入新的术语标准。 +- 保留 i18next placeholder、程序标识符、HTML/React 标记和既有功能行为。 +- 完成后检查本次修改的本地化内容,确认符合对应的术语规范文件(如存在)。 +``` + +## 各语言术语规范 / Per-locale terminology + +| Locale | 语言 / Language | 规范文件 | +| --- | --- | --- | +| `en-US` | English (US) | [terminology-en-US.md](./terminology-en-US.md) | +| `zh-CN` | 简体中文 | [terminology-zh-CN.md](./terminology-zh-CN.md) | +| `zh-TW` | 繁體中文 | [terminology-zh-TW.md](./terminology-zh-TW.md) | +| `ja-JP` | 日本語 | [terminology-ja-JP.md](./terminology-ja-JP.md) | +| `ru-RU` | Русский | [terminology-ru-RU.md](./terminology-ru-RU.md) | +| `de-DE` | Deutsch | [terminology-de-DE.md](./terminology-de-DE.md) | +| `vi-VN` | Tiếng Việt | [terminology-vi-VN.md](./terminology-vi-VN.md) | + +> `en-US` 是运行时的回退语言(fallback),也是新翻译的模板。其措辞应被刻意校准而非将含糊或不通顺的英文直接传播到其他 locale。 + +新增一个语言的术语规范时,复制一份现有文件(建议以 `terminology-en-US.md` 为结构参考),按目标语言重写内容,并在上表中登记。 + +## 翻译工作流 / Workflow + +- 翻译文件位于 `src/locales//translation.json`,按页面划分,最终由 `src/locales/locales.ts` 合并导出。 +- **改进已有翻译**:直接编辑对应语言的 `translation.json`。 +- **新增语言**:在 `src/locales/` 下新建语言代码目录(如 `fr-FR`),复制 `en-US/translation.json` 作为模板翻译,并在 `src/locales/locales.ts` 中注册;如需术语规范,在本目录新增 `terminology-fr-FR.md`。 +- **关键字冲突**:同一页面中关键字相同但翻译不同时,使用 `page.key` 的方式区分。 +- 为满足部分扩展市场要求,`chrome.i18n` 语言文件位于 `src/assets/_locales`。 +- i18n 方案的实现细节见 [`src/locales/README.md`](../../src/locales/README.md)。 + +## 提取翻译提示词 / Extract-translation prompt + +将 React 文件中的中文提取为 i18next key 时使用: + +```md +你是一个翻译专家,使用 react-i18next 做为翻译框架,我需要你帮助我翻译这个 React 文件中的中文,首先你需要提取文件中的中文部分,生成一个合适的 key,使用蛇形命名,添加到 src/locales/zh-CN/translation.json 文件中,然后使用 `useTranslation` 替换原有中文,如果有参数你可以使用 i18next 的格式,不需要处理其他语言,不要做多余的事情 +``` + +## 完成前检查清单 / Checklist + +1. 确认目标 locale,并已阅读本指南与对应的 `terminology-.md`(如存在)。 +2. 使用目标语言的自然表达;对同一 ScriptCat 概念使用规范中的固定术语,不要基于相近措辞合并不同的脚本类型。 +3. 对需结合语境的术语,先核对实际功能、控件类型与上下文文案再决定用词。 +4. 保留 i18next 插值、程序标识符、HTML/React 标记、URL 与元数据标识符(`@match`、`@require` 等)。 +5. 完成后复查本次修改,确认符合对应术语规范,并检查命名一致性、名词/动词混用等问题。 diff --git a/docs/terminology-de-DE.md b/docs/translation/terminology-de-DE.md similarity index 100% rename from docs/terminology-de-DE.md rename to docs/translation/terminology-de-DE.md diff --git a/docs/terminology-en-US.md b/docs/translation/terminology-en-US.md similarity index 100% rename from docs/terminology-en-US.md rename to docs/translation/terminology-en-US.md diff --git a/docs/terminology-ja-JP.md b/docs/translation/terminology-ja-JP.md similarity index 100% rename from docs/terminology-ja-JP.md rename to docs/translation/terminology-ja-JP.md diff --git a/docs/terminology-ru-RU.md b/docs/translation/terminology-ru-RU.md similarity index 100% rename from docs/terminology-ru-RU.md rename to docs/translation/terminology-ru-RU.md diff --git a/docs/terminology-vi-VN.md b/docs/translation/terminology-vi-VN.md similarity index 100% rename from docs/terminology-vi-VN.md rename to docs/translation/terminology-vi-VN.md diff --git a/docs/terminology-zh-CN.md b/docs/translation/terminology-zh-CN.md similarity index 100% rename from docs/terminology-zh-CN.md rename to docs/translation/terminology-zh-CN.md diff --git a/docs/terminology-zh-TW.md b/docs/translation/terminology-zh-TW.md similarity index 100% rename from docs/terminology-zh-TW.md rename to docs/translation/terminology-zh-TW.md diff --git a/eslint.config.mjs b/eslint.config.mjs index 88a57ae40..ad8285e75 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -69,5 +69,5 @@ export default [ "react-hooks/rules-of-hooks": "off", }, }, - { ignores: ["dist/", "example/", ".claude/"] }, + { ignores: ["dist/", "example/", ".claude/", "playwright-report/", "test-results/"] }, ]; diff --git a/playwright.config.ts b/playwright.config.ts index 237dff08e..02bd17284 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", + // 一次性验证脚本放在 e2e/scratch/(已 gitignore),不纳入正式 E2E 套件/CI。 + // 单跑请用 playwright.scratch.config.ts:见 docs/VERIFICATION.md。 + testIgnore: ["**/scratch/**"], timeout: 60_000, expect: { timeout: 10_000, diff --git a/playwright.scratch.config.ts b/playwright.scratch.config.ts new file mode 100644 index 000000000..21ebba5d5 --- /dev/null +++ b/playwright.scratch.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; +import base from "./playwright.config"; + +// 一次性验证脚本专用配置:只发现 e2e/scratch/ 下的脚本,复用主配置其余设置。 +// 用法:pnpm exec playwright test --config playwright.scratch.config.ts +// 详见 docs/VERIFICATION.md。这些脚本已 gitignore,不进正式 E2E 套件/CI。 +export default defineConfig({ + ...base, + testDir: "./e2e/scratch", + testIgnore: [], +});