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

45 KiB
Raw Blame History

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:

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 拿到的必须是自己那次拉的数据"。

修复草案(三选一):

// 方案 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

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 的既有形状,不要另创一套):

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

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

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

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

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())

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)

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;)。照抄那份形状即可。

修复草案:

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

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)。

修复草案(推荐第一种,不动签名):

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 全部暴露在陌生站点上。

修复草案:

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 既有形状):

{
  "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 里那条「行号引用漂到无关代码」:行号必须临引现查,不能凭记忆写。