fix: 修复 Chrome 配置消息 callback 响应兼容性 (#60)

* fix: support callback configuration responses

* chore(task): archive chrome-message-response-compat

* chore: record journal
This commit is contained in:
YBF
2026-09-17 02:21:18 +08:00
committed by GitHub
parent 5a8e976f94
commit 92bc7958e7
10 changed files with 210 additions and 13 deletions
@@ -28,7 +28,7 @@
| [OneTalk 页面桥、Port 与命令路由](./onetalk/page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和命令路由 | 目标契约 |
| [OneTalk 耐久同步与连接生命周期](./onetalk/durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint 和重启恢复 | 目标契约 |
| [OneTalk 扩展安装实例设备身份](./onetalk/device-identity.md) | deviceId 生成、迁移、独立存储和配置生命周期 | 已验证 |
| [OneTalk Service Worker 状态与诊断](./onetalk/runtime-diagnostics.md) | 状态投影、错误脱敏和 development 构建 | 已验证 |
| [OneTalk Service Worker 状态与诊断](./onetalk/runtime-diagnostics.md) | 状态投影、Popup 配置 callback 响应、错误脱敏和 development 构建 | 已验证 |
| [OneTalk PWA 出站发送 SOP](./onetalk/send-sop.md) | `sendUIMessages` 输入、SDK-only 发送、WebSocket 旁路事实与联调顺序 | 目标契约 |
| [OneTalk 联系人资料观察与投递](./onetalk/contact-profile-sync.md) | profile 白名单、独立 ledger、Bright frame、ACK 和账号/epoch 隔离 | 已实现并有 focused tests |
| [OneTalk 买家事实被动 DOM 投递](./onetalk/buyer-fact-sync.md) | 当前客户卡的白名单 DOM 投影、独立 ledger、ACK 与无主动 CRM 边界 | 已实现并有 focused tests |
@@ -2,7 +2,7 @@
## 1. Scope / Trigger
当 OneTalk Service Worker 需要向 Popup 或开发者展示 Bright、页面、锚点、bootstrap 和同步状态,或需要把 unknown 异常安全输出到控制台并让 development 构建可映射时,必须遵循本契约。
当 OneTalk Service Worker 需要向 Popup 或开发者展示 Bright、页面、锚点、bootstrap 和同步状态,响应 Popup 配置消息,或需要把 unknown 异常安全输出到控制台并让 development 构建可映射时,必须遵循本契约。
本文件只负责状态投影、错误展示、脱敏和 development 构建可映射性,不改变同步、发送、路由、授权、协议或持久化语义。
@@ -44,6 +44,16 @@ logOneTalkError(
): void;
\`\`\`
### Popup configuration message callback
type OneTalkConfigurationMessageListener = (
message: unknown,
sender: unknown,
sendResponse: (response: OneTalkConfigMessageResponse) => void,
) => true;
chrome.runtime.onMessage 必须注册同步返回 true 的 callback listener。该 listener 委托唯一的异步配置处理函数,并在 Promise 结算后恰好调用一次 sendResponse;不得直接把 Promise-returning handler 注册为 listener。
## 3. Contracts
### State projection
@@ -68,6 +78,13 @@ logOneTalkError(
- \`binding\`、\`credential\`、Cookie、认证头、token、password、secret、API key、message/content、scope/config 等 assignment key 的值必须脱敏。
- JSON 引号、camelCase、下划线、连字符和空格变体不能绕过脱敏;没有 assignment 的普通上下文保持可读。
### Popup configuration message response
- 配置业务处理函数是 onetalk.config.get、onetalk.config.save、onetalk.config.clear 的唯一异步实现 ownerChrome listener 只负责 API 签名适配和响应转发,不能复制校验、队列或错误映射。
- callback listener 必须立刻返回字面值 true,以保持异步响应通道;不得返回业务 Promise 或省略返回值。
- 每次消息只向 sendResponse 转发一个 OneTalkConfigMessageResponse。已授权响应的字段、未授权的 sender_not_allowed、无效输入的 invalid_configuration 及既有存储错误码保持不变。
- 该兼容适配不改变 Popup 的 sendMessage 调用方式,也不适用于 Port 消息或仅接收状态通知的 listener。
### Development build
- \`vite.config.ts\` 只在 \`mode === "development"\` 设置 \`build.minify = false\` 和 \`build.sourcemap = true\`。
@@ -90,6 +107,10 @@ logOneTalkError(
| 页面断开 | 清理内存 page/bootstrap 状态,保留 durable checkpoint/candidate |
| Bright 已认证但页面或 anchor 未准备 | 投影保持未完成,不伪造同步成功 |
| Profile snapshot 未返回/投递无 response | 独立记为 snapshot/delivery failure;不改消息 checkpoint、candidate、anchor 或 send state |
| 配置消息抵达 callback listener | 同步返回 true,并在异步处理完成后转发一次安全响应 |
| 未授权 Popup sender | callback 返回 sender_not_allowed |
| 无效配置消息 | callback 返回 invalid_configuration |
| get/save/clear 的已知存储或配置错误 | callback 保留现有稳定错误码,不改变队列、初始化或业务处理 owner |
## 5. Good / Base / Bad Cases
@@ -99,6 +120,9 @@ logOneTalkError(
- Base:普通“refreshing token; retrying”上下文因没有 assignment 而保持可读。
- Bad\`console.error(prefix, code, error)\` 或把 cause 直接传给 DevTools。
- Bad:所有环境都关闭压缩或打开 source map,导致 production 体积或源码暴露边界改变。
- Goodcallback listener 调用既有异步配置 handler,立即返回 true,handler 的单一安全结果只转发一次。
- Base:同步且无响应需求的状态通知 listener 不需要返回 true。
- Bad:把 async 配置 handler 直接传给 runtime.onMessage.addListener,使旧版 Chrome 不保持异步 callback 通道。
## 6. Tests Required
@@ -109,6 +133,7 @@ logOneTalkError(
- runtime 回调收到同一原始异常对象。
- \`getSnapshot()\` 的唯一投影包含脱敏配置、Bright、page/anchor/bootstrap 和 engine 状态。
- Vite 配置测试确认 development 的 minify/source map 与 production 默认边界。
- 配置入口测试模拟 message、sender、sendResponse:断言 listener 同步返回 true、每条消息只调用一次 callback,并覆盖 authorized get/save/clear、未授权 sender、无效消息及存储错误。
## 7. Wrong vs Correct
@@ -120,6 +145,15 @@ console.error(prefix, code, error);
logOneTalkError(error, code, ENABLE_ONE_TALK_DIAGNOSTICS, console.error);
\`\`\`
// Wrong: Chrome 旧版不会以 Promise return 保持异步响应通道。
chrome.runtime.onMessage.addListener(handleConfigurationMessage);
// Correct: 事件边界保留 callback 语义,业务处理仍只有一个 owner。
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
void handleConfigurationMessage(message, sender).then(sendResponse);
return true;
});
\`\`\`ts
// Wrong: 所有构建都关闭压缩并启用 source map。
build: { minify: false, sourcemap: true };
@@ -0,0 +1,4 @@
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"Focused test, strict typecheck, build, and formatting expectations for the extension package."}
{"file":".trellis/spec/project/architecture.md","reason":"Review ownership boundaries and ensure the adapter does not duplicate configuration logic."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review that callback adaptation preserves queue, error, and one-response semantics."}
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"Review the Chrome listener signature and external-message boundary types under strict TypeScript."}
@@ -0,0 +1,4 @@
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome extension package boundary, required pre-change reading, and package validation baseline."}
{"file":".trellis/spec/project/architecture.md","reason":"Entry-adapter ownership and source-layout rules for changing the Service Worker composition root."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Preserve the existing async configuration queue and guarantee one callback response per request."}
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"Chrome API boundary typing and single-owner message contract rules."}
@@ -0,0 +1,43 @@
# 兼容低版本 Chrome 的 async onMessage 响应
## Goal
将 Service Worker 接收 Popup 配置消息的 `chrome.runtime.onMessage` 入口改为 Chrome 旧版可用的 callback 响应模式,使配置读取、保存和清除不依赖 Promise-returning message listener,同时保持现有配置、授权边界、队列和错误码语义不变。
## Confirmed Facts
- `apps/chrome-extension/src/service-worker-entry.ts:203``handleConfigurationMessage` 是返回 `Promise<OneTalkConfigMessageResponse>` 的异步业务处理函数,且在 `:284` 被直接注册为 `chrome.runtime.onMessage` listener。
- 该处理函数服务 `onetalk.config.get``onetalk.config.save``onetalk.config.clear`,并保留 Popup sender 校验、初始化等待、串行配置队列、存储错误映射和稳定的 `OneTalkConfigMessageResponse`
- `apps/chrome-extension/test/service-worker-entry-config.test.js:75` 目前直接 `await listener(...)`,未模拟 `sendResponse` 或断言 listener 返回 `true`;save、clear 和回调错误路径也没有入口层测试。
- 页面 `port.onMessage` 长连接,以及 Popup 侧只接收状态通知的 `runtime.onMessage` listener,不是 Promise-returning 配置响应入口,不属于本次改造。
- `apps/chrome-extension/manifest.template.json` 未声明 `minimum_chrome_version`,但 Popup 使用 MAIN world scripting;本任务不改变产品的整体最低 Chrome 支持线。
## Requirements
1. Service Worker 的 Chrome runtime message listener 必须接收 `sendResponse`,并同步返回字面值 `true`,以保持异步响应通道对旧版 Chrome 有效。
2. 可以保留现有异步配置处理逻辑,但必须由适配器在 Promise 完成后调用 `sendResponse`,且每次请求最多响应一次。
3. 保持现有消息校验、Popup sender 拒绝、get/save/clear 分支、配置串行化、初始化等待、稳定错误码和响应字段不变。
4. 不通过增加隐式 fallback、修改业务状态机或新增第二套配置处理逻辑来实现兼容。
5. 仅更新必要的 Service Worker 类型声明、事件适配代码和对应测试;不在本任务中新增环境检查功能、`minimum_chrome_version`、无痕模式策略或隐私权限。
## Acceptance Criteria
- [ ] Service Worker 不再把返回 Promise 的业务函数直接注册为 `chrome.runtime.onMessage` listenerlistener 返回字面值 `true` 并通过 `sendResponse` 返回结果。
- [ ] 已授权 Popup 的 `config.get``config.save``config.clear` 仍返回原有安全响应;未授权 sender 仍返回 `sender_not_allowed`,无效配置仍返回 `invalid_configuration`
- [ ] 异步初始化、配置队列和已知异常路径均能完成 callback 响应,且没有重复响应或未处理 Promise rejection。
- [ ] 回归测试覆盖 callback 调用、`return true`、成功响应、拒绝响应和异步错误/存储错误路径。
- [ ] `pnpm --filter @trade-message-center/chrome-extension test``pnpm --filter @trade-message-center/chrome-extension typecheck` 和受影响包 build 通过;若无法运行真实旧版 Chrome,明确记录为未验证边界。
- [ ] 变更 diff 仅包含预期的 Service Worker 入口及其配置消息测试,不改变页面 Port 消息、Bright 协议或服务端行为。
## Out of Scope
- 插件运行环境检查 UI 或诊断协议。
- 修改 Manifest 的 `minimum_chrome_version` 或权限声明。
- 无痕窗口支持、Chrome 全局隐私设置、第三方 Cookie 或 `chrome.privacy` 检查。
- Bright WebSocket、OneTalk 页面桥、同步引擎、服务端和数据库改造。
## Key Decisions
- 保留现有异步配置业务处理作为唯一的行为实现;仅在 Chrome 事件边界以 `message, sender, sendResponse` 适配,并在其 Promise 结算后调用一次 `sendResponse`
- listener 必须同步返回字面值 `true`,不以 Promise 返回值承载响应。
- 不承诺特定最低 Chrome 版本:这次只解除该 listener 的兼容性约束,其他 Manifest 与 API 依赖保持不变。
@@ -0,0 +1,26 @@
{
"id": "chrome-message-response-compat",
"name": "chrome-message-response-compat",
"title": "兼容低版本 Chrome 的 async onMessage 响应",
"description": "将 Service Worker Popup 配置消息改为 callback + return true,兼容低版本 Chrome,保持现有配置和错误语义",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-17",
"completedAt": "2026-09-17",
"branch": "tree2",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+4 -3
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 78
- **Total Sessions**: 79
- **Last Active**: 2026-09-17
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~1702 | Active |
| `journal-1.md` | ~1713 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 79 | 2026-09-17 | Chrome callback 配置响应兼容 | `64e6a59` | `tree2` |
| 78 | 2026-09-17 | 收尾 OneTalk DOM 二次卡片采集 | `e31ba30` | `main` |
| 77 | 2026-09-17 | 收尾 OneTalk React 卡片观察 | `c19e709`, `971f84b` | `main` |
| 76 | 2026-09-16 | 统一 OneTalk 二次卡片 Mind 类型收尾 | `905e9fb` | `dev` |
@@ -115,4 +116,4 @@
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
- Use `add_session.py` to record sessions
+22
View File
@@ -1700,3 +1700,25 @@ Enforced unique workspace context across Mind authorization HTTP and WebSocket f
### Status
[OK] **Completed**
## Session 79: Chrome callback 配置响应兼容
<!-- trellis-session: v=2 fp=79121b291ac65a77 -->
**Date**: 2026-09-17
**Task**: Chrome callback 配置响应兼容
**Branch**: `tree2`
### Summary
将 OneTalk Popup 配置消息的 Service Worker listener 改为 callback 加 return true,保留原有配置语义并完成测试、类型检查、构建和独立验收;旧版 Chrome 实机验证待隔离环境执行。
### Git Commits
| Hash | Message |
|------|---------|
| `64e6a59` | fix: support callback configuration responses |
### Status
[OK] **Completed**
@@ -42,7 +42,8 @@ declare const chrome: {
listener: (
message: unknown,
sender: unknown,
) => Promise<OneTalkConfigMessageResponse>,
sendResponse: (response: OneTalkConfigMessageResponse) => void,
) => true,
): void;
};
sendMessage: (message: OneTalkConfigStatusEvent) => Promise<unknown>;
@@ -279,9 +280,20 @@ const isAllowedPopupSender = (sender: unknown): boolean => {
}
};
const respondToConfigurationMessage = (
message: unknown,
sender: unknown,
sendResponse: (response: OneTalkConfigMessageResponse) => void,
): true => {
void handleConfigurationMessage(message, sender).then(sendResponse, () =>
sendResponse({ ok: false, code: "storage_unavailable" }),
);
return true;
};
const installServiceWorkerListeners = (): void => {
chrome.runtime.onConnect.addListener(controller.runtime.handleConnect);
chrome.runtime.onMessage.addListener(handleConfigurationMessage);
chrome.runtime.onMessage.addListener(respondToConfigurationMessage);
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local" || !(ONE_TALK_EXTENSION_CONFIG_KEY in changes)) return;
void enqueueConfiguration(applyStoredConfiguration).catch((error: unknown) => {
@@ -9,7 +9,7 @@ import {
import { OneTalkExtensionConfigError } from "../src/onetalk/config.ts";
import { OneTalkBrightClientError } from "../src/onetalk/service-worker/transport/bright-client.ts";
test("rejects configuration messages from non-Popup senders", async () => {
test("responds to configuration messages through the Chrome callback channel", async () => {
const messageListeners = [];
const statusEvents = [];
const storageValues = new Map();
@@ -70,15 +70,33 @@ test("rejects configuration messages from non-Popup senders", async () => {
});
const listener = messageListeners[0];
const startupGetCount = getCount;
const popupSender = { url: "chrome-extension://test-extension/popup/popup.html" };
const invokeConfigurationListener = async (message, sender) => {
const responses = [];
let resolveResponse;
const response = new Promise((resolve) => {
resolveResponse = resolve;
});
const returnValue = listener(message, sender, (value) => {
responses.push(value);
resolveResponse(value);
});
assert.equal(returnValue, true);
const result = await response;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(responses.length, 1);
return result;
};
assert.deepEqual(
await listener({ type: "onetalk.config.get" }, { url: "https://onetalk.alibaba.com/chat" }),
await invokeConfigurationListener(
{ type: "onetalk.config.get" },
{ url: "https://onetalk.alibaba.com/chat" },
),
{ ok: false, code: "sender_not_allowed" },
);
const response = await listener(
{ type: "onetalk.config.get" },
{ url: "chrome-extension://test-extension/popup/popup.html" },
);
const response = await invokeConfigurationListener({ type: "onetalk.config.get" }, popupSender);
assert.equal(response.ok, true);
assert.equal(response.config, null);
assert.equal(response.connectionStatus, "unconfigured");
@@ -90,6 +108,39 @@ test("rejects configuration messages from non-Popup senders", async () => {
);
assert.equal(getCount, startupGetCount);
assert.deepEqual(await invokeConfigurationListener({ type: "unsupported" }, popupSender), {
ok: false,
code: "invalid_configuration",
});
assert.deepEqual(
await invokeConfigurationListener(
{
type: "onetalk.config.save",
config: {
channelAccountId: "account-callback",
binding: "binding-callback",
},
},
popupSender,
),
{ ok: false, code: "missing_bright_websocket_url" },
);
const cleared = await invokeConfigurationListener(
{ type: "onetalk.config.clear" },
popupSender,
);
assert.equal(cleared.ok, true);
assert.equal(cleared.config, null);
chromeApi.storage.local.remove = async () => {
throw new Error("storage unavailable");
};
assert.deepEqual(
await invokeConfigurationListener({ type: "onetalk.config.clear" }, popupSender),
{ ok: false, code: "storage_unavailable" },
);
module.oneTalkServiceWorkerController.setConfigurationError("binding_revoked");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(statusEvents.at(-1), {