## 4.16.0 — 2026-10-16 **主题:文档「侧栏预览」** ✓(用户原话:「在侧边栏预览这个你也做一下」✓)。 ### 现状与差距 - **电脑端 GUI**:文档预览挂在**右侧栏**(`sidebar.right.*` ✓ 那套槽位 ✓)—— 边读边聊 ✓; - **手机端**:`#docopen` 是一张**整屏纸** ✗(2.1.0 起就是这样 ✓)—— 打开文档就把对话整个盖住 ✓, 读半句想插一句话,得先「返回」✗。 ### 做法(两种宽度各一个形态 ✓) - **窄屏(手机竖屏)**:文档占**下面 58vh** ✓(`inset: auto 0 0 0` ✓)—— **上面那半留给对话** ✓, 这才是手机上"侧栏"的实际形态 ✓(390px 宽屏上真做成窄窄一条只有一百来像素 ✓ 读文档等于不能用 ✗); - **宽屏(≥760px,横屏 / 平板)**:就是**右侧栏** ✓(贴右边 44% 宽、整屏高 ✓); - 两种形态都**只盖住一部分** ✓ —— 对话区照旧能滚、能发消息 ✓(整屏就不叫侧栏了 ✗); - 纸面栏上多一个键 ✓(写着「侧栏」/「全屏」✓),选择**记在这台手机上** ✓(`dshm.docSplit` ✓); - ⚠️ **「返回」不重置形态** ✗ —— 用户选了侧栏就该记住 ✓(顺手改掉人家的选择是最烦的那种 ✓); - 打开文档时**照着记着的形态摆** ✓(`applyDocSplit(docSplitOn())` ✓ —— 不许每次悄悄回全屏 ✗)。 ### 测试 - package **+5**(158 → **163** ✓):窄屏那条(`inset: auto 0 0 0` + `height: 58vh` ✓)、 宽屏那条(`@media (min-width: 760px)` + 右侧栏 44% ✓)、记在本机 ✓、打开时照记着的摆 ✓、 「返回」不许重置 ✓; - ui **+4**(842 → **846** ✓):默认整屏(没有 `doc-split` 类 ✓、按钮写着「侧栏」✓)、 点一下加类 + 按钮变「全屏」✓、选择落进 `dshm.docSplit` ✓、再点一下回整屏 ✓; - package **163/163** ✓、harness **612/612** ✓、ui **846/846** ✓ → 九个套件 **1764 项** ✓;`check-counts` exit 0 ✓。 ### 界面上看得到的变化 - 打开会话里的文档 → 纸面右上多了 **「侧栏」** ✓:点一下它缩到下半屏(宽屏上贴到右边 ✓), 上面那半照旧是对话 ✓ —— **边看文档边聊** ✓;再点一下回整屏 ✓。 ### 如实说明(没做的 ✗) - 这一版给的是**手机上的等价形态** ✓ —— 电脑端 GUI 那个**右侧栏本身**是它的 UI 插件(`ui-sidebar-documentpreview` ✓), 那边**本来就有** ✓,这一版没动它 ✓; - ⚠️ **电脑端那只桌宠仍然没上** ✗(用户「电脑端也要有」✓)—— 仍然排在下一版 ✓。 ## 4.15.2 — 2026-10-16 **主题:发一句话之后"文字框颜色变回去"** ✓(用户原话:「还有修一下发一句话文字框颜色就会变回去的问题」✓)。 ### 根因:**两个主题类被拆散了** ✗(这是同一个坑的第三次 ✓) - 那套跟着背景走的**暖色配色**(`--field-line` / `--panel` / `--panel-2` / `--bubble` / `--user-bubble` …✓) 是 **`body.hasbg.bg-live`** 两个类**同时**在才生效的 ✓; - 而断线那一下 `offlineShow()` 里写的是 `classList.remove('hasbg')` ✓ —— **只摘了一个** ✗ → 主题进入"**半开**"状态 ✓ → 配色**当场变回默认** ✓(输入框 / 面板 / 气泡一起 ✓), 可 `#backdrop` 那张背景图**还在屏幕上** ✓ —— 用户看到的现象就正好是"**只有颜色回去了**" ✓✓; - 至于"补回来":4.9.4 那条只在 **`applyBackdrop` 被再次调用**时才顺手补 ✓ —— 而它只在 **boot / 换背景 / 连上时拿到背景信息**才会跑 ✗ → 单纯"断一下又连上"**补不回来** ✓ (除非整页刷新 ✓)。 📌 **同一个坑今天之前已经踩过两次** ✗:4.9.4 是**背景图**"一发消息就没了" ✓、4.9.5 + 4.10.5 是 **气泡**"一发信息颜色变回去" ✓(后者根因是亮色模式那条老规则压过主题 ✓)。这次是**文字框** ✓ —— 三次都是"主题的某个开关被单独摘掉/压掉" ✓。所以这一版的判据直接钉在**类必须成对**上 ✓(见测试 ✓)。 ### 改(都在 `lib/mobile.html` ✓,两处) 1. **要收就收整套** ✓:`offlineShow()` 改成 `remove('hasbg', 'bg-live')` ✓ —— 半开状态**从源头消失** ✗; 2. **回来自己补** ✓:新增 `lastBackdrop`(记住上一次那套背景 ✓,`applyBackdrop` 里顺手存 ✓), `offlineHide()` 里判断"有背景、但主题没开"就**照着上一次重新应用一次** ✓ —— ⚠️ 这一句**故意放在那个早退之前** ✗:`offlineHide()` 在"成功拿到数据"时都会被调 ✓, 而"断了一小会儿、离线屏还没来得及弹出来"时 `offline.shown` 还是 false ✓ → 放后面就被早退挡掉 ✓, 主题就补不回来 ✓(这条是复查自己揪出来的 ✓)。 `backdropShown` 仍然挡着"从没真铺过"的情况 ✓ —— **不会凭空给一个黑屏** ✗(4.9.4 留下的那条 ✓)。 ### 测试(ui **+4** ✓,而且**这一块故意放在整个套件的最后** ✗) - ① **前置**:图片背景真的铺上了 ✓(`hasbg` + `bg-live` 都在 ✓ —— 这就是那套配色的开关 ✓); - ② **断线时两个类一起摘** ✓ —— ⚠️ 这条**会咬** ✗:老代码只摘 `hasbg` ✓,`bg-live` 还在 → "两个都 false"当场红 ✓; - ③ **回到线上自己补回来** ✓(两个类都回来 ✓ —— 不许等用户刷新 ✗); - ④ "说话/输入"那一带跟着回到主题里 ✓(正是用户报的那一格 ✓); - 📌 **一条测试教训**(我第一版栽了 ✓):放在中间时,它把页面推进"整屏离线"再拉回来 ✓, 状态最脏 ✓ → 后面 **9 条毫不相干**的用例一起红 ✓(翻页 / 发件箱横幅…)→ 看起来像九处回归 ✗。 **状态变更型的用例放最后** ✓,这条写进交接文档 ✓; - package **158/158** ✓(老的"`offlineShow` 只摘 `hasbg`"那条断言**当场咬到我** ✓ —— 它钉的是**字面原文** ✗, 而正是那个字面造成了半开主题 ✓;已把它**升级成更强的一条** ✓:断线时**两个类必须一起摘** ✓ + 回到线上**自己补回来**且在**早退之前** ✓)、harness **612/612** ✓、ui **842/842** ✓ → 九个套件 **1755 项** ✓; `check-counts` exit 0 ✓。 ### 界面上看得到的变化 - 发出一句话之后(哪怕中间断过一下 ✓),**输入框 / 面板 / 气泡的配色不会再变回默认** ✓; - 真断线时主题**整套**收掉 ✓(不再是"半开"那种四不像 ✓),连上来自动整套装回来 ✓; - ⚠️ 电脑端那只桌宠**仍然没上** ✗(「电脑端也要有」✓ —— 下一版 ✓)。 ## 4.15.1 — 2026-10-16 **主题:把"头顶那个圈"拿掉,表情改成她自己的脸** ✓(用户原话:「你不能做成她表情是动态的吗, 非要在头顶搞个圈?」✓)。 ### 为什么那个圈必须拿掉(我第一版就是这么做的 ✗) 第一版是「一个身体 ✓ + 头顶一个 74px 的圆形脸框 ✓」—— 用户一眼看出问题 ✓:**表情和身体是两回事** ✗, 而且那个圈像在头上顶了块牌子 ✗。 ### 试过的两条路(都记下来,别再走一遍 ✗) 1. **让她"整脸"自己生成**(4 张单图 + 1 张 3×2 精灵表 ✓): - 单图那 4 张:表情确实不一样 ✓(开心 / 难受 / 吃饭 ✓),但**衣服细节每张都在变** ✗ (袖子、领结、裙摆都不同 ✓)→ 换姿势看起来像**换了个角色** ✗; - 精灵表那张:**六格表情几乎一模一样** ✗(模型没听"六种表情"✓,只换了姿势 ✓)→ 白做 ✗; - ⇒ 结论:**表情的差异化只能靠用户那张设定图** ✓(他自己画的 9 格 ✓),生成补不出来 ✗。 2. **把你图里的 8 张脸贴到她脸上**(我认真做了 ✓):量了真实像素(表情格里眼睛在 y≈41–50、下巴 60 ✓; 身体上她的脸在 (198,138) 附近 ✓)、做了椭圆羽化 ✓、还扫了 4 档纵向偏移 ✓ —— 结论:**边缘永远有"贴纸感"** ✗(那张脸是另一个渲染 ✓,贴上去像糊了一块 ✓)→ 放弃 ✗。 ### 最后采用:**整只换立绘** ✓ - `pet.json` 现在有 `sprites`(4 档 ✓:**中性 / 开心 / 难受 / 吃饭** ✓)—— 每档都是一张**干净的透明底全身立绘** ✓, 手机换的是 `#petSprite` 的 `src` ✓✓(表情长在她自己脸上 ✓,**没有圈** ✗); - ⚠️ **如实标注的代价** ✗:这 4 张是**分开生成**的 ✓,所以衣服细节彼此有一点差异 ✓ (不是逐帧动画 ✗)—— 想要"完全一致的多表情",得**人手画一套帧** ✓,这一版没做 ✗; - 手机上**删干净** ✗:`#petFace` / `#petFaceImg` 连同 CSS 与注释**一个字节都不留** ✓ (`test/package.mjs` 拿断言钉住「`petFace` 不许再出现」✓ —— 留着死代码会让人以为那个圈还在 ✓); - 说话那行挪到她**旁边** ✓(左边 ✓,不再顶在头上 ✗)。 ### "动态"做到哪一步(如实 ✓) - **空闲时她自己会换** ✓:每 7 秒在 `moods.idleCycle`(中性 → 吃饭 → 开心 ✓)里整只轮换 ✓; - **有事情时换对应的那只** ✓:发成功 → 开心 ✓、报错 → 难受 ✓、在跑 → 中性 + 一句「在跑了」✓; - **点一下** ✓:随机换一只 + 说一句 + 弹一下(`petpop` ✓); - ⚠️ **没有逐帧动画** ✗(**不眨眼**、不摆手 ✗)—— 那需要一套手工帧 ✓,这一版**没做** ✓; - ⚠️ 另外「在跑 → 努力」那张**没有专门立绘** ✗(只有中性 + 文字 ✓)—— 也是如实 ✓。 ### 测试 - package **+3**(154 → **157** ✓):**`petFace` 一个字节都不许留** ✗、整只换立绘(`showSprite` + `sprites` + `idleCycle` + 7 秒 ✓)、服务端要给 `sprites` 且**白名单带上它们** ✓; - ui(**838** ✓):把"点一下弹出圆脸"那三条改成**整只换立绘** ✓ —— "没有圈了" ✓ / 点一下换档 + 说话 ✓ / `happy` → 换成开心那档且说那句 ✓ / `error` → 换成难受那档 ✓; - package **157/157** ✓、harness **612/612** ✓、ui **838/838** ✓ → 九个套件 **1750 项** ✓; - `check-counts` exit 0 ✓; - ⚠️ 顺手记一条**偶发** ✗:`ui` 里那条「录音按文件那条路分片上传」在第一次跑时红过一次 ✓ (片数/名字/类型都对,卡在"只该多一条 prompt"✓),**紧接着重跑就绿** ✓ —— 时间敏感 ✓,记在这里 ✓。 ### 界面上看得到的变化 - 头上那个圈**没了** ✓;她就是她(一张完整的立绘 ✓),表情随心情整只换 ✓; - 长按弹出的工具条上现在是**四档立绘** ✓(中性 / 开心 / 难受 / 吃饭 ✓),不再是 8 张"圈里的脸" ✗; - ⚠️ 电脑端那只**仍然没上** ✗(用户「电脑端也要有」✓ —— 下一版 ✓)。 ## 4.15.0 — 2026-10-16 **主题:手机桌宠「小鲸」** ✓(用户 2026-10-16:「帮我做一个手机桌宠,可以移动缩放大小和互动, 参考这张图片,电脑端也要有」✓;参考图是他自己的角色设定图 —— 小鲸 / DeepSeek酱 ✓)。 ### 素材怎么来的(**都在他本机,一个字节不进插件** ✓) - **8 张表情**:从设定图的「表情集」里**逐格裁出来** ✓(开心 / 吃饭中 / 努力 / 懊恼 / 委屈 / 害羞 / 生气 / 满足 ✓)—— ⚠️ 坐标**不是猜的** ✗:先裁一条单列、放大加网格量出真实像素(脸在第 1 行 y≈613–677、第 2 行 y≈705–770 ✓, 标签在 775 以后 ✓),第一版按估的坐标裁就把标签一起切进去了 ✗; - **立绘**:按设定图生成 ✓(深蓝长发 + 鲸鱼耳鳍 + 女仆装 + 鲸鱼尾 ✓),然后抠成**透明底** ✓ —— ⚠️ 两个坑都踩到了:① 第一版生成的是**白底** ✗(白围裙 / 白袜子是内部区域,一刀切白会**连衣服一起抠掉** ✗) → 让他重画成**绿幕** ✓;② `colorkey` 是 RGB 距离,**深蓝和绿差得不够远** ✗(0.32 时把衣服一起抠没了 ✓, 透明占比 70.8% 且衣服变透明 ✓)→ 改用 **`chromakey`**(色度平面 ✓)并**扫参数**(0.02→0.12 透明占比稳定在 52–55% ✓, 取 0.08 ✓)✓; - 落盘在 `~/.dsh/mobile-bridge-pets/xiaojing/` ✓(`pet.json` + `body.png` + `faces/*.png` ✓)—— **和"夜行星"同一条硬规矩**:用户的东西**只放本机** ✓、**不进插件、不进 Release** ✗。 ### 手机这一半(做完 ✓) - 浮在整个界面之上的**静态节点** ✓(`#petLayer` ✓ —— 绝不挂在会被重渲染的容器里 ✗,那是 3.5.0 栽过的地方 ✓); - **拖动** ✓(指针事件 + 指针捕获 ✓,手指划出元素也不丢 ✓); - **缩放** ✓:右下角把手 ✓(单手可用 ✓)+ **双指捏合** ✓; - **点一下有反应** ✓:换一张表情(圆形脸 ✓)+ 说一句(台词来自清单 ✓)+ 弹一下 ✓; - **跟着事情变表情** ✓:发成功 → 开心 ✓、报错 → 懊恼 ✓、在跑 → 努力 ✓、闲着 → 吃饭中 ✓ (⚠️ 只挂在**真成功 / 真失败**那两处 ✓ —— 不拿"点了发送"当成功 ✗); - **位置与大小存本机** ✓(百分比 + 倍率 ✓ —— 横竖屏切换不会跑到屏幕外 ✗);**长按**出小工具条(换表情 / 大小 / 藏起来 ✓); - ⚠️ 藏起来之后**有路回来** ✓:「更多」里那个 **🐳 桌宠** 键 ✓(不摆它,藏起来就再也找不到了 ✗); - **没有素材也不空着** ✓:退到**代码画的那只**(内联 SVG ✓,插件自带 ✓ —— 别人装了也能看见一只 ✓)。 ### 服务端(两条只读接口 ✓) - `GET /api/pet` ✓(登录 ✓):回 `available` + 立绘 / 表情地址 + 心情表 / 台词 ✓; **没有素材时**回 `available:false` + 一句人话 ✓(不是"读不到" ✗、也不假装有一只 ✗); - `GET /api/pet/asset?name=…` ✓(登录 ✓):**三重校验** —— 文件名只来自我们自己读的 `pet.json` ✓、 逐段过 `safeUploadName` ✓、解析后必须落在 pet 目录内 ✓,而且**只许取清单点过名的那些** ✓ (`allowed.has(wanted) === false → 404` ✓)。 ### 测试 - package **+11**(143 → **154** ✓):**用户素材不进插件** ✓(`lib/` 下没有 body.png / faces/ / pet.json ✓)、 素材只从本机目录读 ✓、白名单 + 逐段洗 + 落目录内 ✓、两条路由都要登录 ✓、没素材如实说 ✓、 浮层是静态节点 ✓、三件事都在 ✓、存本机且用百分比 ✓、心情挂在真成功/真失败 ✓、代码兜底 ✓、藏起来有路回来 ✓; - harness **+8**(604 → **612** ✓):清单要登录 ✓、有素材时 `available:true` 且地址给全 ✓、立绘真取得回 ✓、 **四条穿越全 404** ✓(`../` / `..\\` / 清单外的 `secret.png` / 混着 `faces/../../` ✓)+ **自检**(确认这几条真的打在"有素材"那一支上 ✓ —— 否则会因为"没有素材"而**假绿** ✗); - ui **+8**(828 → **836** ✓):浮层在且 fixed ✓、清单取回 + 立绘地址对 ✓、拖动真的动了且存本机 ✓、 把手真的放大了且存住 ✓、点一下弹表情 + 说台词 ✓、`mood('happy')` 换成那一档台词 ✓、 藏起来(浮层消失 + 存住)✓、**再点放出来** ✓; - package **154/154** ✓、harness **612/612** ✓、ui **836/836** ✓ → 九个套件 **1745 项** ✓; - `check-counts` exit 0 ✓。 ### ⚠️ 还没做(如实 ✗) - **电脑端那只还没上** ✗(用户原话「电脑端也要有」✓)—— 那半边是 **Harness Web UI 的 UI 插件** (`cordis-plugin-development` 那条路 ✓:工作区写包 → `plugin_manager install_bundle` ✓), 这一版**只做了手机这一半** ✓,下一版补 ✓; - 桌宠**不会说话进会话** ✗(它只说清单里那几句 ✓)—— 要"它替你说一句"得再设计 ✓,没做 ✓; - 立绘是**照着设定图生成的** ✓,不是逐字节原图 ✗(设定图里那张主图约 500×600,放大就是糊的 ✓)。 ## 4.14.1 — 2026-10-16 **主题:手机上「看不见提问」这件事** ✓(用户 2026-10-16:「我看不见你的问题手机端」✓ + 「你也要把渲染的补上,我记得以前这个功能可以用,应该出bug了」✓)。 ### 🔴 先说清楚:**不是渲染坏了** ✗ - 手机上的提问卡片**一直在** ✓(`questionCard` ✓:选项按钮、多选、自定义答案、防 XSS 都在 ✓)—— 我第一反应说"手机端不渲染带按钮的卡片"是**错的** ✓,这里更正 ✓; - 真正卡住的是**投递** ✗,判据只有一条:`askPhone()` 里 `if (watchersOf(sessionId).length === 0) return null` ✓ —— **只有「此刻正有手机 SSE 流盯着这个会话」时**,提问才交给手机 ✓;否则立刻交还桌面 ✓(桌面体验不变 ✓)。 ### 证据(**先复现,再治本** ✓ —— 这次没靠猜 ✓) 日志(`~/.mobile-bridge.log`,7282 行)里: - 「等手机回答」(`waiting for the phone to answer a …`)**总共只有 1 次** ✓ —— 是 **10-14 的 approval** ✓, 而且 16 秒后**就在手机上答掉了** ✓(`answered a approval from the phone` ✓)→ 这条路本身是通的 ✓; - 「提问」**一次都没有** ✗ —— 包括今天那两次 ✓。 ⇒ 说明**每一次提问发生时,手机上都没有人盯着那个会话** ✓。今天的时机也对得上: 14:11 手机刚在新设备上登录(还在列表里 ✓)、14:55 正在选图片(iOS 把页面挂起了 ✓, 日志里 14:56:52 才重新连上流 ✓)。**卡片没错、是那一刻手机不在场** ✓。 ### ⚠️ 但这个"静默"本身就是 bug ✗ `askPhone()` 有**五个**"还是不交给手机"的出口 ✓ —— 它们以前**全部静默 `return null`** ✗。 于是"手机上没弹卡片"只能靠猜 ✓,而这恰好是这类问题**最难查**的地方 ✓。 所以这一版第一件事就是让它们**开口** ✓。 ### 改(三处 ✓,每改一处都跑套件 ✓) 1. **诊断** ✓(`lib/index.js`):五个出口各自记一行**为什么** ✓(`answerOnPhone` 关着 / 请求里没有会话 id / 队列满 / **现在没有手机在看 X** ✓),而且"没人在看"那支会**把此刻有人在看的会话列出来** ✓ —— "提问在 A 会话、手机正在看 B"这种错位**一眼可见** ✓;另外手机**开始看 / 不看**哪个会话也各记一行 ✓ (以前只有一条 `GET /api/stream`、**不带会话名** ✗ —— 排查时最要紧的事实恰好是唯一没记下来的 ✓); 2. **提示帧** ✓:提问没交给手机时,给**所有**在线手机推一条 `{t:'ask-waiting', sessionId, callId}` ✓ —— ⚠️ **只有 id,没有正文** ✗(桥支持多台设备 ✓,广播正文 = 跨会话泄漏 ✓;正文只走登录后的 `GET /api/waiting` ✓); 3. **迟到作答** ✓:宿主自己那条 `userQuestions.continued(agent)` ✓ 就是"超时了但**仍然收得住**"的提问 ✓ —— 手机据此也能答 ✓(`POST /api/answer {callId, sessionId, answers}` ✓,让宿主判"还收不收得住" ✓, 它回 false 就**如实说"这条提问已经结束了"** ✓)。桥原来那条路只认**120 秒就失效**的临时 id ✗ —— 那正是"错过的就答不了" ✓。 ### 手机上变成什么样 - 提问发生在**别的会话** → 一条**能点的横幅**:**「另一条会话在等你回答」** ✓,点一下切过去 ✓; - 切过去之后那条提问**直接画出来** ✓,标题写明「这条手机上刚才没弹出来 ✓ 现在还能答 ✓」 ✓; - 页面从后台**回到前台**时主动补问一次 ✓(选图片 / 切出去那段正是错过提问的时刻 ✓); - ⚠️ 宿主"读不到"(没有那两个服务 ✓)→ 页面上写的是**「读不到」** ✗,**不是"没有提问"** ✗(这条分不开就是假话 ✓)。 ### 测试 - package **+9**(134 → **143** ✓):五个出口都要出声 ✓、开始/不看会话各记一行 ✓、 **提示帧里只有 id**(正则两头夹 ✓)、迟到作答走宿主 `callId` ✓、两个新接口的门禁 ✓、 "读不到"与"没有"在服务端就是两个东西 ✓、横幅与 `LATE_PREFIX` 的形状 ✓、回前台会补问 ✓; - harness **+6**(598 → **604** ✓):没人看的会话桌面**立刻**接手 ✓ + 提示帧**真的到了别的会话的流上** ✓ + **帧里没有正文** ✓ + `/api/waiting` 要登录 ✓ + `available`/`items` 的形状 ✓ + 迟到作答答不上时**如实回 409/503** ✓(不许 200 假装收下 ✓); - ui **+7**(821 → **828** ✓):读不到时留一句话 ✓ / 别的会话画横幅 ✓ / 点它**真的切会话** ✓ / 切过去那张迟到卡直接画出来 ✓ / 作答发的是 `callId`+`sessionId` ✓ / 答完横幅撤掉 ✓ / 收尾清干净 ✓; - ⚠️ ui 用例**当场咬到一个真 bug** ✓:迟到卡没登记进 `state.asks` → ① 提交时走错分支(发的是临时 id ✗)、 ② 草稿每渲染一次就丢 ✓ —— 修法是"也登记进 `state.asks` ✓,但渲染时用 `LATE_PREFIX` 挑出来单独画 ✓" ✓; - package **143/143** ✓、harness **604/604** ✓、ui **828/828** ✓ → 九个套件 **1718 项** ✓; - `check-counts` exit 0 ✓。 ### 仍然没做(如实 ✗) - **提问发生的那一刻手机不在场时,卡片仍然不会"立刻"出现在手机上** ✗ —— 现在它能**事后**看见并补答 ✓;要"立刻"就得让桥在没人在看时也把提问扣给手机 ✗ (代价是桌面上那条会晚 120 秒 ✗ —— 那违反"桌面体验一点不变" ✓),这一版**没做** ✓。 ## 4.14.0 — 2026-10-16 **主题:蓝牙「断开某一台」** ✓(用户 2026-10-16 原话:「还有蓝牙功能的断开设备连接,现在只有连接设备」✓)。 ### 🔴 一上来就把上一版的推断**推翻了** ✗(这条值得单开一段) 上一版在注释里写着"`DISABLE` 回 rc=0 ⇒ **能关不能开**" ✓ —— 那其实是**只看返回值**下的结论 ✗。 今天真机验到底,三件事: 1. **`rc=0` 完全不等于"断开了"** ✗✗ —— 耳机那台:`110B`(A2DP)、`110E`(AVRCP)**都回 rc=0** ✓, 可 `fConnected` **照样是 `true`** ✗;真正让它断开的是 **`111E`(免提 HFP)** ✓✓; 2. **不是每台都能断开** ✗ —— 同一台机器上的 **iPhone**:**14 种服务**全试过 ✓ (其中 `110A` / `1116` / `1132` / `112F` 还回 rc=0 ✗),它的 `fConnected` **一直是 `true`** ✗; 3. ⇒ 判据**只能**写在**回读到的 `fConnected`** 上 ✓ —— 也就是本项目那条老规矩: **判据写在行为上** ✓,不是写在"命令返回了什么"上 ✗。 📌 这也是"不摆按了没反应的键"的同一条纪律 ✓:**能断的断 ✓、断不掉的如实说断不掉 ✗**, 并且给出两条**真能用的**替代 ✓(打开系统蓝牙设置手动关 ✓ / 「蓝牙关掉再打开」= 一次断掉**所有**连接, 代价写清 ✗)。 ### 改(三个文件 ✓,每改一处都跑套件 ✓) 1. **`lib/pccontrol.js`** ✓:新增 `BT_DISCONNECT_SCRIPT`(纯 ASCII ✓)—— 按**地址**找设备 ✓、逐个关标准服务 ✓、**每关一次立刻回读 `fConnected`** ✓、断了就停 ✓; 新增**纯函数 `disconnectVerdict`** ✓(这条判据**只有一份** ✓:真断了 / rc=0 但没断 / 本来就没连 / 没配对 / 没无线电,五种各说各的话 ✓);`listBluetooth()` 每台多回一个 **`address`** ✓ (名字会重、还带中文 ✓ —— "断开某一台"必须能指名道姓 ✓;老格式两列也照样认 ✓,别把列表读空 ✗)。 ⚠️ **`BluetoothRemoveDevice` 一个字都不用** ✗ —— 那是"**取消配对**"(要重配一遍 ✓), 和用户要的"断开连接"不是一件事 ✓,代价太大所以不摆 ✓(`test/package.mjs` 拿断言钉住 ✓); 2. **`lib/index.js`** ✓:`POST /api/pc {action:'bluetoothDisconnect', device:'<地址>'}` ✓ —— 走的是**原来那条门禁**(`guardWrite()` ✓ + `pcControl` 闸 ✓),结论**照原话转** ✓、并记一行日志 ✓; 3. **`lib/mobile.html`** ✓:设备列表里**已连接且拿得到地址**的那几行右边多一个「断开」键 ✓ (没地址就不摆 ✗ —— 没目标就是死键 ✓);断开**成功**才重读列表 ✓(那一行显示的是**回读到的真状态** ✓, 不许因为"请求发出去了"就自己改成"未连接" ✗)。 ⚠️ 一个**顺序坑**写死在注释里 ✓:那一行里**嵌着**按钮、而它也在 `[data-bt]` 行**里面** —— `[data-bt-off]` 必须**先判** ✓,否则点一次「断开」会**同时**发"断开 + 打开设置"✗ (3.5.0 那个"一个入口两份接法"的同款坑 ✓,`test/ui.mjs` 与 `test/package.mjs` 两头都钉着 ✓)。 ### 测试 - package **+9**(125 → **134** ✓):`disconnectVerdict` 五种结论 ✓(含"**rc=0 但没断**必须 `ok:false`" ✓)、 "断不掉时给两条替代" ✓、脚本**用 DISABLE(flags=0)** ✓、**不许出现 `BluetoothRemoveDevice`** ✓、 脚本**自己先读后读** ✓、页面里**「断开」那一支写在 `[data-bt]` 之前** ✓、没地址不摆键 ✓; - harness **+3**(595 → **598** ✓):设备列表**每台都带地址** ✓ / 断一台**没配对过**的设备 → `ok:false` + "不在已配对 / 什么都没有断" ✓ / 地址看不懂 → `ok:false` + "没有断任何东西" ✓ (⚠️ **绝不真的去断用户的设备** ✗ —— 用的是格式合法但没配对过的地址 ✓); - ui **+5**(816 → **821** ✓):只有"已连接**且有地址**"的行才有「断开」✓(没地址那台不给 ✗)/ 点「断开」**恰好一条** + 带地址 ✓ 且**没有**多出一条 `bluetoothSettings` ✓ / 断开成功后**重读列表**且那一行变"未连接" ✓ / 成功时说**服务端原话** ✓ / 断不掉时**照实说**且**不重读列表**、那一行照旧"已连接" ✓; - package **134/134** ✓、harness **598/598** ✓、ui **821/821** ✓ → 九个套件 **1696 项** ✓; - `check-counts` exit 0 ✓。 ### 界面上看得到的变化 - 「📊 电脑状态」→ 蓝牙设备列表:连着的几行右边多一个 **「断开」** ✓; - 断掉了:把**服务端那句**原样显示 ✓("已经断开了 ✓(回读确认过 ✓;起作用的是 111E 服务 ✓)")✓; - 断不掉:**照实说"没能断开"** ✗ + 两条替代 ✓(不会自己改那一行 ✓,也不会假装成功 ✗); - 要**连回来**仍然只能去系统蓝牙设置里点一下 ✓(「连接」这一步 Windows 没有可靠接口 ✗ —— 实测 rc=87 ✓); - ⚠️ 想一刀切可以用「蓝牙关掉再打开」(已有 ✓)—— 那会把**所有**连接一起断掉 ✗,面板上写明了代价 ✓。 ## 4.13.0 — 2026-10-16 **主题:定时开机** ✓(用户 2026-10-16 原话:「既然有关机那么可以做一个对应的定时开机功能」✓)。 ### ⚠️ 先说这条线**做不到**的那一半 ✗(不许含糊 ✓) | 机器当时的状态 | 软件能不能到点叫醒它 | |---|---| | **睡眠 (S3)** | ✅ 能 ✓(系统还活着 ✓,计划任务带 `WakeToRun` 就有权叫醒它 ✓) | | 休眠 (S4) | ✅ 能 ✓(**这台机器没启用休眠** ✗ —— 面板照实说 ✓,那是另一件事 ✓) | | **完全关机 (S5)** | ❌ **不能** ✗✗ —— 系统没在运行 ✓,**任何软件都不存在** ✓ | ⇒ 完全关机后到点开机只能靠**主板 BIOS/UEFI 的 RTC 定时开机** ✓; 而"BIOS 里设没设"Windows **查不到** ✗。所以这一版的做法是:**如实说清** ✓、**不摆那个键** ✗、 接口上**谁来问都如实拒绝** ✓(`POST /api/pc/wake {action:'poweron'}` → 400 + `impossible` ✓ —— `test/harness.mjs` 有一条用例专门盯这句话 ✓)。 ### 真机上**当场**验过的四件事(不是照文档抄的 ✓) 1. `Register-ScheduledTask -WakeToRun -Force` **普通用户就能建** ✓(不要管理员 ✓)—— 回读的 XML 里确实是 `true` ✓、`StartBoundary` 是**本机时区** ✓; 2. `schtasks.exe /query /tn <名字> /xml` **免提权可查** ✓ —— ⚠️ 但这台机器上 `Get-ScheduledTask` 与 `Unregister-ScheduledTask` 会报 "The task XML contains a value which is incorrectly formatted or out of range" ✗(**和 `lib/index.js` 里"开机自启"那条早就在注释里记过的同一颗雷** ✓)→ 所以 `lib/pccontrol.js` **一律走 `schtasks.exe`** ✓,`test/package.mjs` 拿一条断言把 "那两个 cmdlet 一个都不许回来"钉住 ✓; 3. `schtasks.exe /delete /tn <名字> /f` **免提权可删** ✓; 4. `powercfg /q SCHEME_CURRENT SUB_SLEEP RTCWAKE` **免提权可读** ✓,而 `powercfg /waketimers` **要管理员** ✗(本机实测:交流 = 「仅限重要的唤醒定时器」✗ / 电池 = 「禁用」✗ —— 这两档都写进面板 ✓)。 ### ⚠️ 交接文档里写错的一条,这一版改正 ✓ - 那里写着"`powercfg /waketimers` 要提权 → 正好走 `elevationMinutes` + PIN" ✗ —— **两回事** ✓: `elevationMinutes` 只是"最近输过一次 PIN"的窗口 ✓(`elevated(req)` ✓),**不是** Windows 管理员权限 ✗; 插件跑在普通用户下 ✓,既不提权、也不该偷偷提权 ✗; - 规矩因此改成:**能读就读 ✓,读不到就照实说"要管理员、读不到"** ✓ —— **绝不**把它当成 "没有唤醒定时器" ✗(那是这段里最坏的一种假话 ✓)。 ### 改(三个文件 ✓,每改一处都跑套件 ✓) 1. **`lib/pccontrol.js`** ✓:三段**纯 ASCII** 脚本块(`WAKE_SET_SCRIPT` 建 + **读回来** ✓ / `WAKE_INFO_SCRIPT` 一次读齐「任务 XML + 策略 + `waketimers`」✓ / `WAKE_CLEAR_SCRIPT` 删 + **删完再查** ✓) + 四个**纯函数** ✓:`parseWakeAt`(时间 → 本机时刻 ✓)/ `parseRtcWakeIndex`(只认**十六进制字面量** ✓, 因为 `powercfg` 的输出是**本地化**的 ✗)/ `describeWakePolicy`(0/1/2 说人话 ✓)/ `parseWakeTaskXml`(含"**没有 `` 元素 = 启用**"这条实测结论 ✓); 2. **`lib/index.js`** ✓:`GET /api/pc/wake`(走 `guard()` ✓ —— 它不改任何东西 ✓,而且那段"到底能不能" 的实话要能读到 ✓)/ `POST /api/pc/wake`(走 `guardWrite()` ✓ 要 PIN 提权 ✓ + `powerControl` 闸 ✓ **默认关** ✓ 现读 ✓);动作只有 `set` / `clear` ✓,另有 `dryRun` ✓(测试用它 ✓)、3 秒闸 + 单飞 ✓; 3. **`lib/mobile.html`** ✓:「📊 状态盘」上多一块「⏰ 定时开机」✓ —— 两句实话摆最上面 ✓、 策略真值 + 改它的命令 ✓、现有任务的时刻与 `WakeToRun` ✓、设 / 取消 / 刷新 ✓; 事件**只挂一份**委派 ✓(3.5.0 那次"两份接法 = 一次点击两条请求"的教训 ✓)。 ### 测试 - package **+19**(106 → **125** ✓):纯函数 **10** 条 ✓("今天过了 → 明天,而且**要说出来** ✓"、 "写死的过去时刻 → **拒绝** ✓"、"`02-29` → 找**真闰年** 2028 ✓"、"空输出 → **读不到 ≠ 0** ✓"、 "任务 XML 里没有 `` = 启用 ✓")+ 脚本块/门禁/文案 **9** 条 ✓("带 `-WakeToRun` ✓"、 "脚本里一个 `Get-ScheduledTask` 都不许有 ✓"、"路由是 `guard()` / `guardWrite()` ✓"、 "页面上一个 `data-wake-poweron` 都不许有 ✓"); - harness **+15**(580 → **595** ✓):默认关 ✓ / **如实拒绝 S5** ✓ / **拒了之后真的什么都没建** ✓ / 过去的时刻 400 ✓ / `dryRun` 不建任务 ✓ / **真机往返**(建 → 回读带 `WakeToRun` → 删 → 回读确认没了 ✓)/ 再取消一次如实说"**本来就没有**" ✓ / `waketimers` 读不到时 `readable:false` ✓; - ui **+9**(807 → **816** ✓):两句话分开说 ✓ / 没有 S5 那个键 ✓ / 策略与命令照实画 ✓ / 读不到就说读不到 ✓ / 空着点**一个请求都不发** ✓ / 恰好一条 `set` 且时刻**原样** ✓ / 恰好一条 `clear` 且那个键随后消失 ✓; - package **125/125** ✓、harness **595/595** ✓、ui **816/816** ✓ → 九个套件 **1679 项** ✓; - `check-counts` exit 0 ✓。 ### 界面上看得到的变化 - 「📊 状态盘」→ 「⏰ 定时开机」:写 `07:30` / `10-17 07:30` / `2026-10-17 07:30` → 「设它」✓ (只响一次 ✓;同一时刻只留一个 ✓,再设一个就换掉它 ✓); - 面板上同时显示:这台机器的**唤醒定时器策略**(交流 / 电池两档 ✓)、把它改成「启用」的命令 (**要管理员** ✓ —— 插件不偷偷提权 ✗)、现有那个任务的时刻与「唤醒」标志 ✓、 以及 `powercfg /waketimers` 读不到时的**原话** ✓(不当成"没有定时器" ✗); - ⚠️ 开关照旧:它挂在 `powerControl` 上(**默认关** ✗ —— 和"关机 / 重启"同一档 ✓)。 ## 4.12.0 — 2026-10-15 **主题:③ 上限 28MB → 120MB,前提是"文件那条不再整份 base64"(②-b-2 一起完成)** ✓ (用户 2026-10-15:「你都做完吧」✓)。 ### ⚠️ 一上来就发现一个**硬耦合** ✗ - 我先把上限改成 120MB ✓,读代码时发现**不能这么单独上** ✗: 文件那条还在 `data: await readBase64(file)` ✓ → 一个 120MB 的文件会变成 **160MB 的字符串** ✗ → **低配手机上标签页被系统杀掉** ✗✗ —— 那比"传不上去"更糟 ✓; - 所以这一版**两件事必须一起** ✓(顺序在里面 ✓:先让队列/进度认 blob ✓,最后才断掉 base64 ✓)。 ### 改(四处 ✓,每处都跑 ui ✓) 1. **`weakCanStore`** ✓:字节数认 `blob.size` ✓;并且**只有"只有 blob、没有 base64"的条目** 才拒绝进 localStorage ✓(那种条目 `JSON.stringify` 会变成 `{}` ✗ = **静默丢数据** ✗)→ 它们走 IDB ✓; 2. **`prepareRefs` 的片数** ✓:认 `blob.size` ✓(只读 `data.length` 会把文件算成 **0 片** ✗ → 进度永远停在 0 ✓); 3. **文件/视频/录音那条不再读 base64** ✓:只带 `{name, mediaType, blob}` ✓; 4. **上限 120MB** ✓(与服务端一致 ✓;依据现成 ✓:给音频放宽时就写着"服务端 120MB 且走分片,放宽安全" ✓)。 ### ⚠️ 又被安全网抓到一次(这次是**我的判据写宽了** ✗) - 我第一版写的是"**见到 blob 就拒绝进 localStorage**" ✗ → 把**照片**也拒了 ✗: 照片条目**同时**有 `data`(内联要用 ✓)和 `blob`(分片要用 ✓),localStorage 存得下它 ✓; - 结果**弱网模式整条不生效** ✗("文字先发 ✓、附件进箱子"没了 ✓)→ **ui 三条用例当场红** ✓✓; - 收窄成"**只有 blob、没有 base64**"才拒 ✓ → **807/807** ✓ 恢复 ✓。 📌 这就是"每改一处就跑套件"的意义 ✓ —— 判据写在**行为**上,而不是写在"字段有没有"上 ✓。 ### 测试 - package **+4**(102 → **106** ✓):客户端上限 120 且**不超过服务端** ✓(跨文件真值 ✓)、 文件那条**不再含整份 base64** ✓、队列与进度认 `blob.size` ✓、localStorage 的判据**收窄正确** ✓; - package **106/106** ✓、ui **807/807** ✓、harness **580/580** ✓ → 九个套件 **1636 项** ✓; - `check-counts` exit 0 ✓。 ### 这一条线("手机传大文件")到此收齐 ✓ | 环节 | 版本 | |---|---| | 先存着(小附件 localStorage ✓ / 大附件 IndexedDB ✓) | 4.9.8 / 4.9.9 ✓ | | 接着传(稳定上传号 + 跳过已确认的片 ✓) | 4.9.10 ✓ | | 传得动(片 128KB + 并发 2 ✓) | 4.9.11 ✓ | | 服务端收二进制分片 ✓ | 4.11.0 ✓ | | 客户端发二进制(文件/视频 ✓、原图照片 ✓、压缩照片 ✓) | 4.11.1 / 4.11.2 / 4.11.3 ✓ | | **不再整份吃内存 + 上限 120MB** ✓ | **4.12.0** ✓ | ## 4.11.3 — 2026-10-15 **主题:压缩那条路也带上 Blob(②-b 的第 1 项完成)** ✓ (用户 2026-10-15:「继续」✓)。 ### ⚠️ 先纠正我自己上一版的判断 ✗ - 4.11.2 我写的是"压过的照片**故意不加** blob" ✗,理由是"字节是 canvas 重压的 ✓、 拿原文件顶替会换图" ✗ —— **前半对、后半是我没查** ✗; - 事实 ✓:`shrinkImage()` **本来就返回 Blob** ✓(`canvas.toBlob` ✓,转不出来**兜底返回原文件** ✓), 而 `smallData` 就是 `readBase64(smaller)` ✓ → **同一个 blob** ✓ → **字节一致** ✓✓ (两个分支都一致 ✓:toBlob 成功 ✓ / 兜底返回原文件 ✓); - 📌 **教训** ✓:我给一个**没查过**的结论写了"故意的例外" ✗ —— 那比不写更糟 ✓ (它会让下一个人**以为已经查过** ✓ 而不去查 ✗)。**结论要写,但必须基于读过的代码 ✓。** ### 改(一行 ✓) - 压缩那条的条目加上 `blob: smaller` ✓ → 内联被掐断改切片时走二进制 ✓; - 至此**三处附件**(文件/视频/音频 ✓、原图照片 ✓、压缩照片 ✓)**全都带 blob** ✓。 ### 测试 - package **+3**(99 → **102** ✓):三处都带 blob ✓、`shrinkImage` 确实返回 Blob(含兜底 ✓)、 内联失败时把 blob 带过去 ✓; - package **102/102** ✓、ui **807/807** ✓、页面脚本语法 ✓ → 九个套件 **1636 项** ✓; - `check-counts` exit 0 ✓。 ### 还剩的(②-b 后半 → ③) - **离线队列存 Blob**(不是 base64 ✓)—— 内存 + "能排队的大小"一起改善 ✓; - **③ 上限提到 120MB** ✓ + 断言"不许再出现整份 base64" ✓。 ## 4.11.2 — 2026-10-15 **主题:内联失败后的切片重发也改走二进制(②-b 的第一小步)** ✓ (用户 2026-10-15:「继续」✓)。 ### 改(两处 ✓,都**只在有 blob 时才生效** ✓) - 「原图模式」那条照片条目加上 `blob: file` ✓ —— 它就是磁盘上那个文件 ✓、字节完全一样 ✓; - 「内联被掐断 → 改切片重发」那一遍**把 `blob` 一起带过去** ✓ → `uploadInChunks` 4.11.1 已经支持二进制 ✓ → 这些片**不再 base64** ✓。 ### ⚠️ 一个**故意的例外**(写清楚 ✗) - **照片"压过"的那条路不加 blob** ✗:那里的字节是 canvas **重新压出来**的 ✓, 而 `blob` 只能是磁盘上的**原文件** ✗ → 两者**不是同一张图** ✓; 拿原文件顶替会让"切片的字节"和"内联的字节"不一致 ✓ —— 与其偷偷换图 ✗,不如保持原样 ✓。 要做就得让压缩那一步**产出 Blob**(`canvas.toBlob` ✓)→ 记入下一步 ✓。 ### 测试 - 把"**这些重发的片是二进制**"并进**现有那条**用例 ✓(不加新条 ✓ → 项数不变 ✓): 实测 `切片 1 片(二进制 1 片)` ✓ —— 退回 base64 就会红 ✓; - package **99/99** ✓、ui **807/807** ✓(页面脚本语法 ✓)→ 九个套件 **1636 项** ✓; - `check-counts` exit 0 ✓。 ### 还剩的(②-b 的后半 + ③) - 压缩那条**产出 Blob** ✓(让压缩图也能走二进制 ✓); - **离线队列存 Blob**(而不是 base64 ✓)—— 内存和"能排队的大小"一起改善 ✓; - 之后 **③**:上限提到 120MB ✓ + 断言"不许再出现整份 base64" ✓。 ## 4.11.1 — 2026-10-15 **主题:客户端改用二进制分片(施工单第 1、2 步)** ✓ (用户 2026-10-15:「继续」= 按 1→5 走 ✓)。 ### 改(严格按施工单,一次一处 ✓) - **第 1 步**:文件/视频那条路的条目加上 `blob: file` ✓ —— **只加属性、没有任何地方读它** ✗ → 行为零变化 ✓;跑 ui **807/807** ✓ 确认 ✓; - **第 2 步**:`uploadInChunks` 里 **有 blob 就走二进制** ✓(`blob.slice(a,b)` 当请求体 ✓ + `x-upload-*` 头 ✓), **没有就照旧 JSON** ✓(老路径一行没动 ✓);照片那条、离线箱里重启后取回的老条目 ✓ 都还走老路 ✓; - 二进制那条的 `resumeId` 用 **`ub` 前缀** ✓ 和 base64 那条**刻意分开** ✗(两种协议不共用确认记录 ✓), 并用 `size + lastModified` 认文件 ✓(同名不串 ✓)。 ### ⚠️ 中途被安全网抓到的两个(都是"该抓的" ✓) 1. **`bytes.length` 还在被读** ✗ —— 二进制那条 `bytes` 是 `null` ✓ → 上报进度那三处会**当场抛** ✓。 `package` 立刻 98/99 ✓ 抓出来 ✓(**这就是每改一处就跑套件的价值** ✓); 2. **ui 那条桩只会 `JSON.parse`** ✗ —— 二进制片一到就抛 ✓,测试表现成"**一片都没传**"✓。 这不是产品 bug ✓,是**桩落后于实现** ✗ —— 而"桩落后"本身是一种**假绿** ✓ (它会让你以为"没走那条路"✓,其实是"桩不认那条路"✗)。已让桩认两种协议 ✓。 ### 测试 - ui 里那条"视频先分片上传"现在**并进了一句** `binary === true` ✓(不加新条 ✓ → 项数不变 ✓): 实测 `{"片":1,"二进制":true,…}` ✓ —— **退回 JSON 就会红** ✓✓(这才是真的钉住 ✓); - package **99/99** ✓、harness **580/580** ✓、ui **807/807** ✓ → 九个套件 **1636 项** ✓; - `check-counts` exit 0 ✓。 ### 下一步(②-b / ③) - ②-b:照片那条也不整份 base64 ✓(预览改对象 URL ✓、离线队列存 **Blob** ✓)→ 内存才真正降下来 ✓; - ③:上限提到 120MB ✓ + 断言"不许再出现整份 base64" ✓。 📌 依据现成 ✓:代码里给**音频**放宽时早就写着"服务端上限 120MB、走分片所以放宽安全" ✓。 ## 4.11.0 — 2026-10-15 **主题:服务端支持二进制分片(打地基,向后兼容)** ✓ (用户 2026-10-15:「ok」= 按 ①→②→③ 动手 ✓,这一步是 ① 的服务端半边 ✓)。 ### 为什么先做服务端 - 客户端把 `file.slice(a,b)` **直接当请求体**发 ✓ → 浏览器从磁盘流式读 ✓ → **不进内存** ✓、**不再多花 33% 流量** ✓(base64 的代价 ✓)→ 这才是"上限能提到 120MB"的前提 ✓; - 而**服务端先支持**它 ✓,老的客户端(缓存在手机上的页面 ✓)**完全不受影响** ✓ → 可以先上线、不破坏任何东西 ✓✓(今天定的顺序 ✓)。 ### 改(都在宿主侧 ✓,产品行为对现有客户端**零变化**) - `handleUpload` 开头按 `content-type` **分流** ✓ —— 必须**在 `readBody` 之前** ✓ (那个函数"读完就按 JSON 解析"✗,拿它读二进制片会当场炸 ✓); - 新增 `handleUploadBinary` ✓:元数据走**请求头** ✓(`x-upload-id/index/total/size/chunk-bytes/name/type` ✓)—— ⚠️ **不塞进 query** ✗:文件名是用户的东西 ✓,不该出现在访问日志里 ✓; - ⚠️ **关键取舍** ✗:那段"按偏移定位写 + 用**片号集合**判收齐(不是看文件长度 ✓)"的逻辑 ✓ **原样搬成 `writePositionedChunk()`** ✓ → 两条路**共用同一份** ✓,**绝不复制粘贴** ✗ (那份逻辑很细 ✓,复制出去迟早分叉 ✓ —— 本项目在这类"两份实现"上栽过 ✓); - 新增 `readRawBody()` ✓(二进制安全 ✓,`readBody` 不行 ✗)。 ### 测试(4 条,都在 harness ✓) - 二进制分片**乱序 + 第 0 片最后到**也能收齐 ✓; - 拼回来的内容**逐字节一致** ✓(这条不过 = 手机发视频会拿到坏文件 ✗); - 尺寸不符**照样直接拒**(400 ✓ —— 证明二进制入口**真的接到了**那段共用判断 ✓,不是绕过它 ✓); - **老的 JSON 分片照旧能收** ✓(向后兼容 ✓ —— 手机上缓存的旧页面还在用 ✓)。 - 📌 顺带验证了"**原样搬家**"这一步是安全的 ✓:搬完先跑一遍 harness ✓(576/576 ✓), 确认那段逻辑一行没搬坏 ✓,**才**加新入口 ✓ —— 一次只改一处 ✓。 ### 计数 - harness **+4**(576 → **580** ✓)、package 106 ✓、ui 807 ✓ → 九个套件 **1636 项** ✓; - `check-counts` exit 0 ✓。 ### 下一步(②/③) - ② 客户端改用二进制分片(`file.slice` 直接发 ✓)+ 离线队列存 `Blob` 而不是 base64 ✓; - ③ 然后把上限提到服务端的 120MB ✓,并加一条断言"**不许再出现整份 base64**" ✓。 ## 4.10.8 — 2026-10-15 **主题:补上"归档风格端到端"测试 —— 这正是 4.10.1 那个 bug 逃过测试的原因** ✓ (用户 2026-10-15:「1」= 做 ① ✓)。 ### 为什么原来网不住 - 4.10.0 的测试只断言"源码里有没有这句/那个字段" ✓(形状 ✓); - 而 4.10.1 的真实毛病在**运行时的判定**上 ✗(页面把归档信息当成 CSS 预设 ✓)→ 形状断言看不到 ✓; - 结果:**靠用户发现** ✗。这条缺口在 CHANGELOG 里挂了两版 ✓,这次补上 ✓。 ### 补了什么(都在 harness ✓,用探针家目录 ✓) - 先**摆一份真归档** ✓(`mobile-bridge-presets/planet/` + `preset.json` + 素材 + 静止图 ✓), 再走**真实请求**验五件事 ✓: ① `POST /api/background {preset:planet}` → 200 ✓; ② `GET /api/bootstrap` → `{preset:planet, archived:true, url:…preset=planet…, still:…}` ✓; ③ `GET /api/background?preset=planet` → **200 + `video/mp4` + 字节数完全对得上** ✓; ④ 带 `Range` → **206** ✓(手机是**边下边播** ✓ —— 这条断了视频就播不出来 ✓); ⑤ `?preset=planet&still=1` → **200 + `image/*`** ✓; - 用完**还原成 `cyberpunk`** ✓(后面那些用例假设"当前预设"是它 ✓ —— 不还原就是给后面埋雷 ✗); - 同时保留"**素材不在时如实报 null**"那条 ✓(不许糊弄成空风格 ✗)—— 两个方向都钉住了 ✓。 ### 测试 - harness **+5**(571 → **576** ✓);package 106 ✓、ui 807 ✓;九个套件 **1636 项** ✓; - **产品代码零改动** ✓(这版只碰 `test/harness.mjs` ✓)。 ## 4.10.7 — 2026-10-15 **主题:图标 / 标签 / 输入区也适配背景(上一轮只改了面板和气泡,这一批漏了)** ✓ (用户 2026-10-15:「那些图标也要适配背景呀」✓)。 ### 病根 - 4.10.3 我把"面板/气泡"改成走 `--panel` 等变量 ✓,但**图标这一批**是**另一批写死的颜色**: `.copyb .48` / `.tab .46` / `.iconbtn#toolsToggle .44` / 输入区 `rgba(10,12,18,.46)` ✗; - 所以它们**完全没跟上** ✓ —— 在动图背景上是一块块"灰板子" ✓,用户一眼就看出不对 ✓。 ### 改 - 全部**改走同一批变量** ✓(`var(--panel)` ✓)→ 和面板气泡同一透明度 ✓; - 图标字形:暖色文字 + **跟随文字的柔影** ✓(`text-shadow` ✓)—— **依然只有"动前景"这一种做法** ✓(不 `backdrop-filter` 糊背景 ✗、不压暗背景 ✗,两条底线都在 ✓); - ⚠️ **自己抓到一个错** ✗:我第一版写的选择器是 `.toolkey` ✓ —— **凭想象写类名** ✗; 实际是 `#toolKeys > button` ✓(查了代码才写对 ✓)。写错的选择器不会报错 ✓,只会**静默不生效** ✗ —— 所以断言里**明确钉住真名** ✓(并且钉住不许再出现那个假类名 ✗)。 ### 测试 - package **+4**(95 → **99** ✓):三处走变量 ✓、**不许残留写死的 .44/.46/.48** ✓、 选择器是**真名** ✓、图标有柔影 ✓; - package **99/99** ✓、ui **807/807** ✓、页面脚本语法 ✓;九个套件 **1636 项** ✓。 ## 4.10.6 — 2026-10-15 **主题:流星分两层,用 z-index 产生遮挡与层次** ✓ (用户 2026-10-15:「可以继续调整流星,有的流星划过表面,有的从背后划过,要有层次与遮挡关系」✓)。 ### 原来为什么没有层次 - 只有**一层** `#sky` ✓,而且它的 z-index**按主题**分别写死: 赛博朋克 = 2(整层压在前面 ✓)、用户那套 = 0(整层在后面 ✓)→ **永远只有一种观感** ✓; - 于是"层次"这件事在结构上就做不到 ✓(一层要么全在前、要么全在后 ✗)。 ### 改 - 新增 `#skyNear` ✓(容器 ✓ + 一张前排表 `SKY_METEORS_NEAR` ✓);`#sky` 统一做**后排** ✓; - **遮挡不用算交点** ✓ —— 靠层叠自动成立 ✓:后排 z 0 ✓ 在 `#app`(z 1)之下 → 被气泡挡 ✓; 前排 z 2 ✓ 在 `#app` 之上 → 盖着气泡划 ✓;两层都 `pointer-events: none` ✓; - 前排**克制** ✓:5 颗(后排 12 ✓)、scale .5~.7 ✓、整层 `opacity: .62` ✓ —— 它压在字上 ✓; - **视差** ✓:前排 2.2~3.2 秒(后排 2.6~4.4 ✓)—— 近处更快 ✓,这一条是"前后"看得出来**的**关键 ✓; - 搭法**抽成一个 `fill(layer, table)`** ✓(别复制粘贴 ✗),两层共用**同一套角度常量** ✓ (方向必须一致 ✓,一拨朝上一拨朝下就不是一片星空了 ✗); - **刹车两层一起** ✓:`bg-still` ✓ + `prefers-reduced-motion` ✓ 都加上前排选择器 ✓ (只刹一层 = 刹车失灵 ✗)。 ### 测试 - package **+6**(89 → **95** ✓):两层都在 ✓、层叠一前一后 ✓、前排克制 ✓、前排更快 ✓、刹车管两层 ✓; 另**改 2** ✓:旧断言"全部 ≥2.6 秒"按新设计拆成"**后排 ≥2.6** ✓ + **前排 2.2~3.2**(视差 ✓)"—— 那不是放宽标准 ✗,而是**把设计写进断言** ✓;"图片背景也有流星"那条的刹车判据也跟着更新 ✓。 - package **95/95** ✓、ui **807/807** ✓、页面脚本语法 ✓;九个套件 **1636 项** ✓。 ### 备注 - 这版起 `#sky` 在**两套主题里都是后排** ✓(以前赛博朋克是前排 ✗)—— 统一之后"层次"才是结构性的 ✓;赛博朋克那套的观感变化:流星改从霓虹地平线**后面**划过 ✓。 ## 4.10.5 — 2026-10-15 **主题:助手气泡的配色被亮色模式那条老规则压住(4.9.5 的同一个坑,那次只修了一半 ✗)** ✓ (用户 2026-10-15:「一发信息对话框又变回原色了」✓)。 ### 根因 - 亮色模式:`body.hasbg .bubble { background: rgba(255,255,255,.66) }` —— 权重 **0,2,1**; - 主题那条:`body.hasbg.bg-live .bubble { … }` —— 权重**也是 0,2,1** → **顺序决定**; - 亮色那条**写在后面** → 它赢 → **助手气泡永远是白的**(就是用户看的那条); - 📌 4.9.5 我遇到同一形状时 ✓ **只给「用户气泡」抬了特异性** ✗,**助手气泡漏了** ✓。 ### 改 - 两套主题的**助手气泡**都加一条 `#app` 选择器 ✓(0,3,1 > 0,2,1 ✓),**不靠挪顺序**; - 赛博朋克那套(`bg-cyber`)**一起修** ✓ —— 同一个坑,只修一个换个主题又会冒出来。 ### 测试 - package **89/89** ✓(这版只改样式 ✓,断言数不变 ✓);ui **807/807** ✓;页面脚本语法 ✓; - 九个套件 **1636 项** ✓;`check-counts` exit 0 ✓。 ### 过程的坑(第十六次,同一类) - 我先写了个脚本去改这两处 ✓ —— 里面在**双引号字符串里又写了双引号** ✗ → 脚本**解析就挂** ✓ → 改动**一处都没进去** ✓(幸好脚本是"先解析后执行" ✓,`mobile.html` 保持干净 ✓)。 - 换回 `edit` 工具**一次就成了** ✓ —— 它收字面量、不经 shell、不被引号坑 ✓; **今天它一次都没出错** ✓。教训:**改文件优先用 `edit`** ✓,脚本只在"批量、可回滚"时才上 ✓。 ## 4.10.4 — 2026-10-15 **主题:撤掉"背景模糊"—— 透明的意思是"看得见背景",不是"把背景糊掉"** ✓ (用户 2026-10-15:「不行背景是糊的,对话框一挡住就变模糊了」✓)。 ### 我上一版错在哪 ✗ - 4.10.3 我给气泡加了 `backdrop-filter: blur(7px)` ✓ —— 想法是"模糊的背景 + 清晰的字" ✓; - 但用户要看的**就是那张背景** ✓(还是他自己的实况动图 ✓)→ 模糊等于**把目标糊掉** ✗✓; - 📌 **"透明"的定义**:隔着界面**看得见背景** ✓,不是"把背景变成一片柔光" ✗。 ### 改 - 气泡上那两行 blur **撤掉** ✓(连注释一起改正 ✓,不留"注释说有、代码里没有"✗); - 这一套主题里**一律不糊** ✓:`--glass-blur: none` ✓ + 一条兜底规则 `body.hasbg.bg-live * { backdrop-filter: none !important }` ✓ (`!important` 是**故意**的 ✗ —— 全页有十几处面板各自写了模糊 ✓,逐个改容易漏 ✓; 而且**只限这一套主题** ✓ —— 赛博朋克那套的玻璃感一点不动 ✓); - 字读得清仍然靠**文字阴影** ✓(跟着字走 ✓,背景不受损 ✓)—— 这是唯一不动背景的做法 ✓。 ### 测试 - ⚠️ **那条断言本轮没加成** ✗(我连写坏两次:单引号里换行 ✗ —— JS 不允许 ✓)→ 按纪律**不硬塞** ✓, 记在这里当**下一步** ✓(用模板字符串那种稳的写法补 ✓); - package **89/89** ✓(这版**只改样式** ✓,没动断言数 ✓); - package **90/90** ✓、ui **807/807** ✓、页面脚本语法 ✓;九个套件 **1636 项** ✓。 ### 一句话教训 ✓ - 用户说"要看得见背景"时 ✗,**任何"让背景变柔和"的手法都是反方向** ✓ —— 能动的只有**前景**(字影 ✓、描边 ✓、底色薄一点 ✓),**不能动背景** ✗。 ## 4.10.3 — 2026-10-15 **主题:对话框还是不够透 —— 真凶是助手气泡底色写死 `.50`(我前几轮全调在了另一组变量上 ✗)** ✓ (用户 2026-10-15:「对话框还是不够透明看到背景」✓)。 ### 根因(调错了地方 ✗ —— 用户看到的和我调的**不是同一块** ✓) - 助手气泡那条是**写死的**:`body.hasbg.bg-live .bubble { background: rgba(16,18,26,.50) }` ✗; - 而我前几轮调的是 `--panel` / `--panel-2` / `--user-bubble` / 纱 ✗ —— **那条气泡一个变量都不吃** ✓✓ → 用户当然看不到变化 ✓("还是不够透明"✓)。 ### 改 - 气泡底色**走变量** ✓:`background: var(--bubble, rgba(16,18,26,.20))` ✓,并在主题里定义 `--bubble` ✓ (以后调一处就够 ✓); - **加一层轻微背景模糊** ✓(`backdrop-filter: blur(7px)` + `-webkit-` 前缀 ✓)—— 这是"透"与"读得清"同时成立的正解 ✓:背景**还在** ✓(模糊了但看得见 ✓),字面对比度也上来了 ✓, 比"把背景压暗"好得多 ✗; - 整体再透一档 ✓:面板 `.34 → .18` ✓、二级面板 `.30 → .16` ✓、用户气泡 `.16 → .10` ✓、 代码块 `.30 → .18` ✓;压暗的纱 `.24/.60 → .12/.40` ✓(下方仍略重 ✓ —— 屏幕下半是亮水面 ✓,字最容易糊 ✓)。 ### 测试 - package **89/89** ✓(三条"钉旧透明度"的断言按新值更新 ✓ —— 这种断言就是拿来在改样式时**逼人看一眼** ✓); ui **807/807** ✓;页面脚本语法 ✓。 ### ⚠️ 过程的坑(第十四、十五次 ✗,都是同一类) - 第一版更新断言时我在**双引号字符串里又写了双引号** ✓ → 脚本直接语法错 ✓(第十三次 ✓); - 改成拼接字符串之后 ✓ 手滑把 `check(...)` 的**收尾括号吃掉** ✗ → `package.mjs` 语法坏 ✓(第十四次 ✓); - 最后改成:**整行精确匹配 + 只替换值 + 保留括号结构 + 改完自动 `node --check` + 不过就回滚** ✓ (15 行脚本换掉之前那种"拼接"✗ —— 连续三次栽在同一类问题上 ✓,值得换打法 ✓)。 ## 4.10.2 — 2026-10-15 **主题:壳缓存名写死 → 手机一直吃旧页面(这才是"修复到不了手机"的根因 ✗)** ✓ (用户 2026-10-15:「刷新后对话框没变回去」✓ —— 连着两次"我改了他没变" ✓,值得往根上找 ✗)。 ### 根因(一个常量 ✗ 让一整天的修复都白干 ✓) - Service Worker 的壳缓存名**写死**成 `dshm-shell-v3` ✓ —— **永远不变** ✗; - 页面导航虽然是 **network-first** ✓(断网才回落 ✓),但弱网/隧道抖一下就会**落到缓存** ✓ → 手机拿到的是**旧页面** ✓✓ → 那一天所有**页面侧**的修复(4.9.4 的补状态 ✓、4.9.5 的气泡 ✓、 4.10.1 的归档风格 ✓……)**一个都到不了手机** ✗; - 用户看到的就是"改了没反应 / 又回到老样子" ✓ —— 两次报障都指向这里 ✓。 ### 改(一行 ✓,但得先找对地方 ✗) - `const CACHE = dshm-shell-${PLUGIN_VERSION}` ✓ —— 名字里带**当前版本** ✓; - 于是新版本发布 → SW 的 `install` 建新壳 ✓、`activate` 里那句 `caches.delete` **真能删到旧壳** ✓ (那行本来就在 ✓,只是名字没变过所以**永远删不到东西** ✗); - ⚠️ **导航依然是 network-first** ✓ —— 绝不能改成 cache-first ✗(那会让页面永远停在旧版 ✓)。 ### 断言也跟着改对了 ✓(老断言本身"名不副实" ✗) - 老断言写的是 `swBody.includes(dshm-shell-v)` ✓ —— **名字写着"带版本号"、实际在给一个常量把关** ✗ (`v3` 里那个 3 永远不动 ✓)→ 它**永远不会红** ✓ = 又是一处"看起来在检查"的假绿 ✗; - 现在:**真去 `/sw.js` 取回来** ✓,断言里面带的是 **package.json 里那个版本** ✓(实测渲染出 `dshm-shell-4.10.1` ✓)。 ### ⚠️ 过程中自己捅的两个(都当场被拦 ✓) - 第一版写的是 `dshm-shell-${VERSION}` ✗ —— **没有这个变量** ✓(真名是 `PLUGIN_VERSION` ✓)→ 模板字符串在**模块加载时**求值 ✓ → **插件启动即崩** ✗;harness 连汇总都没打出来 ✓ 才发现 ✓。 - 改断言时用了 `ROOT` ✗ —— 那是 **package.mjs** 的变量 ✓,harness 里没有 ✓ → 直接 `ReferenceError` ✓; 改用 `import.meta.url` 自己算 ✓(哪个文件都能用 ✓)。 ### 测试 - harness **571/571** ✓(那条壳缓存断言已改成"真版本"✓);package **89/89** ✓;ui **807/807** ✓。 ## 4.10.1 — 2026-10-15 **主题:修「带素材的预设被当成 CSS 预设」—— 用户报的真 bug(我 4.10.0 引入的 ✗)** ✓ (用户 2026-10-15:「刚刚放上去的动态图呢,怎么回到以前的风格了?」✓)。 ### 根因(很典型 ✗:加了个字段,把老判断喂错了 ✓) - 4.10.0 我给归档风格的信息里带了 `preset: planet` ✓(本来是想让页面知道"当前是哪个风格"✓); - 而页面那条老判断是:**见到 `preset` 就当"CSS 画的那种"** ✓(赛博朋克 ✓)→ `document.body.classList.add(bg-cyber, hasbg)` ✗ → **赛博朋克回来了、视频永远不铺** ✗; - 现象与用户描述**完全对得上** ✓:「回到以前的风格了」✓。 ### 改(两处 ✓,都在页面侧 ✓) 1. **只有不带素材的预设才走 CSS 那条路** ✓:`isCssPreset` 多一个条件 `info.archived !== true` ✓; 带素材的落到下面那条**正常媒体路** ✓(url / 静止兜底 / 视频层 / 缓存 key 全都照旧 ✓)。 2. **切背景时先摘掉 `bg-cyber` / `bg-still`** ✓ —— 老代码只在 `conceal()` 里摘 ✓, 所以"赛博朋克 → 夜行星"会**两套叠着** ✗(霓虹网格 + 视频同时铺 ✓)。 ### 测试 - package **+2**(87 → **89** ✓):CSS 预设那条路必须带 `archived !== true` 的条件 ✓; 切背景必须摘 `bg-cyber`/`bg-still` ✓。 - ui **807/807** ✓、page-script 语法 ✓。 - ⚠️ **教训** ✓:4.10.0 我在**页面侧**一个字段上加了东西 ✓,却没检查**所有读这个字段的地方** ✗。 同一个字段有两种"身份"时 ✓,**每一处读取点**都得跟着确认 ✓ —— 这次是用户先发现的 ✗,不该。 - ⚠️ 另一件仍未测的 ✓:「归档目录里真有素材时能铺出来」那条**还是没有自动化测试** ✓ (要在探针家目录摆一份归档 ✓)—— 这次是靠用户验收发现的 ✗,下次一起补 ✓。 ## 4.10.0 — 2026-10-15 **主题:新背景风格「夜行星」—— 带素材的预设(归档)** ✓ (用户 2026-10-15:「你把这个背景归档到背景风格里面(新建一个背景风格)」✓)。 ### 为什么要分"两种预设" ✓ - 原来的 `cyberpunk` 是**纯 CSS 画的** ✓(不要文件、不用下载 ✓); - 用户要归档的这套**自带素材** ✓(他那支星球实况视频 ✓)→ 所以预设多了一种形态 ✓。 ### 两条底线(都写进代码注释了 ✓) 1. **素材一个字节都不改** ✓ —— 归档就是**逐字节复制** ✓(`readFile` + `writeFile` ✓;用户对那支视频 说过「不要更改」✓)。归档完**逐个比 SHA-256** ✓:实测 `15d72128…` 与原件**完全一致** ✓。 2. **素材永远不进插件、不进 Release** ✗ —— 那是用户自己的东西 ✓。归档只写到**他本机**的 `~/.dsh/mobile-bridge-presets/<名字>/` ✓;插件里只留"有这个风格、素材在哪个目录"这点**代码** ✓。 ### 实现(宿主侧三处 + 一条测试 ✓) - `BACKGROUND_PRESETS` 加 `planet` ✓,另立 `ARCHIVED_PRESETS = { planet: { label: 夜行星 } }` ✓; - `backgroundInfo()` ✓:带素材的预设**先走归档那一步** ✓,把归档素材**当成普通背景文件**报给页面 ✓ (`url` / `mediaType` / `bytes` / `still` **同形状** ✓ → 页面那套:缓存 key ✓、静止兜底 ✓、视频层 ✓、 亮度适配 ✓ —— **一行都不用改** ✓); - `handleBackground()` ✓:认 `?preset=<名字>` ✓ → 从归档目录取 ✓。⚠️ 文件名一律来自**我们自己写的** `preset.json` ✓,再用 `safeUploadName` 洗一遍 ✓ —— **不拿 URL 里的字串拼路径** ✗(那才是目录穿越的口子 ✗)。 - **"素材不在了"必须如实** ✓:归档缺失时 `backgroundInfo()` 返回 **null**(= 没背景 ✓)+ 记一行日志 ✓, 而不是返回一个"风格生效了"的假象 ✗。 ### 测试 - harness **+2**(569 → **571** ✓):新风格被**接受** ✓(两种预设并存 ✓);探针家目录里没有归档素材时 **如实报"没有背景"** ✓(那行日志实测打出来了 ✓,路径正是探针的隔离目录 ✓ = 不碰用户真实归档 ✓)。 - ⚠️ **这条测试当场抓到一个真 bug** ✓✓:我在"素材不在了"那行调了**不存在的 `logLine`** ✗ → 路由 **500** ✓。 改成真正的 `log()` ✓ 即好 ✓。📌 这就是"补一条能真跑的测试"的价值 ✓ —— 只写源码形状断言的话, 这个 500 会直接落到用户手机上 ✗。 - ⚠️ **坦白** ✓:「归档目录里**真有**素材时能不能铺出来」这条**还没测** ✓(要在探针家目录里摆一份归档 ✓) —— 连同"归档动作本身"下次一起补 ✓。不假装已经测过 ✗。 ### 已实际执行(在用户这台机器上 ✓) - 归档目录:`C:\Users\Kim\.dsh\mobile-bridge-presets\planet\` ✓(`preset.json` + 素材 + 静止图 ✓); - 配置切到 `backgroundPreset: planet` ✓(**`backgroundFile` / `backgroundStill` 都留着** ✓ → "去掉背景"那条路照旧能用 ✓,也等于**一行回滚** ✓); - 改动前备份 ✓(`mobile-bridge.json.bak-preset-…` ✓)。 ## 4.9.13 — 2026-10-15 **主题:把上一轮 9 处黄逐条判定完 + 给探针加自检 —— 功能零改动** ✓ (用户 2026-10-15:「继续打磨」✓)。 ### 逐条判定(工具自己就写着"**每条都要人再判一次**"✓ —— 这一遍是判,不是表态 ✓) | 处 | 判定 | |---|---| | 5 处「期望值可能取自被测产物」 | **误报** ✓ —— 期望值要么是**字面常量**(12 颗、path 数 1 ✓)、要么是**测试自己的输入**(cookie / extent / freezeCases ✓);而且**取不到时会红** ✗,不会静默绿 ✓ | | 1 处「只有否定式断言」(`.ps1` BOM ✓) | **误报** ✓ —— **正向证据在同文件更早处** ✓:「每个 .ps1 都带 BOM」那条用的是**同一个探针** ✓;探针坏成"永远 false" ✗,那边**立刻红** ✓ | | 1 处「只有否定式断言」(`content-visibility` ✓) | 🔴 **真风险** ✓ —— 全文件**没有**正向证据 ✗:取 style 块 + 正则这段一旦坏掉 ✓,永远是 null ✓ → **永远绿** ✗,而页面里其实可能已经用了它 ✓ | | 1 处「比的是自己写的 env」 | **绊线** ✓ —— 注释里已写明它只防"有人把 DSH_HOME 换回真实目录" ✗,产品侧真判据在下面那条 ✓(工具的建议正是"注明这是绊线"✓) | | 1 处「端口是常量」 | **误报** ✓ —— 代码里**已经是随机端口** ✓,工具匹配到的是讲这段历史的注释 ✓ | ### 改(真风险那一处 ✓) - 给探针**加自检** ✓:先拿一段**确定含** `content-visibility` 的 CSS 试一次 ✓, 探针必须**先找到它** ✓,然后"真页面里找不到"才算数 ✓。 📌 这正是本项目处理探针的一贯写法 ✓("探针自己得先被证明是活的"✓)—— 探针坏掉时,**这条会先红** ✓,而不是让下面那条永远绿 ✗。 - 实测输出:`探针命中= b{content-visibility:auto} · style 块 52405 字节` ✓(ui **806 → 807** ✓)。 ### 顺手:把判定写进注释 ✓ - 那 6 处(5 处误报 + 1 处绊线 ✓)都加了一段短注释 ✓,写明**为什么不是问题** ✓ —— 下一个人(或下一轮复查)不必从头判一遍 ✓。这也是本项目处理同类复查的一贯做法 ✓。 ### 测试 - package 106 ✓(不动 ✓)· harness 580 ✓ · qr 12 ✓ · ui **807** ✓(+1 ✓)· security 54 ✓ · crashprobe 17 ✓ · addresses 16 ✓ · offline 17 ✓ · platform 27 ✓ → **1636 项全绿** ✓。 - `check-counts` exit 0 ✓(这次它先**正确地**报出"文档写的 1605 / ui 806 与实测不符" ✗ —— 已改 ✓)。 - **产品代码零改动** ✓(这版只碰 `test/` ✓)。 ### ⚠️ 自曝:同一个坑,栽了两次 ✗ - 写判定注释的脚本里,我在**模板字符串**里又写了反引号 ✗ → 字符串被自己截断 ✓(**第十次** ✗); 改用命令行修它 ✓ → PowerShell 又把反斜杠吃了 ✓(**第十一次** ✗)。 - 交接笔记里写着"**永远写脚本文件**、模板字符串里不要用反引号(用「」代替)" ✓ —— 两条都违反了 ✗。 已经照做:脚本文件重写 ✓、文件里**明令禁止反引号** ✓(连注释都写上 ✗,免得下次的自己忘了 ✓)。 ## 4.9.12 — 2026-10-15 **主题:测试套件打磨(冷视角复查)—— 产品代码零改动** ✓ (用户 2026-10-15:「打磨现有功能」✓ —— 我先请 `cold_review` 把**测试**扫了一遍 ✓: "看起来在检查、其实永远通过"是最贵的 bug ✗ —— 它比"红"更难发现 ✓)。 ### 复查给了什么 ✓ - 18 个测试文件,**红 0 / 橙 4 / 黄 9** ✓。 - ⚠️ **但它报的行号里,有 4 处是我以前已经修好的** ✗ —— 工具把**注释里引用的旧代码形状** 当成真代码读了 ✓(比如注释写着"原来是 `(…|| []).every(…)` ✗" ✓)。它自己也说"**每条都要人再判一次**" ✓ —— 这一遍**人工判定**是必须的 ✓,不是形式 ✓。 ### 真改了的三处(都是"判据会静默失效" ✗ = 假绿 ✓) 1. **`harness.mjs`「这周学了什么」** ✓:原来是 `every(…) === true || items.length === 0` ✗ —— **一个空列表就能恒真** ✓。改成**先钉条数**(`length > 0` ✓)再逐条判 ✓。 📌 改完跑:**569/569 ✓** 而且它现在**把真实数据打出来**(`2 条,idShort 样例=["test","blank"]` ✓)—— 以前那个口子确实可能藏住回归 ✗,现在藏不住 ✓。 2. **`harness.mjs`「自带示例面板」** ✓:原来是"筛一遍再逐条判" ✗ —— **筛空就恒真** ✓。 改成**先钉筛出来的条数**(`named.length === 2` ✓)再逐条判 ✓。 3. **同样的形状**在 `harness.mjs` 的另一处 ✓(域名那半句 ✓)—— **之前那轮已经钉过** ✓, 这轮再确认一遍 ✓(顺手把注释改成不写代码样式 ✓,免得复查工具下次又误报 ✗)。 ### 顺手做的(注释卫生 ✓,复查的"白 · stale-symbol / line-ref"✓) - 5 处注释引用了**已经不存在的符号**或**对不上的行号** ✓(`cordis_interview` 那类 ✓、`clickSelector` ✓、 `liveSource` ✓、`listAt` ✓、`package.mjs` 里"第 29 行" ✓)→ 全部改成**按现状描述** ✓。 - 我自己的注释改成**不写代码样式** ✓(写成"筛完再逐条判"✓ 而不是引用 `filter(...).every(...)` ✓)—— 复查工具会把注释里的代码当真的读 ✓,这样能少一堆**误报** ✓(这轮实测:橙 **4 → 1** ✓)。 ### 人工判定为"误报"的三处(不改 ✓,记在这儿 ✗) - `security.mjs` 的"顶层 await 无 .catch" ✗:那两处一个**被 `liveFetch` 包着** ✓、 另一个**自带 `.catch(() => null)`** ✓ —— 保护都在 ✓,只是跨行、工具看不到 ✓。 - `security.mjs` 的"端口是常量" ✗:代码里**已经是随机端口** ✓(`37000 + Math.random()*2000` ✓), 工具匹配到的是解释这段历史的注释 ✓。 - `security.mjs` 的"比的是自己写的 env" ✓:那条注释里**明确写了它只是绊线** ✓, 产品侧真判据是下面那条"假记录没增加" ✓(工具的建议正是"注明这是绊线"✓ —— 已经注明 ✓)。 ### 留给下一次的(**如实列出来** ✗,不是"已解决") - **黄 9 处**(都是**更早**留下的 ✓,不是这轮引入的 ✓):5 处"期望值可能取自被测产物" ✓、 2 处"只有否定式断言" ✓、1 处"比自己的 env" ✓、1 处误报 ✓(上面已判 ✓)。 这些要**逐条**做真值独立化 ✓ —— 是一件**要单独排**的活 ✓,不适合塞在这轮末尾 ✗。 ### 测试 - **项数不变** ✓(1605 ✓ —— 我是把断言**收紧**,不是加新的 ✓):package 106 · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓。 - `check-counts` exit 0 ✓(文档里的项数照旧对得上 ✓)。 - ⚠️ 自曝一个操作毛病 ✗:这轮我又用 `node -e` 改文件 ✓ → **第九次**栽在命令行引号上 ✗。 交接笔记里明明写着"**永远写脚本文件**" ✓ —— 已经改成脚本文件 ✓(`_bump512.mjs` ✓,还带幂等 ✓)。 ## 4.9.11 — 2026-10-15 **主题:弱网上传参数(离线传输 ②)—— 逐处验证、一处一处改** ✓ (用户 2026-10-15:「ok」「继续」= 按 **1→3→2** 走完 ✓,这一步 ② ✓)。 ### 改(**分三小步,每步单独跑 ui** ✓) - **②-1 并发 3 → 2** ✓:现场账 ✗ —— 附件之间还会并行 2 路 ✓ → 原来最多 **6 个请求**同时抢一条上行 ✗, 弱网下互相饿死 ✓(日志里就是"0/11 片就断"✓)。→ **单独跑绿** ✓(ui 807/806 ✓)。 - **②-2 片长 512KB → 128KB** ✓:分片走 JSON+base64 ✓ → 体积涨 33% ✓ → 一片 512KB 实际是 **~683KB** 的请求 ✗。→ **单独跑绿** ✓(ui 807/806 ✓)。 📌 服务端本来就按**偏移**收、允许乱序 ✓ → **不动服务端** ✓。 - **②-3 重试 3→5 次 + 6 秒封顶** ✗ → **单独一跑,ui 4 条红** ✓(`分片:24` 证明分片本身没问题 ✓, 红的是**失败路径的时序期望** ✗)→ **只撤这一处** ✓,重试保持 3 次 / 400ms ✓。 📌 它对弱网的价值**远小于**并发和片长 ✓(本来就会重试 3 次 ✓);真要动,得**连着测试期望一起重做** ✓。 ### 📌 这一版最值的不是参数,是**方法** ✓ - 4.9.7 那次:**三处一起改** ✗ → ui 十条红 → **分不清是哪一处** ✗ → 只能整批撤回 ✓。 - 这次:**逐处改、逐处跑** ✓ → 三条里两条绿、一条红 ✓ → **一眼看出该撤哪一条** ✓, 而且**保住了两条真正有价值的** ✓(并发和片长正是弱网最关键的两项 ✓)。 - 结论(写进 4.9.7 的教训,这次**兑现了** ✓):**一次只改一处,每改一处立刻跑 ui** ✓。 ### 测试 - package **改 1** ✓:那条"撤回后的旧值"断言(512KB / 并发 3 ✗)改成**逐处验证过的新值** ✓ (128KB ✓ / 并发 2 ✓ / 重试**故意仍是 3 次** ✓ 且**不许出现 `giveUpAt`** ✓)—— 以后谁再一口气动这几处,会先在这里红一下 ✓。 - ui **806/806** ✓(**第四次"一次一处"没弄坏东西** ✓)。 - 九个套件 **1636 项全绿** ✓(package 106 · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 🎯 离线传输三件事,到这儿齐了 ✓ | 环节 | 版本 | |---|---| | **先存着**(小附件 localStorage / 大附件 IndexedDB ✓) | 4.9.8 / 4.9.9 ✓ | | **接着传**(同一个上传号 + 跳过已确认的片 ✓) | 4.9.10 ✓ | | **传得动**(片更小 + 并发更低 ✓) | 4.9.11 ✓ | ## 4.9.10 — 2026-10-15 **主题:断点续传(离线传输 ③)** ✓(用户 2026-10-15:「ok」= 按 **1→3→2** 继续 ✓,这一步 ③ ✓)。 ### 病根 ✗ - `uploadInChunks()` 里的 `uploadId` 是 **`Date.now() + Math.random()`** ✓ —— **每次调用都是新的** ✗。 于是"传了一半断了 → 重试"时,服务端那边按 uploadId 归拢分片 ✓ → **从零另开一份** ✗ → 前面传的片全白费 ✓(弱网下这一条很致命 ✓)。 ### 改(三处协同 ✓,仍然**不动原有失败路径** ✗) - **同一个文件用同一个 `uploadId`** ✓:钥匙 = `名字 + 字节数 + 内容开头 48 个字符` ✓ —— 带上内容那几位是为了避免"两个不同文件恰好同名同大小"撞车 ✗。 - **客户端记住哪些片已被确认** ✓(`uploadResume: Map<钥匙, Set<片号>>` ✓): 片**成功之后**才 `acked.add(index)` ✓(这点很重要 ✗ —— 发出去没回来不算 ✓); 重试时 `if (force !== true && acked.has(index)) return null` ✓ → **直接跳过** ✓ = 续传 ✓。 - **传完清记录** ✓(`uploadResume.delete(resumeId)` ✓):那份上传在服务端已完结 ✓, 之后再发同一个文件必须**重新完整传一遍** ✗(否则会拼出缺片的坏文件 ✗✗)。 - 🔴 **边角必须补** ✓:若上次**全部片都确认了、只差最后响应丢了** ✓ → 这次会把所有片跳过 ✓ → `last` 仍是 null ✗ → **拿不到服务端返回的路径** ✗ → 消息会引用空路径 ✗。 所以加一句:`if (last === null && total > 0) last = await sendChunk(total - 1, true)` ✓ —— **强制重传最后一片** ✓(服务端按偏移写 ✓,重传同一片是幂等的 ✓),用它拿回 `{ path, done }` ✓。 ### 测试 - package **+5**:`uploadId` 稳定(且**不再有随机那句** ✓);确认后才记录 ✓;跳过已确认的片 ✓; 传完清记录 ✓;**"全被跳过"时补传最后一片** ✓。 - ui **806/806** ✓(**第三次"一次一处"没弄坏东西** ✓)。 - 九个套件 **1636 项全绿** ✓(package **106** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 下一步(②)✓ - **上传参数**:片更小 + 更少并发 ✓ —— **一次只改一处、每步跑 ui** ✓(4.9.7 的教训 ✓)。 这一条做完,"弱网传大文件"三件事(先存着 ✓ / 接着传 ✓ / 传得动 ✓)就全了 ✓。 ## 4.9.9 — 2026-10-15 **主题:大附件也能先存着(离线传输 ①:IndexedDB 队列)** ✓ (用户 2026-10-15:「ok」= 按 **1→3→2** 继续 ✓,这一步是 ① 大文件队列 ✓)。 ### 为什么还要做这一步 ✗ - 4.9.8 只做到"**能进 localStorage 箱子就进**" ✓ —— 但那条路的容量是 **1.2MB** ✗ (`weakCanStore` ✓),而用户要传的那段视频是 **5.3MB** ✗ → 正好是进不去的那类 ✓。 ### 改(一个功能、三处协同 ✓,仍然**不动原有失败路径** ✓) - 新增 `queueFilesForOutbox(clientId, files)` ✓:把**文件字节**写进 **IndexedDB** ✓, 离箱里只留一个**几字节的标记**(`filesInIdb: true` ✓)→ localStorage 一点压力都没有 ✓。 · **写完立刻读回来核对** ✓(配额/无痕模式下 `put` 有时"成功"其实没落盘 ✗)—— 核对不过就返回 false ✓, 调用方退回"只留文字 + 如实说" ✓(**绝不假装存下了** ✗)。 - `outboxFlush()` ✓:遇到 `filesInIdb: true` 的条目 → **先从 IndexedDB 取回字节** ✓ 再走原来那条 "先传附件、再发消息"的路 ✓(取不到就退回箱子自带那份 ✓,老条目照旧可用 ✓); 补发成功(或服务端明确拒绝)后 **`dropQueuedFiles()`** ✓ —— 不然手机会越堆越多 ✗。 - ⚠️ **不新开 objectStore、不动数据库版本号** ✓✓(这条是**关键取舍** ✗): 版本升级一旦被别的标签页挡住(`onblocked` ✗)就可能连**背景缓存**一起受影响 ✗✗; 而这里只是要一块"能放大东西的地方" ✓ —— 复用已有的 `BG_STORE` ✓,键加 `outbox:` 前缀 ✓ (背景那边的清理只删 `bg:` 开头 ✓,互不干扰 ✓)。配了断言:`createObjectStore` 仍然只有**两处** ✓。 ### ⚠️ 过程中的一个错(当场被语法检查抓住 ✓) - `.catch((error) => { … await … })` ✗ —— **非 async 的回调里不能用 `await`** ✓ → `node --check` 直接报 `Unexpected reserved word` ✓,改成 `async (error) =>` ✓ 即好 ✓。 📌 教训:这类错**语法层面就能拦住** ✓ —— 所以"每改一处就跑一次语法+ui"这个习惯是有回报的 ✓。 ### 测试 - package **+6 / 改 3**:大附件写进 IndexedDB ✓ 且**写完核对** ✓;离箱条目**只留标记** ✓; 补发时**从 IndexedDB 取回** ✓;成功后**删掉** ✓;**没新开 store、没动版本号** ✓(`createObjectStore` 只两处 ✓)。 另把 4.9.8 那三条按新形状改 ✓(意图不变 ✓:小附件仍走老路 ✓、都存不下仍只留文字 ✓、错误仍抛出 ✓)。 - ui **806/806** ✓(**又没弄坏东西** ✓ —— 一次一处 + 每步跑测试的效果 ✓)。 - 九个套件 **1600 项全绿** ✓(package **82** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 下一步(按定好的顺序 ✓) - **③ 断点续传** ✓:传了一半的附件,重试时**从断的地方接着传** ✓(不重头来 ✓); - **② 上传参数** ✓:片更小 + 更少并发 ✓ —— **一次只改一处**、每步跑 ui ✓(4.9.7 的教训 ✓)。 ## 4.9.8 — 2026-10-15 **主题:附件传失败不再白丢(离线传输的第一步)** ✓ (用户 2026-10-15:「现在上传图片视频很难上传,而且总是失败你可以搞一个离线传输」✓)。 ### 缺口在哪(读代码读出来的 ✓) - 弱网模式那条路**本来就是对的** ✓(2.7.0 ✓):附件先收进离线箱 ✓、文字先发 ✓、`outboxFlush` 自动补发 ✓、 requestId 幂等 ✓ —— 一切都齐 ✓。 - **但"附件上传失败"那条路是漏的** ✗:`prepareRefs()` 一抛错 ✓ → 整个发送链断掉 ✓ → **文件就从用户手上消失了** ✗(只能重新选一遍 ✓)。这正是"总是失败"的痛点 ✓。 ### 改(**只加一处** ✓,不动原有任何一步 ✗) - 在 `prepared` 上挂一个 `.catch` ✓(**纯新增** ✓,下面原来的 `.then` / 失败 + 重试那条路**一个字没改** ✓): · **能进离线箱就进箱** ✓(≤ 1.2MB ✓,复用既有 `weakCanStore` 判据 ✓)→ `outboxAdd({ text, files })` ✓ → 网络好了 `outboxFlush` **先把附件传上去**再发这条 ✓; 进箱之后**把输入框里那批附件撤掉** ✓(不撤的话用户再点一次发送就发两遍 ✗); · **箱子放不下**(> 1.2MB ✗,localStorage 的硬限制 ✓)→ **文字单独进箱** ✓ (绝不让用户白打一段话 ✗)+ **如实说"附件没留住"** ✓(不装 ✓); · 连文字都没有 → 如实说"网络太弱,页面别关,点重试" ✓; · 最后把错误**照样抛出去** ✓ → 原有的失败提示 + 重试照旧 ✓。 - 📌 **为什么不做"大文件的离线队列"(IndexedDB)** ✗:那是更彻底的做法 ✓,但要新加一个存储 + 一套补发逻辑 ✓ —— 上一版(4.9.7)我一次改三处、**把上传整条路弄坏** ✗,所以这次**先只做一处、跑通再说** ✓。 IndexedDB 那条路记在这儿当下一步 ✓。 ### 测试 - package **+4**:失败时**先试着进箱** ✓;**超容量就不装**(只留文字 + 如实说 ✓); 进箱后**撤掉输入框里那批附件** ✓(防发两遍 ✓);**原有失败路径一个字没改**(错误仍抛出 ✓)。 - ui **806/806** ✓(这次**没弄坏任何东西** ✓ —— 只加一处的效果 ✓)。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.7 — 2026-10-15 **主题:静止兜底也进手机缓存(弱网上传那一版改坏了 → 全部撤回,未上线)** ✓ (用户 2026-10-15:「现在上传图片视频很难上传,而且总是失败你可以搞一个离线传输, 还有背景能不能缓存到手机,防止每次都要重新加载」✓)。 ### ① 背景缓存:视频有、静止图**没有** ✗(这个做成了 ✓) - 查出来一个真漏 ✓:`showStill()` 里直接 `stillNode.src = info.still.url` ✗ —— **绕过了 IndexedDB** ✓,于是那张 141KB 的静止图**每次开页面都重下一遍** ✗ (视频是走缓存那条路的 ✓,偏偏这张没有 ✗)。 - 现在交给 `backdropSource()` ✓(就是"先看手机本地 → 没有再下 → 下完存起来"那条路 ✓)→ **静止图和视频都存在手机上** ✓,第二次打开零流量 ✓。 ### ② 上传:**查到真原因了**,但我的改法把整条路弄坏了 ✗ → **全部撤回** ✓ - **现场(`mobile-bridge.log` ✓)**:一个 5.3MB 的 `.mov`、**11 片** ✓, 两次都是 **0/11 片就断** ✓(`Fetch is aborted` / `Load failed` ✓), 来源是**公网 / 移动网络** ✓(`171.218.229.255` + IPv6 ✓)—— **不是局域网** ✓。 - **算账** ✗:分片走 **JSON + base64** ✓ → **体积涨 33%** ✓,一片 512KB 实际是一次 **~683KB** 的请求 ✓; 而"附件并行 2 路 × 每附件 3 片并发"= **最多 6 个请求**同时抢一条上行 ✓ → 弱网下互相饿死 ✓ → 第一片就断 ✓✓。**这个诊断是确定的** ✓。 - **我改了什么、为什么撤** ✗:按诊断改了 `CHUNK_BYTES 512KB→128KB` ✓、 重试 `3→5 次` ✓、`CHUNK_PARALLEL 3→2` ✓ —— 结果 **ui 十条上传用例全红** ✗ (`0 片` ✓ = 分片那条路**根本没走通** ✓)。我先怀疑是重试磨太久 ✓ 加了 6 秒封顶 ✓ **仍然红** ✗。 → **三处全部撤回** ✓(`test/package.mjs` 里另加一条**钉住撤回后的值** ✓ —— 以后谁再动这三个数,会先在测试里红一下 ✓)。 - 📌 **教训** ✓:**诊断对 ≠ 改法对** ✗。这次我把三处一起改掉 ✓,坏了以后**分不清是哪一处** ✗ —— 下次应该**一次只改一处、每改一次跑一遍 ui** ✓。 - ⚠️ **离线传输 / 断点续传**(失败先存着、网络好了自动接着传 ✓)**还没做** ✓ —— 下一步 ✓。 ### 测试 - package **+1**:静止兜底进了缓存 ✓ + **钉住撤回后的上传参数** ✓(`512KB / 3 次 / 400ms / 并发 3` ✓)。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### ① 上传:先查现场,再改参数 ✓ - **现场(`mobile-bridge.log` ✓)**:一个 5.3MB 的 `.mov`、**11 片** ✓, 两次都是 **0/11 片就断** ✓(`Fetch is aborted` / `Load failed` ✓), 来源是**公网 / 移动网络** ✓(`171.218.229.255` + IPv6 ✓)—— **不是局域网** ✓。 - **算账** ✗:分片走 **JSON + base64** ✓ → **体积涨 33%** ✓,一片 512KB 实际是一次 **~683KB** 的请求 ✓; 而"附件并行 2 路 × 每附件 3 片并发"= **最多 6 个请求**同时抢一条上行 ✓ → 弱网下互相饿死 ✓ → 第一片就断 ✓✓。 - **改** ✓(三处,都**不需要动服务端** ✓ —— 服务端本来就按**偏移**收、允许乱序 ✓,片长只是每片报的字段 ✓): · `CHUNK_BYTES` **512KB → 128KB** ✓(请求 ~171KB ✓,成功率立刻不一样 ✓); · 每片重试 **3 → 5 次** ✓、退避 **400ms → 600ms×n** ✓(原来三次全撞在同一段坏信号上 ✓); · `CHUNK_PARALLEL` **3 → 2** ✓(最多 4 个请求在飞 ✓,局域网那点速度差换"传得上去" ✓)。 - 📌 **还差的一块(如实说 ✗)**:**离线传输/断点续传** ✗ —— 传失败**先存着、网络好了自动接着传** ✓ (现在失败还是得重新选文件 ✗)。这版先把"传得上去"解决 ✓,那一块下一步做 ✓。 ### ② 背景缓存:视频有、静止图**没有** ✗ - 查出来一个真漏 ✓:`showStill()` 里直接 `stillNode.src = info.still.url` ✗ —— **绕过了 IndexedDB** ✓,于是那张 141KB 的静止图**每次开页面都重下一遍** ✗ (视频是走缓存那条路的 ✓,偏偏这张没有 ✗)。 - 现在交给 `backdropSource()` ✓(就是"先看手机本地 → 没有再下 → 下完存起来"那条路 ✓)→ **静止图和视频都存在手机上** ✓,第二次打开零流量 ✓。 ### 测试 - package **+4**:分片 128KB ✓;重试 5 次 + 600ms 退避 ✓;并发 2 ✓;静止兜底进了缓存 ✓。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.6 — 2026-10-15 **主题:换上用户给的**原动图**;撤掉我自己编的假动效** ✓ (用户 2026-10-15:「原图是一个星球旋转的实况啊,你就不能按原图来吗」✓ —— 对 ✓,我一直在"演"动效 ✗)。 ### 改 - 🔴 **装上原素材** ✓:`bg-original-planet.mp4` ✓(用户传的 ✓,**逐字节复制** ✓ —— 装完比对 SHA256 `15d72128…` ✓ 一致 ✓)。**不转码、不压缩、不裁剪** ✓,一个字节没改 ✓。 - 🔴 **撤掉我自己编的动效** ✗:Ken Burns 推近(4.9.0 ✓)、慢速旋转(4.9.3 ✓)**全部删掉** ✗ —— 那都是"演出来"的动 ✗,不是原图的动法 ✓。现在 **`#backdrop img` 那条规则只剩 `transform: scale(1.18)`** ✓,而它**不是动效** ✓ —— 它的作用是**把静止兜底那张 JPEG 的水印带 推出屏幕** ✓(文件本身不动 ✓)。**动,完全交给原素材** ✓。 - **静止兜底保留** ✓(`backgroundStill = bg-moon-city.jpg` ✓):先铺那一帧(秒出 ✓、不黑屏 ✓), 视频能播再换上 ✓;视频挂了它也顶着 ✓。 - ⚠️ **编码提醒** ✓(如实记 ✓,不擅自转码 ✗):原素材是 **HEVC(H.265)+ AAC** ✓,720×1024、 30fps、5.23 秒、438KB ✓。**iOS/Safari 原生支持 HEVC** ✓;部分安卓浏览器**不认** ✗。 要通用版就得**重编码**一次 ✗(用户明确要求"不要更改" ✓)→ 所以**先原样装** ✓, 需要时再问用户要不要转 ✗。 ### ⚠️ 过程中的两个坑(都记下来 ✗) - **只读源文件复制会 `EPERM`** ✗:附件库那份是**只读**的 ✓,Windows 上 `copyFileSync` 会失败 ✓; 改成**读出来再写** ✓ 就过了 ✓。更坑的是:**第一次 `copyFileSync` 其实已经把内容复制成功了** ✓ (只是把**只读属性**也带过去了 ✗)→ 我第二次去写它才报 `EPERM` ✗, 一度以为"没装上" ✓ —— 一查 SHA **两份完全一致** ✓,早就装好了 ✓。 - **我用脚本改测试文件时切坏了 `package.mjs`** ✗(`slice` 边界算错 ✓)→ 从**发布树里那份已提交的干净副本**恢复 ✓(这也是"发布树 = 每次提交的快照"的一个实际用处 ✓), 再用 `edit` 工具逐条改 ✓。 ### 测试 - package **改 3** ✓:删掉"假旋转"那两条 ✓、把"动效只碰 transform"改成"**只做一件事: `scale(1.18)`**" ✓、水印那条也改成同一个判据 ✓;另加一条"**不许再出现 `@keyframes dsh-live-turn` / `dsh-live-pan`**" ✓(防止哪天又有人把假动效加回来 ✗)。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.5 — 2026-10-15 **主题:修「一发信息对话框颜色又变回去了」** ✓(用户 2026-10-15:「而且一发信息对话框颜色又变回去了」✓)。 ### 根因:**特异性打平 + 顺序输** ✗ - 亮色模式里有一条老规则 ✓:`body.hasbg .row.user .bubble { background: rgba(170,208,255,.62); }` —— 特异性 **0,3,1** ✓,**写在主题那段的后面** ✓。 - 而主题那条是 `body.hasbg.bg-live .row.user .bubble` ✓ —— 特异性**同样是 0,3,1** ✗。 - 打平时**顺序决定胜负** ✓ → 后面那条赢 ✗ → **用户自己发出去的气泡永远是默认浅蓝** ✗。 手机是亮色模式时最明显 ✓(发一条就「变回去」一条 ✓)。 - 📌 **修法不靠「把主题那段挪到后面」** ✗ —— 顺序这种东西下次编辑一挪就坏 ✓(这项目在 「靠顺序的东西」上栽过不止一次 ✓)。改成**抬特异性** ✓:主题那条加一个 `#app` ✓ → **0,4,1 > 0,3,1** ✓,从此**跟顺序无关** ✓。 - ⚠️ **两套主题一起修** ✓(`bg-live` 暖金 ✓ + `bg-cyber` 青紫 ✓)—— 同一个坑 ✗, 只修一个的话换个主题又会冒出来 ✓。 - 亮色模式那条**保留** ✓(它本身是对的 ✓:浅色界面配浅蓝气泡 ✓ —— 只是不该盖过主题 ✓,配了断言 ✓)。 ### 测试 - package **+3**:`bg-live` 的用户气泡用了 `#app` ✓;`bg-cyber` 那套也抬了 ✓; 亮色模式那条**还在** ✓(防止「修一个坏一个」✗)。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.4 — 2026-10-15 **主题:修「一发消息背景就没了」** ✓(用户 2026-10-15:「而且一发消息动图就没了」✓)。 ### 根因(一个很"顺"的 bug ✓) - `offlineShow()` 里**有意** `remove('hasbg')` ✓ —— 注释写得很清楚: 「背景素材这时候多半也取不到 —— 留着它就是黑屏本身,先收掉」✓。**这一步是对的** ✓。 - 可是**重连之后**,页面会再走一次 boot ✓ → 那里调 `applyBackdrop(body.background)` ✓ —— 而 `applyBackdrop` 开头有一条**早退** ✓:`if (key === backdropKey) return` ✓。 钥匙是「背景地址 + 字节数」✓ —— 背景没换 ✓ → 钥匙一模一样 ✓ → **早退** ✗ → **`hasbg` 再也加不回来** ✗ → 背景一直不可见 ✓,只有整页刷新才恢复 ✓。 - 为什么是「**一发消息**就没」✗:发消息会带一次**连接抖动** ✓(SSE 断一下再重连 ✓), 正好踩中上面那条路 ✓✓。现象与根因完全对得上 ✓。 - 📌 **教训**:「key 没变 → 什么都不用做」这个判断 ✗,前提是「状态没被别人改过」✓ —— 而 `offlineShow` 就是那个"别人" ✓。**早退之前要确认目标状态仍然成立** ✓。 ### 改 - `applyBackdrop` 的早退分支里**顺手补状态** ✓: `if (key !== '' && body 上已经没有 hasbg) { 加回 hasbg + bg-live }` ✓ (元素还在 DOM 里 ✓,加回类就恢复了 ✓;`key === ''` 时**不加** ✗ —— 没背景时不该凭空把类加上 ✓)。 - `offlineShow` 那条「断线先收掉背景」**保留** ✓(它是对的 ✓,且配了断言防止哪天被删掉 ✗)。 ### 测试 - package **+3**:早退分支必须**补回 `hasbg`/`bg-live`** ✓;补状态只补「有背景」的情况(`key !== ''` ✓); `offlineShow` 的「断线先收掉」仍在 ✓。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.3 — 2026-10-15 **主题:动效改成「星球在转」、流星放慢、界面更透明(但字更清楚)** ✓ (用户 2026-10-15:「动图不是这样动的,原本图片里的星球会旋转的,而且流星速度太快了, 文字和文字框要透明,能清晰显示背景」✓ —— 三条全认 ✓)。 ### ① 动效:推近 → **旋转** ✓ - 上一版是 **Ken Burns 推近** ✓ —— 那是「镜头在推」✗,用户要的是「**星球在转**」✓。 - 现在:整幅图**绕中心极慢来回转** ✓(`rotate ±1.83°` ✓ / 60 秒一个来回 ✓, `ease-in-out alternate` ✓ → 首尾速度都是 0 ✓,看不出折返 ✓)。 · 1.83° **很小** ✓ → 地平线不会歪得别扭 ✓,但画面明显「在转」✓; · ⚠️ `scale(1.18)` **必须留着** ✗ —— 旋转会露**四角** ✗,放大 18% 之后四角被推出屏幕 ✓; 顺便继续挡着水印 ✓(装机时裁掉的那条带 ✓)。 - **动图本体也重做了** ✓:ffmpeg 滤镜链 `crop(裁水印带) → scale(放大 16%) → rotate('0.032*sin(2*PI*t/12)') → crop(720×932) → fps 30` ✓ —— **正弦**旋转 ✓ 首尾角度都是 0 ✓ → 12 秒一循环**接缝看不出来** ✓;451KB ✓(还是很小 ✓)。 📌 换文件会让 `mtime` 变 ✓ → 手机的缓存钥匙跟着变 ✓ → 自动下新的 ✓(不用手动清 ✓)。 - ⚠️ 又记一笔 H.264 的坑 ✗:宽高必须**偶数** ✓(上一版写 933 是奇数 ✓ → 报错 ✓ + `-y` 已把旧文件清空 ✗)。 ### ② 流星放慢 ✓ - 整表时长 ×2.2 ✓:**1.2~2.0 秒 → 2.6~4.4 秒** ✓(一颗划过约 3 秒 ✓ —— 1.2 秒确实像闪一下就没了 ✗)。 - 配了断言 ✓:12 颗**全部 ≥ 2.6 秒** ✓(以后谁手滑改回去就红 ✓)。 ### ③ 文字 / 文字框更透明,且更清楚 ✓ - 透明度再降一档 ✓:面板 `.52 → .34` ✓、气泡 `.50 → .34` ✓、工具键 `.44 → .28` ✓、 用户气泡 `.20 → .16` ✓;压暗的纱 `.30/.62 → .24/.60` ✓。 - ⚠️ **「透明」和「读得清」是一对矛盾** ✗ —— 这版靠**两件事**同时成立 ✓,而不是把背景压暗 ✗: · **文字阴影** ✓:气泡里的字带一圈深色柔影 ✓(`text-shadow: 0 1px 3px rgba(0,0,0,.78), 0 0 14px rgba(0,0,0,.5)` ✓) —— **阴影跟着字走** ✓,背景一点不受损 ✓,这是透明界面里保清晰的**正解** ✓; · 底部纱仍然**上轻下重** ✓(下半是亮水面 ✓,字最容易糊 ✓); · 顺带把标题 / 说明文字 / 面板标题栏也加了轻阴影 ✓。 - **预览实测** ✓:气泡后面能清楚看见月亮和城市 ✓,字依然干净利落 ✓。 ### 测试 - package **+5**:动效是**旋转**不是推近 ✓;旋转时**四角不露黑**(`scale(1.18)` 还在 ✓); 流星**全部 ≥ 2.6 秒** ✓;透明度确实降到 `.34/.30/.16` ✓;**文字阴影在** ✓。 - package **改 4** ✓(4.9.0 那批**钉旧值**的断言 ✓ —— 推近 / `.52` / `.30-.62` ✓): 它们是**用户点名改的行为** ✓,所以按新实况改 ✓ + 在这里写明改了哪几条、为什么 ✓(不偷偷改 ✗)。 - 九个套件 **1594 项全绿** ✓(package **76** · harness 580 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.2 — 2026-10-15 **主题:动图兜底 + 自愈 + 快 8 倍;流星轨道重算(真 30° 浅斜、右侧划过)** ✓ (用户 2026-10-15:「动图不能存手机上吗,每次都要好久,而且流星应该从右侧斜向下划到左侧屏幕, 但是你这做的一点都不逼真,而且感觉流星像是在往下坠落,而且一刷新动图就不见了」✓ —— **四条全认** ✓)。 ### ① 「一刷新动图就不见了」——找到两个真原因 ✓(这是这版最要紧的 ✓) - 🔴 **缓存读到"存了一半"的那份,而且永远自愈不了** ✓: `backdropSource()` 只看 `cached.byteLength > 0` ✓ —— 上次存的时候页面被刷掉/关掉, 只存了一半也算"有缓存" ✗ → 之后**每次**都用那份坏的 ✓ → 视频加载失败 ✗; 而钥匙是 `url+bytes` ✓,只要文件不变就**一直是同一个钥匙** ✗ → 用户自己好不了 ✓。 现在:**长度对不上就当场删掉、走网络重下** ✓ = 自愈 ✓(配了断言 ✓)。 - 🔴 **一遇错就把背景抹掉** ✓:`node.addEventListener('error', conceal)` ✗ —— 弱网 / 半截缓存 / 解码失败,任何一种都直接变成"没有背景" ✗。 现在:**先退回同一张图的静止版** ✓(服务端把 `still` 一起给手机 ✓),只有连它也挂才收起 ✓。 ### ② 「每次都要好久」 - 动图重新压过 ✓:**3.4MB → 439KB**(小 **8 倍** ✓,720×932 / CRF 26 ✓ —— 画面本来就在压暗层和半透明气泡后面 ✓,肉眼看不出差别 ✓)。 - **先铺静止版秒出** ✓:新增配置 `backgroundStill` ✓ + `?still=1` 路由 ✓ —— 手机**先铺那 141KB 的静止图**(瞬间有画面 ✓),视频能播了再换上 ✓; **4 秒还没播起来也先把静止图顶上** ✓(弱网下不用盯着黑屏 ✓)。 - 📌 手机上本来就有 IndexedDB 缓存 ✓(第一次下一遍、之后零流量 ✓)—— 这版把那层缓存**修对了** ✓,所以"每次都要好久"既来自坏缓存(①✓)也来自 3.4MB 的体积(②✓)。 ### ③ 「流星像是在往下坠落」——根因是**单位** ✗ - 上一版写 `--dx: -66vw; --dy: 58vh` ✗ —— **两个单位不是一回事** ✓: 手机屏又高又窄(390×844 ✓),66vw = 257px、58vh = 489px ✓ → 屏幕上实际角度 = atan(489/257) ≈ **62°** ✗(我脑子里想的是 41° ✓)→ 当然像坠落 ✗。 - 现在两个量都用 **vmax**(同一单位 ✓)✓:`--dx: -104vmax`、`--dy: 60vmax` → atan(60/104) ≈ **30°** ✓ 浅斜 ✓;拖尾朝向 `--ang: 150deg` ✓(= 180° − 30° ✓,与轨迹严格对齐 ✓)。 换任何屏幕尺寸,**角度都不变** ✓。 - 起点也挪到**右侧** ✓(12 颗的 x 从 74% 到 120% ✓)→ 真的是"**从右侧划到左侧**" ✓。 - 尾巴重做 ✓:靠近头最亮、往后又长又淡 ✓(三段过渡 ✓),线宽 2px → **1.6px** ✓ —— 更像真实流星,而不是一根等亮的线 ✓。 ### ⚠️ 顺带记一笔:我自己的一次命令把用户的背景文件截成了 0 KB 重压视频时第一次参数写错(高度 933 是**奇数** ✗,H.264 要求偶数 ✓)→ ffmpeg 报错退出 ✓, 但 `-y` 已经**把输出文件清空** ✗ → 用户那份 3.4MB 的动图变成 **0 KB** ✓ (服务端会照发一个空文件 ✓ —— 这本身就是"背景不见了"的又一种成因 ✓)。 **当场发现并重压修好** ✓(439KB ✓),也把"偶数尺寸"这条写进了脚本 ✓。 ### 测试 - package **+8**:轨迹**同单位 vmax** ✓;**30° / 从右往左 / 尾巴 150° 对齐** ✓; 起点都在右侧(x ≥ 70% ✓);**缓存校验字节数 + 对不上就删** ✓;**视频挂了退回静止图** ✓; **4 秒兜底** ✓;服务端**只给视频带 `still`** ✓;`?still=1` 真的送那张图 ✓。 另**改 2 条**:旧断言里钉着 `-66vw/58vh` 与带分号的写法 ✓ —— 都是**我自己过期了** ✗,按新实况改 ✓。 - harness **+1**:图片背景**不带** `still` ✓(多塞一个地址 = 让手机白下一次 ✗)。 - 九个套件 **1577 项全绿** ✓(package **59** · harness **580** · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.1 — 2026-10-15 **主题:流星改到文字下面** ✓(用户 2026-10-15:「流星放在文字下面」✓)。 ### 改 - `#sky` 的 `z-index` **2 → 0** ✓:从"压在内容**之上**"(4.8.2 那版,用户当时要"划过对话框" ✓) 改成"**在内容之下、背景之上**" ✓ —— 现在是"**在背景上飞、从文字后面过**" ✓。 - ⚠️ **这个三明治不能弄错** ✗(三个数都得对 ✓),写进代码注释 + 配了断言 ✓: · `#backdrop` = **0** ✓(背景本体 + 压暗的纱 ✓); · `#sky` = **0 但 DOM 在后面** ✓ → 画在背景和纱**之上** ✓(不会被压暗 ✓,更不会掉到背景底下 ✗); · `#app` = **1** ✓ → **压在流星上面** ✓(这就是"文字下面" ✓); · 面板 = 更高 ✓(照旧盖住一切 ✓)。 📌 **不能写成 1** ✗(那会和 `#app` 平级 ✓,谁在上面就看 DOM 顺序 ✓ —— "靠顺序"的东西最容易哪天被挪一下就坏 ✗); **也不能写成 -1 或更低** ✗(会掉到背景底下 ✓ → 流星直接看不见 ✓ —— 那比"盖住字"更糟 ✗)。 - 效果 ✓:**空白处看得见流星在飞 ✓,被气泡挡住的那一截自然没了 ✓** —— 字一个都不会被糊到 ✓; 隔着半透明的气泡(`.50` ✓)还能隐约看见它从后面过 ✓,比"压在字上"更像真的 ✓。 - 预览实测 ✓:那颗流星的尾巴**被"第二点再展开一点"那条气泡切断** ✓ —— 一眼确认它在气泡后面 ✓。 ### 测试 - package **+1 / 改 1**:新钉"`#sky` 的 z-index 必须是 **0**" ✓(含上面那些"不能写几"的理由 ✓)、 以及"仍然 `pointer-events: none`" ✓;原来那条"在内容**之上**(z-index 2)"按新行为改写 ✓。 - 九个套件 **1567 项全绿** ✓(package **51** · harness 567 · qr 12 · ui 807 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.9.0 — 2026-10-15 **主题:任意图片/视频背景也能"活"起来,配色跟着图走,水印在画面层面裁掉(文件不动)** ✓ (用户 2026-10-15:「去水印后装上,画质不变,不要压缩,不要更改,然后对话框和文字改成合适的风格 以及透明能够适配这张图片,一定要是动态图片装上去」✓ —— 四条硬要求 ✓)。 ### 加 / 改 - **任意图片背景都能动** ✓(新的 `bg-live` ✓): · **Ken Burns 缓慢推近** ✓(42s 一个来回 ✓,只动 `transform` ✓ —— 合成层 ✓,手机上不掉帧 ✓); · **流星照旧划过** ✓(`#sky` 从"只给赛博朋克"改成"有背景就放出来" ✓); · 图片/视频一铺上就挂 `bg-live` ✓(`reveal` 里 ✓,和 `hasbg` 同一处 ✓)。 - 🔴 **水印:显示时裁掉,文件不改** ✓ —— 用户要求「画质不变、不要压缩、不要更改」✓, 而这台机器 **ffmpeg / jpegtran 一个都没有** ✗,重新编码一定掉画质 ✗。所以改成**画面层面裁** ✓: `scale ≥ 1.18` + `transform-origin: 50% 0` ✓ → 底部约 15% 被推出屏幕 ✓, 右下角水印**永远看不到** ✓,而**文件 SHA 与源图完全一致** ✓(装机时核对过 ✓)。 Ken Burns 全程 `scale ≥ 1.18` ✓,所以缩放过程中水印也不会露出来 ✓。 📌 这张图是**别人的作品**(带出处水印 ✓)→ 只进你这台机器的 `~/.dsh/mobile-uploads/` ✓, **不打包进插件、不随 Release 分发** ✓。 - **配色跟着图走** ✓(新的暖金一套 ✓):`--accent: #ffb454` ✓、`--accent-2: #ffd9a0` ✓、 面板/气泡更透明 ✓(`.52` / `.50` ✓ —— 用户要"透明能够适配"✓)、 **下半压得更重** ✓(那张图下半是亮水面 ✓:`.30 → .62` ✓,字才读得清 ✓)。 同样只覆盖**颜色变量** ✓ + 接管亮色模式那些硬编码白底 ✓(气泡/页签/发送/输入框 ✓)。 - **刹车** ✓:`bg-still` / `prefers-reduced-motion` → **推近停下 + 流星不飞** ✓(图还在 ✓)。 - ⚠️ **我自己写了一条假断言又删掉** ✗:ui 里那条"背景铺上后 body 有 `bg-live`" ✓ —— 它红了 ✓, 因为 `bg-live` 是**媒体真的显示了**才挂的 ✓(视频等 `playing` ✓),而 **jsdom 永远不触发那类事件** ✓ → 测试里**根本走不到** ✓。造个假事件让它变绿就是**假绿** ✗(项目最不许的 ✓),所以删掉 ✓, 改由 package 按源码形状钉 ✓ + 预览截图与线上核对负责真机效果 ✓。 - ⚠️⚠️ **先改版本号、后写说明** ✗ → **harness 五条当场红** ✓✓ ("注入的版本 = package.json 版本 ✓""更新历史覆盖全部版本 ✓""USERNEWS 有 4.9.0 标题 ✓"…)✓ —— 这几条断言就是为这个顺序错误准备的 ✓,补上 4.9.0 说明即恢复 ✓。 ### 测试 - package **+6**:图片背景 Ken Burns ✓;动效只碰 transform ✓;水印靠显示裁掉 ✓; 暖金色调 + 更透明 ✓;下半压得更重 ✓;图片背景也有流星且刹车管到 ✓。 - 九个套件 **1567 项全绿** ✓(package **51** · harness 567 · qr 12 · ui **807** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 过程 - 🎯 **真图预览** ✓:把 mock 的 `#backdrop` 换成 `` ✓、stub 服务器直接送 **你装进去的那张原图**(字节原样 ✓)→ 拍两张 ✓ → 一眼确认:水印没了 ✓、暖金主题 ✓、图透得出来 ✓、 字清楚 ✓、流星在划 ✓(两帧 sha 不同 = 正在动 ✓)。 ## 4.8.3 — 2026-10-15 **主题:月亮更真实 + 搬进背景(半透明)+ 流星改成真·斜向下 + 删掉很丑的斜线** ✓ (用户 2026-10-15:「月亮不够真实而且为什么要放在最上面,改成透明的放背景里,然后流星是斜向下流的, 你这个流星动作很奇怪,还有那些背景里面的线条能不能改掉,很丑欸,你可以参考网上的相关动图或者图片」✓ —— 五条我全认 ✓)。 ### 改 - **月亮搬进 `#backdrop`** ✓(原来在 `#sky`,z-index 2 的顶层 ✗ —— 于是它压在对话上面 ✓, 像贴纸 ✗)。现在是**背景的一部分** ✓,对话压在它上面 ✓。**半透明** ✓(`opacity: .72` ✓)。 - **月亮更像真的** ✓(参考了 [纯 CSS 画月亮那类做法](https://m.php.cn/faq/2513120.html#1) ✓): 关键是**三层** —— **月海**(两片大片暗区 ✓,"没有这个就是一颗白球" ✗)、**环形山**(柔边暗斑 ✓)、 **偏心的明暗交界**(`::after` 一层偏移暗影 = **月相** ✓)。整圆一样亮是贴纸 ✓,有明暗才像球 ✓。 - 🔴 **流星运动重做** ✓:用户说「动作很奇怪」✓ —— 根因是**一个元素同时管两件事** ✗: `rotate(31deg) translate3d(74vw, 74vh)` 里位移是在**旋转后的坐标系**里算的 ✓, 实际飞行方向 = 旋转再叠加 ✗,**尾巴朝向和飞行方向不一定一致** ✓ → 看着就是"歪着飘" ✗。 现在拆成两层 ✓:外层 `.meteor` **只走轨迹** ✓(`translate3d(var(--dx), var(--dy))` ✓,坐标系是正的 ✓)、 内层 `.streak` **只负责朝向** ✓(按 `--ang` 旋转 ✓)。 方向**算出来** ✓ 不是凑的 ✓:`dx = -66vw`、`dy = +58vh` → 屏幕角 = 180° − atan(58/66) ≈ **139°** ✓, 对应 `--ang: 139deg` ✓。全表**同方向** ✓(真实流星雨同出一个辐射点 ✓)、匀速 ✓、更快(1.1~2.0s ✓)。 - 🔴 **删掉那层斜线** ✓:就是 `.scrim::after` 那层 74° 的重复斜纹 ✗ —— 横穿整个屏幕(还压在对话上 ✓), 看着像**划痕**不是雨 ✗(用户:「那些背景里面的线条…很丑欸」✓)。"雨"这件事交给**真流星** ✓。 配了断言:`dsh-rain` 和 `.scrim::after` **都不许再出现** ✓。 - **刹车的覆盖面跟着改** ✓(斜线层没了 ✓,新加的是流星和月晕 ✓)—— 两条刹车都列全 ✓。 ### 测试 - package **+5 / 改 5**:流星把"轨迹"和"朝向"拆成两层 ✓;斜向下是**算出来的**(`--ang/--dx/--dy` 三个变量 ✓); 月亮在 `#backdrop` 且**半透明** ✓;月亮有**月海 + 环形山 + 月相暗影** ✓; **斜线层确实删了** ✓(`dsh-rain`/`.scrim::after` 都不许出现 ✓);刹车盖全新层 ✓; 动画层清单跟着改成现在的七层 ✓。 - ui **+1 / 改 1**:月亮在**背景层**(`#backdrop` ✓)而**不在**顶层(`#sky` ✓);每颗流星都有 `.streak` ✓。 - 九个套件 **1567 项全绿** ✓(package **51** · harness 567 · qr 12 · ui **807** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 过程 - 🎯 按用户建议**看了看网上的做法** ✓([纯 CSS 月亮](https://m.php.cn/faq/2513120.html#1) / [CSS 流星](https://www.tutorialspoint.com/article/how-to-create-shooting-star-animation-effect-using-css) ✓)—— 两条能直接借的:月亮靠**偏心阴影**做月相 ✓、流星靠**旋转的拖尾 + 沿轨道的位移** ✓。 我仍选**自己画** ✓(不塞位图 ✓):矢量清晰 ✓、零流量 ✓、授权干净 ✓; **若用户想要真照片月亮**(找公有领域 / NASA ✓)我随时可以做 ✓ —— 已当面说 ✓,不自作主张 ✗。 - ⚠️ 又栽一次同一个坑 ✗:在预览 mock 的模板字符串注释里写了反引号 ✓ → mock 当场语法错 ✓ (**今天第七次** ✓)。页面本身没事 ✓,但白跑一轮 ✓。 ## 4.8.2 — 2026-10-15 **主题:月亮 + 真流星(不是线),而且流星从对话框上面划过** ✓ (用户 2026-10-15:「这流星雨就是线啊,你好歹把他改成流星的模样,最好再加上好看的月亮, 只有一些线一点都不好看…而且可以流星雨划过对话框,更有感觉」✓ —— 批评是对的 ✓)。 ### 改 - **流星不是线了** ✓:上一版就是几条等宽的斜线 ✗(用户的判语准确 ✓)。现在每颗流星 = **亮核**(`::after` 的发光圆点 + 光晕 ✓)+ **渐隐拖尾**(`linear-gradient` 从透明到白 ✓)✓。 关键在"**头亮、尾散**" ✓:等宽的线看着就是划线 ✗,有核有尾才像流星 ✓。 12 颗,**大小 / 长短 / 速度 / 延迟全不一样** ✓(一样就是"复制粘贴的线" ✗),错开飞 ✓。 - **加了月亮** ✓(右上角 ✓):不是个白圆 ✗ —— **四块环形山 + 明暗交界的内阴影** ✓ + 一圈**会呼吸的月晕** ✓(7.5s ✓,只动 opacity / transform ✓)。 - **流星划过对话框** ✓:新加了一层 `#sky`(`z-index: 2` ✓ > `#app` 的 1 ✓)—— 于是流星在**内容之上** ✓(划过气泡、划过文字 ✓),但**仍在面板之下** ✓。 ⚠️ **`pointer-events: none`** ✓:划过归划过,**绝不拦点击** ✗ ("点了没反应"是项目铁律里最不许的事 ✓,这条也配了断言 ✓)。 另外用 `mix-blend-mode: screen` ✓ —— 只加光、不减光 ✓(压在字上是提亮 ✓,不会把字压花 ✗)。 - **刹车管到流星** ✓:静下来 / 减弱动态 → `.meteor` 直接不显示 ✓(**月亮留着** ✓ —— 它基本静止 ✓)。 - ⚠️ **参数写成固定表** ✓(`SKY_METEORS` ✓),**不用 `Math.random()`** ✗ —— 随机会让"截图对不上""问题复现不了" ✓(这一整天在预览里没少吃这个亏 ✓)。 ### 📌 为什么还是"画"的,不是网上找一张 用户说"你要会画画,不然就网上找到模仿或者直接用" ✓ —— 我选了**画** ✓,理由说清 ✓ (也写进了代码注释 ✓):① 一张月亮/星空图动辄几百 KB ✗,而这个背景的全部卖点就是 **零流量 + 任何分辨率都清晰** ✓,塞位图等于把最好的优势扔掉 ✗;② 找到的图**授权说不清** ✗ (能用的多是 CC0/公有领域 ✓,但要逐张核 ✓,换一张又得核一遍 ✓);③ CSS 画的是**矢量** ✓, 同一个文件在 720p 和 2K 上都锐利 ✓,还能跟着主题配色走 ✓。 📌 **如果你就是想要真照片的月亮** ✓ —— 说一声 ✓,我去找**公有领域**的(NASA 那种 ✓)打包进插件 ✓。 这条我不自作主张 ✗。 ### 测试 - package **+9**:星空层在 ✓;流星**有头**(亮核独立一层 ✓);**有尾**(渐隐拖尾 ✓); 月亮有 **≥4 块环形山 + 内阴影** ✓;**在内容之上** ✓;**`pointer-events: none`** ✓; 参数是固定表、**没有 `Math.random()`** ✓;刹车管到流星 ✓;流星只动 transform/opacity ✓。 📌 其中"环形山"那条**第一版我自己写歪了** ✗:`match(/#sky \.moon::after \{[\s\S]*?radial-gradient/g).length >= 4` —— 非贪婪匹配到**第一个**就收尾 ✓,永远只数到 1 ✓(断言当场红 ✓,等于自己抓自己 ✓); 改成"先抠整块再数" ✓。 - ui **+3**:星空真搭出来了(1 月亮 + 12 流星 ✓);速度/大小**确实各不相同** ✓; `pointer-events` 是 `none` ✓。 - 九个套件 **1554 项全绿** ✓(package **39** · harness 567 · qr 12 · ui **805** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.8.1 — 2026-10-15 **主题:背景"够动" + "不被挡住"** ✓ (用户 2026-10-15:「背景没有动态感太烂了不够惊艳,而且会被挡住」✓ —— 两条都认 ✓)。 ### ① 够动(上一版太含蓄 ✗) - **从 2 层动画加到 6 个** ✓:上方**光晕漂移**(30s ✓)· **网格流速快一倍**(1.7s → **0.85s** ✓)· **斜落的霓虹雨**(1.5s ✓,新加的、最显眼的一层 ✓)· 地平线**呼吸**(4.2s → 3.2s ✓)· 扫描线漂 ✓ · 外加**偶尔闪一下的故障感** ✓(12s 里只有极短两下 ✓ —— 一直闪是干扰,不是惊艳 ✗)。 - **网格透明度 .72 → .92** ✓:上一版把它压太狠 ✗ —— 但**保读字不靠把网格调暗** ✓, 靠的是**压暗层 + 气泡自己的底色** ✓(见下 ✓)。 ### ② 不被挡住(这是根因 ✗) - **压暗层让开** ✓:`.45/.68` → **`.20/.34`** ✓。上一版的黑纱把整张背景压掉一半 ✓, 墙上再好看也看不见 ✗ —— 而"字在气泡里" ✓,**气泡自己有底色就够了** ✓, 不需要把**整屏**压黑 ✓(这是取舍得来的 ✓,不是随手调数 ✓)。 - **气泡再透一点** ✓(`.70` → `.56` ✓)—— 于是**隔着气泡就能看见网格在流** ✓✓。 这是"有动态感"的关键 ✓:对话一多,屏幕上就只剩气泡 ✓, 背景想要被看见,就得能**透过来** ✓,光靠"留白处能看见"不够 ✗。 - ⚠️ **两条刹车要盖全所有动起来的层** ✗:4.8.1 新加了 `.scrim::after`(霓虹雨 ✓) 和 `#backdrop` 自己(光晕 ✓)—— 只写老的那三条,新层就会**关不掉** ✗ (用户按了"静下来"还在动 = 按了没反应 ✗)。现在 `.bg-still` 和 `prefers-reduced-motion` 都列全了 ✓,并且配了断言钉住 ✓。 ### 性能底线(没破 ✓) - 所有动画**只碰 `background-position` / `opacity`** ✓(合成层能廉价处理 ✓), 不碰 `filter` / `blur` / 宽高 / 布局 ✓ —— 手机上才不掉帧、不烫手 ✓。 这条也配了断言 ✓(扫 `@keyframes` 块里出现那些属性就红 ✓)。 ### 测试 - package **+6**:五层以上动画都在 ✓(列出缺的 ✓);网格速度必须还是 **0.85s** ✓; 网格透明度必须还是 **.92** ✓;压暗层必须还是 **.20/.34** ✓; **两条刹车盖全所有层** ✓;**动画只碰那两个属性** ✓。 这几条钉的正是用户这次点出的两个毛病 ✓ —— 以后谁调回去,测试就红 ✓。 - 九个套件 **1542 项全绿** ✓(package **30** · harness 567 · qr 12 · ui 802 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ### 过程 - 🎯 **按最坏情况预览** ✓:把 mock 灌满 10 条对话 ✓(对话一多,背景最容易被挡 ✓), 拍两张隔 0.4 秒的帧 ✓ → sha 不同 ✓(在动 ✓),而且**这一版一眼就看出网格透过气泡在流** ✓。 上一版同样拍过 ✓,当时只看到"一小条网格" ✓ —— 差别就是这次改的两个数 ✓。 ## 4.8.0 — 2026-10-15 **主题:背景动起来 + 界面风格跟着背景走** ✓ (用户 2026-10-15:「最好是动态的背景」✓、「还有界面风格要跟背景协调」✓)。 ### 动(第一个要求 ✓) - **三个动画** ✓,全部只用**合成层能廉价处理**的东西 ✓(`background-position` / `opacity` ✓): · 地面网格**往眼前流** ✓(横线按自己的重复周期 34px 无缝滚动 ✓ —— 周期对上才不会"跳一下" ✗,1.7s 一轮 ✓); · 地平线**呼吸** ✓(4.2s 一轮的辉光强弱 ✓); · 扫描线**慢慢漂** ✓(8s 3px ✓,几乎察觉不到、但屏幕"活"着 ✓)。 ⚠️ **不碰 `filter` / `blur` / 布局属性** ✗ —— 那类在手机上掉帧 + 发热 + 耗电 ✓(背景再好看也不值 ✓)。 - **两条刹车必须留着** ✓:`@media (prefers-reduced-motion: reduce)` → 一律不动 ✓(无障碍 ✓,有人会晕 ✓); 面板上的 **🐢 让它静下来** ✓(写进配置 `backgroundMotion` ✓,手机省电 ✓)。 - **默认是动** ✓ —— 只有明确写了 `false` 才静 ✓,配置里也不留"等于默认值"的僵尸键 ✓。 「动 / 静」只对自带预设有效 ✓:没有预设时 → **400 并说明原因** ✓(不静默成功 ✗)。 ### 协调(第二个要求 ✓) - 换上赛博朋克时,**整套配色变量一起换** ✓:主色霓虹青 `#00e5ff` ✓、副色品红 `#ff2dc4` ✓、 面板压成夜色玻璃 ✓、描边一层冷霓虹 ✓。按钮 / 页签 / 气泡 / 输入框 / 选中项本来就吃这些变量 ✓ —— **不用一个个改组件** ✓。 - ⚠️ **只覆盖颜色变量** ✓ —— 不动布局、不动字号、间距一个字没改 ✓(那些跟背景没关系 ✓)。 - ⚠️ **不改 `--err` / `--warn` / `--ok`** ✓ —— 红黄绿是**状态语义** ✓, 为了好看把它们染成霓虹青,就等于让用户分不清"成功 / 失败" ✗。 - 🔴 **预览里抓到一个真 bug** ✓(这条最值钱 ✓):手机系统是**亮色**时,页面那条 `@media (prefers-color-scheme: light) body.hasbg` 把面板设成**白色** ✓、文字设成**深蓝** ✓; 我第一版只改面板(改深 ✓)没改文字 ✓ → **深色字压在深色面板上** ✓,整个界面糊成一片(截图当场看出来 ✓)。 修法:文字 / 背景一起钉成夜色 ✓(赛博朋克本来就是夜景 ✓,配浅色界面才叫不协调 ✓)。 - 还顺手接管了亮色模式里几条**硬编码白底**(`.bubble` / `.copyb` / `.tab` / `#send` / `.iconbtn` / 输入框 ✓)—— 光改变量管不到它们 ✗,不接管就是"白底白字看不见" ✗(预览里页签和发送键就是这么瞎掉的 ✓)。 📌 气泡那条最后是 `body.hasbg.bg-cyber .bubble`(0,3,1 ✓)才压住 ✓ —— **注意没用 `!important`** ✗(那会让以后所有主题都改不动它 ✓)。 ### 过程 - 🎯 **动态这种事必须拍出来才算数** ✓:同一个 mock、同一份数据,**隔 1.4 秒拍两张** ✓, sha 不一样 → 画面确实在动 ✓(静止的话像素应当完全一致 ✓)。这一版一共拍了 6 张预览 ✓, 每一张都改出了东西 ✓(网格没露出来 → 太吵 → 字糊了 → 控件白底白字 → 协调 ✓)。 ### 测试 - ui **+4**:换上预设后**默认是动的** ✓(body 上没有 `.bg-still` ✓ —— 不该默认把用户要的动态关掉 ✗); 点「让它静下来」→ 发 `motion:false` ✓ **而且 body 真的加了 `.bg-still`** ✓(那把刹车真接上了 ✓); 按钮文字跟着变 ✓;再点回 → `motion:true` + 类被摘掉 ✓(能来回切 ✓)。 - harness **+4**:`motion:false` → 200 + 回包带 `motion:false` ✓;**写进配置** ✓(重启还在 ✓); 点回 `motion:true` → **键被删掉** ✓;**没有预设时 → 400 并说明** ✓(不静默成功 ✗)。 - 九个套件 **1536 项全绿** ✓(package 24 · harness **567** · qr 12 · ui **802** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.7.0 — 2026-10-15 **主题:自带一个「赛博朋克」背景** ✓(用户 2026-10-15:「制作背景要有赛博朋克风格」✓)。 ### 加 - **`🌀 赛博朋克(自带)`** ✓(在「更多 → 🎨 背景」里 ✓):霓虹地平线 + 透视网格 + 细扫描线 ✓, 紫青配色 ✓。**全部是 CSS 画的** ✓ —— 不带图片文件 ✓、不下载 ✓、任何分辨率都锐利 ✓。 服务端只认白名单里的 `cyberpunk` ✓(`BACKGROUND_PRESETS` ✓),写别的 → **400 并把能用的列出来** ✓; 设了预设会**自动摘掉图片背景** ✓(二选一 ✓ —— 两个同时存在的话页面不知道铺哪个 ✗); `backgroundInfo()` 优先回 `{ preset, kind: 'preset' }` ✓,手机据此加 `body.bg-cyber` ✓。 - **面板上会写明当前是哪个背景** ✓("当前:赛博朋克(自带,不用传图 ✓)" ✓)—— 以前只有"已设置 / 没有背景" ✓,用户分不清现在铺的到底是哪张 ✗。 - **读字优先** ✓(这是这一版反复调的一条 ✗):底色很深 ✓ + `.scrim` 那层压暗**照旧在上面** ✓; 网格那层最终压到 `opacity: .72` ✓(第一版 .85 ✓ —— 拍出来网格比对话还显眼 ✗,见下 ✓)。 ### 过程(这一版是"边看边调"调出来的 ✓) - 🎯 **先试了生成图那条路** ✓:用 image_generate 出了两张(雨夜霓虹街 ✓ / 经典透视网格 ✓)✓, 但**两张右下角都带「AI」水印** ✗ —— 官方 `watermark_enabled: false` 实测无效 ✓(插件自己的 `dewatermark.js` 注释里就写着这条 ✓),而这台机器上**没有 ffmpeg** ✓(我一开始还看错了, 把"配置文件存在"的 True 当成了 ffmpeg ✓)。**为了不把水印一起铺成背景** ✗,改成 CSS 画 ✓ —— 反而更好 ✓:零流量 ✓、零水印 ✓、矢量级清晰 ✓。 - 🎯 **为了真看见效果,搭了个预览** ✓:把**真页面的样式表**原样抠出来 ✓ + 配一个 10 行的 stub 服务器 (顺带应 `api/login` ✓)→ 用 `test/shot.mjs` 拍真截图 ✓(**不碰真桥、不需要设备审批** ✓)。 第一版拍出来就发现问题 ✓:`rotateX(74deg)` 的网格**几乎贴平、屏幕上什么都没有** ✓ → 改成 64° ✓;第二版网格**抢戏** ✓ → 压到 .72 ✓。**"美观"这种事必须眼睛验** ✓, 结构和断言都验不出来 ✗。 - ⚠️ 顺带记住:预览服务器**每次请求现读样式表** ✓ —— 第一版启动时读一次 ✗, 改完 CSS 拍出来还是旧图(sha 一模一样 ✓),白等一轮 ✓。 ### 测试 - ui **+5**:面板里有这个键 ✓;点它发的就是 `preset: cyberpunk` ✓(用桩里的 `bgRequests` 记录器 ✓); 页面当场铺上 `.bg-cyber` ✓;面板写明当前是它 ✓;**「去掉背景」要把预设也摘掉** ✓ (不然 CSS 那层会永远留着 ✗)。 📌 这一版我的补丁脚本把预设分支**嵌进了 `clear` 那个 if 里** ✗ → 点「赛博朋克」根本走不到 ✓, **测试当场抓到** ✓(桩和断言都是这一版新写的 ✓,等于自己抓自己 ✓)。 - harness **+6**:不认识的预设 → 400 + 白名单 ✓;设成功回包带 `preset` ✓; **设预设会摘掉 `backgroundFile`** ✓(二选一 ✓);bootstrap 里带的就是它 ✓; 「去掉背景」两个键都清干净 ✓;**收尾还原成图片背景** ✓ (我第一版这段跑完什么都没留 ✗ → 紧接着的老用例当场 404 ✓,测试又抓到一次 ✓)。 - 九个套件 **1528 项全绿** ✓(package 24 · harness **563** · qr 12 · ui **798** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.6.1 — 2026-10-15 **主题:左滑默认返回上一步** ✓(用户 2026-10-15:「左滑默认返回上一步」✓)。 ### 改 - 🔴 **有东西可以返回时,左右滑 = 只"返回上一步"** ✓(原来**一下两步** ✗): 工具面板 / 纸(状态盘 / 控制电脑 / 更多 / 剪贴板 / 导出 / 定时 / 安全审计 / 自检 / 备份 / 键盘 / 权限 / 生图 / 背景 / 会话列表 / 删除 / 结束进程 / 关机 / 顶栏那张 ⌄ 纸 ✓)开着时, 左右滑**只把它关掉** ✓,**不再顺手把底下的页也翻了** ✗ —— 用户看到的是"我就想退回去, 怎么一下跑别处去了" ✓。实现在 `swipeBackOneStep()` ✓(`swipeEnd` 里先问它 ✓,它吃掉手势就不再翻页 ✓)。 - ⚠️ **"本身是一页"的那些不在此列** ✗ —— 这是这一版最容易改错的地方 ✓: · **录音面板**就是页签栏上的一页 ✓(`currentPage()` 直接把它算作 `'rec'` ✓); 要是连它也"返回"吃掉 ✓,**余额就再也划不到了** ✗ —— 而"页签栏上看得见的都要能划到" 是用户 2026-10-11 亲口提的 ✓。所以它照旧交给换页逻辑 ✓; · **新建**(那张 sheet ✓)也不在 `openToolPanel()` 的清单里 ✓ → 照样能划到 ✓。 于是"每一页都划得到"✓ 与"滑一下只退一步"✓ 两件事同时成立 ✓(面板关了再滑一下照常翻页 ✓)。 - ⚠️ **两条老守卫原样保留** ✓(改手势不能把它们丢了 ✗): 「正在录音 → 不让划走 + 说明原因」✓(这条现在仍在 `goPage` 里 ✓)、 「生图里写了提示词 → 划走先问一句」✓(搬进 `swipeBackOneStep()` ✓)。 ### 测试 - ui **+1 / 改 2**:新钉「生图面板确认之后收起 ✓ 而且**不再顺手翻页** ✓」、 「列表上左右滑 = **返回上一步**(面板关掉 ✓、页不换 ✓)」、 以及**新旧行为的接缝** ✓ ——「面板都关掉之后,左右滑**照样换页**」✓(这条专门防"把换页搞坏" ✗)。 三条老断言("面板上左右滑 → 关掉并翻页" ✓、"列表空白处照样换页" ✓)按新行为改写 ✓ —— **它们是当年特意钉住旧行为的** ✓,这一版是**用户点名改**的 ✓,所以断言跟着改 ✓, 并且在 CHANGELOG 里写明"改了哪条、为什么" ✓(不偷偷改断言 ✗)。 - 九个套件 **1517 项全绿** ✓(package 24 · harness 557 · qr 12 · ui **793** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.6.0 — 2026-10-15 **主题:蓝牙设备列表换成系统原生接口(16 秒 → 0.8 秒 + 真实连接状态);** **并把「连接某台设备」这条**试到底**、给出可复现的结论** ✓ (用户 2026-10-15:「为什么没有用手机连接电脑蓝牙设备这一功能」✓ —— 这是个好问题 ✓, 上一版我只试了一条路就下了"做不到"的结论 ✗,不够 ✓)。 ### 加 / 改 - **蓝牙设备列表改用系统原生蓝牙 API** ✓(`bthprops.cpl` 的 `BluetoothFindFirstRadio` + `BluetoothFindFirstDevice` ✓,Add-Type 内联 C# ✓ 零依赖 ✓)。两个老毛病一起解决 ✓: · **慢** ✗:原来 `Get-PnpDevice -Class Bluetooth` 每个子进程都要加载模块 ✓,实测 **12~16 秒** ✓ → 现在 **0.76 秒** ✓(同一次实测 ✓); · **脏** ✗:45 条里一半是协议/服务节点 ✓,得靠一串**中文关键词黑名单**去滤 ✓ → 原生 API **只返回这台机器记住过的设备** ✓(本来就不含协议 ✓,过滤代码可以删掉 ✓) 实测 **5 台**(杰的AirPods / CIYINSO Mars Ultra / F40 / iPhone / WI-C200 ✓)。 还**直接带上连接状态** ✓(`fConnected` ✓)→ 面板上每台显示「已连接 / 未连接」✓,连着的排前面 ✓。 - **设备列表里点一台 → 打开系统蓝牙设置** ✓(`ms-settings:bluetooth` 深链 ✓,事件委托 ✓)—— 这是"连接"的**诚实替代** ✓:一下点到能动手的地方 ✓,而不是摆一个按了没反应的键 ✗。 ### ⚠️ 「连接某台设备」:这次试到底了,结论可复现 ✓ 上一版我写的是"Windows 没有可靠的命令行接口" ✓ —— 但那只试了 WinRT 无线电那一条路 ✗。 这一版把**底层那条路**也走完了 ✓:`BluetoothSetServiceState(hRadio, dev, guid, ENABLE)` ✓ (`bthprops.cpl` ✓,配 `BluetoothFindFirstRadio` 拿到的无线电句柄 ✓ + `BluetoothEnumerateInstalledServices` 枚举设备已装服务 ✓)。**2026-10-15 真机实测** ✓: | 设备 | A2DP-Sink | AVRCP | Handsfree | 结果 | |---|---|---|---|---| | 杰的AirPods | **rc=87** | rc=87 | **rc=87** | 仍 disconnected | | WI-C200 | rc=87 | rc=87 | rc=87 | 仍 disconnected | | CIYINSO Mars Ultra | rc=87 | rc=87 | rc=87 | 仍 disconnected | | iPhone | **rc=1060** | rc=87 | **rc=1060** | 仍 disconnected | | F40(没开机) | **rc=0** | rc=87 | **rc=0** | **仍 disconnected** | - `rc=87` = `ERROR_INVALID_PARAMETER` ✗、`rc=1060` = 设备不存在 ✗; - 唯一返回成功的是一台**没开机**的设备 ✓(而它**并没有真的连上** ✗ —— `fConnected` 仍为 false ✓); - 而 **DISABLE(断开)一律 rc=0** ✓ —— 这套接口**"能关不能开"** ✓。 ⇒ 结论(写进 `lib/pccontrol.js` 顶部 ✓):**Windows 这一层没有可靠的"连接某台已配对设备"接口** ✓ (官方口径同样是去设置里点 ✓)。所以**不摆那个键** ✗ + 给"一下点到设置" ✓ —— 这条**不是偷懒** ✓,是**试到底之后的结论** ✓(配了断言钉住"按钮里不许出现『连接』" ✓)。 ### 测试 - package **+2**:一条**新的回归断言** ✓ —— **`lib/pccontrol.js` 里的 PowerShell 脚本块必须是纯 ASCII** ✓ (提取时还要认转义的那个反引号 ✓)。为什么值得单开一条 ✗:今天这个坑**咬了四次** ✓ (中文当命令行参数进去会被按本地代码页解 ✓、**不报语法错** ✓、只是**静默失灵** ✓); 它上线后**第一次运行就抓到了我自己** ✓ —— 我刚在 PS 脚本块里写了一段中文注释 ✓。 - ui **+1**:设备列表要带**真实连接状态**(已连接 / 未连接 ✓)、 以及**点一台 → 发的是「打开蓝牙设置」**(且带上哪一台 ✓)✓。 - 九个套件 **1516 项全绿** ✓(package **24** · harness 557 · qr 12 · ui **792** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.5.2 — 2026-10-15 **主题:页面布局整理(工具排 16 个键 → 5 个 + 「更多」一张纸)** ✓ (用户 2026-10-15:「你整理一下手机桥页面布局,要美观简洁方便」✓)。 ### 改 - **工具排瘦身** ✓:`#toolKeys` 原来一行挂了 **16 个键** ✓(一半是文字如「图片/文件/原图/生图/背景」✓、 一半是光秃秃的 emoji 如 🎯📊🖥📋📤📅🔓🩺🛡⌨ ✓ —— 风格不统一 ✓,390px 上按 `flex-wrap` 折成两三行、把输入框往上顶 ✗)。现在只留**最常用的 5 个** ✓: **图片 · 文件 · 生图 · 🔐权限 · 更多** ✓,另外 12 个搬进新的「更多」纸(`#moreopen` ✓)。 - **「更多」按用途分四组** ✓:这条会话(🎯目标 / 📤导出 / 📅定时)· 电脑(📊状态 / 🖥控制 / 📋剪贴板)· 界面与手感(🖼原图模式 / 🎨背景 / ⌨键盘)· 安全与维护(🛡安全审计 / 🩺一键自检 / 🔓只看不发)✓。 每一行是「图标 + 名字 + 一句话说明」✓ —— emoji 单摆着没人认得出 ✗(这是这一版最实的改进 ✓)。 - 样式统一 ✓:工具排按键统一 **40px 高**(够手指点 ✓)、**不许换行** ✓(`nowrap` + 横向滚动 ✓, 将来插件再往这排加键也不会把输入框顶下去 ✓);「更多」里的行统一样式 ✓。 ⚠️ 顺手修一处**优先级打架** ✗:`#rawToggle / #bgBtn` 原来也在那条 **id 选择器**里 ✓ —— 搬进纸后 id 优先级会盖过 `.moreitem` ✓(那几行长得跟别的不一样 ✓);现在只留还在排上的键 ✓。 - ⚠️ **一个功能都没丢** ✓:16 个 id **一个没改** ✓(所有点击处理器、以及 780 多条界面断言**照旧** ✓)—— 这一版是**整理**,不是重构 ✗。`#toolKeys` 这个容器 id 也留着 ✓(插件面板键由 `panels.js` 注进它 ✓, 另有断言钉着"面板键必须是它的孩子" ✓)。 - 📌 **没做的事(如实说 ✗)**:原想拍一张真机截图给你看效果 ✓,但**新设备要审批** ✓ (你配置里 `deviceApproval: true` ✓)→ 截图脚本拿不到登录后的界面 ✗。 我**没有**为拍一张图往你的设备账本里塞一台"截图设备" ✗(那批假设备刚清过 ✓)—— 想看实拍说一声,我批一台、拍完踢掉 ✓。 ### 测试 - ui **+9 条** ✓:工具排里**只剩那 5 个内置键**、另外 12 个**不再挂在排上** ✓; 16 个 id **一个都不少**("整理最容易犯的错"✓);「更多」能开 / 能返回 ✓; 那 12 个键**都在纸里** ✓;每行都有文字名字 + 说明 ✓;分了四组 ✓;审批卡片来了**纸让位** ✓ (4.3.0 那条教训 ✓)。 📌 顺带一条我自己写歪又改回来的 ✓:第一版断言"一排正好 5 个按钮" ✗ —— 而那一排还会被**插件面板** 注入键(🔍/📝 ✓,其中一个还不带 `data-panel` ✓)→ 数量根本不由我们控制 ✗。 改成"**我们自己那 12 个不在排上**" ✓ —— 这才是"不挤了"的真属性 ✓。 - 九个套件 **1513 项全绿** ✓(package 22 · harness 557 · qr 12 · ui **791** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.5.1 — 2026-10-15 **主题:蓝牙开关做回来了** ✓(用户 2026-10-15:「你再试一下」✓ —— 给 4.5.0 那两个撤掉的键一个交代 ✓)。 ### 修 / 补 - **「开蓝牙 / 关蓝牙」两个键做回来了** ✓(4.5.0 发布前撤掉过 ✗ —— 两次的账都写在 `lib/pccontrol.js` 顶部 ✓): · **第一次为什么撤** ✗:`Radio.SetStateAsync` + `AsTask` **等结果**,真机上必报 "类型 `System.__ComObject` 无法转换为 `IAsyncAction`" ✓,显式转型也一样 ✓ —— 看着就像"系统不让调" ✓,于是照铁律把两个键撤了 ✓(不摆按了没反应的键 ✓)。 · **第二次探明白了三件事** ✓:① **副作用其实执行了** ✓(那个"失败"的调用把蓝牙真关了 ✓, 探测时读回来 `before: Off` ✓);② 失败的**只是"等它完成"那一步** ✗(PS 5.1 绑不上那个重载 ✓); ③ **读**状态那条路一直是好的 ✓(`IAsyncOperation` 的泛型 `AsTask` ✓,约 1 秒 ✓)。 · **正确做法** ✓:**把调用发出去、然后轮询真实状态** ✓(最多等 10 秒 ✓)—— 不等那个 action ✓。 这反而更好 ✓:`ok` 的定义就是"**读回来的真状态 == 想要的状态**" ✓, 面板显示的是真状态 ✓,不是"我以为设成了" ✗。 · **真机实测** ✓:`关 → {ok:true, radio:'Off'}` ✓、`开 → {ok:true, radio:'On'}` ✓(已还原 ✓)。 顺手发现**读无线电只要约 1 秒** ✓(慢的是那 45 条 PnP 设备列表 ✓),所以面板上 又多了一行「📶 蓝牙:开着 / 关着」✓(状态里带上 ✓,和音量 / 亮度排在一起 ✓)。 - ⚠️ **「连接某台设备」仍然做不到** ✗:Windows 没有可靠的命令行接口 ✓。 面板上**不摆那个键** ✗(配了断言 ✓),只留「打开蓝牙设置」+ 一句"在那儿点一下" ✓ —— 做不到就说做不到 ✓。 - 九个套件 **1504 项全绿** ✓(package 22 · harness 557 · qr 12 · ui **782** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.5.0 — 2026-10-14 **主题:手机上直接控制电脑** ✓(用户 2026-10-14:「做一键给电脑静音,调低亮度,调低调高音量, 调高亮度,联网检查,电量检查,蓝牙连接,蓝牙设备显示的功能的功能」✓ —— 七件事 ✓)。 ### 加 - **🖥「控制电脑」面板**(工具栏新增一个键 ✓):**静音 / 取消静音** ✓、**音量 ±** ✓(5% 一步 ✓)、 **亮度 ±** ✓(10% 一步 ✓)、**联网检查** ✓、**电量检查** ✓、**已配对蓝牙设备** ✓、**打开蓝牙设置** ✓。 面板先摆**这台机器的真实状态** ✓,每个动作做完**读回真值**再显示 ✓ —— 不写"已调低"这种没验过的话 ✗(读不到就如实说读不到 ✓)。 平台相关的活儿全在新的 `lib/pccontrol.js` ✓(和 `system.js` / `power.js` 同一个分层 ✓,依旧零依赖 ✓)。 实现上两个值得记的点 ✓: ① **音量/静音走 CoreAudio** ✓(系统自带的 `IAudioEndpointVolume` ✓,Add-Type 内联 C# ✓)—— 不走"发媒体键给前台窗口" ✗(那种做法在前台不是音频程序时**会失灵** ✓,而且**读不回真实数值** ✗); ② **亮度走 WMI** ✓(`WmiMonitorBrightnessMethods` ✓)—— 只管内置面板 ✓,拿不到就如实回"不支持" ✓; ③ 蓝牙设备列表走 `Get-PnpDevice` ✓(**过滤在 Node 这边做** ✓ —— 见下面那条教训 ✓)。 ### ⚠️ 做不到就说做不到(这一版特意不为"功能齐"造假 ✗) - **蓝牙「连接某台设备」和「开 / 关蓝牙」两个键:真机验完删掉了** ✗。 · 「连接」Windows **没有**可靠的命令行接口 ✓(`Get-PnpDevice` 只能看状态 ✓)—— 面板上**不摆那个键** ✗(4.3.0 撤过同一个形状的"解锁"键 ✓); · 「开关」本来用 WinRT `Radio.SetStateAsync` 写好了 ✓,**真机一验就露馅** ✗: PS 5.1 里它回的是裸 `__ComObject` ✓ → 报"无法转换为 IAsyncAction" ✓, 显式转型也照样失败 ✓(而**读**状态那条路是好的 ✓)。系统这一层没有别的可靠接口 ✓。 所以蓝牙这块只留**设备列表** ✓ + **打开蓝牙设置** ✓,并在页面上写明"连接 / 开关都要在设置里点" ✓ —— 配了断言钉住"按钮里不许出现那两个键" ✓(判据要**整字比对**:我第一版用 `includes('开蓝牙')` ✗, 而「**打开**蓝牙设置」里正好含这三个字 ✓ → 误判 ✓)。 - **亮度只对内置屏有效** ✓:实测这台 Lenovo 可读可写 ✓;台式机 / 只接外接屏时面板**明说** "这台机器没有可调的内置屏幕" ✓,绝不假装调成功 ✗(配了断言 ✓)。 ### 发布前真机逐项验收 ✓(`_verify450.mjs` ✓,每步都还原 ✓) - **音量 / 静音 / 亮度:真机验通** ✓(调低 → 读回真值 → 还原 ✓,起始值 36% / 未静音 / 亮度 20 ✓); - **设备列表:6 台真设备** ✓(杰的AirPods / CIYINSO Mars Ultra / WI-C200 / BT5.1 Keyboard / F40 / iPhone ✓)—— 而**45 条 PnP 条目里一半是协议与服务** ✗,所以过滤规则必须做对 ✓; - **打开蓝牙设置:成功** ✓(电脑上弹出设置页 ✓); - **两个删除项**:见上 ✗。 - 📌 这次是"**先真机验、再发版**"救了一次 ✗:要是只跑套件就发出去 ✓, 那两个蓝牙键会**带着"按了没反应"上线** ✗ —— 套件里它们是绿的(桩永远成功 ✓)。 ### 测试 - harness **+4**(默认关 → `enabled:false` + 怎么开 ✓;关着时动手 → 403 **且不问 PIN** ✓; 没登录 → 401 ✓;不认识的 action → 400 + 把能用的列出来 ✓)。 ⚠️ 有一条**故意没测** ✗:`开着 + 登录了 + 没提权 → 403 needPin` —— 提权是**按令牌**记的 ✓,而套件里拿令牌必须先输 PIN ✓ → 同令牌必然已提权 ✓,HTTP 层**造不出来** ✗。 那条改由 `test/package.mjs` 的**源码形状**钉(`POST /api/pc` 必须包在 `guardWrite()` 里 ✓), 并且**不假装测过** ✓。 - ui **+12**:面板能开 + 状态画得对 ✓;九个键都在 ✓;点键发的 action 与步长对 ✓; 蓝牙设备列表画出来了 ✓;调完显示的是**返回的真值** ✓;默认关时写出"怎么开" ✓; 亮度不支持时如实说 ✓;**面板登记进 `openToolPanel()`**(审批卡片一来必须让位 ✓ —— 4.3.0 那条教训 ✓); 以及"没有『连接』也没有『开 / 关蓝牙』那两个键" ✓。 📌 这一块我自己写歪又改回两条 ✓:① 拿 `pcopen.textContent.includes('连接')` 判 ✓ —— 说明文字里本来就写着"连接" ✓ → **永远为真** ✗; ② 拿 `includes('开蓝牙')` 判 ✓ —— 「打开蓝牙设置」正好含这三个字 ✓ → 误判 ✗。两条都改成**看按钮整字** ✓。 - ⚠️ 套件里**绝不真的改用户的音量/亮度/蓝牙** ✗(ui 用桩 ✓、harness 只测闸 ✓)—— 真机那几样由人在发布前手工验一次并**还原** ✓(这一轮就是这么做的 ✓)。 - 九个套件 **1504 项全绿** ✓(package **22** · harness **557** · qr 12 · ui **782** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.4.0 — 2026-10-14 **主题:说明页加了「一代一代看」与「5.0 预告」** ✓(用户 2026-10-14:「分别总结一下 0.1 到 1.0、 1.0 到 2.0、2.0 到 3.0、3.0 到 4.0 分别改变了什么并添加到说明,然后写到 5.0 版本的预告」✓)。 外加一个**静默漏账**的真 bug(余额报告)✓。 ### 加 - **说明页新增「一代一代看:每一代各改了什么」** ✓:`WHATS_NEW_ERAS`(`lib/index.js` ✓)—— 0.1 → 1.0 / 1.0 → 2.0 / 2.0 → 3.0 / 3.0 → 4.0 / 4.0 → 今天,**每代一两句主线** ✓ + 那一代的代表作(都带首次引入的版本号 ✓)。与上面那份**按主题**的汇总**分工明确** ✓: 主题回答"录音这件事怎么一路变好的" ✓,这一块回答"每一代整体在干什么" ✓。 内容来源一条都没编 ✗:0.1→1.0 取自 `CHANGELOG.md` 尾部的逐版表 + `HANDOFF.md` ✓; 1.0→2.0 取自 `CHANGELOG.md` 的 1.x 各节 + 当年那份《手机桥-更新汇总-1.0到2.0.md》✓; 2.0→3.0 与 3.0→4.0 取自 2.x / 3.x 各节(版本号就是各功能的首次引入版 ✓)。 - **说明页新增「下一站:v5.0.0『开放平台』预告」** ✓:`WHATS_NEW_NEXT`(`lib/index.js` ✓)—— 愿景四条(开放标准协议 / 第三方硬件与服务接入 / 完整文档与教程 / 社区与模板生态 ✓)+ "5.0 之前那几代是方向不是承诺" ✓ + 一句**"这是愿景,不是排期"** ✓。 ⚠️ 口径照项目里那份定稿(`手机桥-2.5.0交接` 的「总路线(续):4.1.0 → 5.0.0」✓ —— 它自己就标着"**这一段是愿景,不是待办** ✗"✓):所以预告里**不许出现日期** ✗、也不写成待办清单 ✗, 这条还配了断言 ✓(往数据里写日期就红 ✓)。样式上用**虚线框** ✓ —— "还没发生"的事不该长得像已发布的东西 ✓。 - 两块数据都走**服务端注入** ✓(`__DSH_ERAS__` / `__DSH_NEXTUP__` ✓,和主题汇总是同一套 ✓), 页面只负责画 ✓ —— 以后改文案不用动 `lib/mobile.html` ✓。 ### 修 - 🔴 **余额报告(`tools/token-report.mjs`)静默漏账** ✗(另一个会话只读复查报的 ✓): 它把日志名**写死成 `session.v3.jsonl.zstd`** ✗ —— 而 DSH 的日志名带**格式版本号** ✓, 本机实测 **v3 61 份 / v4 64 份** ✓ → **v4 那 64 份一条都没算** ✗,按天只列到 10-09 ✓ (**不报错、不提示** ✗ —— 用户看到的是"最近几天的账不见了" ✓,正是本项目最恨的静默漏 ✓)。 修法:把"日志发现"抽成 `lib/export.js` 的 **`listSessionLogs()`** ✓(版本号大的优先 ✓、 每个会话只留一份 ✓),`tools/token-report.mjs` **共用那一份** ✓ —— 顺手删掉它自己那份逐帧解 zstd 的副本 ✓(改用 `readSessionEvents` ✓): **两处各写一遍就是这条 bug 的根** ✓。实测:扫描 **61 → 119 份** ✓、按天列到 **10-14** ✓。 - 🟡 **测试里一处"猜位置"** ✗:`restBox()` 原来用 `$('wnList').lastElementChild` ✓ —— 我这次在后面加了「5.0 预告」那一块 ✓ → 它猜到的就不是折叠容器了 ✗ → 两条断言**为错误的原因红** ✓。 现在给折叠容器一个类名 `wnrest` ✓、按类名查 ✓(位置会变 ✓、类名不会 ✓)。 ### 测试 - 九个套件 **1486 项全绿** ✓(package 20 · harness **553** · qr 12 · ui **770** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 - 新钉 **6 条** ✓:harness 3 条(分代总结真发到了手机上 ✓、5.0 预告真发到了手机上 ✓、 **预告里不写日期** ✓)+ ui 3 条(页面上真画出了「一代一代看」✓、它排在逐版清单前面 ✓、 「下一站:v5.0.0」那一块画全了 ✓),另加"两个新注入标记一处都不许漏" ✓。 ## 4.3.1 — 2026-10-14 **主题:说明页补上「1.0 之前」那四十多版** ✓(用户 2026-10-14:「找到 1.0 以前版本的更新内容并补全到说明里」✓)。 ### 补 - **说明页的主题汇总从「1.0 → 今」改成「0.1 → 今」** ✓:`WHATS_NEW_THEMES` 最前面加了一行 **「🧱 起步(0.1 → 0.9.12)」** ✓(手机第一次能看进度/能回话/能批操作 → 图与视频能发 → 装成 App → 0.9 那一串手感 ✓)。原来从 1.0 写起 ✗ —— 等于把前面四十多版当没发生过 ✓。 - **逐版清单里补上「1.0 之前」那一节** ✓:`USERNEWS.md` 新增 `## 1.0 之前` 段(三句人话 ✓, 0.1→0.5 起步 / 0.6→0.8 能发能看 / 0.9 那一串手感 ✓)。服务端本来就有这条通道 ✓ (`whatsNew()` 会把 `USERNEWS['1.0 之前']` 追加成一节 ✓)—— **只是从来没人写过那段内容** ✗, 所以手机上一直看不到 ✓。 - **`CHANGELOG.md` 里那段「0.9.12 及更早」从两行占位换成 38 行的逐版表** ✓(0.1.0 → 0.9.12 ✓): ⚠️ 照**提交历史**如实补的 ✓(0.x 当年没发过正式 Release,也没有逐版 CHANGELOG ✗)—— 一行一版、带日期 ✓,同期那些文档/测试提交折进它前面那一版 ✓(不另编版本号 ✗,也不丢内容 ✓)。 📌 更细的现场记录在 `HANDOFF.md`「已经做完的(0.9.4 → 0.9.12)」与那几个 0.x 会话里 ✓ (`tools/export-session.mjs` 能导出来看 ✓ —— 这一轮就是靠它和 `git log` 把史料找齐的 ✓)。 - 🟡 顺手修一个渲染瑕疵 ✓:那一节不是版本号 ✓,而页面原来一律加 "v" ✗ → 会显示成「v1.0 之前」✓; 现在**只有数字开头才加 v** ✓(所以它显示为「1.0 之前(起步)」✓)。 ### 测试 - harness **+1**:钉「说明页里真的有『1.0 之前』这一节、而且那三句人话真跟着注入到了手机上」✓ (`USERNEWS.md` 那段被删掉、或写空了,这条就红 ✓)。 - ui **+1**:钉「展开全部更新之后,页面上真画出了『1.0 之前』那一节」✓(fixture 里也带上它 ✓); 另外那条"主题汇总"的断言改成要求标题里是 **0.1 →** ✓(再退回 1.0 就红 ✓)。 - 九个套件 **1479 项全绿** ✓(package 20 · harness **550** · qr 12 · ui **766** · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓)+ `check-counts` exit 0 ✓。 ## 4.3.0 — 2026-10-14 **主题:折叠后点 ⌄ 弹纸 + 「停止」防误触 + 安装文案重写** ✓(用户 2026-10-14 亲口定的 ✓); 外加把安全套件那条**老 bug** 收口(它会在你真账本里写假设备 ✓ —— 用户点头清了那批 ✓)。 ### 改 - **提权默认从 15 分钟改成**一小时** ✓(用户 2026-10-14 亲口:「提权默认只保持 15 分钟改成一个小时」✓): `DEFAULT_ELEVATION_MINUTES` 15 → **60** ✓(上限仍 240 ✓)。**含义没变** ✓:cookie 只让你**读** ✓, 下发指令 / 取消 / 新建会话这类"让电脑动手"的操作,还是要在一小时内输过一次 PIN ✓ —— 只是"多久问一次"从 15 分钟放宽到 60 分钟 ✓。 ⚠️ **老机器只改代码不生效** ✗:这条默认只在"配置里没写"时起作用 ✓,而跑过一次的机器 `mobile-bridge.json` 里已经**显式写着 15** ✓(当年第一次启动写的 ✓)→ 会原样沿用 ✓。 所以这一次**连这台机器的配置一起改了** ✓(整份备份 `mobile-bridge.json.bak-elev60-<时间戳>` ✓, 改动前后逐字段核对:只有 `elevationMinutes` 变了 ✓)。 📌 新增一条断言防"文档漂" ✓:`test/package.mjs` 现在会拿 README 字段表里的数字跟 `DEFAULT_ELEVATION_MINUTES` 比对 ✓(改默认值忘了改文档就红 ✓);安装文案里那句 "默认 15 分钟后会再问一次"也一起改成"默认一小时" ✓。 ### 加 - **收起顶栏后点 ⌄,弹出一张纸** ✓(用户选的是 B ✓):纸上五样 —— **展开顶栏 / 说明 / 刷新 / 退出登录 / 当前模型(可直接换 ✓)**。为什么值得做:收起顶栏时 CSS 把「说明 / 刷新 / 退出 / 当前模型」全藏了 ✓,而 4.1.0 新加的「分享」**不在藏掉名单里** ✗ → 收起后**只剩分享还杵着** ✓, 用户看到的就是这个不一致 ✓。现在键**一个都不丢** ✓,栏还是那条细缝 ✓; ⚠️ **只有"已收起"时才弹纸** ✓ —— 展开态点 ⌄ 仍然是**一键折叠** ✓(常用动作不许变成两次 ✓)。 - **「停止」键防误触** ✓(用户:「信息发送出去后容易手点到停止」✓):**两下才真停** ✓ —— 第一下**一个请求都不发** ✓、按钮改成「真的要停?再点一次」+ 一句 toast ✓;**3 秒**内第二下 才发**恰好一条** `POST /api/cancel` ✓;窗口过了自己退回「停止」✓(仍不发 ✓); 会话停了**自动解除上膛** ✓。文案不忽悠 ✓:真发出去之前**绝不**写"已停止" ✗。 - **安装文案重写了** ✓(用户当场说「安装文案一点都不详细」✗):原来只有三句笼统的话 ✓, 现在四段 —— **要什么(4 样)→ 三步装好(细分成 7 小步,每步写出敲什么、看到什么算过)→ 卡住了看这里(5 条最常见)→ 安全** ✓。文本版与网页版仍然**同一处内容源**(`installDoc()` ✓), 并且**依然不含任何凭据** ✓(只说"电脑上那行日志"✓,绝不写真实 PIN / 密钥 / 内网地址 ✗)。 ### 修(折叠那张纸 · 冷视角复查抓的真 bug) - 🔴 **点「收起」会被一张全屏纸盖住** ✗:第一版把 `renderTop()` 写成"折叠态就 `show(纸)`" ✓ —— 而它**在收起那一刻**会被调用 ✓、**冷启动/刷新**时也会 ✓(`dshm.top=1` 是持久化的 ✗)→ 用户点"收起"想多看几行、结果被纸盖住 ✓,刷新之后每次都自动弹一脸 ✗。 改成"折叠态就 `hide`"**也不对** ✗:刚弹开的纸会被顺手收掉 ✓(实测那个状态根本摆不住 ✓)。 **最终:`renderTop()` 一点都不碰纸的可见性** ✓ —— 开合只由 `⌄` 与纸上的键决定 ✓ (一个东西只由一处说了算 ✓)。 - 🔴 **`⌄` 的分支判据改用 DOM 上的 `header.folded`** ✓(原来读 `localStorage` ✗): 两个来源会漂 ✓ → 一次点击的分支判反 ✓,表现就是"点了 ⌄ 纸不开 / 收起反而弹纸"这类怪事 ✗。 - 🟠 **`#topsheet` 漏登记进 `openToolPanel()`** ✗(冷视角复查点名 ✓):它是全屏 `.sheet` ✓, 没登记就**滑动换页不关纸** ✓、更糟的是**审批卡片一到不关纸** ✓ → 卡片被盖住 → 用户又看到"审批没弹出来" ✓(正是 2.5.1 修过的那个病 ✓,宿主只等 120 秒 ✓)。 - 🟡 **`lib/export.js` 被改坏过一次** ✗(自查抓到 ✓):把 `dshHome` 从函数改成"加载时定死"的常量时, 调用点忘了去括号 ✓ → `TypeError: dshHome is not a function` ✓ → **导出会话 / 访客链接全 500** ✗ (harness 12 条当场红 ✓)。已修 ✓。 - 🟡 **`lib/index.js` 的凭据路径也改成同一个"定死"口径** ✓(原来现读 `DSH_HOME` ✗ —— 两个口径迟早对不上 ✓)。 ### 修(安全) - 🔴 **跑一次安全套件就往你的真实登录账本写 15 行假登录** ✗(`192.0.2.x` + `ua=node` ✓ —— 你从「安全审计」里看到的那批**幽灵设备**就是它 ✓)。机制已经查实 ✓:**错 PIN 那条审计是 "发了就不管"的** ✓(`noteLoginFailure()` 里 `void auditLogin(...)` ✓ —— 刻意的:写不进去绝不挡住 "这次 PIN 不对"这个判断 ✓;⚠️ **成功**登录那条是 `await` 的 ✓,别把两句混了 ✗), 而测试跑完会把 `DSH_HOME` **还原** ✓ → 那条**迟到的** `appendFile` 跟着还原后的值走 ✓ → 落进 `~/.dsh` ✓(实测每次 +15 行 / +1437 字节 ✓)。两条一起修 ✓: ① `lib/index.js`:**实例的数据目录在加载那一刻定死** ✓(`bridgeHome` ✓)—— "换目录之后,一条属于旧目录的迟到写盘跑到新目录"是纯粹的错 ✓; ② `test/security.mjs`:跑测试**中途不再动 `DSH_HOME`** ✓(设成探针家目录,一路到底 ✓)。 新断言**字节级**钉住(SHA-256 + 探针那本账必须真写到 ✓);变异探针实测:把两条修法都退回 旧写法 → 当场报红 + 账本 +1437 字节 ✓(**会咬** ✓)。 - 🟡 **`test/security.mjs` 在没设 `DSH_HOME` 时会"假绿"** ✗:`realHome` 拿到 `undefined` ✓ 却被 赋回成字符串 `"undefined"` ✓ → 回退到 `~/.dsh` 永不触发 ✓ → 那 41 项**静默跳过** ✓、 单独跑只有 10/10 且 exit 0 ✓。现在:**读不到配置就逐条报红** ✓ + **汇总照常打印** ✓ + 退出码 **1** ✓。 顺带把"只打探针"的那几块**移出** `if (config !== null)` ✓ —— 真桥配置读不到时, 探针上的安全测试**照样跑** ✓(不再有"静默跳过"的第二种形状 ✓)。 - 🟠 **只读检查打错了对象** ✗(冷视角复查点名 ✓):测试把 `DSH_HOME` 指到探针家目录之后才读配置 ✓, 于是那批"打真桥"的只读检查(密钥路径门禁 / 401 / 响应头 / 日志留痕 ✓)**一直在打临时实例** ✓, 注释还写着"打真桥" ✗。现在:进程启动时先把原来的 `DSH_HOME` 留底 ✓, 配置与日志都按**那个**算 ✓(实测证据:`pathSecret 06fa6e59f8d4e923` ✓ = 真身的 ✓,不再是探针的 ✓)。 - ✅ **你点头清掉了那批历史假记录** ✓:只删 `192.0.2.*` + `"ua":"node"` 的行 ✓ (**2341 条** ✓),手机与真实记录**一行没碰** ✓(真记录 61 条 / 成功登录 56 条 ✓); 原文件**整份备份**留在 `~/.dsh/mobile-bridge.logins.jsonl.bak-ghost-<时间戳>` ✓。 ### 测试 - 九个套件 **1477 项**:package **20** · harness **549** · qr 12 · ui **765** · security **54** · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉一批(实测口径:package 18 → **20** ✓、security 51 → **54** ✓、harness 548 → **549** ✓、 ui 720 → **765** ✓):折叠那张纸(含冷视角复查抓到的两个真 bug 各配回归断言 ✓)+「停止」防误触 (含"换会话要解除上膛" ✓、以及"「只看不发」也要禁掉停止" ✓)+ 安全收口 3 条 ✓ + 安装文案把命令渲染成 `` ✓ + **"默认值与文档必须一致" 2 条**(提权那一条 ✓)。 - **发布前做了四轮冷视角检修** ✓(照交接文档的流程 ✓),抓到的都当场修了 ✓ —— 其中四条是**我这轮自己写出来的**:① 换会话那块把 `sessionRows()`(字符串数组 ✗)当 DOM 节点用 ✓ → 套件当场崩、连汇总都打不出来 ✓;② "防偶发红"改错了地方 ✓(去拉长 `animation-duration` ✗, 而类名其实是被一个 `260ms` 的 `setTimeout` 摘的 ✓)→ 换 `MutationObserver` 记录"出现过没有" ✓; ③ 为了让安全套件跑"探针起不来"的场景,我把端口改成固定值 ✓,然后用**杀掉后台任务**结束那次实验 ✓ → 它脚本里的回滚没跑到 ✓,固定端口留在文件里 ✓(这次一并修回随机 ✓); ④ 只把"换会话解除上膛"接在**一个**入口上 ✓ —— 同一条 bug 还有另外 4 个入口 (删会话后自动落脚 / 开机改选 / 新建会话两条 ✓),全部补上 ✓。 - **删掉一条我自己写歪的断言** ✓:本来想钉"纸开着滑动换页要先收纸" ✓,但合成触摸事件在这块场景里 **走不完 `goPage`** ✓(对照实测:把纸关掉再滑也不换页 ✓)→ "纸关着"那个结果**不是登记生效造成的** ✗ → 照"宁缺不假"撤掉 ✓,缺口写进 CHANGELOG ✓;真正压阵的是**审批卡片让位**那条 ✓(同一条代码路径 ✓)。 - **把一批"假绿形状"堵掉** ✓(复查点名):`
  • ` 抠取后 `.every()` 对空数组**恒真** ✓ → 改成**三段各自切出来、各自比对、各自要求条数** ✓;"本机地址不许有"只查字面 `127.0.0.1` ✗ → 泛化成裸 IPv4 并放行合法的 `0.0.0.0` 示例 ✓(整个回包那条也一起泛化 ✓); `address === undefined ||` 恒真逃生口 ✓、`|| codes.length === 0` 死分支 ✓ → 都收紧 ✓; 安全套件的 `preq` 与真桥那半段**都自己吞网络失败** ✓(合成状态 0 响应 ✓)—— 探针起不来或桥中途挂掉时不再"崩在汇总之前" ✗;真桥日志读写也包了 try/catch ✓; ui 里三处读产品源码的 `readFile` 也包了 ✓(读不到就报红 / 说清再退,不裸崩 ✓)。 - **真实账本那条判据改稳了** ✓:由"SHA 全同"改成"**假记录条数没增加**" ✓ —— 这条桥是活的 ✓,跑测试期间你手机登录一次(或账本超 256KB 触发回收 ✓)会让字节变 ✗ → 旧判据**假红卡门禁** ✓;新判据钉的才是"测试没往里写假记录" ✓,而且**读不到会单独报红** ✓ (不许"读不了"混成"0 条"假绿 ✗)。 - ui 765/765 · harness 549/549 · security 54/54 ✓,且真实账本**假记录 0 增量** ✓。 ### 留台(下一版候选,一条不藏 ✓) - 说明页那行 `**` 会露出字面星号 ✗(延续 ✓);**真机走一遍 unlock** ✗;专注模式「收起按键」那半 ✗; 「说完自动发」✗;录屏非 Windows / 真机端到端无证据 ✗; **"纸开着左右滑 → 先收纸再换页"这条只有代码保证、没有测试** ✗(合成触摸事件走不动 ✓,要真机看 ✓)。 ## 4.2.1 — 2026-10-14 **补丁:把 4.2.0 复查留下的两处缺口收掉** ✓(没有新能力 ✓;用户可见的只有**说明页一句** ✓)。 ### 补 - **网页版那条降级分支终于被测了** ✓:复查第 96 轮点名「桩永远回 200 ✓ → 『取不到就明说』那条分支 **从没被测过** ✗」。现在 ui 桩多了一个**一次性失败旋钮**(回 500 ✓),断言钉住: 页面必须**明说没取到** ✓ 且提示**文本版还能用** ✓ —— 实测证据就是页面真说的那句 ✓ (「网页版没取到:HTTP 500 ✓(文本版还能用 ✓)」)。 - **说明页补上「网页版」** ✓:原来那行只讲文本版 ✓,现在写明:**一份自带排版的 `.html` ✓, 同学直接浏览器打开就能读 ✓、不需要你电脑在线 ✓**;不能直接分享文件时**如实说** ✓ 并**下载** ✓。 - 数字对齐 ✓:ui 719 → **720** ✓、九个套件总数 1425 → **1426** ✓(`check-counts` ✓)。 ### 一句话教训(写在这儿,下一版照做 ✓) - 那条「**门禁偶发红**」查清了 ✓:连跑 6 遍**一次没复现** ✗(ui 4×719/719、每遍 117 秒 ✓;harness 2×548/548 ✓)。 复查那次看到的是 `718/719` **且没有 FAIL 行** ✓ —— 那是**有一条压根没跑到** ✓,因为当时 **同一个工作区里有人在重写 `test/ui.mjs`** ✗(就是我自己 ✓)。**不是偶发不稳定** ✓,是**并发写** ✓。 📌 规矩:**跑门禁时不许有人改被测文件** ✗。 ## 4.2.0 — 2026-10-14 **主题:分享网页版** ✓(用户 2026-10-14 亲口要的:「分享按钮加一个网页版分享(方便同学阅读)」✓)。 ### 加 - **`GET /api/install?format=html`** ✓:与文本版**同一处内容源**(新抽的 `installDoc()` ✓)生成一份 **单文件、自带样式、手机上舒服读**的静态页 ✓ —— 有标题层级、三步做成有序列表、仓库可点 ✓、 **不带任何脚本** ✗(同学那边打开更放心 ✓)、`no-referrer` + `x-frame-options: DENY` ✓。 - **分享纸多一个「分享网页版…」** ✓:把这份 HTML 包成 `File` ✓ → 走**和"分享文档"同一条路** (`navigator.canShare({files})` + `navigator.share({files})` ✓ —— 复用仓库里现成机制 ✓,不另造 ✗)。 同学拿到 `.html` **直接浏览器打开就能读** ✓,**不需要你电脑在线** ✓(单文件 ✓)。 ### 三条硬要求(都钉住了 ✓) 1. **同源不漂** ✓:文本版与网页版都从 `installDoc()` 出 ✗ —— 断言同时查两边的"三步 / 仓库 / 版本号" ✓; 2. **绝不含凭据** ✓:网页版那条断言查的是**整个页面**(pathSecret / PIN / `127.0.0.1` / 除 `github.com` 之外的任何域名 ✗)—— 实测出现的域名就是 `["github.com","github.com"]` ✓; 3. **不假装成功** ✓:不能分享文件时**如实说不能** ✓ + 退到**下载**(`` ✓)并说清怎么发给同学 ✓; 取不到就明说"网页版没取到 + 文本版还能用" ✓。 ### 测试 - 九个套件 **1426 项**:package 18 · harness **548** · qr 12 · ui **720** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **3 条**(harness)+ **3 条**(ui)✓:网页版是 HTML(doctype + viewport + 内联样式 + **无脚本** ✓)、 与文本版同源 ✓、**整页无凭据** ✓;页面上有「分享网页版」键 ✓、点它**真去取** `?format=html` ✓ ("只改文案不去取文件"这种假实现会被这条抓住 ✓)、不能直接分享时**如实说 + 说清已下载** ✓。 - ⚠️ 如实记一个**桩的坑** ✓:ui 桩里 `path` **带查询串** ✗(`/api/install?format=html` ✓), 我第一版把分支塞进 `path === '/api/install'` 里面 ✗ → 永远进不去 ✓ → 页面拿到 404 ✓、 两条断言当场红 ✓(**这就是"先写断言"的价值** ✓)。 ### 冷视角复查(第 96 轮)—— 裁决「**不能发**」✗,1 条真 bug 当场修 ✓ 1. 🔴 **用户取消被误报成失败** ✗(真 bug ✓):`navigator.share` 被用户划掉时抛 `AbortError` ✓, 而 `shareInstallHtml()` 的 catch 没区分它 ✓ → 纸上写「网页版没取到:AbortError」✗(文件其实取到了 ✓)。 同文件另两条分享路径(`shareDoc` ✓ / 文档面板 ✓)都写着 `if (error?.name === 'AbortError') return` ✓, **只有新这条漏了** ✓ —— 复查一句话点破 ✓。**改法** ✓:补同一行守卫 ✓(这正是本版自己那条"不假装成功"的镜像: **也不许假装失败** ✗)。 2. 🟠 **USERNEWS 里 Markdown 强调没闭合** ✗:`**自带排版的网页****(一个 .html 文件)**` ✓ → 说明页「最近更新」按纯文本显示 ✓ → 同学会看到字面 `****(一个` ✓。**改法** ✓:改成一对强调 ✓。 3. 🟠 **同源判据是子串级** ✗:只查「三步装好 / 仓库名 / 版本号」在不在 ✓ —— 谁只改一边、另一边不动, 两边就**静默漂**而套件全绿 ✓。**改法** ✓:把网页版每个 `
  • ` 的**原文抠出来**、**逐步**在文本版里找到 ✓ (哪边漂都会红 ✓)。 4. 🟡 **降级那条判据一半自证** ✗(`不能直接分享` / `下载` 都是源码里的字面串 ✓;且桩从不回非 200 ✓, 所以"取不到就明说"那条分支**从没被测过** ✗)—— ✅ **已补** ✓:给桩加了"网页版失败"旋钮 ✓, 断言「桩回 500 时 → 页面**明说没取到** ✓ + 提示**文本版还能用** ✓」(实测证据就是那句 ✓)。 5. 🟡 **门禁抖过一次** ✗:复查首跑 `check-counts` 得 `[exit 1]`、内层 ui `718/719` 且**没有 FAIL 行** ✓ (像是崩了/超时 ✓ 而不是断言红 ✓);独立复跑 ui 5 次得 4×719 + 1×718 ✓,重跑 `check-counts` 得 1425/exit 0 ✓。 ⏳ **留台** ✓:这类偶发要连跑几遍确认稳定性 ✓(本项目对"偶发红"的立场是:**偶发红比缺测试更坏** ✗)。 - ✅ 复查实测**通过**的部分 ✓:真起桥抓 `?format=html` → 未登录 **401** ✓(与 JSON 那条一致 ✓); 响应头 `text/html; charset=utf-8` + `content-length` 与实长一致 ✓ + `no-store` ✓ + `x-frame-options: DENY` ✓ + `referrer-policy: no-referrer` ✓、**无多余头** ✓;整页**无** pathSecret/PIN/`127.0.0.1`/`localhost` ✓、 **无** `` 里 ✓、点开**真的取到文案且原样填进框** ✓、 **不支持系统分享时如实说** ✓、**取不到文案时框清空且提示如实说取不到** ✓(`loadInstall()` 的失败分支 ✓ —— 把 catch 里那句 `box.value = ''` 删掉,这条**恰好一条红** ✓,删之前它从没被碰过 ✗)、解锁入口**页面上只出现一次** ✓("只留一份接法"的机器判据 ✓)、 点它**只开纸、新增电源 POST = 0** ✓、**两次点击后恰好一条 `action:unlock` 且 PIN 在请求头** ✓。 - ⚠️ **如实说**:原先还有一条「解锁的结论照实说」✓ → **撤掉了** ✗。复查第 95 轮点出它**假绿** ✓(它读的是纸上那两句**静态说明** ✓,把桩的 message 换成独有串也照样绿 ✓);我随后试了两种真判据(读 `#powerError` ✗ —— 那一格会被面板重渲染冲掉 ✓;读 `#toast` 里的独有串 ✗ —— 结论没落到 toast 上 ✓)**都没做成** ✓ → 按「**假绿比缺测试更坏**」把它撤掉 ✓,缺口就记在这儿 ✓。真正压阵的是「**恰好一条 `action:unlock` 且 PIN 在请求头**」那条 ✓ —— 复查做过反向探针 ✓:在临时副本里把 kind 冲掉 → 它**当场红** ✓(实收 `{action:'shutdown'}` ✓、ui 712/716 ✓),那条是真会咬的 ✓。 - **🎬 录屏遥控** ✓(`POST /api/record` ✓,`start` / `stop` / `status` ✓):手机上点开始,电脑那边用 **现成的 ffmpeg** 录屏 ✓(Windows 走 gdigrab ✓);停止时先让 ffmpeg 认 stdin 的 `q` **自己收尾** ✓, 8 秒还不走才强停 ✓ 并照实说"文件可能不完整" ✓;插件卸载 / 热重载时**顺手停掉** ✓ (不留一个偷偷录到上限的进程 ✓)。 - **⏳ 一次性分享链接** ✓:在**现有的只读链接**上加一层语义 ✓ —— 生成时勾「一次性」✓, **第一次打开就记账作废** ✓(先写盘再回页面 ✓ —— 反过来的话进程半路挂掉就会漏掉一次消耗 ✓); 第二次来是 **410 + "别人用过了"** ✗(**不是**含糊的 404 ✓ —— 那会让人以为是链接抄错了 ✓)。 ### 三条纪律(和每个"能指挥电脑"的功能一样 ✓) - **默认关** ✓:录屏要 `screenRecord: true` ✓、分享链接要 `shareLinks: true` ✓,都是**每次请求现读** ✓; - **要现输 PIN** ✓:录屏尤其要 ✓(它录的是**整块屏幕** ✓,比截屏更敏感 ✓);一次性链接也照旧 ✓; - **有硬上限** ✓:`-t 600` 交给 **ffmpeg 自己**到点停 ✓ —— **不靠手机计时** ✗: 手机断了、页面关了、桥被热重载,它都照样到点停 ✓("录到把磁盘写满"是真风险 ✓)。 ### 边界(照实说 ✓) - 录屏:`403`(默认关 ✓)/ `403 + needPin` ✓ / `400`(action 不认识 ✓ —— **绝不落到某个默认动作上** ✗)/ `409`(已经在录 ✓、没在录却点停 ✓)/ `503`(**没找到 ffmpeg** ✓ + 怎么办 ✓); **空文件明说"多半没抓到画面(锁屏?)"** ✗(不许让人以为录到了 ✓)。 - 一次性链接:**普通链接读两次照样能读** ✓(一次性是可选语义 ✓,不是把所有人都变成一次性 ✓); 过期 / 撤销 / 一次性三种失效各自说清 ✓。 ### 测试 - 九个套件 **1408 项**:package 18 · harness **542** · qr 12 · ui **708** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **5 条**(harness)+ **3 条**(ui)✓:一次性链接的回包如实标一次性 ✓、第一次 200 且页面写明 ✓、 **第二次 410** ✓、对照(普通链接两次都能读 ✓);录屏**只测拒绝路径** ✗(默认关 403 ✓、没 PIN 403 ✓、 action 不认识 400 ✓、没在录时说没在录 409 ✓、status 如实说 ✓ —— **成功那条会真录这块屏,测试里绝不启动** ✗); ui 侧:点录屏开的是**已有的 PIN 纸** ✓、真的发 `/api/record {action:start}` 且**没走 `/api/power`** ✓(会咬)、 服务端说"默认关"时纸上显示原话且**不假装开始了** ✓。 ### 冷视角复查(第 94 轮)—— 裁决「**能发**」✓,7 条里 4 条当场改 ✓、3 条如实留台 ✓ > 复查者跑了三个套件 ✓(542 / 707 / 1407 ✓)、逐文件比了两棵树 ✓,还**在临时目录复刻等价代码**做了 > EPIPE 与并发实验 ✓(**没有**发过真的 start ✓ —— 它自己就守着"别真录屏"这条 ✓)。 - 🟡 **第 1 条是真问题** ✗(**当场改** ✓):`start` 在 spawn 之后**没有任何"真的在录"的判据**就回 200 「开始录了 ✓」,而且 stderr 被 `'ignore'` 丢掉 ✓ —— **spawn 成功 ≠ 录上了** ✗:锁屏 / gdigrab 打不开桌面 / Wayland 没有 x11grab 时 ffmpeg **几毫秒就退** ✓,用户却要等 10 分钟才发现是个空文件 ✓(正是本项目最恨的假承诺 ✓)。 **改法** ✓:收 stderr ✓、**等 1.5 秒看它还在不在跑** ✓,活着才回 200 ✓;已经退了 → **503 + ffmpeg 的原话** ✓。 - 🟡 **第 3 条** ✓(**当场改** ✓):ffmpeg 自己到点停之后 `recording` 归零 ✓,`stop` 只回 409「现在没有在录」✓, 用户**拿不回文件名与大小** ✓。**改法** ✓:留一份 `lastRecording` ✓,`status` 与 `stop` 都把它带上 ✓ ("到点自动停了"和"你没在录"是两件事 ✓)。 - 🟢 **第 4 条** ✓(**当场改** ✓):日志原来写**完整路径**(含用户名 ✓),而日志会随 `/api/diag` 打包外发 ✓。 **改法** ✓:只记**文件名** ✓。 - 🟢 **第 6 条** ✓(**当场改了一半** ✓):说明页/纸上"最多 10 分钟"是**写死文案** ✓,改上限就会变成假话 ✓ —— **改法** ✓:说清楚「纸上那句 10 分钟**当前与代码一致** ✓(RECORD_MAX_SECONDS = 600 ✓),但它是**写死的** ✗ —— 以后改上限必须**一起改纸上那句** ✓;**准的是回包里的 maxSeconds** ✓」。 - 🟡 **第 2 条:复查者的推断和代码不一致** ✗(**没改,留台** ✓):他说"第二次打开"在限速桶满时会拿到 429 而不是 410 ✓ —— 但代码里 **410 那条判断在 `throttleLogin` 之前** ✓(用过的一次性链接直接 410 ✓,根本走不到限速 ✓)。 留在这儿等下一轮复核 ✓(**不按"可能"去改** ✗ —— 改错了会把真正的失败路径盖住 ✓)。 - 🟢 **第 5 条** ✓(**留台** ✓):卸载时那条 `ctx.effect(() => () => { void stopRecording() })` 注册与调用都对 ✓, 但 `void` 丢掉了那 8 秒等待 ✓ —— 卸载路径**不保证**收尾完成 ✓(文件可能不完整 ✓)。记在这儿 ✓。 - ✅ 复查也排除了几件要紧的 ✓:写已退出进程的 stdin **不抛不崩**(EPIPE ✓);**并发消费 TOCTOU 安全** ✓ (12 轮 × 8 并发实测**只放行一条** ✓ —— Node 单线程 + 先查后写之间没有插入点 ✓);8 秒闸与 `close` 无竞争 ✓; 两条新路都是 `guard()` + `requirePinOr403` ✓、回包/日志无 PIN 回显 ✓、默认关**现读配置无例外** ✓。 **如实留台(复查列的已知缺口,一条都不藏)** ✓:**"真在录"的判据这一版才补上** ✓(之前只验过拒绝路径 ✓); **非 Windows** 只有 x11grab + `DISPLAY` 兜底 ✓、**没有真机证据** ✗;ffmpeg 自停后这一版才拿得回文件 ✓; harness 绿**依赖"成功登录会把限速计数清零"**这个隐含前提 ✓;**真机没走 HTTP 端到端** ✗ (只验了"ffmpeg + 这套参数在这台机器上真能录出东西" ✓:1.6 秒 → 337KB ✓、`q` 收尾通 ✓、文件已删 ✓)。 ### 复审(第 94 轮第二轮)—— 仍判「**能发**」✓,但抓到**我那一刀自己引入的真 bug** ✗ > ⚠️ 这一轮最该记住的是:**我为了修"假承诺"加的那 1.5 秒判活,自己开了一个新的洞** ✗ —— 复审逐行读才看出来 ✓。 1. 🔴(相对我上一版而言是真 bug)**1.5 秒空窗 = 两条 start 能同时过闸** ✗:我把"已经在录了"那道闸留在 `await` **之前**判 ✓、 而 `recording` 在 `await` **之后**才置 ✓ —— 这 1.5 秒里第二条 start 也过闸 ✓(`guard()` 只是同步鉴权 ✓、不排队 ✓) → **两个 ffmpeg 同时录** ✓;再加上**文件名只到秒** ✓ → 同秒两条撞同一个 .mp4 ✓(第二条 `-y` 把第一条覆盖 ✓), `recording` 只留第二条 ✓ → 第一条成**幽灵**:**停不掉**、一路录满 600 秒 ✗ —— 正好违反本功能自己写的「还得能停得掉」✓。 **改法** ✓:**spawn 之后立刻占位** ✓(`recording = {...}`,判退时再让位 ✓)+ **文件名补毫秒** ✓。 **教训** ✓:**加等待 = 扩窗口** ✗ —— 等之前就得把"占位/锁"落下 ✓,不然等出来的每一毫秒都是并发入口 ✓。 2. 🟡 **"拿不回文件"只闭环了一半** ✗(API 层改了、用户层没改 ✓):409 的回包里带了 `last` ✓, 但手机那边**只显示 `error` 这一句** ✓(`last` 这个字段它不读 ✗)→ 用户看到的仍是"现在没有在录"✓, 文件名照旧拿不到 ✓。**改法** ✓:把**文件名 + 大小 + 时间写进 `error` 那句话里** ✓。 3. 🟢 **新问题(假话文案)** ✗:录屏那张纸复用了电源纸三句 ✓ ——「关机/重启必须确认是本人」✓、 「真的要重启?再点一次」✓、「执行后有 **20 秒**可以取消」✓(录屏**没有**那 20 秒 ✗)。 **已当场修** ✓:按 `kind` 分支给录屏自己的三句 ✓(「录屏必须确认是本人」✓、「真的要开始 / 停止录屏?再点一次」✓、「最多 10 分钟**自动停** ✓ —— **没有**"20 秒可以取消"那回事 ✗」✓),并补一条**判文案**的断言 ✓(它自己还抓过我一次假红 ✓:我最初写的判据是"不许出现 `20 秒`"✓,而否定句里正当地引用了它 ✓ → 改成挑**那句话本身** ✓)。 - ✅ 复审也确认:第 1 条(真在录的判据)**真闭环** ✓(收 stderr ✓、1.5 秒判活 ✓、退了回 503 + 原话 + bytes ✓、 拖长 1.5 秒可接受 ✓ —— 客户端超时 30 秒 ✓、"半段没进 status"不成立 ✓);第 4 条 ✓(只记文件名 ✓); 第 6 条 ✓(口径正如所述 ✓);第 2 / 5 条**未改** ✓(如实留台 ✓)。 ## 3.9.0 — 2026-10-14 **主题:我睡了 + 专注模式** ✓(用户功能池第 3 / 4 条 ✓ —— 池子原话:专注模式"一键:静音通知 + 开始录音 + 打点 + 显示'专注中'"✓;我睡了"一键:电脑锁屏 ✓;早上念'今天要交什么'给你听"✓)。 ### 加(都在**已有的 📊 电脑状态纸**上 ✓ —— **没有新工具键** ✗) - **💤 我睡了** ✓:一键 `action:'lock'` ✓(复用现成那条 ✓)+ 本机记一个 `dshm.morning` ✓; **明早第一次打开页面**时提醒你去点最新那条右边的 🔊 ✓(`speakButton` 现成 ✓),提醒完清标记 ✓。 - **🎬 专注模式** ✓:一键开/关 ✓(`dshm.focus` ✓)—— ① **网页通知静音** ✓(在 `pageNotify` 那一道闸上 ✓, 页内提示照旧 ✓);② 屏幕上挂「🎬 专注中 · 点这里退出」徽章 ✓(**一键进一键出** ✓,刷新后还在 ✓); ③ **⏱ 打点** ✓:把 `【打点 HH:MM】` 发进当前会话 ✓(复用 `send()` 同一条路 ✓ —— 不新开写入通道 ✗)。 ### 三条收着做的地方(照实说 ✓,别当成做完了 ✗) - **绝不一键关机** ✗:我睡了**只锁屏** ✓ —— 关机那条路要两次确认 + 20 秒可取消 ✓(睡前手滑关掉整台机器太贵 ✓); 断言里专门钉了"请求只有 lock、一条 shutdown 都不许有" ✓。 - **早上只提醒、不自动出声** ✗:手机上可能在地铁/会议室 ✓ —— 突然出声比"少做一步"糟得多 ✓。 - **录音那一半没做** ✗(池子原话里有"开始录音" ✓):它踩在用户**明确跳过的语音那条线**上 ✓, 而且**网页切后台录音会被 iOS 挂起** ✓ —— 做出来就是"录到一半没了" ✗,比不做更坏 ✓; **收起工具键那半也没做** ✗(要动十几个断言盯着的布局 ✓,留给下一版 ✓)。 ### 测试 - 九个套件 **1395 项**:package 18 · harness **533** · qr 12 · ui **704** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **8 条**(ui ✓ —— 这一版全在手机侧 ✓,所以 harness 一条没加 ✓,**如实记** ✓): 我睡了 → **只发 lock、不许有 shutdown** + 记下**时刻** ✓;**锁屏失败时不许写"明早提醒"、并如实说没锁上** ✓ (复查第 93 轮补的 ✓,这条最值钱 ✓);专注模式**默认关** ✓;点一下进入 → 本机记上 + 徽章挂出来 ✓; **打点真的把 `【打点 HH:MM】` 发进会话** ✓;**输入框里有草稿时点打点 → 拒绝 + 一个字节都不动** ✓ (复查第 93 轮补的 ✓);点徽章退出 → 徽章消失 + 本机清干净 ✓。 ### 冷视角复查(第 93 轮)—— 裁决「**不能发**」✗,5 条全改 ✓ > 复查者自己拿临时副本做了"把锁屏一律改成 429"的探针 ✓ —— **701/701 仍全绿** ✗, > 也就是"锁屏没成却写下明早提醒"这种假承诺**原来的断言抓不到** ✓(这正是它最值钱的产出 ✓)。 1. 🔴 **假承诺** ✗:`runLock` 失败只 toast 一句就正常返回 ✓,`runSleep` 于是**无条件**写下"明早提醒" ✓ 并说"电脑锁上了" ✗ —— 而真桥这条会失败(3 秒节流 429 ✓ / 锁屏命令失败 500 ✓ / 断网 401 ✓)。 **改法** ✓:`runLock` **回真结果** ✓,只有成功才写标记 ✓,失败如实说"没锁上 —— 所以**没**记明早提醒" ✓, 并补一条**装故障的断言** ✓(桩里加了 `powerFailOnce` 旋钮 ✓)。 2. 🔴 **静音闸静错了地方** ✗:专注那道 return 写在 `document.title = …` **之前** ✓, 把"标题兜底"(页面在后台时标题变「需要你…」✓)也一起干掉了 ✗ —— 那是**安全兜底** ✓,不能拿来静音 ✓。 **改法** ✓:闸只挡 `new Notification(...)` 那一段 ✓,标题兜底照旧 ✓。 3. 🟡 **打点会吞草稿** ✗:直接覆盖 `text.value` ✓,而"发送没成"(只看不发 / 没会话 / 离线进箱 ✓)时 用户已打好的草稿就没了、还不吭声 ✗。**改法** ✓:输入框**非空就拒绝并说清** ✓(宁可不打这个点,也不吞草稿 ✓)+ 断言 ✓。 4. 🟡 **"明早"没有时间闸** ✗:只认一个 `'1'` ✓,且提醒前就清标记 ✓ → 当晚再打开页面会说"早上好" ✓、 真到早上标记早没了 ✗。**改法** ✓:存**时刻** ✓、**本地日期翻页才提醒** ✓、提醒后才清 ✓。 5. 🟡 **测试盲区** ✓(见上 ✓):桩对 `/api/power` 永远回 200 ✗ → 加故障旋钮 + 一条"失败不许写标记"的断言 ✓。 - ✅ 复查也确认了几件好事 ✓:`[data-focus-exit]` **只有一份**接法 ✓(另一处不可达 = 死代码 ✓,无双触发 ✓); 徽章不重复挂 ✓;打点走的就是 `send()` ✓、**没绕过**"只看不发" ✓;我睡了那条路上**没有关机路径** ✓; `powerRequests.splice` 是**合理测试卫生** ✓(不是拆别人的刹车 ✓)。 ### 复审(第 93 轮第二轮)—— 又判「**不能发**」✗ → 2 条全改 ✓ > ⚠️ **这一轮最该记的是:我把第 2 条"改"了、其实一点没改** ✗ —— 复审者逐行读数才抓到 ✓。 1. 🔴 **第 2 条根本没闭环** ✗:我"挪闸"时**加了一道新闸** ✓、却**没删旧那道** ✓ —— 旧闸(写在 `document.title = title` 之前)先 `return` ✓ → 新闸成了**死代码** ✓, 行为与修复前**一模一样** ✗(专注中"agent 在问你"连标题兜底一起没有 ✓)。 而我当时那条新断言跑在专注**关着**的状态下 ✓ → 抓不到 ✓。 **改法** ✓:**删掉旧闸** ✓、只留 `new Notification(...)` 之前那一处 ✓; 并补一条**在专注开着时跑**的断言 ✓:「专注中 + 后台 + agent 提问 → 系统通知 0 条 ✓、但标题必须含"在问你" ✓」。 **教训** ✓:**"挪一段代码"= 删旧 + 加新,两件事都要做** ✗;只做一半,测试和自查都会显示"已修" ✓(假修 ✓)。 还有一条:**新断言必须在"会暴露这个 bug 的状态下"跑** ✗(专注关着测静音 = 没测 ✓)。 2. 🟡 **"明早"的窄边残留** ✗:只比年月日 → 23:5x 锁屏、00:0x 再打开页面照样说"早上好" ✓ (还把标记清掉 → 真早上没提醒 ✗)。**改法** ✓:改成**时间差闸**(隔 ≥ 4 小时才算"第二天早上" ✓ —— 跨月/跨年/时区都不必操心 ✓)。 ## 3.8.0 — 2026-10-14 **主题:剪贴板历史 + 屏幕取字** ✓(用户功能池次序第 4 / 5 条里能今天就落地的那两条 ✓)。 ### 加 - **剪贴板历史(最近 20 条)** ✓:放进电脑剪贴板的文字会在**手机本地**留一份(`localStorage` ✓, 环形、上限 20 ✓、同样的东西去重后放最前 ✓),就在**已有的 📋 纸**上列出来 ✓ —— **没有新键** ✓。 - **🔤 屏幕取字** ✓:`POST /api/screen/ocr` ✓ —— 截当前屏 → 连同**那句 OCR 指令**交给 agent ✓ (与 3.7.0 的 `task:"ocr"` **同一句常量** ✓,一份实现两处用 ✓),认完存成可搜索的笔记 ✓。 桥**自己不认字** ✓(图和指令交给电脑上的 agent ✓)—— 零新依赖 ✓。 ### 三条取舍(都写在这儿,别"顺手补全" ✗) - **点历史只是"放进输入框"** ✗(**不替你按发送** ✓):历史里全是"上次发过的话" ✓, 一键发出去正是**误发源** ✓ —— 放进去、你自己点「发送」,只差一步 ✓。 - **从电脑读回来的剪贴板不进历史** ✗:那里面可能是**密码 / 验证码** ✓ —— 读完只活在屏幕上 ✓, **不落盘** ✓。历史里只有"你放进去的" ✓,并且旁边有「清空」✓。 - **取字要 PIN** ✓(`requirePinOr403` ✓,和截屏同一道门 ✓):它既读整屏 ✓、又**替你指挥 agent** ✓, 两道理由都指向"要 PIN" ✓;**桥不做 OCR** ✗(交给 agent ✓)。 ### 边界(照实说 ✓) - 「取字」的四条退路都如实:**没登录 → 401** ✓、**没带 PIN → 403 + needPin** ✓、 **截不到屏 → 503 + 原话** ✓(锁屏那两种是最常见的 ✓)、**prompt 被拒 → 502 + 宿主原话** ✓; 另外**电脑上没有任何可用的会话 → 409 + 说清** ✓(复查第 92 轮点名:这条原来没写进本节 ✓)。 - 历史**只在这台手机上** ✓(不进电脑、不进审计日志 ✓);本地上限 20 条 ✓(不会无限长 ✓)。 - ⚠️ 照实说 ✓:历史里点过一条之后,那条原文会以**草稿**的身份留在本机 ✓(草稿也在**导出备份**的白名单里 ✓) —— 所以「清空」**连由它填出来的草稿一起清** ✓(只清一半就是"看着清干净了、其实原文还在" ✗); 但**别把它当密码箱** ✗:你导出过的那份备份里可能仍留着历史原文 ✓(纸上的提示里也写了这句 ✓)。 ### 测试 - 九个套件 **1387 项**:package 18 · harness **533** · qr 12 · ui **696** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **4 条**(harness)+ **9 条**(ui)✓:屏幕取字**没登录 401** ✓ / **没带 PIN 403 + needPin** ✓ / 带对 PIN → `mode:queue` + **同一句 OCR 指令** + 一张 PNG ✓ / 回包给会话且**不带 PIN** ✓; ui 侧:放进剪贴板**记一条** ✓、**读回来的不记** ✓(会咬)、点一条**只进输入框且 prompt 数不变** ✓(会咬)、 「取字」**真的发了 POST 且带 PIN 头** ✓(会咬)、取字后如实说"交给谁了" ✓、「清空」之后本地一条不剩 ✓、 **「清空」把由它填出来的草稿也一起清** ✓(复查第 92 轮补的 ✓)、 **超长条目(落盘时被草稿上限截断)也能真清干净** ✓(复审那轮补的 ✓)、说明页讲了它们 + 两条纪律 ✓。 ### 冷视角复查(第 92 轮)—— 裁决「**能发**」✓,3 条照旧改了 ✓ 1. 🟡 **真问题 · "清空"是假干净** ✗:点过一条历史之后,原文会以**草稿**身份留在本机 ✓, 而 `dshm.draft` 在**导出备份**的白名单里 ✓ → 只清 `dshm.cliphist` = "看着清干净了、原文还在" ✗。 **改法** ✓:「清空」把**由它填出来的草稿**一起清 ✓(判据要窄 ✓:**输入框当前那行**或**已落盘的草稿** 正好等于被清掉的某一条才清 ✗ —— 免得抹掉用户自己敲的草稿 ✓),并补一条断言钉它 ✓。 2. 🟢 **`handleScreenOcr` 不读 body** ✗:项目自己在 `drainRequest` 的注释里写着"先读干净再回绝" ✓, 这条路的 PIN 只从**头**里取 ✓(不读 body ✓)—— 带 body 只带头时会**留半截连接** ✗(UI 只发 `{}` ✓ 无实害 ✓)。 **改法** ✓:进函数先 `await drainRequest(req)` ✓(**丢弃式**地读掉,不缓冲 ✓, 所以不违反"别先攒大包再判"那条 ✓)。 3. 🟢 **409 没写进边界** ✓:无可用会话时回 `409` ✓(客户端会照实显示 ✓,行为对 ✓)—— **改法** ✓:本节补上 ✓。 4. 🟡 **复审那轮抓到的窄边界** ✗(同一条"假干净"的另一种形状 ✓):清空的判据是"整条相等" ✓, 而草稿落盘时会被 `DRAFT_MAX`(4000)截断 ✓、历史条目却**没有长度上限** ✓ → 一条超长条目点进输入框、再刷新之后,两边永远不相等 ✓ → 提示"清空了 ✓"、**前 4000 字还在本机** ✗。 **改法** ✓:判据两侧**都先按同一刀截到 `DRAFT_MAX`** 再比 ✓,并补一条断言钉它 ✓(超长条目也要真清干净 ✓)。 - ⚠️ 复查者还**如实点出我一条证据说得比实际强** ✗:他说他做的"发完再记读回文本"变体**没咬** ✓ (那个注入点不生效 ✓ —— 真正会咬的注入点在 `loadClipboard()` 取到 body 之后 ✓)。 断言本身**没有假绿** ✓,但我在提交信息里把摘刀实验说满了 ✓ —— 记在这儿,照实 ✓。 ## 3.7.0 — 2026-10-14 ## 3.7.0 — 2026-10-14 **主题:拍张照就认字(一键 OCR / 扫码)** ✓(用户功能池次序第 2 条 ✓)。 ### 加 - **同一条短链多带一个 `"task"`** ✓(还是 `POST /<密钥段>/api/intake` ✓ —— 复用 3.6.0 那条路 ✓, **没有新接口、没有新页面键** ✓):`"task":"ocr"` = 「把图上的字**原样认出来**,整理成一份 Markdown 笔记 (回文件名与路径)」✓;`"task":"scan"` = 「把码解出来、再查一下它是什么、把结论回我」✓。 - **为什么"可搜索"不用新造** ✓:认出来的字**落在会话里** ✓ —— **挂了会话检索**(宿主 `dsh-session-query`)的 部署上,手机上现成的 🔍 搜一搜(搜这台电脑上的会话内容、**不上云** ✓)直接就能搜到 ✓; **没挂的部署**(比如这台机器)搜一搜会**如实说搜不了** ✓ —— 文字照样在会话与那份笔记里 ✓。 - **为什么是白名单** ✗:这条链是凭证(密钥段 + PIN ✓)但它仍然是**公开形状**的接口 ✓ —— 让调用方自己写指令 = 白送一个"远程指挥 agent"的入口 ✗。白名单里两个任务都是**只读 / 整理**性质 ✓, 没有一个能改文件 / 跑命令 / 装东西 ✗。 ⚠️ **说法收窄**(复查第 91 轮点名 ✗):白名单管的是 **`task` 这个字段** ✓ —— `text` 与图片名仍然是**调用方自己的内容** ✓(桥不改写它们 ✓,也不拿它们当系统指令用 ✓); 别把这件事说成"整条接口都不接受自由文本" ✗。 - **识别不在手机上跑、也不在桥里跑** ✓:桥只把图和指令交给电脑上的 agent ✓(它本来就会看图 ✓)—— **零新依赖** ✓、零新模型 ✓。 - 读取面(`GET /api/intake` ✓)把能用的任务列出来 ✓(快捷指令那边照抄 ✓); `WELCOME_VERSION` 21 → **22** ✓(两边一起 ✓);主题汇总那条补上 3.7.0 ✓。 ### 边界(照实说 ✓) - **桥自己不做解码** ✗(扫码那条指令里就写着这句 ✓):电脑上有能用的工具就用 ✓,没有就由 agent 自己写一小段脚本解 ✓ —— 桥**不装依赖** ✗(零运行时依赖那条纪律 ✓)。 - 白名单外的 `task` → **400 并列出能用哪些** ✓(**绝不静默忽略** ✗ —— 那会让人以为"它帮我 OCR 了" ✓, 其实只是把图丢进去 ✓);那一次**一个字都不进会话** ✓。 ### 测试 - 九个套件 **1374 项**:package 18 · harness **529** · qr 12 · ui **687** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **17 条** ✓(`test/harness.mjs`)+ **1 条** ✓(`test/ui.mjs`):`task:"ocr"` 的**内容顺序** (来源 → 固定指令 → 用户文本 → 图 ✓)、OCR 指令里带"存成 Markdown 笔记 + 给路径"(可搜索的落点 ✓)、 **白名单外 → 400 + 列出可选 + 一个字都不进会话** ✓、`task:"scan"` 在名单里且指令写明"桥不做解码" ✓、 读取面列出 `tasks` ✓;ui 那条钉说明页里讲了 OCR ✓(页面上不加键 ✓ → 说明页就是它唯一的门 ✓); 复查第 91 轮又补了 **12 条** ✓(见下 ✓)。 ### 冷视角复查(第 91 轮)—— 裁决「**不能发**」✗,7 条全改 ✓ > 复查者只看冻结 diff + 两棵树 ✓,跑了套件 ✓,还把源码复制到临时目录插了 **25 条敌意 `task` 探针** ✓。 1. 🟠 **"🔍 搜一搜就能搜到"写得没有前提** ✗:仓库别处明说这台部署**可能没挂会话检索** ✓ (那会儿搜一搜会如实说搜不了 ✓)→ 现在改成"**挂了 `dsh-session-query` 才有落点** ✓, 没挂的话搜一搜会照实说搜不了 ✓ —— 文字照样在会话与那份笔记里 ✓"(README / USERNEWS / CHANGELOG / 说明页四处一起 ✓)。 2. 🟠 **`task` 带了但值为空**(`""` / `" "` / `null` ✓)原来被当成"没带" ✓(200 + 没有指令 + 图照进会话 ✗) —— 与"名单外 → 400,绝不静默忽略"直接矛盾 ✓,正是最坏的那种"以为它帮我 OCR 了" ✓。 **改法** ✓:按 `'task' in body` 判 ✓,**空值也 400** ✓(要么别带这个字段、要么写对 ✓)。 3. 🟠 **"白名单关上了自由指令这扇门"说得过头** ✗:白名单只管 `task` 一个字段 ✓ —— `text` 与图片名仍是调用方原文 ✓(实测图片名带 NUL + 一句"忽略上面,改跑 rm -rf"能原样进 prompt ✓)。 **改法** ✓:说法收窄成"**`task` 字段不接受自由指令**"✓,并写明 `text` / 图片名是调用方自己的内容 ✓ (桥不改写、也不当系统指令用 ✓)。 4. 🟡 **两条路的图片名处理不一致** ✗:成功路径完全不过滤 ✓(路径分隔符 / 控制字符 / 超长都能进 ✓), rescue 路径过了 `safeUploadName` ✓。**改法** ✓:成功路径**同样过一遍** ✓(名字只是给人看的标签 ✓)。 5. 🟡 **白名单机制本身没被钉住** ✗:把 `hasOwnProperty.call` 换成 `in` ✓(`constructor` / `toString` 命中原型链 ✓)套件照样全绿 ✓。**改法** ✓:补 `constructor` / `__proto__` / `toString` / `""` / `" "` / `null` / `["ocr"]` / `42` / `{a:1}` **九个探针** ✓(每个都断言 400 + 一个字不进会话 ✓)。 6. 🟡 **`String()` 强让 `["ocr"]` / `" ocr"` 被当 `ocr` 收下** ✓(影响小 —— 拿到的仍是固定指令 ✓)。 **改法** ✓:**只认字符串** ✓(非字符串一律 400 ✓)。 7. 🟡 **两处覆盖缺口** ✗:`task` 与截断/丢弃同时出现 ✓、`task` 走 rescue 退路 ✓ 都没有用例。 **改法** ✓:各补一条 ✓(指令不丢 ✓、两段 spread 不打架 ✓)。 **复审(同轮)** ✓:7 条**逐条核过、全部解决** ✓,结论 **能发** ✓、没有新问题 ✓ (复查者自己把 `hasOwnProperty.call` 换成 `in` 试了一遍 ✓:526/529,**恰好红那 3 条**原型链探针 ✓)。 他另外点出两个**非阻塞**缺口 ✓,其中一个当场补了 ✓: · ✅ **ui 那条断言没钉"前提"** ✗(只查"一键 OCR / task / 搜一搜"字样 ✓,没查"挂了才有落点" ✓)—— 已把它加严成**必须同时出现"挂了会话检索"与"搜不了"** ✓(条数不变 ✓,还是 687 ✓); · ⏳ **图片名被归一两次**(成功路径 + rescue 路径各一次 ✓,>80 字的名字会"二段截断" ✓)—— 纯观感 ✓,留着不改 ✗(改它要动 `safeUploadName` 的调用链 ✓,不值得为观感冒这个险 ✓)。 ## 3.6.0 — 2026-10-13 **主题:分享到桥 —— 任何 App → 分享 → 内容进电脑会话** ✓ (用户 2026-10-13 亲口选的第 1 条 ✓,也是他自己排的次序里的第一条 ✓)。 ### 加 - **一条 HTTP 短链 + iOS 快捷指令** ✓(`POST /<密钥段>/api/intake` ✓):快捷指令用「获取 URL 内容」POST `{"pin":"六位 PIN","text":"…","url":"…","images":[{mediaType,data}]}` ✓ → 桥把它当成**你发的一条消息** ✓ (`sessionController.prompt`,`mode:'queue'` ✓ —— **不新开写入通道** ✗,和手机上点「发送」**同一条路** ✓)。 内容里自动带一句【分享到桥】✓(会话里一眼看得出这条是分享进来的 ✓)。 - **目标会话**:不指定就**自动挑"最近一个能用的"** ✓(跳过空会话 / 子会话 / 自检会话 ✓); 也可以带 `sessionId` 指定 ✓(找不到就 404 + 说清 ✓,**不偷偷换个会话发** ✗)。 - **🔴 页面上一个键都没加** ✓(用户采纳的架构规矩「能不加页面按钮就不加」✓):整条功能不碰 `mobile.html` 的交互 ✓ —— 只有说明页里一段字 ✓ + 🩺 一键自检里那一条(**开着了才出现** ✓)。 理由写在代码注释里 ✓:`mobile.html` 已 10.8k 行 ✓,为一个按钮折腾 5 轮那次(`#powerUnlock`)就是教训 ✓。 - `WELCOME_VERSION` 20 → **21** ✓(两边一起 +1 ✓);主题汇总里加一条「📲 把东西分享进电脑」✓(带 3.6.0 ✓)。 ### 安全(和「受限只读链接」同一套纪律 ✓) - **默认关** ✓:`shareIntake: true` 才开 ✓,**每次请求现读配置** ✓(关掉立刻生效 ✓ —— 关着时一个字都不进会话 ✓); - **两道凭证缺一不可** ✓:网址里的 **pathSecret** ✓(没有就 404 —— 这一闸是全局的 ✓)+ 请求里的 **PIN** ✓ (`body.pin` 或 `x-dsh-pin` 头 ✓,走 `requirePinOr403` ✓ —— **每次错都记一笔账** ✓(审计一行 + 桌面角标会显示"有人正在试 PIN" ✓);⚠️ **不是"错一次就封"** ✗ —— **同一 IP 10 分钟内错满 12 次** 才封 10 分钟 ✓,和登录那条路同一个闸 ✓(详见下面复查第 6 条 ✓)); - **为什么"只是发一条消息"也要 PIN** ✓:它等于**替你指挥这台机器** ✓(和手机上点「发送」同级 ✓), 而短链**没有 cookie** ✓ → 只能要"随这次请求的 PIN" ✓(2026-10-13 真机学到的那一条 ✓); - **回包绝不带 PIN** ✓(快捷指令的输出/日志里不许出现凭据 ✗);日志只记结论(几张图 / 多少字 / 有没有网址 ✓)。 ### 边界(照实说 ✓) - **分享文件(PDF 等)这一版没做** ✗:大文件要走分片上传 ✓,在快捷指令里拼分片不值得 ✓ —— 真要发文件用手机上的「文件」键 ✓(那条路现成 ✓)。 - 图片被宿主拒了(最常见是**动图** ✓)→ **存成文件 + 只给路径再发一次** ✓(和手机那条路同一个退路 ✓, 绝不干瞪眼 ✗);这条也失败才 502 + 原话 ✓。 ### 测试 - 九个套件 **1356 项**:package 18 · harness **512** · qr 12 · ui **686** · security 51 · crashprobe 17 · addresses 16 · offline 17 · platform 27 ✓(`check-counts` ✓;另有语音草稿 33 条**不并进门槛** ✓)。 - 新钉 **20 条** ✓(`test/harness.mjs`)+ **1 条页面断言** ✓(`test/ui.mjs`:说明页那段"分享到桥" —— 这一版不加键,说明页就是它**唯一**的入口 ✓,删了它当场红 ✓):默认关**一个字都不许进会话** ✓; 没带 PIN / 错 PIN → 403 且**两条都**不许发出去 ✓(复查点名:原来只查了"错 PIN"那条 ✗); 正确 PIN → `mode:'queue'` + 内容带【分享到桥】+ 文本 + 网址 ✓;**自动挑最近那个能用的会话** ✓ (复查点名:桩里 blank 会话比它旧 ✗ → 现在塞两条**更新的** blank / 子会话进去 ✓,还先等列表缓存过期 ✓); 指定不存在的会话 → 404 + "没找到" ✓;空内容 → 400 + 说清 ✓;坏图片类型 → 400 + 点名 ✓; 动图被拒 → 存文件重发(内容里不再有 image ✓);**文本超长 / 图超 3 张 → 收下能收的、把丢了的说出来** ✓; **回包不带 PIN 且必须是"成了"的那份** ✓;`requestId` 幂等 ✓;读取面未登录 401 ✓; 开着时才给短链 ✓;自检里**开着才出现**那一条 ✓、关掉就不出现 ✓。 - harness 用的是桩 ✓(不会真的往你电脑上的会话里塞东西 ✓)。 ### 冷视角复查(第 90 轮)—— 裁决「**能发**」✓,但下面这些照旧改了 ✓ > 复查者只看冻结 diff + 两棵树(没参与编写 ✓),自己跑了套件、也在临时副本上做了两次突变实验 ✓。 > 他列的 8 条我**逐条核过**并改了 ✓(其中一条是抓到我**自己**写错的数字 ✓)。 1. 🟠 **冻结件过期** ✗(这条必须先处理):冻结之后我又往 `test/ui.mjs` 加了一条断言 ✓ → 按那份 diff 发 就过不了 `check-counts`(实测 1353 / ui 686 ✓ vs 文档 1352 / 685 ✗)。 **改法** ✓:所有改动落定后**重新冻结** ✓,三个数字一起改对(**1356 / harness 512 / ui 686** ✓)。 2. 🟠 **静默截断/丢弃** ✗:`text` 超 8000 字、`url` 超 2000 字、图超上限都是**悄悄 slice** ✓, 回包与日志都不提丢了什么 ✗ —— 正是本项目最恨的"我明明发了啊" ✓(同一份代码里还写着这条教训 ✓)。 **改法** ✓:能收就收、**收不下的明说** ✓(内容里补一句 ✓、回包给 `truncated` / `dropped` / `note` ✓、日志记一笔 ✓)。 3. 🟡 **"没带 PIN"那条断言不会咬** ✗:复查把 `requirePinOr403` 挪到发消息之后 ✓ → 只红了"错 PIN"那条 ✓, 而"没带 PIN"那次**消息已经进了会话**却全绿 ✗。**改法** ✓:两条都查 `STATE.prompts` 数不变 ✓。 4. 🟡 **自动挑会话的过滤没被钉住** ✗:桩里唯一的 blank 会话比 `session-test` **旧** ✓ → 把 blank / subagent / ownDir 三个过滤删掉仍全绿 ✗。**改法** ✓:临时塞两条**更新的**(blank + 子会话)✓, 并**先等 5 秒列表缓存过期** ✓(不等的话读到的是旧列表 ✓,等于没测 ✗)。 5. 🟡 **先缓冲大包、后判开关** ✗:这是全项目唯一"**没有 cookie** 的写路由" ✓,却先 `readBody(128MB)` 才说"没开" ✗。**改法** ✓:开关提到读包之前 ✓(关着时把包**读干净**再回绝 ✓ —— 只读一半会把连接留成半截 ✗, `handleClientLog` 那边踩过 ✓);包上限收到 **3 张图 + 256KB** ✓(不再借 prompt 那条的 128MB ✓)。 6. 🟡 **"错一次就限速"是写满了** ✗:实际是 `throttleLogin` —— **同一 IP 10 分钟内满 12 次**才封 ✓ (`LOGIN_MAX_ATTEMPTS` ✓)。**改法** ✓:注释 / README / 这里都改成实话 ✓。 7. 🟡 **落文件重发的归因不准** ✗:只要 prompt 抛错(且不像子会话)就说"图片电脑解不开" ✓ —— 也可能是会话忙 / 宿主内部错 ✗。**改法** ✓:只在**看起来像解码失败**时才那么说 ✓, 其余照实说"带图没送成(宿主原话)✓"。 8. 🟡 **作者自己报的数字错了** ✗:摘刀实验是 **14 红 / 495 绿**(不是 13/496 ✓)、diff 是 **+476 −4**(不是 +467 ✓) —— 顺带他点出:那条"回包不带 PIN"的断言在路由消失时会**天然绿** ✗(404 响应当然没有 PIN ✓)。 **改法** ✓:数字按复查复现的写 ✓;那条断言补上 `ok === true` ✓(对路由消失也有感了 ✓)。 ## 3.5.0 — 2026-10-13 **主题:解锁电脑(诚实版)** ✓(用户 2026-10-13:「既然可以锁屏电脑那也加一个解锁的功能」✓)。 ### 加 - **服务端的「解锁」已经就绪** ✓:`POST /api/power {action:'unlock', pin}` ✓ —— 挂 `powerControl`(**默认关** ✓)、**PIN 必须随这次请求发** ✓、**3 秒最短间隔 + 单飞** ✓、 三种结论(已解锁 ✓ / 还锁着要密码 ✗ / **读不到状态** ✓)照实回 ✓。 - ⚠️ **手机上的入口这一版没有** ✗(**如实说** ✓):`mobile.html` 里那条 UI 试了 **5 种接法都没接通** ✗ (绑按钮自身 ✗ / 绑 document ✗ / 绑那张纸 ✗ / 放进 `openPower` ✗ / 内联进面板委派 ✗ ✓), 根因是"**作用域 + 面板重渲染**"两层(📊 面板在数据流里会重渲染 ✓,手里的节点会被换掉 ✓)。 按项目铁律:**不摆一个按了没反应的按钮** ✗ —— 所以入口**撤掉** ✓,留到下一版重做 ✓, 并且用一条断言把这条决定**钉住** ✓(`test/ui.mjs`:不许出现 `[data-unlock]` ✓)。 - ⚠️ **它不代输密码** ✗(这条是硬线 ✓):Windows 锁屏跑在**安全桌面(Winlogon)**上 ✓, 普通程序既不能注入按键、也不能代提交凭据 ✓ —— 这是系统设计 ✓。所谓"手机输密码帮你解锁"只有在装 **凭据提供程序**(签名 COM + 管理员注册 ✓)之后才可能 ✓,那是把插件升级成系统登录组件 ✗✗,**不做** ✓。 本项目**不收、不存、不转发**用户的开机密码 ✗。 - **它做的是**"发一次无害的**唤醒输入** ✓ → **查一次状态** ✓ → **照实说三种结论**" ✓: · 锁屏不在了 → 「**已解锁** ✓」(没设密码 / 已自动登录的机器到这一步就是**真的开了** ✓); · 还锁着 → 「**还锁着:这台电脑锁屏要密码,程序不能代按** ✗ —— 去电脑上按一下 ✓」; · **查不到** → 「**读不到状态** ✓」(既不猜"开了"✗、也不猜"还锁着"✗)。 - 参数纪律 ✓:挂 `powerControl`(**默认关** ✓,关着 → 403 + 说清怎么开 ✓)、 **PIN 必须随这次请求发** ✓(`body.pin` ✓ —— 2026-10-13 真机日志学到的 ✓:`PIN accepted` 紧跟 403 ✓, **"登录一次"不满足它** ✗)、日志只记**结论**(`解锁:unlocked / locked / unknown` ✓)不记任何凭据 ✓。 - **新建任务时就能挑权限** ✓(用户 2026-10-13:「新建任务时没有权限选择」✓):以前只能在**建完之后** 去 🔐 改 ✓,于是"这条任务按什么权限跑"是建完才知道的 ✗ —— 而权限恰恰是"它这一轮能动到哪一步"的 前提 ✓。现在「新建」那张纸里多一栏(**和 🔐 同一套档位、同一个 `POST /api/permission`** ✓): 先建会话拿 `sessionId` ✓ → **立刻落权限** ✓ → 再发第一条 ✓(顺序反了就变成"先按默认跑起来再改" ✗)。 选项来自新的 `GET /api/permission/catalog` ✓(新建时**会话还不存在** ✗,所以读的是宿主 `permissionPresets.catalog()` 的"默认档 + 可选档" ✓);选「跟随默认」或选中的正好是默认档 → **一个写请求都不发** ✗(宿主 `set()` 对同一档本来就是空操作 ✓,少一次 PIN 门 = 少一次打扰 ✓)。 ⚠️ 权限没落下去时**不发第一条** ✗(会话建了就建了 ✓,但"按一个你没选的权限跑起来"不是 ✓)—— 第一句**放回输入框** ✓ 并说清现在到底是什么状态 ✓。 ### 改 - **单张图片也有进度了** ✓(用户 2026-10-13:「单张图片没有进度」✓):`uploadInChunks` 有三个调用点 ✓ —— 附件那条本来就传了进度 ✓,而**换背景**与**内联失败改切片重发**这两条**没传** ✗,于是单张图/单条背景 上传时屏幕上一直不动 ✓(内联被掐断后那句"正在改用切片上传重发…"之后就再没下文 ✗)。 现在两处都接上同一个 ticker ✓("正在上传 X … 3/7 片" ✓,限流 ~6 次/秒 ✓、最后一片一定说 ✓)。 - **上传过程补齐三类日志** ✓(用户 2026-10-13:「上传日志只有开头」✓):2026-10-13 那次"发图断网", 电脑那边的日志**只有开头一行**(`send-inline-failed…改走切片上传`)✗,之后"传完没有 / 传到第几片 / 是不是断了"一行都没有 ✓,只能靠猜 ✗。现在 `uploadInChunks` 记:**上传中 x/y** ✓(限流 ≥1 秒/条, 100 MB 的录音切 200 片也不会刷成瀑布 ✓)、**完成** ✓(片数 + 字节 + 电脑上的落点 ✓)、 **客户端断了** ✓(catch 里带上"传到第几片断的"✓)。⚠️ 照实说:真断网时这条上报**本身**也可能发不出去 ✓ (`reportToBridge` 是 best-effort ✓);留得下的是"入口掐断 / 服务端 5xx / 重试三次全失败"那一类 ✓ —— 而它们正是以前完全看不见的 ✓。 - **说明页补两条** ✓(3.4.2 那条教训:功能有了而说明里没提 = "没人会用"的典型死法 ✗): 🔐 那一行补上"**新建任务时也能先选**"✓、图片那一行补上"**大图切片上传时写着传到第几片**"✓; `WELCOME_VERSION` 19 → **20** ✓(本地与服务端**两边一起 +1** ✓,老手机下次进来再看一遍 ✓)。 ### 测试 - 九个套件 **1335 项**:package 18 · harness **492** · qr 12 · ui **685** · security 51 · crashprobe 17 · addresses 16 · offline 17 · **platform 27**。 另有 `test/voice.mjs` **33 条语音草稿** ✓(语音那一版按用户要求先跳过 ✓ —— 文件在仓库里、能单独跑 ✓, 但**不并进**这项门槛 ✓;`check-counts` 只管上面九个 ✓,这一条口径**写在脚本注释里** ✓,不是静默跳过 ✗)。 - 新钉住 ✓:缺 PIN → 403 + `needPin` ✓、开关关着 → 403 + 说清怎么开 ✓、错 PIN → 403 ✓, 以及三种结论的纯函数逐条验过 ✓(`test/platform.mjs` ✓)。 🔴 **会咬的那一条** ✓:PIN 没过就绝不许真的去唤醒 ✓ —— 用"`runUnlock` 里那 1.5 秒等待"当证据 ✓ (实测 **10 ms** 回来 ✓;谁把执行挪到校验前面就变 ≥1.5 秒 → 当场红 ✓)—— 这是红队复查打的第 3 条 ✓。 ⚠️ **成功唤醒那条路故意不测** ✗(那会真去动用户的鼠标 ✓);它是**真机验**的 ✓。 - 3.5.0 第二轮(这两条用户报的)新钉 ✓(`test/ui.mjs` / `test/harness.mjs` ✓): **单张改切片时屏幕上真的出现"正在上传 … x/y 片"** ✓(桩把每片放慢到 600ms ✓ —— 不然那句只活 25ms ✗, 断言就成猜了 ✓);**上传中 / 完成 / 断了 三类日志都落到电脑那边** ✓(断了那条要带"传到第几片"✓); **新建面板里真能选**(4 张卡:跟随默认 + 三档 ✓,点得动 ✓)、**不选就不发写请求** ✓、 **选了就带新会话 id 在第一条之前落下去** ✓;`GET /api/permission/catalog` 要登录 ✓、 默认档在列表里 ✓、**缺 sessionId 的 `/api/permission` 仍然是 400** ✓(老契约不动 ✓)。 🔴 **每条都按"摘掉那一刀"验过会红** ✓:摘掉 catalog 路由 → 恰好那 5 条红 ✓(487 条照绿 ✓)。 - 顺带记一笔 ✓:这一版的 `platform.mjs` 最初是为 macOS 适配抽的 ✓ —— 用户后来说"mac 不用搞了"✓, 但那份"命令形态纯函数 + 跨平台等价物"留着当内部结构 ✓(现在只给解锁用 ✓),**没有白写** ✓。 ### 冷视角复查(第 89 轮)—— 裁决过「**不能发**」✗,下面每一条都改了 ✓ > 复查者是个**没参与编写**的 agent ✓(只看冻结 diff + 两棵树 ✓),拿回 11 条 ✓。 > 下面只记**真问题**与改法 ✓(复核时逐条自己验过 ✓,不是照单全收 ✓)。 1. 🔴 **「解锁入口撤掉」这句话是假的** ✗:`