diff --git a/.github/workflows/chatgpt-dev-build.yml b/.github/workflows/chatgpt-dev-build.yml new file mode 100644 index 00000000..64614214 --- /dev/null +++ b/.github/workflows/chatgpt-dev-build.yml @@ -0,0 +1,77 @@ +name: chatgpt-dev-build + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + developer-artifact: + runs-on: ubuntu-latest + timeout-minutes: 35 + env: + TF_TEST_WORKERS: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24.21.0" + - run: npm ci --no-audit --no-fund + - name: Focused release and provider checks + run: node --test tests/wxt-assets.test.mjs tests/chatgpt-plan-provider.test.mjs + - name: Public production package + run: npm run build:extension:release + - name: Audited ChatGPT developer package + run: npm run build:extension:dev + - name: Verify both Manifest editions + run: | + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + import assert from 'node:assert/strict'; + const pub = JSON.parse(readFileSync('dist/extension/manifest.json', 'utf8')); + const dev = JSON.parse(readFileSync('dist/extension-dev/manifest.json', 'utf8')); + assert.equal(pub.version_name, undefined); + assert.equal(dev.version_name, `${dev.version}-dev`); + assert.deepEqual(dev.host_permissions, pub.host_permissions); + assert.deepEqual(dev.permissions, pub.permissions); + assert.deepEqual(dev.optional_permissions, pub.optional_permissions); + NODE + - name: Prepare Chromium + run: npx playwright install --with-deps chromium + - name: Public ChatGPT UI regression + env: + TF_E2E_ARTIFACT: dist/extension + TF_I18N_ARTIFACT: dist/extension + run: npm run test:e2e -- e2e/chatgpt-plan-nativehost.spec.mjs + - name: Developer ChatGPT UI regression + env: + TF_E2E_ARTIFACT: dist/extension-dev + TF_I18N_ARTIFACT: dist/extension-dev + run: npm run test:e2e -- e2e/chatgpt-plan-nativehost.spec.mjs + - name: Pack verified unpacked developer edition + run: | + mkdir -p artifacts + (cd dist/extension-dev && zip -qr ../../artifacts/translateflow-extension-dev.zip .) + unzip -p artifacts/translateflow-extension-dev.zip manifest.json | node -e ' + let s=""; process.stdin.on("data",c=>s+=c).on("end",()=>{ + const m=JSON.parse(s); + if (m.version_name !== m.version+"-dev") process.exit(1); + });' + (cd artifacts && sha256sum translateflow-extension-dev.zip > SHA256SUMS) + git rev-parse HEAD > artifacts/SOURCE_COMMIT.txt + - name: Upload developer extension and checksums + uses: actions/upload-artifact@v4 + with: + name: translateflow-chatgpt-developer-${{ github.run_id }} + path: artifacts/ + if-no-files-found: error + retention-days: 30 + - name: Upload focused E2E evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: chatgpt-dev-e2e-${{ github.run_id }} + path: test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/README.md b/README.md index 9a759cd9..f7c50668 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TranslateFlow v0.8.47 +# TranslateFlow v0.8.48 轻量、缓存优先的 Chrome Manifest V3 双语网页翻译扩展。单词查词与句段/全文分别保存一种翻译方式:单词可选免费在线、词典、Chrome 本地或 AI;短语、句子、段落和全文可选 AI、Chrome 本地或免费在线。未命中、未就绪或失败时保留所选方式,用户可在划选面板临时切换。字幕保持独立路径。直接支持 OpenAI、Anthropic、Gemini 与主流 OpenAI-compatible API,并按有效配置与原文指纹缓存结果。 diff --git a/docs/CHATGPT_DEVELOPMENT_BUILD.md b/docs/CHATGPT_DEVELOPMENT_BUILD.md new file mode 100644 index 00000000..1c85169a --- /dev/null +++ b/docs/CHATGPT_DEVELOPMENT_BUILD.md @@ -0,0 +1,62 @@ +# TranslateFlow ChatGPT 订阅实验开发版 + +> 开发者预览;**不是** Chrome Web Store 发行版。此功能需要使用自己的本机 Native Host、符合条件的 ChatGPT 账号及官方授权。实验实现或模拟测试通过不代表已获得公共应用接入许可、真实套餐权限或商店审核通过。 + +## 公开版与开发版 + +- **公开版(默认)**:保留普通网页、划词、字幕、本地词典、API Key/自托管模型翻译;在设置 → Provider 只显示 ChatGPT 订阅开发者预览提示及本页链接。隐藏登录、模型和 Host 命令。保存过的订阅配置不会被删除,但公开版拒绝订阅推理。 +- **开发版(单独构建)**:扩展 Manifest 额外带 `version_name: "-dev"`,解锁现有 ChatGPT 订阅设置和 Native Messaging 连接。Chrome 应使用“加载已解压的扩展程序”安装,不属于 Chrome 商店自动更新渠道。 +- 两个版本使用同一份 Provider/缓存/页面代码,不创建第二套翻译引擎;默认 `npm run build:extension` 和 `npm run build:extension:release` 始终生成公开版。 + +## 获取开发版 ZIP(GitHub Actions) + +在项目 [GitHub Actions — ChatGPT developer extension](https://github.com/CoderLambert/translateflow-plugin/actions/workflows/chatgpt-dev-build.yml) 页面,打开一次成功的运行并下载 `translateflow-chatgpt-developer-` Artifact(通常需登录 GitHub)。压缩包中含独立的 `translateflow-extension-dev.zip`、`SHA256SUMS` 和 `SOURCE_COMMIT.txt`。 + +如果暂无成功运行,维护者可从默认分支手动运行该 workflow;也可以按下方从源码构建。不要下载来源不明的“已配置好 ChatGPT 账号”的扩展包。 + +解压 `translateflow-extension-dev.zip` 到一个空目录,确认目录根部有 `manifest.json`,并确认其中 `version_name` 以 `-dev` 结尾。在 Chrome 打开 `chrome://extensions`、启用开发者模式,点击“加载已解压的扩展程序”选择此目录。复制页面显示的 **32 位扩展 ID**。本地安装路径变化或重新安装可能产生不同 ID,Host 必须使用这次实际显示的 ID。 + +## 自行从源码构建 + +环境:Node.js 24.21.x、npm 11.19.x;从可信 Git 提交获取源码: + +```bash +git clone https://github.com/CoderLambert/translateflow-plugin.git +cd translateflow-plugin +npm ci +npm run build:extension:dev +``` + +输出在 `dist/extension-dev/`,将该目录加载为 unpacked 扩展。默认公开构建仍在 `dist/extension/`,互不覆盖。需要自己压缩时,应从 `dist/extension-dev` 目录内部创建 ZIP,使 `manifest.json` 位于归档根部。 + +## Native Host 本机配置(需要 Go 1.26+) + +Native Host **不随扩展 ZIP 自动安装**,账号令牌不进入扩展 ZIP。当前尚无面向公众的已签名稳定 Host 可下载,开发者需自行从可信源码构建并进行当前用户级安装。 + +在**同一个仓库 checkout** 里,在扩展安装后将 `<扩展ID>` 换成 `chrome://extensions` 当前显示的 ID: + +```bash +TF_CHROME_EXTENSION_ID=<扩展ID> TF_NATIVE_HOST_VERSION=0.8.48 npm run build:native-host:candidate +``` + +`TF_NATIVE_HOST_VERSION` 必须与当前候选扩展版本相符;更新版本后请按实际 `manifest.json` 更新它。构建产物位于 `.output/native-host-candidate/`,生成 `release-candidate.json`、`SHA256SUMS` 和适用于 Linux/Windows x64 的候选二进制。候选标记为 `publishable:false`、`NOT_SIGNED`,**不得当作已签名公共安装包分发或绕过系统的未知发布者警告**。 + +Linux(Google Chrome)自建示例: + +```bash +./.output/native-host-candidate/translateflow-host-0.8.48-linux-amd64 install --extension-id <扩展ID> --browser chrome +``` + +如果使用 Chromium 而不是 Chrome,将最后一个参数改为 `--browser chromium`。Windows 在 PowerShell 中构建候选后,使用相应 `windows-amd64.exe install --extension-id <扩展ID>` 安装。具体平台、安装注册位置和安全边界见 [Native Host 安装文档](../native-host/INSTALLATION.md)。 + +Linux 图形会话还必须提供已解锁的 Secret Service;Windows 使用 DPAPI。插件不会替用户安装或解锁密钥环。Native Host 的卸载不会自动清除账号凭据,退出账号请先使用插件的“退出登录”操作。 + +## 使用与安全边界 + +1. 在开发版设置 → Provider → ChatGPT 订阅,明确点击启用 `nativeMessaging` 权限。 +2. 检测 Host 后,点击“连接 ChatGPT 账号”,在官方授权页确认 `chatgpt.tokens.use.direct` 等实际授权范围。 +3. 授权完成后从账号返回的列表选择模型、保存设置,再用非隐私测试句验证翻译。测试前不要输入账号密钥、网页私人内容或未脱敏日志。 +4. 服务可能因账号资格、套餐额度、地区、模型或 OpenAI 接入审批限制而拒绝请求;**不通过私人网页登录态、Cookie、非官方接口绕过限制**。 +5. OAuth 真实授权、套餐用量、系统安全存储与 Windows/Linux 安装均须由开发者在自己的环境中明确执行;自动化测试使用模拟输入,不触发真实账号操作。 + +Chrome Web Store 发行版不承诺此功能可用。开发者版独立试验不影响普通 API Key、Chrome 本地翻译、词典和字幕功能。 diff --git a/docs/WXT_COMPAT_V1.md b/docs/WXT_COMPAT_V1.md index 142e83bf..d8e26f1c 100644 --- a/docs/WXT_COMPAT_V1.md +++ b/docs/WXT_COMPAT_V1.md @@ -15,6 +15,8 @@ npm run test:wxt:smoke WXT compiles Background, Content, Popup, Options and the learning center from their existing owners. Only mapped MAIN/Worker relative-import closures and locales remain raw assets. `src/shared/runtime-assets.js` owns the stable runtime paths. The package audit rejects undeclared public assets and preserves source confinement, exact Worker/MAIN bytes, manifest permissions and compiled React boundaries. Source locks, corpora, compilers, docs and tests stay outside the package. +Package size, platform code size and runtime/UI closure sizes are reported as measurements. Builds have no package-size threshold or advisory budget; the former #245 platform-code budget and its overage warnings are retired. Historical budget figures below remain evidence of the original migration. + The default empty dictionary library is normal. Users explicitly download or import dictionaries; production never probes missing bundled resources. Smoke and browser tests start from actual audited production bytes and explicitly prepare synthetic user dictionaries in isolated storage for tests requiring hits. They do not add runtime dictionary assets to the extension copy. Content remains an ISOLATED classic/IIFE registry compiled to `content-scripts/content.js/.css`; MAIN and Workers retain their existing contexts. Permissions, OPFS schemas and user settings are unaffected by dictionary content removal. diff --git a/docs/tasks/index.json b/docs/tasks/index.json index 3d576d03..6e164101 100644 --- a/docs/tasks/index.json +++ b/docs/tasks/index.json @@ -328,6 +328,13 @@ "branch": "feat/free-online-mt-20261016", "candidateHead": "0d245001483b7b653452e0c0889f713c310be7fe" }, + { + "task": "latest-main-build", + "status": "ready_to_sync", + "dependencies": [], + "branch": "integration/latest-main-local-build", + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2" + }, { "task": "offline-dictionary", "status": "working", diff --git a/docs/tasks/latest-main-build/acceptance.json b/docs/tasks/latest-main-build/acceptance.json new file mode 100644 index 00000000..04bc3650 --- /dev/null +++ b/docs/tasks/latest-main-build/acceptance.json @@ -0,0 +1,186 @@ +{ + "schema": 1, + "task": "latest-main-build", + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "tree": "dbe6f1a773751cf0b9ed51a61ee8c028aec32a34", + "inputTree": "b78459d34329a551ffa7ae83c1225c8de80683fa64d75edcef2f61313ac24eef", + "frozenAt": "2026-10-27T15:55:50.762Z", + "environment": { + "node": "v24.21.0", + "npm": "11.19.0", + "browser": "153.0.8010.12" + }, + "checks": [ + { + "command": [ + "npm", + "run", + "check" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 16179.662654, + "startedAt": "2026-10-27T15:55:50.908Z", + "log": "docs/task-execution/local/latest-main-build/273ee5f3-a744-40d3-b68f-474350411c69.log", + "logSha256": "4f6357b014a55284d3d44370d94b89ae0c96fb1b2c955fd8dcd8590f0b0d2139" + }, + { + "command": [ + "npm", + "test" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 48626.876569, + "startedAt": "2026-10-27T15:56:07.243Z", + "log": "docs/task-execution/local/latest-main-build/cc277a88-7287-4701-bf99-c6a4b7dc3cc2.log", + "logSha256": "e341519d9b2ab8f6661c5986aa59ddb7d2c9eab48c8a486fc8a02aee98881eb3" + }, + { + "command": [ + "npm", + "run", + "typecheck" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 5005.334612, + "startedAt": "2026-10-27T15:56:56.025Z", + "log": "docs/task-execution/local/latest-main-build/555af830-6825-41b3-ae5e-4834c9f943f0.log", + "logSha256": "723d94fda5dea4eb02261a721afd95ff1d33d5f53a6da4626f70f8fda0b582c8" + }, + { + "command": [ + "npm", + "run", + "test:unit" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 4493.682776, + "startedAt": "2026-10-27T15:57:01.180Z", + "log": "docs/task-execution/local/latest-main-build/8dba4de5-53d5-4331-8ae7-09359ef2ad45.log", + "logSha256": "8515d27f3fc8cd1e65e0a3eb26b9ca8741a781eb7a6fde946ad7cc9aefa39a56" + }, + { + "command": [ + "npm", + "run", + "build:extension" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 2520.789114, + "startedAt": "2026-10-27T15:57:05.824Z", + "log": "docs/task-execution/local/latest-main-build/2ae4e67e-bb21-41a4-934a-15c872d4c584.log", + "logSha256": "f1184c25958f820adc299a8317d46d58a4ae87766d963dd5a2bd4e8fb37fdaf4" + }, + { + "command": [ + "npm", + "run", + "build:extension:dev" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 2536.123104, + "startedAt": "2026-10-27T15:57:08.496Z", + "log": "docs/task-execution/local/latest-main-build/20faaac7-41ed-449e-89ef-422905493530.log", + "logSha256": "bd0e32ea4455d22b462e23365489ccea24be8bde34f817f73116380607761037" + }, + { + "command": [ + "env", + "TF_E2E_ARTIFACT=dist/extension", + "TF_I18N_ARTIFACT=dist/extension", + "npm", + "run", + "test:e2e", + "--", + "e2e/chatgpt-plan-nativehost.spec.mjs", + "e2e/provider-connections.spec.mjs" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 10472.777184, + "startedAt": "2026-10-27T15:57:11.184Z", + "log": "docs/task-execution/local/latest-main-build/4eeaaccc-2e3f-4716-8869-0bc809c7ce8d.log", + "logSha256": "bc0601b4ef192c55f4bceec40aac67796e2aea82e0b845df5e3ae08efb5593ac" + }, + { + "command": [ + "env", + "TF_E2E_ARTIFACT=dist/extension-dev", + "TF_I18N_ARTIFACT=dist/extension-dev", + "npm", + "run", + "test:e2e", + "--", + "e2e/chatgpt-plan-nativehost.spec.mjs" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 4032.287493, + "startedAt": "2026-10-27T15:57:21.814Z", + "log": "docs/task-execution/local/latest-main-build/a0d67615-354c-4af2-8883-f5b512aa5f58.log", + "logSha256": "f62c972645ab2012d328124c881068c4bdb193c709354974ceca42e829b8fad5" + }, + { + "command": [ + "npm", + "run", + "build:extension:wxt" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 2646.252689, + "startedAt": "2026-10-27T15:57:26.010Z", + "log": "docs/task-execution/local/latest-main-build/533a5756-f22e-4848-8233-6076ecb92544.log", + "logSha256": "8a6c79830c349098d90d229fd3ce4aa93a5d6db0c42da0dbeba548238962a240" + }, + { + "command": [ + "env", + "TF_SMOKE_ARTIFACT=dist/extension", + "npm", + "run", + "test:wxt:smoke" + ], + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "result": "PASS", + "exitCode": 0, + "signal": null, + "durationMs": 1215.034773, + "startedAt": "2026-10-27T15:57:28.810Z", + "log": "docs/task-execution/local/latest-main-build/6e18423f-4e4c-4435-9328-7bb8bd7c2f21.log", + "logSha256": "02f6e9c96cbafeee592bcf4e4d693cbf22b966d03c4a43e7d10e44141cde4607" + } + ], + "artifact": { + "path": "dist/extension", + "treeSha256": "32a9a5cb8a6065bb46dfb65f37cb57359598ee5a55638d2f6cf0f8b638172a8b" + }, + "limitations": ["Full validate stages were executed as separately recorded commands, not a single npm run validate invocation.", "Public and developer E2E each skip only the case for the other edition; all applicable cases passed.", "Real account OAuth, paid Provider, user browser installation identity, full E2E and Chrome Web Store delivery were not tested.", "Initial candidate b7e781b9 failures remain in ignored local first-acceptance.json and logs; final candidate resolves all recorded failures."], + "developerArtifact": { + "path": "dist/extension-dev", + "treeSha256": "ebca883fdb8071abbfd074b18a274b2d0d34080c64e4fb313799366af6b9052c" + } +} diff --git a/docs/tasks/latest-main-build/review.md b/docs/tasks/latest-main-build/review.md new file mode 100644 index 00000000..b6cb70fa --- /dev/null +++ b/docs/tasks/latest-main-build/review.md @@ -0,0 +1,27 @@ +# 最新 main 集成自查 + +主体:主 Agent 自查,不是独立模型审核。候选:`c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2`。 + +## 集成范围与分支去重 + +- PR #363 已按准确 head squash 合入 `7a2c9ed1f367a22cf3ddda9426d67067732b7669`;合入 tree 与本地元数据验证 tree 完全一致。状态 blocked 如实表示历史证据未归档,没有改造成 completed。 +- 本候选包含 PR #371 公开/ChatGPT 开发版分离、8920ca2 的旧预算提示移除、b9ea931/2e7c394 的原文标记辅助模块与测试。 +- Ollama、YouTube 和完成记录已在 main;核对后的 Ollama/完成记录分支最终 tree 与初始 main 完全相同,不重复回合。近期其它分支中不存在未合入的新增源码文件(上述辅助模块除外);历史已合并/被后续改动替代的整条分支未回灌。 +- 当前旧主工作区及其未跟踪文件、旧安装包未覆盖。 + +## 已修复的验收发现 + +1. 新增 TSX 使用 `globalThis.chrome` 不在本仓严格类型声明中;改用已有的 `typeof chrome` 守卫,未扩大声明或关闭类型检查。 +2. 升级时历史注册脚本列表没有 `youtubeCaptionDom`,导致 YouTube source/controller 不注册,进一步让 Content 入口抛错。只在该旧列表过渡期禁用缺失的 DOM 字幕 fallback,继续 TextTrack/timedtext 并保留原生字幕;刷新后恢复完整模块图。保留不可变旧注册 fixture 和原测试断言。 +3. Anthropic 浏览器流程仍要求公开版显示 ChatGPT 登录区,改为根据真实 manifest 校验公开说明或开发版入口;翻译请求次数、凭据隔离、持久化和清除测试均保留。 +4. 首次 smoke 缺 `.wxt/reports/asset-map.json`,补跑仓库明确要求的 `build:extension:wxt` 前置,不伪造映射或降低审计。 + +## 边界核对 + +- 公开/开发判定取自实际 manifest 精确 version_name;shared 只接收参数,不读取 chrome。公开包 Provider 路由和 ChatGPT action 路由均先拒绝订阅调用。 +- 已保存订阅配置保留,公开版提示开发版入口;无自动切换其它付费 Provider、权限扩展、缓存格式变化或数据清理。 +- 开发版由同一 WXT 输入生成,独立输出目录;raw asset 范围不变,旧体积预算移除不放宽资源/源码/权限审计。 +- 原文辅助合同未接入 Provider 或缓存运行链路,不宣称其修复了 AI 翻译失败。 +- 新 GitHub 构建入口改为仅 workflow_dispatch,遵守现有仓库手动验证约定。 + +最终验证结果见 acceptance.json 与 validation-report.md;首轮失败保留在 Git 忽略的本地证据。真实账号 OAuth/套餐、真实付费 Provider、真实用户网页、商店发布与用户现有浏览器安装身份未验证。本任务先交付最新测试包,可靠性审核继续绑定该版本,不把旧版合成复现当作用户真实根因。 diff --git a/docs/tasks/latest-main-build/state.json b/docs/tasks/latest-main-build/state.json new file mode 100644 index 00000000..17279eda --- /dev/null +++ b/docs/tasks/latest-main-build/state.json @@ -0,0 +1,77 @@ +{ + "schema": 1, + "task": "latest-main-build", + "status": "ready_to_sync", + "owner": "main", + "dependencies": [], + "branch": "integration/latest-main-local-build", + "worktree": "/home/lambert/githubRepos/lambert/translateflow-latest-main-build", + "candidateHead": "c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2", + "mergeHead": null, + "artifactRequired": true, + "validationCommands": [ + [ + "npm", + "run", + "check" + ], + [ + "npm", + "test" + ], + [ + "npm", + "run", + "typecheck" + ], + [ + "npm", + "run", + "test:unit" + ], + [ + "npm", + "run", + "build:extension" + ], + [ + "npm", + "run", + "build:extension:dev" + ], + [ + "env", + "TF_E2E_ARTIFACT=dist/extension", + "TF_I18N_ARTIFACT=dist/extension", + "npm", + "run", + "test:e2e", + "--", + "e2e/chatgpt-plan-nativehost.spec.mjs", + "e2e/provider-connections.spec.mjs" + ], + [ + "env", + "TF_E2E_ARTIFACT=dist/extension-dev", + "TF_I18N_ARTIFACT=dist/extension-dev", + "npm", + "run", + "test:e2e", + "--", + "e2e/chatgpt-plan-nativehost.spec.mjs" + ], + [ + "npm", + "run", + "build:extension:wxt" + ], + [ + "env", + "TF_SMOKE_ARTIFACT=dist/extension", + "npm", + "run", + "test:wxt:smoke" + ] + ], + "nextAction": "候选各项验收与自查已通过;归档后执行本地 gate,按准确远端 head 合入 main,再交付同一源码的本地测试包。" +} diff --git a/docs/tasks/latest-main-build/task.md b/docs/tasks/latest-main-build/task.md new file mode 100644 index 00000000..3b91e1b9 --- /dev/null +++ b/docs/tasks/latest-main-build/task.md @@ -0,0 +1,9 @@ +# 最新 main 集成与本地测试包 + +用户要求先将分支最新代码合入 main,再编译本地插件供其测试,之后继续 AI 翻译可靠性审核。 + +范围:集成 PR #371 的公开/ChatGPT 开发版拆分、分支 8920ca2 的旧体积预算移除、b9ea931/2e7c394 的原文标记辅助合同;复用已合入的 PR #363 记录修正。保留既有数据、权限和 Provider 行为,原文辅助合同不接入运行链路。交付 0.8.48 的公开/开发包及准确源码绑定。 + +兼容:公开版拒绝 ChatGPT 订阅操作但保留已有设置,订阅测试使用显式开发版;不更改 DB 或清空数据,不调用真实付费服务,不发布商店。Actions 延续仓库手动触发约定。修正候选引入的类型/构建问题属于集成范围,AI 可靠性修复仍在用户测试包交付之后进行。 + +验收:state.json 的 validationCommands 合计覆盖完整 validate 的所有阶段,并执行公开/开发包受影响 Chromium 流程及公开包 smoke。主 Agent 自查实际 diff、两种 manifest 权限一致、安装包文件边界;记录版本、提交、包指纹和截图。合入满足本地 gate、准确远端 head 和实际保护。用户真实账号与网页体验不由 mock 测试替代。 diff --git a/docs/tasks/latest-main-build/validation-report.md b/docs/tasks/latest-main-build/validation-report.md new file mode 100644 index 00000000..ea0edbf3 --- /dev/null +++ b/docs/tasks/latest-main-build/validation-report.md @@ -0,0 +1,25 @@ +# 0.8.48 集成验收 + +候选 `c8b5b59b91cfaa51f2ab261df7670f3fa8d5f1c2`;Node 24.21.0、npm 11.19.0、Chromium 153.0.8010.12。 + +| 实际执行 | 结果 | +| --- | --- | +| `npm ci --no-audit --no-fund` | PASS,按锁文件安装 | +| `npm run check` | PASS,含语法/架构与 i18n | +| `npm test` | PASS,2066/2066 | +| `npm run typecheck` | PASS | +| `npm run test:unit` | PASS,82/82 | +| `npm run build:extension` | PASS,公开版 | +| `npm run build:extension:dev` | PASS,ChatGPT 开发版 | +| `npm run build:extension:wxt` | PASS,显式 WXT 包与审计报告 | +| 公开包 ChatGPT / provider-connections E2E | PASS,5 项通过;开发版专属 1 项不适用 | +| 开发包 ChatGPT E2E | PASS,1 项通过;公开版专属 1 项不适用 | +| 公开包 `test:wxt:smoke` | PASS,页面翻译/缓存恢复/本地词典/扩展页/Worker/MAIN | + +完整 validate 的阶段逐项执行并分别绑定日志,不声称额外运行了一次组合命令。所有命令参数、单调计时与日志 SHA256 在 acceptance.json。首轮候选的 Node 1 项、类型、公开版 E2E、smoke 失败均已修复并重验,原失败证据保留在本地,不改写为 PASS。 + +公开包与显式 WXT 包 fingerprint 均为 `32a9a5cb8a6065bb46dfb65f37cb57359598ee5a55638d2f6cf0f8b638172a8b`;开发包为 `ebca883fdb8071abbfd074b18a274b2d0d34080c64e4fb313799366af6b9052c`。开发包只增加 `version_name=0.8.48-dev`,权限字段相同。安装包不含测试/源码库/文档或私有词典。 + +截图:`test-results/run-1793116641905-2ce13baa-85fd-499c-abfa-459b34c75e0b/artifacts/chatgpt-plan-nativehost-Ch-48677-e-user-level-host-is-absent-functional/chatgpt-host-missing-narrow.png`;另有公开包 Ollama/LM Studio 窄屏截图。截图和原始日志仅本地保留。 + +未验证:用户真实网页与 Provider、真实账号 OAuth/套餐、完整 E2E、浏览器同 ID 升级重启及商店发布。旧注册兼容由真实旧脚本列表/jsdom 回归覆盖,不等同于浏览器同 ID 升级。用户测试包交付后,可靠性审核以这一版为基线继续。 diff --git a/e2e/chatgpt-plan-nativehost.spec.mjs b/e2e/chatgpt-plan-nativehost.spec.mjs index 4f9cfa96..51b10fc6 100644 --- a/e2e/chatgpt-plan-nativehost.spec.mjs +++ b/e2e/chatgpt-plan-nativehost.spec.mjs @@ -5,9 +5,26 @@ test.use({ nativeMessagingPregranted: true }); test.describe("ChatGPT Plan native host Settings", () => { test.beforeEach(async ({ harness }) => { await harness.reset(); }); + test("public WXT shows a GitHub developer guide rather than account controls", async ({ harness }) => { + const page = await harness.context.newPage(); + await page.goto(`chrome-extension://${harness.extensionId}/options.html#provider`); + const manifest = await page.evaluate(() => chrome.runtime.getManifest()); + test.skip(manifest.version_name === `${manifest.version}-dev`, "Public edition only"); + const section = page.locator("#settings-panel-provider"); + await expect(section.getByText("ChatGPT 订阅 · 开发者预览")).toBeVisible(); + await expect(section.getByRole("link", { name: "GitHub 开发版及配置说明" })) + .toHaveAttribute("href", "https://github.com/CoderLambert/translateflow-plugin/blob/main/docs/CHATGPT_DEVELOPMENT_BUILD.md"); + await expect(section.getByRole("button", { name: "检查状态并加载模型" })).toHaveCount(0); + await expect(section.getByRole("button", { name: "连接 ChatGPT 账号" })).toHaveCount(0); + await expect(section.getByRole("option", { name: "ChatGPT 订阅", exact: true })).toHaveCount(0); + await page.close(); + }); + test("the real WXT Settings surface recovers when the user-level host is absent", async ({ harness }, testInfo) => { const page = await harness.context.newPage(); await page.goto(`chrome-extension://${harness.extensionId}/options.html#provider`); + const manifest = await page.evaluate(() => chrome.runtime.getManifest()); + test.skip(manifest.version_name !== `${manifest.version}-dev`, "Developer edition only"); const section = page.getByRole("heading", { name: "Provider · ChatGPT 订阅", exact: true }).locator(".."); await expect(section).toBeVisible(); diff --git a/e2e/provider-connections.spec.mjs b/e2e/provider-connections.spec.mjs index 26d0c6df..a2024282 100644 --- a/e2e/provider-connections.spec.mjs +++ b/e2e/provider-connections.spec.mjs @@ -109,7 +109,13 @@ test.describe("API key provider connections", () => { await options.goto(`chrome-extension://${harness.extensionId}/options.html#provider`); await expect(options.getByRole("heading", { name: "API Key 连接" })).toBeVisible(); - await expect(options.getByRole("heading", { name: "Provider · ChatGPT 订阅" })).toBeVisible(); + const manifest = await options.evaluate(() => chrome.runtime.getManifest()); + if (manifest.version_name === `${manifest.version}-dev`) { + await expect(options.getByRole("heading", { name: "Provider · ChatGPT 订阅" })).toBeVisible(); + } else { + await expect(options.getByRole("link", { name: "GitHub 开发版及配置说明" })).toBeVisible(); + await expect(options.getByRole("heading", { name: "Provider · ChatGPT 订阅" })).toHaveCount(0); + } await expect(options.locator(".connection-card").filter({ hasText: "OpenAI-compatible" })).toBeVisible(); const editor = options.locator("form.connection-editor"); diff --git a/manifest.json b/manifest.json index f8bdcc1e..75d2bc43 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_extensionName__", - "version": "0.8.47", + "version": "0.8.48", "description": "__MSG_extensionDescription__", "default_locale": "en", "permissions": [ diff --git a/package-lock.json b/package-lock.json index 457227b3..de18cc66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "translateflow-plugin", - "version": "0.8.47", + "version": "0.8.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "translateflow-plugin", - "version": "0.8.47", + "version": "0.8.48", "dependencies": { "markdown-it": "15.0.2", "p-limit": "7.2.0", diff --git a/package.json b/package.json index 60b2e5c5..7188474b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "translateflow-plugin", - "version": "0.8.47", + "version": "0.8.48", "private": true, "type": "module", "scripts": { @@ -19,6 +19,7 @@ "lock:kaikki": "node scripts/lock-kaikki-source.mjs", "build:extension": "node scripts/build-extension.mjs", "build:extension:release": "node scripts/build-extension.mjs", + "build:extension:dev": "node scripts/build-extension.mjs --developer", "build:native-host:candidate": "node scripts/build-native-host-candidate.mjs", "measure:extension-footprint": "node scripts/measure-extension-footprint.mjs", "project:kaikki": "node scripts/project-kaikki-rich.mjs", diff --git a/scripts/audit-wxt-extension.mjs b/scripts/audit-wxt-extension.mjs index c64e8889..3ff4ad77 100644 --- a/scripts/audit-wxt-extension.mjs +++ b/scripts/audit-wxt-extension.mjs @@ -9,15 +9,15 @@ import { EXTENSION_PAGES, WORKER_PATHS, YOUTUBE_MAIN_BRIDGE_FILES } from "../src import { expectedProductionManifest } from "./production-manifest.mjs"; import { checkManifestLocales } from "./i18n-locales.mjs"; -export function assertProductionManifest(manifest, baseline) { - assert.deepEqual(manifest, expectedProductionManifest(baseline), "Production WXT Manifest must equal the approved compiled Content projection"); +export function assertProductionManifest(manifest, baseline, { developer = false } = {}) { + assert.deepEqual(manifest, expectedProductionManifest(baseline, { developer }), "WXT Manifest must equal the approved compiled Content projection for this build edition"); } -export async function auditWxtExtension({ output = resolve(ROOT, ".output/chrome-mv3"), reportDir = resolve(ROOT, ".wxt/reports") } = {}) { +export async function auditWxtExtension({ output = resolve(ROOT, ".output/chrome-mv3"), reportDir = resolve(ROOT, ".wxt/reports"), developer = false } = {}) { const manifest = JSON.parse(await readFile(resolve(output, "manifest.json"), "utf8")); const baseline = JSON.parse(await readFile(resolve(ROOT, "manifest.json"), "utf8")); // WXT's compiled Content entrypoint is the only approved build-time projection. - assertProductionManifest(manifest, baseline); + assertProductionManifest(manifest, baseline, { developer }); const runtime = await sourceClosure(runtimeAssetRoots()); const locales = await sourceClosure((await checkManifestLocales()).files); const assetMap = JSON.parse(await readFile(resolve(reportDir, "asset-map.json"), "utf8")); @@ -82,16 +82,9 @@ export async function auditWxtExtension({ output = resolve(ROOT, ".output/chrome } } } - const codeBudgetBytes = 1576595; // #245: old code + max(10%, 100 KiB), excluding dictionary data. const uiExclusive = summary.files.filter(entry => approvedUiCompiled.has(entry.path) && !nonUiCompiled.has(entry.path)); const uiBytes = uiExclusive.reduce((sum, entry) => sum + entry.size, 0); const platformCodeBytes = summary.codeBytes - uiBytes; - const codeBudgetExceeded = platformCodeBytes > codeBudgetBytes; - const codeBudgetOverageBytes = Math.max(0, platformCodeBytes - codeBudgetBytes); - const codeBudgetWarning = codeBudgetExceeded - ? `Platform code exceeds #245 advisory budget by ${codeBudgetOverageBytes} bytes (${platformCodeBytes}/${codeBudgetBytes}).` - : null; - if (codeBudgetWarning) console.warn(`WARN: ${codeBudgetWarning}`); const sizes = new Map(summary.files.map((entry) => [entry.path, entry.size])); const chunks = new Map(compiled.map((item) => [item.fileName, item])); function compiledClosure(roots) { @@ -108,10 +101,9 @@ export async function auditWxtExtension({ output = resolve(ROOT, ".output/chrome const uiClosure = compiledClosure((await Promise.all(Object.values(EXTENSION_PAGES).map(pageRoots))).flat()); uiClosure.htmlBytes = Object.values(EXTENSION_PAGES).reduce((sum, path) => sum + sizes.get(path), 0); uiClosure.bytes += uiClosure.htmlBytes; - const report = { status: codeBudgetExceeded ? "PASS_WITH_WARNING" : "PASS", source: "WXT production .output/chrome-mv3", manifestDifferences: ["content_scripts"], + const report = { status: "PASS", source: "WXT production .output/chrome-mv3", manifestDifferences: ["content_scripts"], runtimeFiles: runtime.length, compiledContent: { js: [...CONTENT_SCRIPT_FILES], css: [...CONTENT_STYLE_FILES] }, ...summary, - codeBudgetBytes, platformCodeBytes, codeBudgetExceeded, codeBudgetOverageBytes, - warnings: codeBudgetWarning ? [codeBudgetWarning] : [], + platformCodeBytes, warnings: [], reactPageClosures: Object.fromEntries([...pageCompiled].map(([name, files]) => [name, [...files].sort()])), uiExclusiveClosure: { files: uiExclusive.map(entry => entry.path), bytes: uiBytes }, backgroundClosure, uiClosure, compiledOutputs: compiled.map((item) => ({ fileName: item.fileName, type: item.type })) }; diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index 90b29415..6b220a72 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -11,8 +11,9 @@ import { auditWxtExtension } from "./audit-wxt-extension.mjs"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const DEFAULT_OUT = resolve(ROOT, "dist/extension"); +const DEVELOPER_OUT = resolve(ROOT, "dist/extension-dev"); -export async function buildExtension({ outDir = DEFAULT_OUT, allowExternalOutput = false } = {}) { +export async function buildExtension({ developer = false, outDir = developer ? DEVELOPER_OUT : DEFAULT_OUT, allowExternalOutput = false } = {}) { const options = { allowExternalOutput, allowWxtOutput: true }; const output = await assertBuildOutputPaths(ROOT, outDir, options); // Each invocation builds in its own staging directory, including parallel Node @@ -23,9 +24,9 @@ export async function buildExtension({ outDir = DEFAULT_OUT, allowExternalOutput const artifact = resolve(staging, "output/chrome-mv3"); await promisify(execFile)(process.execPath, [resolve(ROOT, "node_modules/wxt/bin/wxt.mjs"), "build", "-b", "chrome", "--mv3"], { cwd: ROOT, maxBuffer: 4 * 1024 * 1024, - env: { ...process.env, TF_WXT_BUILD_ROOT: staging } + env: { ...process.env, TF_WXT_BUILD_ROOT: staging, TF_CHATGPT_PLAN_DEV_BUILD: developer ? "1" : "0" } }); - const audit = await auditWxtExtension({ output: artifact, reportDir }); + const audit = await auditWxtExtension({ output: artifact, reportDir, developer }); await assertBuildOutputPaths(ROOT, output, options); await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); @@ -35,16 +36,17 @@ export async function buildExtension({ outDir = DEFAULT_OUT, allowExternalOutput await mkdir(resolve(ROOT, ".wxt/reports"), { recursive: true }); await cp(reportDir, resolve(ROOT, ".wxt/reports"), { recursive: true }); } - return { output, builder: "WXT", fileCount: audit.fileCount, totalBytes: audit.totalBytes, + return { output, edition: developer ? "chatgpt-developer" : "public", builder: "WXT", fileCount: audit.fileCount, totalBytes: audit.totalBytes, lexicalBytes: audit.lexicalBytes, lexicalAssetsIncluded: audit.lexicalBytes > 0, largestFiles: [...audit.files].sort((a,b) => b.size - a.size || a.path.localeCompare(b.path)).slice(0,20) }; } finally { await rm(staging, { recursive: true, force: true }); } } function parseArgs(argv) { - const args = { outDir: DEFAULT_OUT }; + const args = { developer: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; + if (arg === "--developer") { args.developer = true; continue; } if (arg === "--out") { const value = argv[++index]; if (!value || value.startsWith("--")) throw new Error("missing value for --out"); diff --git a/scripts/production-manifest.mjs b/scripts/production-manifest.mjs index 5d549bae..7356ec63 100644 --- a/scripts/production-manifest.mjs +++ b/scripts/production-manifest.mjs @@ -7,14 +7,16 @@ export const GLOBAL_CONTENT_SCRIPT = Object.freeze({ run_at: "document_idle" }); -export function projectProductionManifest(baseline) { +export function projectProductionManifest(baseline, { developer = false } = {}) { const { content_scripts: _legacyContentScripts, ...manifest } = baseline; - return manifest; + // Chrome's version_name distinguishes the experimental unpacked build without changing + // version, storage layout, Content scripts or existing host permissions. + return developer ? { ...manifest, version_name: `${manifest.version}-dev` } : manifest; } -export function expectedProductionManifest(baseline) { +export function expectedProductionManifest(baseline, { developer = false } = {}) { return { - ...projectProductionManifest(baseline), + ...projectProductionManifest(baseline, { developer }), content_scripts: [{ matches: [...GLOBAL_CONTENT_SCRIPT.matches], js: [...GLOBAL_CONTENT_SCRIPT.js], diff --git a/src/background/providers/index.js b/src/background/providers/index.js index 92f59fb0..4bc9db3d 100644 --- a/src/background/providers/index.js +++ b/src/background/providers/index.js @@ -1,6 +1,7 @@ import { MACHINE_ENGINE } from "../../shared/machine-translation.js"; import { translateMachineRoute } from "./machine-translation-routing.js"; import { PROVIDER_IDS } from "../../shared/constants.js"; +import { requireChatGPTPlanDeveloperBuild } from "../../shared/chatgpt-native-host.js"; import { deepSeekProvider } from "./deepseek.js"; import { openAIChatProvider, openAICompatibleProvider } from "./openai-compatible.js"; import { chatGPTPlanProvider } from "./chatgpt-plan.js"; @@ -20,6 +21,7 @@ const PROVIDERS = new Map([ export function getProvider(config) { const providerId = String(config?.provider || PROVIDER_IDS.DEEPSEEK).trim().toLowerCase(); + if (providerId === PROVIDER_IDS.CHATGPT_PLAN) requireChatGPTPlanDeveloperBuild(globalThis.chrome?.runtime?.getManifest?.()); const provider = PROVIDERS.get(providerId); if (!provider) throw new Error(`不支持的翻译 Provider:${providerId}`); return provider; diff --git a/src/background/router.js b/src/background/router.js index 6bcbf0f8..02583f36 100644 --- a/src/background/router.js +++ b/src/background/router.js @@ -1,4 +1,5 @@ import { EXTENSION_PAGES } from "../shared/runtime-assets.js"; +import { requireChatGPTPlanDeveloperBuild } from "../shared/chatgpt-native-host.js"; import { MACHINE_SETTING_MESSAGES, handleMachineSettingsMessage } from "./machine-translation-settings.js"; import { handleCacheMessage } from "./cache/message-handler.js"; import { readingTranslationResult } from "./selection/reading-result.js"; @@ -152,7 +153,9 @@ export async function handleBackgroundMessage(message, sender) { case BACKGROUND_MESSAGES.QUICK_CONTROL_POSITION_SET: return saveQuickControlPosition(sender, message.position); case BACKGROUND_MESSAGES.PUBLIC_SETTINGS_SET: return writePublicSettings(message.settings); case BACKGROUND_MESSAGES.CHATGPT_PLAN_ACTION: - assertOptionsSender(sender); return handleChatGPTPlanAction(message.action, undefined, { accountId: message.accountId }); + assertOptionsSender(sender); + requireChatGPTPlanDeveloperBuild(globalThis.chrome?.runtime?.getManifest?.()); + return handleChatGPTPlanAction(message.action, undefined, { accountId: message.accountId }); case BACKGROUND_MESSAGES.SELECTION_RESOLVE: return resolveSelectionRequest({ text: message.text, diff --git a/src/content/subtitles/sources/youtube.js b/src/content/subtitles/sources/youtube.js index f14d39ff..544d88ba 100644 --- a/src/content/subtitles/sources/youtube.js +++ b/src/content/subtitles/sources/youtube.js @@ -4,7 +4,6 @@ !app?.modules.subtitleSource || !app.modules.textTrackSubtitleSource || !app.modules.youtubeBridgeProtocol - || !app.modules.youtubeCaptionDom || !app.modules.youtubeTimedtext || app.modules.youtubeSubtitleSource ) return; @@ -13,7 +12,12 @@ const { createTextTrackSource } = app.modules.textTrackSubtitleSource; const protocol = app.modules.youtubeBridgeProtocol; const runtime = app.modules.runtime; - const { CAPTION_CONTAINER_SELECTORS, CAPTION_SEGMENT_SELECTORS, createCaptionDom } = app.modules.youtubeCaptionDom; + // An already registered legacy script list cannot load the new DOM helper until refresh. + // Keep TextTrack/timedtext and the rest of Content available without hiding native captions. + const { CAPTION_CONTAINER_SELECTORS, CAPTION_SEGMENT_SELECTORS, createCaptionDom } = app.modules.youtubeCaptionDom || { + CAPTION_CONTAINER_SELECTORS: [], CAPTION_SEGMENT_SELECTORS: [], + createCaptionDom: () => ({ readCues: () => [], suppress() {}, restore() {} }) + }; const PLAYER_SELECTORS = Object.freeze(["#movie_player", ".html5-video-player"]); const VIDEO_SELECTORS = Object.freeze(["video.html5-main-video", "video"]); diff --git a/src/i18n/catalog-options-chatgpt.js b/src/i18n/catalog-options-chatgpt.js index def4b379..fefa28f7 100644 --- a/src/i18n/catalog-options-chatgpt.js +++ b/src/i18n/catalog-options-chatgpt.js @@ -1,5 +1,9 @@ export const en = Object.freeze({ "options.chatgptPlan.providerName": "ChatGPT subscription", + "options.chatgptPlan.devOnlyTitle": "ChatGPT subscription · developer preview", + "options.chatgptPlan.devOnlyDescription": "The public edition does not offer ChatGPT subscription sign-in. To test this experimental feature, use the self-built GitHub developer edition and configure the native host yourself.", + "options.chatgptPlan.devGuide": "GitHub developer build and setup guide", + "options.chatgptPlan.savedDevChoice": "ChatGPT subscription (developer edition only; choose another service)", "options.chatgptPlan.title": "Provider · ChatGPT subscription", "options.chatgptPlan.summary": "Connect through the separately installed Native Messaging Host. Access tokens stay in system credential storage and never enter the extension.", "options.chatgptPlan.notChecked": "Connection has not been checked. The host starts only after you request a status check or connect.", @@ -74,6 +78,10 @@ export const en = Object.freeze({ export const zh_CN = Object.freeze({ "options.chatgptPlan.providerName": "ChatGPT 订阅", + "options.chatgptPlan.devOnlyTitle": "ChatGPT 订阅 · 开发者预览", + "options.chatgptPlan.devOnlyDescription": "公开版暂不提供 ChatGPT 订阅登录。需要体验此实验功能,请使用 GitHub 开发版并自行安装、配置本地组件。", + "options.chatgptPlan.devGuide": "GitHub 开发版及配置说明", + "options.chatgptPlan.savedDevChoice": "ChatGPT 订阅(仅开发版可用,请选择其他服务)", "options.chatgptPlan.title": "Provider · ChatGPT 订阅", "options.chatgptPlan.summary": "通过单独安装的 Native Messaging Host 连接。访问令牌保存在系统凭据存储中,不会进入扩展。", "options.chatgptPlan.notChecked": "尚未检查连接。只有你主动检查状态或连接时才会启动 host。", diff --git a/src/options/CommonSections.tsx b/src/options/CommonSections.tsx index 0866bead..04a7774f 100644 --- a/src/options/CommonSections.tsx +++ b/src/options/CommonSections.tsx @@ -3,6 +3,7 @@ import { TRANSLATION_APPEARANCES } from "../shared/appearance.js"; import type { OptionsConfig } from "./client"; import { useOptionsI18n } from "./LocaleContext"; import { ChatGPTPlanSection } from "./ChatGPTPlanSection"; +import { CHATGPT_PLAN_DEV_GUIDE_URL, isChatGPTPlanDeveloperBuild } from "../shared/chatgpt-native-host.js"; import type { OptionsClient } from "./client"; import type { ProviderConnectionState } from "./client"; import { ProviderConnectionsSection } from "./ProviderConnectionsSection"; @@ -45,7 +46,15 @@ export function ShortcutsSection() { } export function ProviderSections({ config, disabled, update, client, connections, loading, error, reload, setStatus }: Props & { client: OptionsClient; connections: ProviderConnectionState | null; loading: boolean; error: string; reload: () => Promise; setStatus: (message: string, error?: boolean) => void }) { + const i18n = useOptionsI18n(); + const developer = isChatGPTPlanDeveloperBuild(typeof chrome === "undefined" ? undefined : chrome.runtime?.getManifest?.()); return <> - ; + {developer ? + : } + ; } function preventComposingSubmit(event: React.KeyboardEvent) { if (event.key === "Enter" && event.nativeEvent.isComposing) event.preventDefault(); } diff --git a/src/options/ProviderConnectionsSection.tsx b/src/options/ProviderConnectionsSection.tsx index cfb68ce1..a25c4c1e 100644 --- a/src/options/ProviderConnectionsSection.tsx +++ b/src/options/ProviderConnectionsSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { PROVIDER_PLATFORMS, PROVIDER_PROTOCOLS, providerPlatformPreset } from "../shared/provider-connections.js"; import type { OptionsClient, ProviderConnection, ProviderConnectionState, ServiceRef } from "./client"; import { useOptionsI18n } from "./LocaleContext"; +import { isChatGPTPlanDeveloperBuild } from "../shared/chatgpt-native-host.js"; type Draft = Omit; type ConnectionTestStatus = { connectionId: string; message: string; kind: "loading" | "success" | "error" }; @@ -17,6 +18,7 @@ export function ProviderConnectionsSection({ client, state, loading, error, disa const [testStatus, setTestStatus] = useState(null); const connections = state?.connections || []; const defaultValue = serviceValue(state?.defaultService || null); + const developer = isChatGPTPlanDeveloperBuild(typeof chrome === "undefined" ? undefined : chrome.runtime?.getManifest?.()); const platformOptions = useMemo(() => PROVIDER_PLATFORMS, []); const update = (patch: Partial) => setDraft(value => ({ ...value, ...patch })); const settings = (patch: Partial) => update({ settings: { ...draft.settings, ...patch } }); @@ -78,7 +80,7 @@ export function ProviderConnectionsSection({ client, state, loading, error, disa : draft.platform === "lmstudio" ? "options.connections.lmstudioHelp" : "options.connections.customHelp"; return

{i18n.t("options.connections.title")}

{i18n.t("options.connections.summary")}

- +
{loading ?

{i18n.t("options.connections.loading")}

: error ?

{error}

: connections.map(connection =>
{connection.name}{connection.platform} · {connection.protocol} · {connection.defaultModel || i18n.t("options.connections.modelUnset")}{connection.baseUrl}{connection.hasApiKey ? i18n.t("options.connections.keySaved") : connection.settings.allowEmptyApiKey ? i18n.t("options.connections.keyOptional") : i18n.t("options.connections.keyMissing")}{testStatus?.connectionId === connection.id ? {testStatus.message} : null}
)}
void save(event)}>

{i18n.t(editing ? "options.connections.edit" : "options.connections.add")}

diff --git a/src/shared/chatgpt-native-host.js b/src/shared/chatgpt-native-host.js index c8dd2931..afbb1f20 100644 --- a/src/shared/chatgpt-native-host.js +++ b/src/shared/chatgpt-native-host.js @@ -2,6 +2,21 @@ export const CHATGPT_PLAN_NATIVE_HOST = "com.coderlambert.translateflow"; export const CHATGPT_PLAN_NATIVE_PERMISSION = "nativeMessaging"; export const CHATGPT_PLAN_MIN_HOST_VERSION = "0.8.0"; export const CHATGPT_PLAN_HOST_DOWNLOAD_URL = "https://github.com/CoderLambert/translateflow-plugin/releases/latest"; +export const CHATGPT_PLAN_DEV_GUIDE_URL = "https://github.com/CoderLambert/translateflow-plugin/blob/main/docs/CHATGPT_DEVELOPMENT_BUILD.md"; + +// Only the separately built, explicitly identified unpacked developer edition exposes this experiment. +// Accept manifest input rather than reading chrome.* inside a shared pure module. +export function isChatGPTPlanDeveloperBuild(manifest) { + return Boolean(manifest && typeof manifest.version === "string" && + manifest.version_name === `${manifest.version}-dev`); +} + +export function requireChatGPTPlanDeveloperBuild(manifest) { + if (isChatGPTPlanDeveloperBuild(manifest)) return; + const error = new Error("ChatGPT subscription is available only in the GitHub developer build. Your existing settings are preserved."); + error.code = "CHATGPT_PLAN_DEV_ONLY"; + throw error; +} export function compareHostVersions(left, right) { const a = parseVersion(left), b = parseVersion(right); diff --git a/src/shared/translation-source.js b/src/shared/translation-source.js new file mode 100644 index 00000000..0a47dfaf --- /dev/null +++ b/src/shared/translation-source.js @@ -0,0 +1,109 @@ +import { normalizeSourceText } from "./text.js"; + +const MARKERS = /⟦TF:(\d+):(S|E)⟧/g; +const TAGS = new Set(["a", "strong", "em", "code", "kbd", "mark"]); +const PROTECTED_TAGS = new Set(["code", "kbd"]); + +/** + * An auxiliary, deterministic source contract; NOT a Provider request, an HTML + * parser or a translation cache key. Keeping providerText untouched preserves + * the existing exact-cache protocol until a later, separately gated wave. + * + * @param {{text?: unknown, rich?: boolean, descriptors?: unknown[]}} source + */ +export function createTranslationUnitV1(source) { + const providerText = String(source?.text ?? ""); + const rich = source?.rich === true; + const inputDescriptors = source?.descriptors ?? []; + if (!Array.isArray(inputDescriptors) || (!rich && inputDescriptors.length)) { + throw new TypeError("invalid translation source descriptors"); + } + + const descriptors = inputDescriptors.map((descriptor) => { + const tag = descriptor?.tag; + if (!TAGS.has(tag)) throw new TypeError("unsupported source descriptor"); + const copy = { tag }; + if (PROTECTED_TAGS.has(tag)) { + if (typeof descriptor.protectedText !== "string") { + throw new TypeError("missing protected source text"); + } + copy.protectedText = descriptor.protectedText; + } + if (tag === "a") { + if (typeof descriptor.href === "string") copy.href = descriptor.href; + if (typeof descriptor.title === "string") copy.title = descriptor.title; + } + return Object.freeze(copy); + }); + + let semanticText; + let protectedSpans = []; + let layout = []; + if (!rich) { + // Literal marker-looking text in ordinary user content stays literal. + semanticText = normalizeSourceText(providerText); + } else { + if (!descriptors.length) throw new TypeError("structured text has no descriptors"); + const stack = []; + const opened = new Set(); + const closed = new Set(); + const raw = []; + const ranges = []; + let rawOffset = 0; + let lastIndex = 0; + for (const match of providerText.matchAll(MARKERS)) { + const chunk = providerText.slice(lastIndex, match.index); + if (chunk.includes("⟦TF:")) throw new TypeError("ambiguous source marker"); + raw.push(chunk); + rawOffset += chunk.length; + + const id = Number(match[1]); + const kind = match[2]; + if (!Number.isSafeInteger(id) || !descriptors[id]) throw new TypeError("unknown source marker"); + layout.push(Object.freeze([rawOffset, id, kind])); + if (kind === "S") { + if (opened.has(id)) throw new TypeError("duplicate source marker"); + opened.add(id); + stack.push({ id, start: rawOffset }); + } else { + const top = stack.pop(); + if (!top || top.id !== id || closed.has(id)) throw new TypeError("unbalanced source marker"); + closed.add(id); + if (PROTECTED_TAGS.has(descriptors[id].tag)) { + ranges.push({ descriptorId: id, tag: descriptors[id].tag, start: top.start, end: rawOffset }); + } + } + lastIndex = match.index + match[0].length; + } + const tail = providerText.slice(lastIndex); + if (tail.includes("⟦TF:") || stack.length || opened.size !== descriptors.length || closed.size !== descriptors.length) { + throw new TypeError("incomplete source markers"); + } + raw.push(tail); + const semanticRaw = raw.join(""); + semanticText = normalizeSourceText(semanticRaw); + protectedSpans = ranges.map(({ descriptorId, tag, start, end }) => { + const text = descriptors[descriptorId].protectedText; + if (normalizeSourceText(semanticRaw.slice(start, end)) !== normalizeSourceText(text)) { + throw new TypeError("protected source text mismatch"); + } + // The sentinel accounts for whitespace immediately before an inline tag, + // which trim() would otherwise remove from a prefix-only calculation. + const offset = (position) => normalizeSourceText(`${semanticRaw.slice(0, position)}\uE000`).length - 1; + return Object.freeze({ descriptorId, tag, start: offset(start), end: offset(end), text }); + }).sort((a, b) => a.start - b.start || a.descriptorId - b.descriptorId); + } + + const unit = { + version: 1, + semanticText, + providerText, + format: rich ? "structured" : "plain", + descriptors: Object.freeze(descriptors), + protectedSpans: Object.freeze(protectedSpans), + // Comparison signatures, deliberately NOT persisted IndexedDB keys. + semanticIdentity: JSON.stringify(["tf.semantic.v1", semanticText]), + formatIdentity: JSON.stringify(["tf.format.v1", rich ? "structured" : "plain", layout, descriptors]) + }; + return Object.freeze(unit); +} diff --git a/tests/chatgpt-plan-provider.test.mjs b/tests/chatgpt-plan-provider.test.mjs index e169ec35..988bc847 100644 --- a/tests/chatgpt-plan-provider.test.mjs +++ b/tests/chatgpt-plan-provider.test.mjs @@ -20,8 +20,20 @@ function fixtureClient(reply) { }; } -test("ChatGPT subscription is registered as a shared Provider", () => { - assert.equal(getProvider({ provider: "chatgpt-plan" }).id, "chatgpt-plan"); +test("ChatGPT subscription is registered only for the developer extension", () => { + const previous = globalThis.chrome; + try { + globalThis.chrome = { runtime: { getManifest: () => ({ version: "0.8.47" }) } }; + assert.throws(() => getProvider({ provider: "chatgpt-plan" }), { code: "CHATGPT_PLAN_DEV_ONLY" }); + assert.equal(getProvider({ provider: "deepseek" }).id, "deepseek"); + globalThis.chrome = { runtime: { getManifest: () => ({ + version: "0.8.47", version_name: "0.8.47-dev", optional_permissions: ["nativeMessaging"] + }) } }; + assert.equal(getProvider({ provider: "chatgpt-plan" }).id, "chatgpt-plan"); + } finally { + if (previous === undefined) delete globalThis.chrome; + else globalThis.chrome = previous; + } }); test("translation uses the injected host once and validates exact response IDs", async () => { diff --git a/tests/translation-source.test.mjs b/tests/translation-source.test.mjs new file mode 100644 index 00000000..dd3105e1 --- /dev/null +++ b/tests/translation-source.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTranslationUnitV1 } from "../src/shared/translation-source.js"; + +const text = "It is important to understand that useMemo does not prevent re-rendering."; +const rich = { + text: "It is important to understand that ⟦TF:0:S⟧useMemo⟦TF:0:E⟧ does not prevent re-rendering.", + rich: true, + descriptors: [{ tag: "code", protectedText: "useMemo" }] +}; + +test("plain and rich expose identical semantic identity, distinct format identity and untouched Provider text", () => { + const plainUnit = createTranslationUnitV1({ text, rich: false, descriptors: [] }); + const richUnit = createTranslationUnitV1(rich); + assert.equal(plainUnit.semanticText, richUnit.semanticText); + assert.equal(plainUnit.semanticIdentity, richUnit.semanticIdentity); + assert.notEqual(plainUnit.formatIdentity, richUnit.formatIdentity); + assert.equal(richUnit.providerText, rich.text); + assert.equal(richUnit.protectedSpans[0].text, "useMemo"); + assert.equal(richUnit.semanticText.slice(richUnit.protectedSpans[0].start, richUnit.protectedSpans[0].end), "useMemo"); + assert.equal(plainUnit.providerText, text); +}); + +test("descriptors preserve ordered code/kbd with strong and trusted source link metadata", () => { + const unit = createTranslationUnitV1({ + text: "Press ⟦TF:0:S⟧Ctrl⟦TF:0:E⟧ and ⟦TF:1:S⟧useMemo⟦TF:1:E⟧; then read ⟦TF:2:S⟧⟦TF:3:S⟧docs⟦TF:3:E⟧⟦TF:2:E⟧.", + rich: true, + descriptors: [ + { tag: "kbd", protectedText: "Ctrl" }, + { tag: "code", protectedText: "useMemo" }, + { tag: "strong" }, + { tag: "a", href: "https://example.test/help", title: "Help", onclick: "not copied" } + ] + }); + assert.equal(unit.semanticText, "Press Ctrl and useMemo; then read docs."); + assert.deepEqual(unit.protectedSpans.map((span) => unit.semanticText.slice(span.start, span.end)), ["Ctrl", "useMemo"]); + assert.equal(unit.descriptors[3].href, "https://example.test/help"); + assert.equal(Object.hasOwn(unit.descriptors[3], "onclick"), false); + assert.ok(Object.isFrozen(unit.descriptors[3])); +}); + +test("repeated technical tokens remain distinct protected spans", () => { + const unit = createTranslationUnitV1({ + text: "⟦TF:0:S⟧useMemo⟦TF:0:E⟧ and ⟦TF:1:S⟧useMemo⟦TF:1:E⟧", + rich: true, + descriptors: [{ tag: "code", protectedText: "useMemo" }, { tag: "code", protectedText: "useMemo" }] + }); + assert.deepEqual(unit.protectedSpans.map((p) => [p.start,p.end]), [[0,7],[12,19]]); +}); + +test("plain literal marker-like user text is preserved, structured collisions are rejected", () => { + const plain = createTranslationUnitV1({ text: "Literal ⟦TF:0:S⟧ should survive", rich: false }); + assert.equal(plain.semanticText, "Literal ⟦TF:0:S⟧ should survive"); + assert.throws(() => createTranslationUnitV1({ ...rich, text: "Literal ⟦TF:0:S⟧ " + rich.text }), /duplicate|ambiguous|incomplete/); +}); + +test("unknown, missing, duplicated, crossed or forged markers fail closed", () => { + const invalid = [ + rich.text.replace("⟦TF:0:E⟧", ""), + rich.text.replace("⟦TF:0:E⟧", "⟦TF:0:E⟧⟦TF:0:E⟧"), + rich.text.replace("⟦TF:0:E⟧", "⟦TF:1:E⟧"), + rich.text.replace("⟦TF:0:S⟧", "⟦TF:0:X⟧"), + rich.text.replace("useMemo", "fakeUseMemo") + ]; + for (const providerText of invalid) { + assert.throws(() => createTranslationUnitV1({ ...rich, text: providerText }), TypeError); + } + assert.throws(() => createTranslationUnitV1({ text: "hi", rich: false, descriptors: [{tag:"code"}] }), TypeError); +}); + +test("semantic normalization only affects comparison, not Provider payload", () => { + const original = " It\t is important\n"; + const unit = createTranslationUnitV1({ text: original, rich: false }); + assert.equal(unit.providerText, original); + assert.equal(unit.semanticText, "It is important"); +}); diff --git a/tests/unit/options-react.test.tsx b/tests/unit/options-react.test.tsx index 5a6967b5..b3c01cb4 100644 --- a/tests/unit/options-react.test.tsx +++ b/tests/unit/options-react.test.tsx @@ -3,7 +3,7 @@ import { StrictMode } from "react"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { GeneralSection } from "../../src/options/CommonSections"; +import { GeneralSection, ProviderSections } from "../../src/options/CommonSections"; import { ChatGPTPlanSection, chatGPTSignInActionDisabled, nativeHostDevelopmentCommands, nativeHostDevelopmentTarget } from "../../src/options/ChatGPTPlanSection"; import { GlossarySection } from "../../src/options/GlossarySection"; import { ProviderConnectionsSection } from "../../src/options/ProviderConnectionsSection"; @@ -39,6 +39,25 @@ function stubNativeHostTarget(platform: string, userAgent: string) { }; } +test("public Provider settings show only the ChatGPT developer guide and preserve a previous selection", () => { + vi.stubGlobal("chrome", { ...chrome, runtime: { getManifest: () => ({ version: "0.8.47" }) } }); + const chatGPTPlanAction = vi.fn(); + const baseState = { version: 1, defaultService: { kind: "chatgpt-plan" }, connections: [] }; + render(zhNode()); + expect(screen.getByText("ChatGPT 订阅 · 开发者预览")).toBeTruthy(); + expect(screen.getByText(/公开版暂不提供 ChatGPT 订阅登录/u)).toBeTruthy(); + const guide = screen.getByRole("link", { name: "GitHub 开发版及配置说明" }); + expect(guide.getAttribute("href")).toBe("https://github.com/CoderLambert/translateflow-plugin/blob/main/docs/CHATGPT_DEVELOPMENT_BUILD.md"); + expect(screen.queryByRole("button", { name: "检查状态并加载模型" })).toBeNull(); + const selected = document.querySelector("#defaultService") as HTMLSelectElement; + expect(selected.value).toBe("chatgpt-plan"); + expect(selected.querySelector('option[value="chatgpt-plan"]')?.disabled).toBe(true); + expect(chatGPTPlanAction).not.toHaveBeenCalled(); +}); + test("provider connection test feedback stays visible in the tested card", async () => { let finish: ((value: string) => void) | undefined; const testProviderConnection = vi.fn(() => new Promise((resolve) => { finish = resolve; })); diff --git a/tests/wxt-assets.test.mjs b/tests/wxt-assets.test.mjs index 4b2507e5..3fe82fd5 100644 --- a/tests/wxt-assets.test.mjs +++ b/tests/wxt-assets.test.mjs @@ -7,6 +7,7 @@ import { assertAssetPath, isInsideSourceRoot, runtimeAssetRoots, sourceClosure } import { CONTENT_SCRIPT_FILES, CONTENT_STYLE_FILES } from "../src/shared/constants.js"; import { EXTENSION_PAGES, WORKER_PATHS, YOUTUBE_MAIN_BRIDGE_FILES } from "../src/shared/runtime-assets.js"; import { GLOBAL_CONTENT_SCRIPT, projectProductionManifest } from "../scripts/production-manifest.mjs"; +import { isChatGPTPlanDeveloperBuild, requireChatGPTPlanDeveloperBuild } from "../src/shared/chatgpt-native-host.js"; import { YOUTUBE_MAIN_BRIDGE_FILES as runtimeMain } from "../src/background/youtube-bridge.js"; import { assertProductionManifest } from "../scripts/audit-wxt-extension.mjs"; @@ -56,6 +57,26 @@ test("bridge follows only relative imports and rejects symlink escapes", async ( } finally { await rm(root, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); +test("ChatGPT developer edition is explicitly marked without changing standard site permissions", async () => { + const baseline = JSON.parse(await readFile(new URL("../manifest.json", import.meta.url), "utf8")); + const publicManifest = projectProductionManifest(baseline); + const developerManifest = projectProductionManifest(baseline, { developer: true }); + assert.equal(publicManifest.version_name, undefined); + assert.equal(developerManifest.version_name, `${baseline.version}-dev`); + assert.equal(isChatGPTPlanDeveloperBuild(publicManifest), false); + assert.equal(isChatGPTPlanDeveloperBuild(developerManifest), true); + assert.equal(isChatGPTPlanDeveloperBuild({ ...developerManifest, + permissions: [...developerManifest.permissions, "nativeMessaging"], optional_permissions: undefined }), true); + assert.deepEqual(developerManifest.host_permissions, publicManifest.host_permissions); + assert.deepEqual(developerManifest.permissions, publicManifest.permissions); + assert.throws(() => requireChatGPTPlanDeveloperBuild(publicManifest), { code: "CHATGPT_PLAN_DEV_ONLY" }); + assert.doesNotThrow(() => requireChatGPTPlanDeveloperBuild(developerManifest)); + const contentScripts = [{ matches: [...GLOBAL_CONTENT_SCRIPT.matches], js: [...CONTENT_SCRIPT_FILES], + css: [...CONTENT_STYLE_FILES], run_at: "document_idle" }]; + assertProductionManifest({ ...developerManifest, content_scripts: contentScripts }, baseline, { developer: true }); + assert.throws(() => assertProductionManifest({ ...developerManifest, content_scripts: contentScripts }, baseline)); +}); + test("production Manifest fails closed for permissions, static injection and development changes", async () => { const baseline = JSON.parse(await readFile(new URL("../manifest.json", import.meta.url), "utf8")); const production = { ...projectProductionManifest(baseline), content_scripts: [{ diff --git a/wxt.config.mjs b/wxt.config.mjs index 3c49bda4..57c77db1 100644 --- a/wxt.config.mjs +++ b/wxt.config.mjs @@ -7,7 +7,7 @@ import { projectProductionManifest } from "./scripts/production-manifest.mjs"; import { checkManifestLocales } from "./scripts/i18n-locales.mjs"; const { manifest_version: _version, ...baselineManifest } = JSON.parse(await readFile(resolve(ROOT, "manifest.json"), "utf8")); -const manifest = projectProductionManifest(baselineManifest); +const manifest = projectProductionManifest(baselineManifest, { developer: process.env.TF_CHATGPT_PLAN_DEV_BUILD === "1" }); const staging = process.env.TF_WXT_BUILD_ROOT; const reportDir = staging ? resolve(staging, "reports") : resolve(ROOT, ".wxt/reports"); const compiledChunks = [];