Files
MailUI4Agents/docs/reviews/gui-review-2026-10-03.md
jianf d94717aa95 test(ele): ★★ preload 加载闸门 —— 一句类型标注能静默废掉整条桥
## 为什么要这条(一个当场踩到、且 tsc 抓不到的坑)

上一个提交给 `preload.cjs` 加窗口动作时,第一版写了 TS 类型标注:

    onMaximizedChanged: (cb: (maximized: boolean) => void) => {   // ← .cjs 里不能有

`preload.cjs` 是纯 JS,整个文件**静默**加载失败。症状链:

    hasBridge: false  hasWin: false  base: "/api/v1"  tok: <null>  store.n: 0
    text: "用户名 | 密码 | 登录"

  · 账号读不到 ⇒ 回退到**网页版**登录分支(用户名+密码,桌面壳里注定失败)
  · 标题栏不渲染 ⇒ 自绘窗口那条顶栏整个消失

★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,一路全绿。
我当时就是靠它判断「没问题」的 —— 它证明不了这件事。

## 为什么这条判据要「真的加载」而不是读文件看字符串

失效形状是「**静默少给一整条桥**」:不抛错、不警告,症状出现在**别处**
(看起来像「功能没做」而不是「代码坏了」)。

与 `93697c4`(stripComments 两趟正则吃掉 137 行真代码)同一族,但更狠 ——
那条至少还会让某条判据红;这条让**所有**判据都无从察觉
(大家都不读 preload,自然没人会发现它没加载)。

⇒ 所以闸门是 `new Module(...)._compile(code(PRELOAD), PRELOAD)`,
真的解析并执行到底(stub 掉 `electron` 模块)。
不是 `node -c`、不是正则、不是 `require`。

**变异验证**:把类型标注加回去 ⇒

    失败  ★ preload.cjs 能真的加载 — 实际错误:Unexpected token ':'
    主进程安全防线:18 通过,1 失败

★ 且验证了它在 `code()` 剥掉注释后**仍然有效**(剥注释会改行号,
但这条判的是「能不能解析执行」,不依赖行号)。

## 另 4 条(都属自绘窗口这一批)

* preload 不得暴露任意 IPC 透传(只有具名动作)
* 窗口三个动作都过了窄接口(`frame:false` 之后没有系统标题栏兵底)
* 最大化状态是订阅来的(`pushMaxState`:`maximize`/`unmaximize`/进退全屏)
* `frame: false` 与 `titleBarStyle: 'hidden'` 同时在

## 自报条数 14 → 19

`run-all.mjs` 的显式编辑(§6.6)。**这条是套件自己抓到的**,
不是我改的 —— 判据报「自报 19 条 > 登记的 14 条,新加的那几条不在
"被删会红"的保护内」。

## 顺带修一条自己的判据缺陷

新写的闸门一开始用了裸 `readFileSync` 读 preload,被
`criteria-hygiene` 抓(「判据目录里不得出现裸 readFileSync」)。
改成 `code()` —— 判「有没有这个符号」时要的是代码,裸读原文会被
解释性注释骗(同坑本仓踩过两次,其中一次就是这条判据自己)。

## 两份 GUI 复查报告一并存档

`docs/reviews/gui-review-2026-10-03.md`(基线 `5e312c6`,848 行)
`docs/reviews/gui-fixes-review-2026-10-03.md`(基线 `b4610f1`,575 行)

★ 第二份里**否证了上游报告两条**,避免下一任白做工:
  ① 鸿蒙日历「格/行时间基准不一致」是**误报** —— 5 个时区实测
     `hhmmAtOffset(ts, deviceOffsetMinutes)` 恒等于 `getHours()`;
  ② `MailStore.bump()` 不发布 AppStorage 是**设计**(混用会死循环),
     `harmony-state-review.md` #3 是误读。

★ 另外查出一条比那批修复更要紧的事:HarmonyOS 客户端**当前编译不过**
(`Theme.hairline` 从未定义,48 个提交前就坏了),而修复提交的信息写着
「BUILD SUCCESSFUL」—— 路径在本机不存在。已用 `git worktree` 对照旧基线
证明**不是这批提交引入的**。已在报告 §2/§5.2 记明并给了修法选项,
**未动业务代码**(等决策)。

## 本机实测读数(装依赖 + 补齐 4 个环境变量后)

    RESULT files=39 ran=39 checks=635 pass=623 fail=2 skip=10 red=5
           broken=0 unreported=0
           变异通道 mutants=52 ran=52 on_new_criteria=37
    npx vitest run   → Test Files 16 passed / Tests 271 passed
    npm run typecheck → 通过

剩 5 个红**全是环境**,不是缺陷:
  · `build-stamp`   —— 本机没 `npm run build` 时的产物
  · `harmony-push`  —— `agconnect-services.json` 不存在(该欠账的 `due` 就是"拿到真机")
  · `--exitcode-selftest` + 因果对照 + `summary.py baseline-residue`
    —— 三者**同一个根因**:本会话 uid=1000 非 root,`runuser`/`setpriv`
       都无法降权(实测 `runuser -u nobody` 报"非 root 用户不能使用")
       ⇒ `run-all.mjs:1043` 的 `dropTo` 探测返回 null ⇒ 那 6 个需要降权的
       变异案例被跳过并计红,连带 baseline 残留检测一起失效。
       ★ 那条报错文案写着「优先按变异残留查」会**把人引向查工作区**,
         而底本在 `mkdtempSync` 造的 /tmp 迷你仓库里、与工作区无关
         —— 已在报告 §4.3 记下,建议文案补一句先看 selftest 是否先红了。
2026-10-04 11:08:57 +08:00

849 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GUI 复查与转交报告(2026-10-03)
> **基线**:HEAD `5e312c6`(`main`,工作区干净,923 提交)
> **范围**:`client/electron`(React 18 + Vite + Tailwind + Zustand + Electron 主进程)
> 与 `client/harmony`(ArkTS / ArkUI,Stage 模型)
> **方法**:对现存代码逐条复核,不引用印象。所有 `file:line` 均在 HEAD `5e312c6` 上实测过。
> **上游**:`docs/reviews/` 2026-09-28 那四份报告(`electron-gui-review.md`、
> `harmony-client-review.md`、`harmony-pages-review.md`、`harmony-state-review.md`)的每一条 finding 重新判一次。
---
## 0. 给接手的 agent:怎么用这份报告
| 你要做的事 | 看哪节 |
|---|---|
| 只想知道「上一轮报告的东西修了没」 | §1 结算表 |
| 要动手修 | §2 的 **H-1 / H-2**(两端各一条 HIGH),§5 有逐条修复草案 |
| 想先确认我说的缺陷是不是真的 | §2 每条都带**触发路径**与**代码引证**,可直接复现 |
| 想知道哪些**不要动** | §6(一条已实测失败并撤回的修法) |
| 想先立判据再修 | §4(每条给了判据建议与"判据该红在哪") |
★ **纪律提醒**(本仓 `client/electron/test/CRITERIA.md` 已写明,这里复述三条最常踩的):
1. 判据要钉**结构与行为**,不要钉字面与邻接 —— 本报告里所有行号都请自行 grep 复核,
`DEBTS.json` 已登记「行号引用漂到无关代码」这条债。
2. 每条判据都要**验证它能红,且红在正确位置**(本仓 `criteria-hygiene.test.mjs` 有自检)。
3. **不要**用自动格式化工具。`.pi-lens.json` 与 `biome.jsonc` 都关掉了 formatter/linter,
历史上自动格式化制造过约 7000 行无关 diff。
---
## 1. 上一轮报告的结算
### 1.1 Electron(`electron-gui-review.md`,12 条 → 修掉 9 条)
| # | 原 finding | 现状 | 证据(HEAD `5e312c6`) |
|---|---|---|---|
| C1 | 切账号不清 store ⇒ 跨账号邮件泄露 | ✅ 已修 | 新增 `src/lib/resetAccountData.ts`;三个入口都调:`AccountSwitcher.tsx:71`(主动切)、`authStore.ts:86`(登出)、`authStore.ts:115`(任意 401)。判据 `test/stores/resetAccountData.test.ts` |
| C2 | `lastUploadedImage` 不分账号 | ✅ 已修 | `appearanceSync.ts:53` 已改成 `new Map<string, string>()`,`:56` 的 `currentAccountKey()` 逐账号分桶 |
| H1 | `PermissionPanel.submit` 吞掉错误 | ✅ 已修 | `MailView.tsx:772` 有 `submitErrorBanner`,`:780` 的 `submit` 末尾 `setSubmitError(err …)` |
| H2 | `ForwardBar` 双提交 + `Promise.all` 卡住关窗 | ✅ 已修 | `MailView.tsx:393` `inFlight = useRef(false)` 同步闸门;`:415` 改 `Promise.allSettled` |
| H3 | ICS `revokeObjectURL` 在 `click()` 同步之后 | ✅ 已修 | `CalendarView.tsx:292` `setTimeout(() => URL.revokeObjectURL(url), 0)` |
| M1 | `CalendarView.load()` 竞态 | ✅ 已修 | `CalendarView.tsx:104` `const loadGen = useRef(0)`、`:107` `const myGen = ++loadGen.current`、`:111/:114/:117` 三处守卫 |
| M2 | `ComposePage` `setTimeout` 未清 | ✅ 已修 | `ComposePage.tsx:62` `closeTimer` ref + cleanup |
| M3 | `AdminUsersPage` / `KeyPanel` 同形状 | ✅ 已修 | `AdminUsersPage.tsx:97` `noticeTimer` ref(`:90-96` 是说明它的注释)+ cleanup;`KeyPanel.tsx:36` `copiedTimer` ref + `:40` cleanup |
| M4 | 审批 chip 缺同步闸门 | ⚠️ **半修** | 见 §2 **E-1** |
| M5 | `BackgroundPicker` 预设缩略图被 `--bg-image` 盖住 | ✅ 已修 | `BackgroundPicker.tsx:118-127` 是一段解释「**为什么不能**写 `style={{ backgroundImage: 'var(--bg-image)' }}`」的注释,`:116` 只留 `bg-preset-${p.id}` 类 |
| M6 | 附件覆盖上传用陈旧 `items` 闭包 | ⚠️ **半修** | 见 §2 **E-2** |
**另确认一条不在旧报告里的修复**:`aeb1f41` 修的 SSE 账号凭证问题 ——
`App.tsx:125-133` 的注释记录了症状与根因(依赖只有 `[phase]`,而切号只换 base/token 不改 phase),
`:134` `const credentialSig = useCredentialChange();` 取的是 `api/config` 那个唯一发生地。
切号不再拿旧账号的 SSE 连接,`aeb1f41` 提交信息里记的用户报「页面停留不动,新邮件不自动同步」已闭环。
判据:`test/sse-credentials.test.mjs`。
### 1.2 HarmonyOS(`harmony-pages-review.md` 22 条 → 修掉约 10 条)
已修(抽样核过源码):
| 原 finding | 证据 |
|---|---|
| Enter 守卫的 `mail_type` 写错(`'permission'` ⇒ `'permission_request'`) | `MailDetailPage.ets:1946` 已改 |
| 顶栏轮播的 `setTimeout` 未清 | `MainPage.ets:2921-2923` `this.topFadeTimer` 存句柄 + `clearTimeout` |
| `mail.cc_list.length` 无守卫(`harmony-state-review.md` #4) | `MailStore.ets:513` 与 `:642` 都是 `(mail.cc_list ?? []).length` |
| `MailStore.clear()` 零调用点(`harmony-state-review.md` #2) | `Logout.ets:108` 与 `SettingsPage.ets:605` 都调了;`Logout.ets:92-107` 记了为什么必须放这里(这段流程有**两个入口**) |
| 管理台 fail-open | `AdminUsersPage.ets:337` 改三态 `if (!this.roleKnown) …` |
| 旧 `MyPage` / `InboxPage` 若干文案 | 已收敛(详见 `git show 18b148e`) |
未修(见 §2 的 H-1 / M-4 / M-5 / L-1):
| 原 finding | 编号 |
|---|---|
| `loadInbox` / `loadSent` 共用 `this.snapshot` | **H-1**(旧报告标 CRITICAL,**从 9/28 至今没人动**) |
| `closeDetail()` 是死代码 ⇒ 系统返回后 `KEY_OPEN_MAIL_ID` 残留 | M-1(已登记 `harmony-system-back-key`) |
| `ContactsTab.openSession()` 无请求令牌 | M-2 |
| 对话树 `ForEach` key 用数组下标 | M-3 |
| 每个 SSE 事件全量重拉多账号收件箱 | M-4 |
| 每次 `build()` 新建 `MarkdownController` | M-5 |
| `loading` 不在 `finally` | M-6 |
| `Index.ets` Hello World 注册为路由 + 两个孤儿页 | L-1(已登记 `harmony-dead-pages`) |
### 1.3 我**排除**的疑点(核过,不是问题)
**① 鸿蒙日历「格与行时间基准不一致」(旧报告 finding 19)—— 误报,已实测否证。**
`CalendarPage.ets:1317` 的 `hourEvents` 用 `d.getHours()`,
`:1344` 的 `EventRow` 用 `hhmmAtOffset(e.event_time, this.offsetMinutes)`,
而 `offsetMinutes = deviceOffsetMinutes(now) = -now.getTimezoneOffset()`(`model/Calendar.ts:226`)。
看着像两个基准。实测五个时区,**两者恒等**:
```
Asia/Shanghai: hhmmAtOffset=00:30 getHours=00:30
America/Los_Angeles: hhmmAtOffset=09:30 getHours=09:30
UTC: hhmmAtOffset=16:30 getHours=16:30
Pacific/Kiritimati: hhmmAtOffset=06:30 getHours=06:30
Pacific/Midway: hhmmAtOffset=05:30 getHours=05:30
```
原因:`hhmmAtOffset` 内部是 `new Date(ms + offset*60000)` 读 `getUTCHours()`,
而 `-getTimezoneOffset()` 恰好把设备本地偏移补回去 ⇒ 它算的就是本地钟点。
**只有判据进程时区 ≠ 设备时区时才会分叉**,那不是产品缺陷。
`harmony-calendar.test.mjs:288` 那条判据正是为「三个时区都能真跑」写的,方向正确。
★ 这条是本报告里唯一一个**否证了上游报告**的条目,请不要再去"修"它。
**② 鸿蒙 `MailStore` 的 `bump()` 不发布 AppStorage —— 是设计,不是漏发。**
`harmony-state-review.md` #3 断言 `loadInbox` 的 `finally` 走 `bump()`(不自增 AppStorage)
会让窗格"静默错过刷新"。实测 `MailStore.ets:175-178`:
```ts
private bump(): void { this.revision += 1; }
```
而 `:180-196` 的注释把为什么分开写得很清楚:`bump()` 是"我自己改了快照,持有者自己接进 `@State`",
`publishChange()` 才是"别的组件可能不知道,要广播"。两者混用会**死循环**
(`bump` → 发布 → `onMailRevChanged` → `loadData` → `loadInbox` → `bump` → …)。
**不是缺陷,不要合并这两个函数。**
**③ Electron Markdown XSS —— 无。** 三处渲染都是 `<Markdown remarkPlugins={[remarkGfm]}>`,
`react-markdown@^9.0.1` 无 `rehype-raw` ⇒ raw HTML 转义、`defaultUrlTransform` 中和 `javascript:`。
全库 `dangerouslySetInnerHTML` / `innerHTML` / `insertAdjacentHTML` 命中 **0** 次。
鸿蒙侧用 `@luvi/lv-markdown-in` 原生 ArkTS 引擎,不经 WebView。`test/markdown-xss.test.mjs` 钉着这条。
**④ `LEGACY_BACKUP_KEY` 永久残留 —— 有意决策。** `backgroundStore.ts` 注释明确写了
「没有任何代码路径能判定何时安全删除」并列出代价(体积有界、共享机器上是一份别人的外观)。
**⑤ 深色模式对比度 —— 有依据。** `tailwind.config.js` 的 `solid()` / `textColor.white` / `chrome`
三套覆盖都有注释记录**实测对比度数字**(`bg-red-600` 白字 1.6:1、`bg-chrome-700` 白字 1.34:1)。
不是猜的。
---
## 2. 仍然成立的问题(按严重度)
### H-1 【HIGH · 正确性】鸿蒙 `MailStore` 收件箱与发件箱共用一个快照
**位置**:`client/harmony/entry/src/main/ets/common/MailStore.ets`
```
447: async loadInbox(ctx, accountFilter) { const snap = this.snapshot; … } // ←
608: async loadSent(ctx, accountFilter) { const snap = this.snapshot; … } // ← 同一个对象
```
`MailSnapshot`(`:120-140`)有 8 个字段:`mails` / `groups` / `loaded` / `unread` /
`notice` / `accountErrors` / `loading` / `error`。**两个方法全部往同一份写**,
而且 `loadSent` 写的时候会把 `unread` 置 0(`:678`)。
**读侧**:`MainPage.ets` 里 `InboxTab`(`:191`)与 `SentTab`(`:1319`)是两个独立 `@Component`,
各自 `@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged')`(`:230` / `:1376`),
各自 `await store.loadInbox(...)` / `await store.loadSent(...)` 然后
`applyStoreSnapshot(store.snapshot)`(`:358` / `:1410`)。
两个 `applyStoreSnapshot` 都逐字段拷贝(InboxTab 版 `:408-424`),包括 `this.unread = snap.unread`。
**触发路径(三条,都是常规操作)**:
1. **切页签时旧 pane 未回**:`commTab` 分支(`:2357-2371`)同一时刻只 mount 一个 tab,
所以不是"两个 pane 同时在屏"。但用户点页签时旧 pane 的 `loadInbox` 还在飞,
新 mount 的 pane 的 `applyStoreSnapshot` 读到的是旧 pane 写到一半的 `mails`。
2. **SSE 与切页签交错**:`MainPage.ets:3069-3095` 的 `onGlobalSseEvent` 对
`new_mail` / `permission_decision` / `session_update` / `session_archived`
**四类事件一律** `notifyRemoteChange()` → 叫醒已 mount 的 pane → 开始 `loadInbox`。
用户此时点开发件箱页签,`loadSent` 与仍在飞的 `loadInbox` 交错写同一个对象。
3. **`loading` 互相关闭**:`:462`(收件箱置 true)/ `:596`(收件箱置 false)
与 `:609` / `:687`(发件箱同)共用一个字段。谁最后完成谁置 false ⇒
会出现"转圈停了但列表是空的,无 spinner 无报错"。
4. **`unread` 单向污染**:发件箱 pane 没有自己的 `unread` 字段(实测 `SentTab` 的
`@State` 只有 `hoveredMailId` / `groups` / `expandedKeys` / `loading` / `error` / `loaded`),
所以它**不受影响**;但 `loadSent` 把 `snap.unread = 0` 之后,
若收件箱 pane 的 `loadInbox` 还没回来,它 `applyStoreSnapshot` 就会读到 `unread = 0`
⇒ **收件箱未读数被清零**。
★ 好消息:底栏与侧栏的徽标走的是**另一条路** ——
`MainPage.refreshCounts()`(`:1934`)自己遍历账号发 `api.inbox('all', …)`,
不读 `snapshot.unread`。所以**徽标不受污染**,受污染的是收件箱栏内的未读显示。
**为什么至今没修**:旧报告(`harmony-state-review.md` #1、`harmony-pages-review.md` #16)
把它标成 CRITICAL 并描述成 `Navigation` 分栏下"两个 pane 同时在屏"的必然场景。
但 `commTab` 后来改成同一时刻只 mount 一个(`:2357-2371`),**那个"必然"没了**,
于是它从"每次都坏"降级成"切页签/SSE 交错时坏" ⇒ 症状变罕见 ⇒ 没人动。
★ 这是典型的**判据缺失导致缺陷降级**:`harmony-pages-review.md` 有 22 条,
但没有一条判据钉住"发件箱 pane 拿到的必须是自己那次拉的数据"。
**修复草案(三选一)**:
```ts
// 方案 A(最小,推荐先落):拆成两个快照字段
export class MailStore {
snapshot: MailSnapshot = new MailSnapshot(); // 收件箱(保持不动,调用点零改)
sentSnapshot: MailSnapshot = new MailSnapshot(); // 发件箱(新增)
private inboxGen: number = 0;
private sentGen: number = 0;
async loadInbox(ctx, accountFilter): Promise<void> {
const myGen = ++this.inboxGen;
const snap = this.snapshot; // ← 只碰收件箱那份
…
// 每次 await 之后、每次写 snap 之前:if (this.inboxGen !== myGen) return;
}
async loadSent(ctx, accountFilter): Promise<void> {
const myGen = ++this.sentGen;
const snap = this.sentSnapshot; // ← 换一份
…
}
}
```
- `SentTab.applyStoreSnapshot`(`MainPage.ets:1410`)改一行:`store.sentSnapshot`。
- `SentTab` 的 `@Watch('onMailRevChanged')` 建议改订**独立**的 revision key
(否则发件箱的 revision 变化不会叫醒它)。这一步可选,但不做的话 `markReadLocal`
之类走 `publishChange()` 的路径仍会误叫醒收件箱。
- `clear()`(`:716`)要同时清两份。
- generation 守卫**治不了 `loading` 互相关闭**(那是共用字段),
所以**方案 A 的拆字段是必须的,generation 是附加的**。
← 这点很重要:旧报告推荐的"加 generation 守卫"单独用**并不足以**修好 `loading`。
**验判据**(`harmony-logic.test.mjs`,设备判据,见 §4.1):
「发件箱 tab 加载完成后,收件箱 tab 的 `unread` 不被清零」+「切页签时新 pane 拿到的
`from_name`/`to_name` 列方向属于自己的那一份」。
---
### H-2 【HIGH · 资源】鸿蒙壁纸:`PixelMap` 泄漏 + 全尺寸解码 + UI 线程阻塞
**位置**:`client/harmony/entry/src/main/ets/common/AppearanceStore.ets:188-197`
```ts
async loadWallpaper(api: AppearanceApi): Promise<void> {
try {
const bytes: ArrayBuffer = await api.fetchImageBytes();
const src: image.ImageSource = image.createImageSource(bytes);
this.wallpaper = await src.createPixelMap(); // ← 无 desiredSize,无 release
} catch (e) {
this.wallpaper = null;
}
}
```
三个问题叠在一起:
1. **旧 `PixelMap` 与 `ImageSource` 都从不 `release()`**。
`PixelMap` 是 native 内存,`syncFromServer`(`:143`,`:182` 调 `loadWallpaper`)
每次同步、每次切账号都会重新解码一次,全部留在映射表里等 GC 压力。
`src` 在**成功路径**上也从不释放。
对照 `BackgroundPicker.ets:167-229`:那里 `head`(`:228`)、`passSource`(`:200`)、
`scaled`(`:197`)都在 `finally` 里 `await ... .release()`,而且注释写明了
为什么必须用 `finally` 收口(那条路径有三个出口,逐处 release 必漏一处或重一处)。
**store 里没有对应物。**
2. **无 `desiredSize`** ⇒ 最坏一张 2560×2560 ARGB ≈ **26 MB** 常驻,
而它永远被合成器降采样着全屏画。`BackgroundPicker` 已把上传边长卡在
`MAX_EDGE = 2560`(`ImagePrep.ts:35`),所以来源有界但仍然很大。
`createPixelMap(options)` 支持 `DecodingOptions.desiredSize`,
`BackgroundPicker.ets:189-193` 就在用,这里没用。
3. **在 UI 线程内联解码** ⇒ `syncFromServer` 被设置页 `await` 之后才应用主题,
所以每次同步都有一次几百毫秒的可见卡顿,不只启动时。
★ **加重项**:`MailStore.clear()` 已在登出时调(`Logout.ets:108`),
但 `AppearanceStore.wallpaper` **不在任何清理路径里** ——
它是个静态单例,活过登出(实测全文件 `clear` 命中 0 次,`wallpaper` 命中 `:45`/`:172`/`:192`/`:195`)。
所以"换账号"这条路上,第 1 条的泄漏**必然发生**。
**修复草案**(照 `BackgroundPicker.ets:167-229` 的既有形状,不要另创一套):
```ts
async loadWallpaper(api: AppearanceApi, targetW: number, targetH: number): Promise<void> {
let src: image.ImageSource | null = null;
let next: image.PixelMap | null = null;
try {
const bytes: ArrayBuffer = await api.fetchImageBytes();
src = image.createImageSource(bytes);
const opts: image.DecodingOptions = { desiredSize: { width: targetW, height: targetH } };
next = await src.createPixelMap(opts);
const old: image.PixelMap | null = this.wallpaper;
this.wallpaper = next; // 先换引用
next = null; // 转移所有权,避免 finally 释放掉新的那个
if (old !== null) {
await old.release(); // ← 释放**旧**的,不是新的
}
} catch (e) {
this.wallpaper = null;
} finally {
if (next !== null) await next.release();
if (src !== null) await src.release();
}
}
```
- `targetW/targetH`:从 `display.getDefaultDisplaySync()` 或窗口尺寸取屏幕像素,
不要写死一个魔数。**判据里要钉住"传了 desiredSize"而不是钉住具体数值**
(数值会随屏幕变)。
- 离屏解码(`@Concurrent` / taskpool)**本轮不做** —— 全仓 `grep taskpool|worker|@Concurrent`
命中 0,没有先例,单独引入会扩大风险面。记一笔即可。
**验判据**:`harmony-appearance.test.mjs`(设备判据)
「每次切账号后,壁纸对象被 `release()` 过」—— 真机上不好直接观测 native 内存,
可行形态是**静态判据钉形状**:断言 `loadWallpaper` 里存在 `desiredSize`,
且存在 `finally` 且 `finally` 里有 `release()`(按 §4.2 的"取结构体"写法,不要用固定宽度窗口)。
---
### M-1 【MEDIUM】鸿蒙系统返回键仍不清理详情状态 —— **不要动**
已登记为 `docs/DEBTS.json` 的 `harmony-system-back-key`(count=2)。
`MainPage.ets:1845-1856` 那段注释把现状与踩坑都写全了:
> ★ 注意:这条路**只接 Esc**。系统返回键/手势绕开它走 `Navigation` 自己的 pop
> ⇒ `closeDetail()` 与这里重发的层数都不覆盖那条路(这是已知缺口)。
> 2026-09-26 试过在 `EntryAbility` 加 `onBackPressed()` 来补,**实测失败并已撤回**
> (模拟器上按一次系统返回键直接销毁 UIAbility)。
实测:`closeDetail()`(`MainPage.ets:2305`)定义完整、**零调用点**。
后果是系统返回 pop 掉 nav 栈后 `KEY_OPEN_MAIL_ID` 仍指向刚看的那封邮件,
下次在根上按 Enter 会跳回一封可能已被归档/删除的邮件;
`publishStackDepth`(`:2232`)同样不在 pop 路径上,Esc 会永久被吞。
到期前提已写得准确:**必须真机验证 `Navigation` 的返回事件消费点在哪一层**。
★ 请不要在没有真机的情况下"顺手修" —— 上一轮就是这么失败的。
---
### M-2 【MEDIUM】鸿蒙 `ContactsTab.openSession()` 无请求令牌
**位置**:`ContactsTab.ets:202-221`
```ts
async openSession(c: Contact): Promise<void> {
…
this.openSessionId = c.session_id;
this.openSessionTitle = …;
this.sessionMails = [];
this.sessionLoading = true;
try {
this.sessionMails = await m.sessionMails(c.session_id); // ← 无令牌
} catch (e) {
const ae = e as ApiError; // ← 无收窄
```
连点两个会话:先发的请求后回 ⇒ 标题已经是 B、`sessionMails` 是 A 的。
**同一类问题 Electron 侧已经修过**(`CalendarView.tsx:104-117` 的 `loadGen` / `myGen`),
只修了一端。
另外 `e as ApiError` 无收窄:非 `ApiError` 的 reject(例如 `abort`)会在错误处理里
再抛一次 `TypeError`,把真正的错误盖掉(旧报告 finding 11,同一形状)。
**修复草案**:照 `CalendarView.tsx:104-117` 的形状加 `private reqGen: number = 0`,
`const myGen = ++this.reqGen`,`await` 之后与 `finally` 里各判一次。
`catch` 改成 `e instanceof ApiError ? e.message : String(e)`。
---
### M-3 【MEDIUM】鸿蒙对话树 `ForEach` key 用数组下标 + 拍平成字符串
**位置**:`MailDetailPage.ets:1990-1999`
```ts
List() {
ForEach(this.threadLines, (line: string, idx: number) => {
ListItem() { Text(line) … }
}, (line: string, idx: number) => idx.toString())
}
```
`threadLines` 是把树拍平成"缩进字符串"的数组(`:1985-1989` 的注释说明了做法)。
用数组下标做 key,在数组头部插入/删除时会让**所有后续行**被复用成错误的组件。
分块加载(`limit` + `next_offset`,`MailApi.ets:348`)一旦在中间插入,新旧行身份全错。
**修复草案**:key 用能稳定标识一封邮件的字段(`mail_id`),
或至少用 `(mail_id, depth)` 组合。需要先让 `threadLines` 带上标识而不只是字符串。
---
### M-4 【MEDIUM】鸿蒙 SSE 事件无合并,每个事件全量重拉多账号收件箱
**位置**:`MainPage.ets:3069-3095`
```ts
private onGlobalSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') { …showToast…; MailStore.getInstance().notifyRemoteChange(); return; }
if (event.type === 'permission_decision' || event.type === 'session_update'
|| event.type === 'session_archived') {
MailStore.getInstance().notifyRemoteChange(); // ← 三类事件同一个动作
}
};
```
四类事件**一律**广播 ⇒ 窗格重拉 ⇒ 多账号时逐账号 `GET /me/mail/inbox` 一次。
一批同时到达的事件就是一批全量拉。
★ **Electron 侧已经做了这件事**,而且做法正好可以抄:
`src/lib/inboxFallbackPoll.ts` 用 `total` 当**廉价探针**(`limit=1` 只取计数),
变了才真拉列表(原理见该文件头部注释「为什么轮询只比 `total`」;
接在 `App.tsx:151-157`,判据 `test/inbox-fallback-poll.test.mjs`)。
**这条优化只在 Electron 侧做了,鸿蒙侧没有对应物。**
**修复草案**:给 `notifyRemoteChange` 加去抖(例如 300ms 合并窗),
并在 `loadInbox` 里加 `limit=1` 的探针快路径。
判据建议放在 `harmony-logic.test.mjs`:一批同时到达的事件 ⇒ 探针请求数固定,不随事件数线性增长。
---
### M-5 【MEDIUM】鸿蒙每次 `build()` 新建 `MarkdownController`
**位置**:`MailDetailPage.ets:812`
```ts
private mdController(): MarkdownController {
const c: MarkdownController = new MarkdownController();
c.setTextColor(Theme.textPrimary);
…
```
`build()` 每次重渲染都调它。`build()` 的调用频次由状态决定,
**主题切换**(`@StorageProp('agentmail.appearance.revision') @Watch`)会整页重渲染一次。
控制器是纯样式对象,每次新建 = 每次重新解析一遍样式。
**修复草案**:改成 `@State` 私有字段 + 主题变化时才重建(或 `@Provide` 一份全局的)。
⚠️ 注意 ArkTS 的 `@State` 装饰器对 class 类型的约束,先确认能编译。
---
### M-6 【MEDIUM】鸿蒙日历 `loading` 不在 `finally`
**位置**:`CalendarPage.ets:499-520`(`loadEvents()`)
```ts
this.loading = true; // :504
this.error = '';
try {
const resp = await a.listEvents(this.rangeFrom(), this.rangeTo());
this.events = resp.events;
} catch (e) {
this.error = e instanceof ApiError ? … : '日程加载失败';
this.events = [];
}
this.loading = false; // :517 —— 在 try/catch 之后裸写
/* 事件拉完再拉农历(不 await:农历是附加信息,不该拖慢事件列表的出现) */
this.loadLunar();
```
`this.loading = true` 与 `this.loading = false` 之间的代码**全部**在 `try` 内,
且 `rangeFrom()` / `rangeTo()` 也是同步调用(`:507` 在 `try` 里),
所以**今天不会真的卡住**。
**修复草案**:`try { … } catch { … } finally { this.loading = false; }`,
`loadLunar()` 放在 `finally` 之后(保持"不 await"的既有语义)。
---
### E-1 【MEDIUM】Electron 审批闸门仍是异步 state,缺同步 ref
**位置**:`client/electron/src/components/MailView.tsx:780`(`PermissionPanel` 的 `submit`)
```tsx
const submit = async (decision: string, noteText: string) => {
setSubmitError('');
setBusy(true);
try {
await api.decidePermission(mail.mail_id, decision, noteText || undefined);
```
`PermissionPanel` 里的 `@State`(`:730-741` 附近)有 `note` / `busy` / `decided` /
`picked` / `staleWarning` / `submitError`,**没有 `useRef`**。
`disabled={busy}` 挡得住渲染后的点击,但 React 提交前的那一帧里
两次快速激活(双击、Enter 连按、鼠标+键盘同时)仍会打出**两条** `decidePermission`。
对审批来说这不是"多点一次"的问题:服务端会**记账两次**,Agent 侧收到的通知也两条。
★ **同文件的 `ForwardBar` 已经解决了一模一样的问题**(`:393` `inFlight = useRef(false)`,
`:400` `if (!to.trim() || inFlight.current) return;`)。照抄那份形状即可。
**修复草案**:
```tsx
const inFlight = useRef(false);
const submit = async (decision: string, noteText: string) => {
if (inFlight.current) return;
inFlight.current = true;
setSubmitError('');
setBusy(true);
try { … } catch { … } finally { inFlight.current = false; setBusy(false); }
};
```
**验判据**:`test/components/PermissionPanel.test.tsx` 补一格
「同一帧内两次激活只发出**一条** `decidePermission`」。
★ 该文件已存在(`test/components/PermissionPanel.test.tsx`),不是新建。
---
### E-2 【MEDIUM】Electron 附件连续选取会丢前一次的选择
**位置**:`client/electron/src/components/Attachments.tsx:70-94`
```tsx
async function handleFiles(files: FileList | null) {
…
for (const file of Array.from(files)) { // 逐个上传,可能几秒
setUploading({ name: file.name, pct: 0 });
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
added.push({ … });
}
setUploading(null);
if (added.length > 0) onChange([...items, ...added]); // ← items 是渲染闭包里的
if (inputRef.current) inputRef.current.value = ''; // ← 上传**完成后**才清
}
```
`uploading` 只加在**按钮**的 `disabled` 上(`:121` `disabled={disabled || uploading !== null}`),
`<input type="file">` 本身(`:105-111`)**没有任何闸门**,
而 `inputRef.current.value = ''` 要等**上传全部完成**才执行。
用户在第一个文件还在传的时候再选一次文件 ⇒ 第二次的 `handleFiles` 拿到的是
**旧的 `items` 闭包** ⇒ `onChange([...旧items, ...第二次])` **把第一次的结果整批覆盖掉**。
且因为 input 还没清,同一个文件重复选也不报错 ⇒ 表现就是"选的附件少了",无任何提示。
`onChange` 的签名是 `(next: PendingAttachment[]) => void`(`:61`),
不是函数式更新,所以**改签名会影响所有调用点**(`ComposePage`、`ReplyBar`)。
**修复草案(推荐第一种,不动签名)**:
```tsx
const picking = useRef(false);
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
if (picking.current) return; // ← 同步闸门,挡住上传期间的重入
picking.current = true;
if (inputRef.current) inputRef.current.value = ''; // ← 提前清,别等上传完
try {
…
if (added.length > 0) onChange([...items, ...added]);
} finally {
picking.current = false;
}
}
```
⚠️ 若选"提前清 input"要确认不会影响"上传失败后重选同一个文件"这个既有行为 ——
现在这个行为是靠末尾那句 `value = ''` 提供的。**改之前先读一遍失败路径。**
---
### X-1 【MEDIUM · 安全】Electron 主进程零导航/新窗口拦截
**位置**:`client/electron/electron/main.cjs`(204 行)
实测 `grep will-navigate|setWindowOpenHandler|session.webRequest` **命中 0**。
`webPreferences` 本身是**对的**(`:47-49` `contextIsolation: true` /
`nodeIntegration: false` / `sandbox: true`),preload 只暴露窄接口,
token 走主进程文件 + `additionalArguments` 而**不落 localStorage**
(理由见 `main.cjs:139-148`),`accounts.json` 原子写(tmp + rename)+ `0600` +
解析失败时**不覆盖**(`main.cjs:159-173`)。
**这份安全姿态是本工程最扎实的部分之一,不要动它。**
缺的是最后一道:默认 `loadFile(dist/index.html)` 下,
邮件正文里的链接(`react-markdown` 会渲染 `<a href>`)点下去会
**在应用窗口里导航走**。渲染层的 Markdown 纪律很好(无 `dangerouslySetInnerHTML`、无 `rehype-raw`),
所以**目前没有 XSS 面**;但一个 `href="https://…"` 就能把整个应用窗口变成浏览器 ——
窗口标题、preload 注入的 API base/token 全部暴露在陌生站点上。
**修复草案**:
```js
const { shell } = require('electron');
const openExternal = (url) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url); // 只放行 http(s)
else e.preventDefault();
};
mainWindow.webContents.setWindowOpenHandler(({ url }) => { openExternal(url); return { action: 'deny' }; });
mainWindow.webContents.on('will-navigate', (e, url) => { e.preventDefault(); openExternal(url); });
```
(`will-navigate` 的 `e` 要在 handler 签名里拿对,草案里的 `preventDefault` 传参需调整。)
**另**:`main.cjs` 没有 `requestSingleInstanceLock()` ——
双击图标会起两个进程、两个窗口、两个 SSE 连接、两套 `accounts.json` 写入。
这是 5 行的事,建议同一批改。
**验判据**:`test/packaging.test.mjs` 已存在,可加一格静态断言
「`main.cjs` 含 `setWindowOpenHandler` 且含 `will-navigate`」
—— 静态可机检、适合钉住不退化(按 §4.2 的"取结构体"写法)。
---
### X-2 【LOW · 加固】`index.html` 无 CSP
**位置**:`client/electron/index.html`
全文件没有任何 `<meta http-equiv="Content-Security-Policy">`。
当前**无实际漏洞**(无 `dangerouslySetInnerHTML`、无 `eval`、
动态 `src` 只有本地 data URL),但这是**唯一一道会在有人加一行
`dangerouslySetInnerHTML` 之后消失的防线** ——
而 `sandbox: true` + `contextIsolation: true` **不会**替你挡渲染层里的注入
(那两处只挡 node 与跨上下文)。
⚠️ 首帧防闪屏那段内联脚本(`index.html:31-61`)**要求** `'unsafe-inline'`
(它必须同步执行,早于任何外链),所以 CSP 要写成
`script-src 'self' 'unsafe-inline'`,**不要**写成严格 CSP。
`test/theme.test.mjs` 逐字符断言 `:54` 是 `document.documentElement.classList.add('dark');`
——单引号;`index.html:42-43` 的注释已经写了「引号别顺手统一成双引号,
曾经被 prettier 改写过一次,直接让测试变红」。
---
### L-1 【LOW】鸿蒙孤儿页与 Hello World 路由仍在
**位置**:`client/harmony/entry/src/main/resources/base/profile/main_pages.json`
仍含 `pages/Index`(DevEco 模板 "Hello World",38 行)。
`pages/InboxPage.ets`(262 行)与 `pages/SessionsPage.ets`(170 行)仍在树上:
按路由表现状,`SessionsPage` 被 `InboxPage.ets:171` push,
而 `InboxPage` 自己**不在**路由表里 ⇒ 两者一起不可达。
`InboxPage` 里那份 `loadInbox()` + 未读计数与 `MainPage.InboxTab` 重复。
已登记 `harmony-dead-pages`(count=2,到期前提「下次清理鸿蒙页面时」)。
清理前**先确认**它们不是将来要用的留存实现 —— 旧报告已提醒过,仍然有效。
---
## 3. 两端共同的空白(不在任何 review 里)
### G-1 无障碍:接近于零
| 指标 | Electron | HarmonyOS |
|---|---|---|
| 交互元素 | `<button` **139** 处 | `@Component` **27** 个 |
| 可访问名 | `aria-label` 全库 **22** 次(还含非 button 元素) | `accessibilityText` / `accessibilityLevel` / `accessibilityDescription` **0** |
| 隐藏装饰 | `aria-hidden` **4** 次 | — |
| 角色 | `role=` **11** 次 | — |
| 键盘处理 | `onKeyDown` **4** 次(其中 2 处是 `AddressInput` 的补全上下键) | — |
| 焦点顺序 | `tabIndex` **0** 次(纯靠原生顺序) | — |
**已有的正面样本**(说明作者知道要留钩子):
`CommTabs.tsx:41-52` 用了 `role="tablist"` + `role="tab"` + `aria-selected`;
`ComposeFab`(`CommTabs.tsx:88-92`)用了 `data-testid` + `aria-label`。
**这不是"顺手加一下"的量级。** 建议的做法:
- Electron 侧先落一条**不退化**判据:`test/components/a11y.test.mjs`
数「纯图标按钮(无文本子节点)缺 `aria-label`/`aria-hidden` 的数量 = 0」。
这条是静态、可机检、今天就会红 —— 所以**先记账**(见 §5.4),再逐个补。
- HarmonyOS 侧需要设备判据(ArkUI 的无障碍属性只能上设备验)。
- 两个"4"要一起处理:除 `AddressInput` 外只有 `AccountSwitcher.tsx:44` 一处全局
`keydown` 监听(用于关闭下拉),**没有**全局快捷键体系 ——
而鸿蒙侧有完整的 `PopIntent` / `KeyboardShortcuts`(Esc 返回、Enter 打开)。
**两端的键盘交互面不对等**,窄屏与桌面端的行为会分叉。
### G-2 列表虚拟化:两端都全量渲染
| | 做法 | 后果 |
|---|---|---|
| Electron | `MailList.tsx:99` `groups.map(...)` + `:225` `g.mails.map(...)`,无虚拟滚动 | 251 封长链线索(`GUI-PLAN-HARMONY.md` M4 提到的规模)一次性进 DOM |
| HarmonyOS | **`LazyForEach` 使用 0 次**,`ForEach` 46 处(含收件箱、发件箱、日历、会话、联系人) | 同上 |
★ **计划与实现已经分叉**:`GUI-PLAN-HARMONY.md:152` 明确写了
「组件用 `struct` + `@Component`,状态用 `@State`/`@Prop`/`@Provide`;列表用 `LazyForEach`
(邮件量可能上百,`ForEach` 全量渲染会卡)」—— **计划里写了,实现没做,
而且 `docs/DEBTS.json` 的 56 条里没有这一条。**
**线程树是个例外且做得对**:Electron `ThreadView.tsx:122` 用 `IntersectionObserver`
分块加载(`limit=60` + `next_offset` + `hasMore`),鸿蒙 `MailApi.thread()`(`:348`)
也有 `dir`/`limit` 参数 —— 但鸿蒙侧 `MailDetailPage.ets:1990` 把树**拍平成缩进字符串**后全量渲染
(M-3 同一个位置的两个问题)。
**建议**:把 `LazyForEach` 这条补进 `docs/DEBTS.json`(§5.4 给了草稿),
先记账再排期。这正是"计划说了但没人记"的典型,而本仓的 `DEBTS.json`
就是为了防这种事才存在的。
---
## 4. 判据纪律(写判据前必读)
`client/electron/test/CRITERIA.md` 已经把规矩写全了,这里只挑**本报告相关**的三条复述。
### 4.1 静态判据 vs 设备判据
`.ets` 的**运行时**行为(状态机、文案、渲染结果)只能上设备验。
`test/run-all.mjs` 的 `STATIC_ONLY` 名单(`:1395` 起)登记了当前 5 条静态判据
及其到期前提(探针三值转 true 时自动变红)。
⇒ **H-1 的判据应该落在 `harmony-logic.test.mjs`**(它在 `STATIC_ONLY` 里,
理由是「页面状态机与文案:`.ets` 状态要跑起来才算数」)——
但注意它目前在静态名单里,所以**升级成设备判据时要从 `STATIC_ONLY` 移除并相应减 count**,
否则 `debt-visibility.test.mjs` 会因"该升级却没升级"而红。
★ 这正是 `static-criteria`(count=5)与 `harmony-p4c-boundary-decls`(count=4)
这两条欠账管理的机制 —— 按它做,不要另创流程。
### 4.2 判结构必须配对/解析,不要固定宽度窗口
`CRITERIA.md` §1 记的是**同一个坑在本仓露头的第三次**:
固定宽度窗口正则(如 `\{[\s\S]{0,80}…`)会被"往规则里加一行注释"绕过。
⇒ H-2 的判据要断言 `loadWallpaper` 方法体里有 `desiredSize`,
应该**取出那个方法体再断言**(按 `}` 配对,且**要跳过成对的括号组** ——
ArkTS 的链式修饰符会让"往前看一个字符是不是 `}`"失效,见 `CRITERIA.md` §1 的第 ③ 次露头)。
`CRITERIA.md` §1 还记了**已知存量**:
`test/background.test.mjs` 里还有一批 `\{[\s\S]{0,80}…` 窗口,
"下次碰那个文件时按本节形状改成取规则体,不要在那里再加一条注释算了"。
X-1 的判据若要断言 `main.cjs`,请用取结构体的写法。
### 4.3 判据要能红,且红在正确位置
每条新判据都要做**变异测试**:故意把代码改成错的,确认判据变红,
然后改回来确认变绿。本仓有 `test/mutants/` 目录与 `criteria-hygiene.test.mjs` 的自检,
照它们的形状做(`CRITERIA.md` §6.6 记了"被变异测试抓住两次的窗口式正则"这个反例)。
---
## 5. 建议的修复顺序与分工
### 5.1 优先(两端各一条 HIGH)
| # | 项 | 端 | 改动量 | 判据落点 |
|---|---|---|---|---|
| 1 | **H-1** `MailStore` 拆快照 + generation 守卫 | 鸿蒙 | 中(store + 1 个 pane + `clear`) | `harmony-logic.test.mjs`(设备) |
| 2 | **H-2** `AppearanceStore` `release()` + `desiredSize` | 鸿蒙 | 小(一个方法 + 一个清理入口) | `harmony-appearance.test.mjs` |
★ 两条都在鸿蒙侧,且**都不需要动服务端**。
若要分给两个人,建议一人一条 —— 它们没有共享文件。
### 5.2 次优先(Electron)
| # | 项 | 改动量 |
|---|---|---|
| 3 | **X-1** 导航拦截 + `requestSingleInstanceLock` | 3-5 行 |
| 4 | **E-1** `PermissionPanel` `inFlight` ref(照 `ForwardBar:393` 抄) | 4 行 |
| 5 | **E-2** `Attachments` `picking` ref | 6 行 |
★ E-1 与 E-2 **都在既有判据文件里补格**(`PermissionPanel.test.tsx` 已存在),
不需要新建测试文件 —— 这是"最便宜的高价值修复"。
### 5.3 再次(鸿蒙,多为一致性对齐)
M-2(`ContactsTab` 请求令牌,与 Electron `CalendarView` 同形)→
M-6(`loading` 进 `finally`)→ M-5(`MarkdownController` 复用)→
M-3(对话树 key)→ M-4(SSE 去抖 + 探针,**抄 Electron 的 `inboxFallbackPoll`**)。
### 5.4 先记账,不排期
| 项 | 动作 |
|---|---|
| **G-2** `LazyForEach` 零使用 | 补进 `docs/DEBTS.json`(草稿见下) |
| **G-1** a11y | 先落一条"当前数量"的基线判据(今天会红),记 `count`,再逐个补 |
`LazyForEach` 欠账草稿(字段名照 `DEBTS.json` 既有形状):
```json
{
"id": "harmony-no-lazyforeach",
"count": 1,
"due": "收件箱/发件箱/日历任一列表的条目数上到三位数之前(判据:设备上滚动掉帧,或用 `inspector` 读出节点数超过量级)",
"where": "client/harmony/entry/src/main/ets/ 全树:`LazyForEach` 命中 0 次、`ForEach` 46 处。计划侧的依据是 docs/GUI-PLAN-HARMONY.md:152(「列表用 LazyForEach(邮件量可能上百,ForEach 全量渲染会卡)」)—— 计划写了,实现没做,且此前无人记账。",
"kind": "correctness",
"note": "线程树是例外且已做对:Electron `ThreadView.tsx` 用 IntersectionObserver 分块(limit=60 + next_offset),鸿蒙 `MailApi.thread()` 也有 dir/limit 参数;分叉的是渲染侧(拍平成缩进字符串后 ForEach,见本报告 M-3)。"
}
```
⚠️ 两点已核实的口径(省得下一个 agent 再查一遍):
1. **`kind` 没有 schema 约束。** 实测 `DEBTS.json` 现有 56 条里 `kind` 只有 7 个值
(`scope` 17 / `env` 6 / `observability` 3 / `bug` 2 / `correctness` 1 / `consistency` 1,
剩下 26 条把结论直接写进了 `kind` 字段)。
全仓**没有任何判据读 `kind`**(grep `DEBTS` 只命中 `commit-hygiene.test.mjs` 等注释)。
所以填 `correctness` 不会被红,但**与既有枚举一致更可读**。
2. **`DEBTS.json` 里只有 `static-criteria` 那笔有机器镜像。**
`commit-hygiene.test.mjs:180-196` 的 `debtLedgerStaticMatches` 只比对
`static-criteria.count` 与 `run-all.mjs` 的 `STATIC_ONLY.length`;
Go 侧那几笔靠 `go test` 的 `TestDebtLedgerMatchesMeasurement` 比对。
⇒ **新增别的 id 不会触发任何账目漂移检查**,所以 count 要自己算准;
而**若将来把 H-1 的判据升级成设备判据并从 `STATIC_ONLY` 移除,
`static-criteria` 与 `harmony-p4c-boundary-decls` 两个 count 必须同时减**(见 §4.1)。
---
## 6. 不要动的部分
| 项 | 原因 |
|---|---|
| **M-1** 鸿蒙系统返回键 | 已登记 `harmony-system-back-key`;上一轮在 `EntryAbility` 加 `onBackPressed()` **实测失败并已撤回**(按一次返回键直接销毁 UIAbility)。到期前提是"真机验清 `Navigation` 的返回事件消费点"。**没有真机就别动。** |
| `MailStore.bump()` 不发布 AppStorage | **是设计**(`MailStore.ets:180-196` 注释写明:两者混用会死循环)。`harmony-state-review.md` #3 是误读。 |
| 鸿蒙日历 `hourEvents` / `hhmmAtOffset` | §1.3 ① 已用五个时区实测否证,两者恒等。**不要去"统一时间基准"。** |
| `main.cjs` 的 `webPreferences` | `contextIsolation` / `nodeIntegration: false` / `sandbox: true` 已正确。X-1 只加导航拦截,不要顺手"加强"这部分。 |
| `backgroundStore` 的 `LEGACY_BACKUP_KEY` | 有意永久残留(注释已论证代价与无法判定安全删除的理由)。 |
| `.pi-lens.json` / `biome.jsonc` 的 formatter 关闭 | 历史上有一次自动格式化把 `index.html` 里 `classList.add('dark')` 的单引号改成双引号,直接让 `test/theme.test.mjs` 变红。 |
---
## 7. 本报告的证据边界(如实标注)
**我做了什么**:对着 HEAD `5e312c6` 逐条 grep / 读源码复核上游 4 份报告的每一条 finding;
实测了两个未记录的量(a11y 计数、`LazyForEach` 计数);
**实测运行**了日历时间基准的时区等价性验证(§1.3 ①,5 个时区)。
**我没能做什么**(这些结论的性质必须说清):
1. **没有运行任何测试**。`client/electron/node_modules` 不存在,本机 `go` 也不可用
(`go: 未找到命令`)。所以 `npm test`、`run-all.mjs`、`go test ./...` 一条都没跑。
⇒ **本文所有"判据建议"都只是建议,没有任何一条经过"能红 / 红在正确位置"的验证。**
落地时请按 §4.3 做变异测试。
2. **没有真机 / 模拟器**。H-1、H-2、M-1..M-6 的**运行时症状都是静态推断**,
不是实测。特别是 H-1:我说清了三条触发路径,但**没有一条在设备上复现过** ——
旧报告把它标成 CRITICAL 的那个"必然场景"已经不成立(`commTab` 改成只 mount 一个),
所以它在设备上到底是"切页签必现"还是"极难触发",**我不确定**。
⇒ 建议接手者**先在设备上构造一次**(切页签时断网 / 慢速网络),
拿到实测症状再动手,这样判据才知道该红在哪。
3. **HarmonyOS 侧没有编译过**。本机无 `devecocli` / `hvigorw` / SDK,
`AppearanceStore` 的修复草案**未经 ArkTS 编译验证** ——
`@State` 对 class 类型的约束、`image.DecodingOptions` 的字段名、
`finally` 里 `await` 的合法性都需要在真工程里试。
4. **没有做性能测量**。G-2 的"卡"是推断(基于 `LazyForEach` 零使用 + 计划自己的话),
**没有实测帧率或节点数**。§5.4 草稿里的 `due` 因此写成可机检的形式而不是"觉得卡"。
★ 交接时请把这份边界一起转过去 —— 上面 4 条里任何一条被当成"已验证",
下一个 agent 就会在没有依据的地方下结论。
---
## 8. 报告自身的可机检部分
本报告里唯一能写成判据的东西,是 §1.3 ① 的时区等价性实测:
`hhmmAtOffset(ts, deviceOffsetMinutes(now))` 应恒等于 `new Date(ts).getHours()` 的两位补零形式。
**建议**把它落成 `harmony-calendar.test.mjs` 里的一格
(现有 `:288` 那格已用 `hhmmAtOffset(iso, 480)` 这种显式偏移写法,新增格沿用同一形状)。
价值是:**日后若有人把 `hourEvents` 改成别的时间基准,判据会红**,
而不是像现在这样靠"两份 review 都恰好读过这段"来防误改。
⚠️ 一条容易踩的坑:`getHours()` 无法注入偏移,所以这一格**只能在同一个
`process.env.TZ` 下同时断言两侧**,不能写跨 TZ 循环 ——
跨 TZ 循环验证的是"两套算法在给定偏移下相等",那正是上面实测做的;
而设备上的情形(ArkTS 在本地时区跑 `getHours`)由
`deviceOffsetMinutes` 取设备偏移这个事实保证。
两件事别混在一格里,否则判据会断言一个比真实约束更强的性质。
## 9. 本报告引证的自检结果
写完正文后对全文 `file:line` 引证做了一次机检(HEAD `5e312c6`):
- 53 处引用去重后**全部在文件范围内**,无越界、无失效文件。
- 其中 **72 个行号落在这段引文真正要指的代码上**;
另有 12 处刻意指向**注释或空行**(如 `MailStore.ets:175-196` 那段
"为什么 `bump()` 与 `publishChange()` 必须分开"的说明、`main.cjs:139-148`
"为什么账号放主进程而不是 localStorage"),这些是有意引注释,不是坐标漂移。
★ 首轮自检确实抓到了 4 处漂移,已修正:`CalendarView.tsx:105→104`、
`AdminUsersPage.tsx:96→97`、`KeyPanel.tsx:35→36`、`App.tsx:131/143-152→134/151-157`,
另修正 `index.html`(脚本在 `:31-61` 而非 `:40-58`)与 `main.cjs`(原子写段在 `:159-173`)。
⇒ 这印证了 `DEBTS.json` 里那条「行号引用漂到无关代码」:**行号必须临引现查,不能凭记忆写。**