# 鸿蒙客户端代码审查报告 审查对象:`client/harmony/entry/src/main/ets/`(65 个源文件,约 23,600 行) 审查方式:加载 `arkts-grammar-standards` 技能后逐文件通读 + 交叉核对服务端契约 基线:`node test/run-all.mjs` → **577 判据 / 565 通过 / 3 红 / 9 跳过** --- ## 一、总体评价 这份客户端的**工程素养显著高于常见水平**,审查中需要特别说明: - 大量注释带日期 + 现象 + 根因 + 判据,可追溯到具体 commit - 已系统性修过 ArkUI 生命周期、SSE 解码、UTF-8 分片、主题应用、键盘避让等问题 - 已接入 34 个判据文件、577 条判据的自动化回归网 **ArkTS 语言规范层面零违规**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、 无 `for...in`、无 `@ts-ignore`、无 V1/V2 装饰器混用、无伪造的 ArkUI 修饰符、无属性名与 ArkUI 通用属性冲突。本文所有问题都是**逻辑缺陷**,不是语法问题。 因此下面列出的不是"代码很烂",而是**一处安全边界失效 + 一处数据隔离失效 + 一批边界态缺陷**。 --- ## 二、必须修(会出事) ### 1. `AdminUsersPage.ets:319` — 管理员门禁**失败开放**(fail-open) ```ts if (this.roleKnown && !this.isAdmin) { // 只有"确定不是管理员"才拦 ``` `loadRole()` 在**任何**失败路径上都把 `roleKnown` 置 false(`:113-116` —— 网络抖动、 token 过期、500、DNS 失败全都一样)。于是: - `aboutToAppear` 里 `loadRole()` 与 `load()` **都未 await 且并行** ⇒ 首帧 `roleKnown=false` ⇒ 完整管理控制台**在角色校验返回之前就已挂载** - 此后每次网络失败都会再次 fail-open **讽刺的是该文件自己的头注释(`:100-103`)写的就是正确规则**: > 「不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口)」 代码做的正是这条注释禁止的事。 **影响边界**:服务端 `middleware/user.go:70-77` 的 `AdminOnly` 仍在,所以**不是越权**。 但非管理员会看到完整用户列表、建号表单、改密/重置入口,并向管理端点发起请求 —— 属于 客户端信息泄露 + 无意义的失败请求风暴。 **修法**: ```ts if (!this.roleKnown) { /* 读不到:显示"正在确认身份",不要渲染管理台 */ } else if (!this.isAdmin) { /* 现有那面墙 */ } else { /* 管理台 */ } ``` --- ### 2. `MailStore.ets:446` / `:593` — 收件箱与发件箱**共用同一份快照** ```ts async loadInbox(...) { const snap = this.snapshot; ... snap.mails = merged; ... } async loadSent (...) { const snap = this.snapshot; ... snap.mails = merged; ... } ``` `loadSent` 会把 `snap.unread` 置 0、用发件箱分组重建 `groups`。 `MainPage.ets:1947` 的 `navPathStack.clear()` 只保证了"同一时刻只挂载一个页签", **没有串行化在途请求**:SSE 事件在 `loadSent` await 期间落地 → `notifyRemoteChange()` → `InboxTab.onMailRevChanged()` → `loadInbox()`, 两个 promise 同时在飞,**后落地的覆盖先落地的**。 **修法**:给 `MailStore` 加 generation 计数器,`await` 之后校验自己仍是最新一代, 否则丢弃结果;或者按视图拆成 `inboxSnapshot` / `sentSnapshot` 两个对象。 --- ### 3. `MailStore.ets:702` — `clear()` **零调用方**,登出不清内存 ```ts /** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */ clear(): void { this.snapshot = new MailSnapshot(); this.bump(); } ``` 全仓检索确认:**除了定义处,没有任何调用点**。 `api/Logout.ets:34-101` 的 `performLogout()` 与 `SettingsPage` 的 `removeAccount()` 都没调。 ⇒ 上一个账号的邮件、`AppearanceStore.wallpaper`、`TopbarStore` 内容全部留在内存里。 新账号走 `LoginPage.aboutToAppear` 快速路径时,会在请求返回前**先渲染出上一个账号的邮件**。 这正是 `clear()` 注释自己声称要防的那件事。 **修法**:`performLogout()` 与 `removeAccount()` 里补 `MailStore.getInstance().clear()`。 --- ### 4. `MailDetailPage.ets:1939` — 回车键守卫比较了一个**不存在的值** ```ts if (this.mailType === 'permission' && this.permissionResult.length === 0) { ``` 服务端只发 `'normal'`、`'permission_request'`(`server/internal/handler/permission.go:286`、 `repo/repo.go:442`)。同文件 `:1100` 与 `:1388` 都正确写的是 `'permission_request'`, **只有这一处是 `'permission'`** ⇒ 该守卫恒为假,是死代码。 后果:在一封**尚未决策**的权限请求上按回车,会走进"打开回复框"的分支 (`MainPage.ets:4263-4274` → `ReplyIntent.request()`),而正确行为是进入决策面板。 **修法**:改为 `'permission_request'`。 --- ## 三、应修(生产环境会撞上) ### 5. `MainPage.ets:2759` — 顶栏轮播的内层 `setTimeout` 句柄被丢弃 ```ts this.topTimer = setInterval(() => { ... setTimeout(() => { this.topIndex = ...; ui.animateTo(...) }, TOPBAR_FADE_MS); }, TOPBAR_ROTATE_MS); ``` `stopTopbarRotation()`(`:2768-2773`)**只 `clearInterval(topTimer)`**。 那个 `setTimeout` 的句柄被丢弃 ⇒ 页面销毁后回调仍会执行,在正在销毁的组件上写 `topIndex`/`topOpacity`,并对已失效的 `UIContext` 调 `animateTo`。 同一块还**缺重入保护**:`TOPBAR_ROTATE_MS`(5500) 远大于 `TOPBAR_FADE_MS`(260), 但一次超过一个周期的卡顿(大收件箱 `JSON.parse`、切后台)会让下一 tick 再排一个淡入, `topIndex` 前进两次而只显示一次淡入 ⇒ 静默跳过一条。 **修法**:把 timeout 句柄与 `topTimer` 一起保存/一起清;加一个"淡入中"标志。 --- ### 6. `MainPage.ets:2154` — `closeDetail()` 是死代码,系统返回键绕过了它 ```ts private closeDetail(): void { this.currentMailId = ''; AppStorage.setOrCreate(KEY_OPEN_MAIL_ID, ''); } ``` 全仓**仅此一处定义、零调用**。`KEY_OPEN_MAIL_ID` 由 `openMail` 写、 由根部按键分发器(`:4263-4274`)读;唯一的清除点是 `NavDestinations.ets:54-57` 里 应用内返回箭头的 `onBack` 回调。 **两个 `Navigation` 都没有注册 `onPop`**(已核对 `MainPage.ets` / `ContactsTab.ets`, 都只调了 `.navDestination(...)`)⇒ 硬件返回键与侧滑返回直接弹栈,绕过那个回调。 后果:用系统返回键退出详情页后,`KEY_OPEN_MAIL_ID` 仍是旧值 ⇒ 在收件箱按回车 会打开**一封你已不在看的邮件的回复框**。`currentMailId` 同样不复位 ⇒ 已读行高亮残留、 `groupHasActive()` 继续给已离开的组描边。 --- ### 7. `MainPage.ets:2081` — `KEY_COMM_STACK_DEPTH` 只在按 Esc 时递减 `publishStackDepth()` 的调用点里,**唯一能减的地方是 `PopIntent` 监听器(`:1708`), 而它只在按 Esc 时才跑**。 推入详情 → 用系统返回键弹出 ⇒ 真实栈已空但发布出去的深度仍是 1。 下一次 Esc 读到 `depth > 0` → 触发 `PopIntent.request()` → `navPathStack.pop()` 在空栈上是空操作 → handler `return true`(`:4239`)**吞掉了这次按键**。 ⇒ 此后 Esc 永远无法透传给系统,**用户无法用键盘退出应用**。 **修法**:深度必须由真实的 pop 观察驱动(`NavDestination.onHidden`,或任何栈变化时重发), 而不是只由 Esc 路径驱动。 --- ### 8. `ContactsTab.ets:202` — `openSession()` 没有请求令牌 ```ts this.openSessionId = c.session_id; this.sessionTitle = ...; this.sessionMails = []; this.sessionLoading = true; try { this.sessionMails = await m.sessionMails(c.session_id); } ... ``` 连点 A 再点 B,两个请求同时在飞。若 A 的响应后到(账号慢/冷连接), 赋值会**用 A 的列表覆盖 B 的**,而标题仍显示 B ⇒ 面板上方是 B 的名字和主题、 下方是 A 的邮件列表;`openSessionId`(`confirmArchive` 用)指向 B,内容却是 A 的。 `sessionLoading` 也被先完成者清掉,另一个还在飞却已无转圈。 **修法**:加序号,`await` 之后校验仍是最新一次请求。 --- ### 9. `MailStore.ets:499` — `cc_list.length` 少了 `?? []` 兜底 ```ts mail.cc_count = mail.cc_list.length; // ← 无兜底 mail.attach_count = (mail.attachments ?? []).length; // ← 两行之下就有兜底 ``` 注释里详细记录了 `attachments` 因为服务端 `omitempty` 导致 94/96 缺失、 `.length` 当场崩掉的全过程 —— 紧接着的下一行**又犯了同形状的错**(只是 `cc_list` 目前恰好 96/96 都有)。 聚合模式下单个账号的 `try/catch`(`:529`)会把这个 TypeError 吞成 `failed.push(...)` ⇒ **静默丢掉一整个账号的邮件,并污染未读总数**。 --- ### 10. `AppearanceStore.ets:188-197` — 壁纸 PixelMap 泄漏 + 全尺寸解码 ```ts const bytes: ArrayBuffer = await api.fetchImageBytes(); const src: image.ImageSource = image.createImageSource(bytes); this.wallpaper = await src.createPixelMap(); // 无 desiredSize,且不 release ``` 三个问题叠在一起: - 旧 `PixelMap` **从不 `release()`**,`ImageSource` 也不释放 —— 而同仓 `BackgroundPicker.ets:167-229` 对释放是极其严谨的,这里的标准不一致 - `createPixelMap()` **不带 `desiredSize`** ⇒ 2560px 壁纸按全尺寸解码(约 26 MB ARGB), 并在整个会话里由静态单例持有 ⇒ **低内存设备 OOM 风险** - 全尺寸解码**跑在 UI 线程**上,且设置页会 `await` 它才应用主题 ⇒ 每次同步都有 数百毫秒卡顿 --- ### 11. `MainPage.ets:2917` — 每条 SSE 事件都触发一次全量多账号重拉 ```ts MailStore.getInstance().notifyRemoteChange(); // → 所有已挂载页签 loadData() ``` `loadInbox` 会**逐个账号**各发一次 `GET /me/mail/inbox`(`:477-487`),且 **没有 debounce、没有在途抑制、没有 "正在加载" 早退** ⇒ 事件会排队而不是合并。 一分钟 20 条 `session_update`、3 个账号 = 60 次冗余请求。 这是"接收邮件不正常/卡顿"最可能的来源。 **修法**:修订号处理侧加短 debounce + `loadInbox` 入口加在途早退。 --- ### 12. `CalendarPage.ets:1312` vs `:1344` — 同一事件按两个时间基准放置与标注 ```ts if (new Date(e.event_time).getHours() === hour) { ... } // 设备本地时 Text(hhmmAtOffset(e.event_time, this.offsetMinutes)) // 日历配置时区 ``` 日历时区与设备时区不一致的用户,**每个事件被画在这一行、却被标注成另一时间**。 `nowHour()` 同样用设备本地 `getHours()`,所以"当前时间"参考线也对不上所有标签。 --- ### 13. `ApiClient.ets:119` — `persistBase()` 绕过了归一化闸口 ```ts async persistBase(base: string): Promise { this.apiBase = base; // ← 直接赋值,未经 normalizeApiBase pref.putSync(PREF_KEY_API_BASE, base); ``` `setBase()`(`:95-97`)的注释明确说它是"归一化的唯一闸口", 但 `persistBase` 绕过了它。`LoginPage.ets:216-217` 虽然先调了 `setBase`, 可传入 `persistBase` 的是**未经归一化的原始输入** `this.serverAddr`。 后果:用户填 `https://x.com/` ⇒ 内存里是对的,**存进 preferences 的是不带 `/api/v1` 的**, 下次冷启 `init()` 虽会再归一化一次,但这条路径本身就是设计上的漏洞。 另外该方法还会**丢弃调用方已算好的 `check.base`**。 --- ### 14. `SseService.ets` — 重连不销毁旧连接,且不支持事件回放 - **`doConnect` 没有 `destroy()` 旧 `httpRequest`**(`:201-202` 直接新建并覆盖 `conn.httpRequest`)⇒ 每次重连泄漏一个 `HttpRequest`,旧实例的回调也不会被解除。 `disconnectAccount` 里的 `destroy()`(`:127`)只对**当前**那个生效。 - **完全不支持 `Last-Event-ID`**(全仓零命中)。服务端 `server/internal/sse/manager.go:184-193` 专门实现了环形缓冲回放,而 WebUI 靠 `EventSource` 自动带这个头。 ⇒ 鸿蒙端**断线期间的事件永久丢失**,这正是服务端那段回放代码想解决的问题。 - 重连固定 3 秒、无指数退避(`:305-308`),WebUI 侧有 backoff。 --- ### 15. `MailStore.ets:690` — `dropSession` 忽略账号,而分组键带账号 ```ts if (m.session_id !== sessionId) { kept.push(m); } ``` 而 `MailGrouping.ts:193` 的 `sessionKey` 明确要求键必须是 `account + '/' + session_id` ("同一个 `session_id` 出现在两个账号里是两件事")。 后果:聚合模式下归档 A 账号的某条会话,会把**其他账号里同 id 的会话一并删掉**, 且要等下次重拉才恢复。 --- ### 16. `MailStore.ets:375-419` — 缓存读路径没有归一化 `paintFromCache` 走 `JSON.parse(...) as MailLike[]` 后**直接使用**, 不像 `MailApi.inbox()` 那样过 `MailSummary.normalize()`。 旧版本构建留下的缓存里,`session_alias` / `permission_result` / `attachments` 都会是 `undefined` ⇒ **复现 `Models.ets:139-163` 记录过的那次白屏崩溃** (`Cannot read property length of undefined`)。 --- ## 四、可选清理 | 位置 | 问题 | |---|---| | `Index.ets:1-38` | DevEco "Hello World" 模板页仍注册在 `main_pages.json` 里,可被路由到并显示模板文案 | | `InboxPage.ets` | 既不在 `main_pages.json`,也无任何 `pushUrl` 指向 —— 完全死代码,~262 行,且自带一套与 `MainPage.InboxTab` 重复的取数与未读计数 | | `SessionsPage.ets` | 仅被上面那个死页面引用 | | `MainPage.ets:38` | `import { MailDetailView }` 从未使用(细节渲染走 `MailDetailDestination`)—— 半途重构的痕迹 | | `MainPage.ets:455` | `InboxTab.openCompose()` 仍是迁移前的 `pushUrl` 全页路径(当前不可达,但离回归只差一个调用点) | | `WideSidebar.ets:97` | 注释声称"订阅 SSE 状态变化",实际只在 `aboutToAppear` 读一次 ⇒ 连接指示灯永远冻结在挂载时的状态 | | `MailDetailPage.ets:113` | `autoReadMailId` 带着 7 行去重注释但从未被读或写,真正的去重没实现(`status` 在 await 之后才置 `'read'`,不构成重入锁) | | `MailDetailPage.ets:2008` | `const sessionId: string \| null = this.sessionId;` 而 `@State sessionId: string = ''` 永不为 null ⇒ 那个 `=== null` 守卫恒假,空串会漏过去 | | `SessionApi.ets:48` | `proposal: RenameProposal \| null` 用了 `null`(本仓约定偏向 `undefined`) | | `ContactsTab.ets:416` | `ForEach` 用数组下标做 key(其余列表都用稳定标识),归档后行组件会错位复用 | | `CalendarPage.ets:499/993/1036/1060` | `loading = false` 写在 `catch` 之后而非 `finally`(同仓 `PermissionTab`/`AdminUsersPage` 是对的) | --- ## 五、审查过程中**排除**的疑点 避免这些被当成 bug 去"修": - **ICS 生成**:客户端**根本不生成** ICS,`IcsFile.ets` 只负责选/读/写文本; 生成在服务端 `handler/calendar.go`,CRLF 与 UTC `DTSTART` 都正确。 - **`EntryAbility.onCreate` 里登录前就 `reportToken`**:已知且**已修**(`LoginPage:179` 的 `reportPushToken` 补报,注释记录了完整因果)。 - **顶栏 `setInterval`**:外层 interval 在 `aboutToDisappear` 确实清了(内层 timeout 漏了,见第 5 条)。 - **SSE 监听器生命周期**:`addListener`/`removeListener` 在 `MainPage:2868/2876` 正确配对。 - **`bump()` 与 `publishChange()` 共用 revision 计数器**:曾被怀疑会导致 AppStorage 值永久漂移, 实际不会 —— `@Watch` 关心的是"值有没有变",两者同步自增反而是必需的。 - **`CalendarPage.ets:335` 的 `Date.UTC(year, month-2, 1)`**:1 月时索引为 -1 是**故意的**溢出算术。 - **Markdown 渲染远端邮件正文**:全仓无 WebView / `loadUrl` / `innerHTML` 路径, 走原生 `@luvi/lv-markdown-in` 组件 ⇒ **不存在 XSS 执行路径**。 - **`.onClick` 签名**:16 个页面里全部零参,无 `ClickEvent`/`GestureEvent` 混用。 - **ARKTS 语法层**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、 无 `for...in`、无 `@ts-ignore`、无 V1/V2 混用、无伪造修饰符、无 `Record` 字面量未加引号的键。 --- ## 六、建议的修复顺序 1. **第 1 条**(管理员门禁 fail-open)—— 唯一的"安全边界失效",改动量最小 2. **第 3 条**(`clear()` 零调用)—— 一行调用,堵住跨账号数据泄露 3. **第 4 条**(`'permission'` 拼写)—— 一个字符串,修掉一处死守卫 4. **第 6、7 条**(系统返回键相关的两处状态未复位)—— 一起改,都需要真实 pop 观察 5. **第 2、11 条**(快照争用 + 无 debounce 重拉)—— 一起做,加 generation 与 debounce 6. 第 14 条(SSE 重连销毁 / `Last-Event-ID`)—— 影响实时性,可排后 7. 其余按需 --- ## 附:测试基线 `node test/run-all.mjs` 本身是**红的**,3 条失败均与鸿蒙客户端代码无关: - `build-stamp` —— 产物是在旧提交 `d9e71a4` 上构建的,当前 HEAD 是 `3b46126`(需重构建) - `commit-hygiene` —— **两个 `.hap` 产物被 git 跟踪**,内含 AGC 信封密钥/校验码/api_key。 已确认**尚未推送到 `origin/main`**(`git cat-file -e origin/main:` 均失败), 提交于 `f51c9c8` ⇒ **赶在下一次 push 前**执行 `git rm --cached AgentMail-v1.2.1-pushdiag.hap AgentMail-v1.2.1-pushlog2.hap` 即可; 但既然是客户端签名凭证,仍建议**轮换** - `criteria-hygiene` —— 判据自身的探针路径在本地历史里查不到(探针自身有问题) 9 条"跳过"全部是**设备相关**判据(hdc 服务/模拟器不在), 按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。