Files
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

722 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 独立验证报告(task-4 · 对抗性审查)
- 验证人:**verifier**(独立于 lead / web-ui-dev / device-dev,只读项目文件)
- 验证时间:本机 macOS,Python 3.12.14(`.venv`),pymobiledevice3 11.29.1,Chrome headless
- 验证方式:**全部结论都由本人独立复跑/独立实现客户端得到**,未采信任何既有测试结论
- 我方实例端口:**8877**(8787 为 lead 实例,全程未触碰);所有临时脚本在 `/tmp`,验证结束后已关闭全部自建进程(`lsof` 确认 8877 无监听)
- 真实设备:全程**只做代码级/只读枚举核对**,未设置定位、未改设备状态、未建隧道
---
## 0. 结论
**状态:有阻塞问题(不可直接交付)。**
功能主干是**真实可用的**:图形界面上手绘轨迹、加载路线、播放/暂停/继续/停止/进度、GPX 导入导出、真机 DVT 虚拟定位链路在本机跑通;82/82 自检与 19/19 图形界面验收我已独立复现通过。自绘地图引擎、坐标转换方向、静态文件穿越防护、WebSocket 分帧/并发广播等关键面均**未发现缺陷**。
但有 5 个必须修复才能交付的缺陷(D1–D5),其中 D2/D5 只需**一次 HTTP 请求**即可让地图永久空白或把手机定位钉住,D3 会在真实设备上让定位在两条轨迹间来回跳,D4 会让 iOS 17+ 慢隧道下「连接设备」永远报超时。
| 编号 | 严重度 | 一句话 | 位置 |
| --- | --- | --- | --- |
| D1 | 高 | `play.seek` 传 NaN → 之后所有推送都是**非法 JSON**(`NaN` 字面量),浏览器 `JSON.parse` 抛错,引擎进度被污染成 NaN | `core/playback.py:154-173` + `core/protocol.py:71-84` |
| D2 | 高 | `POST /api/config` 无类型校验 → 一次 `{"tile_sources":"x"}` 之后**所有瓦片请求永久 500**(地图空白),`{"send_hz":NaN}` 让 welcome 变非法 JSON | `core/config_store.py:120-142`、`core/server.py:699-703` |
| D3 | 高 | 播放线程僵尸化:`stop` 的 join 超时后旧线程仍活着,下一次 `start` 清掉 stop 标志 → **两个线程同时驱动设备** | `core/playback.py:144-152, 75-106` |
| D4 | 高 | 前端心跳只认 `pong`,不认任何其它入站消息 → 慢隧道(README 自己写「最长约 1 分钟」)下 40s 强制断线,`device.connect` 结果丢失、90s 报超时 | `web/js/ws.js:163-167, 246-262`、`web/js/app.js:845` |
| D5 | 高 | HTTP/WS 无 Origin/Host 校验、无 token → **任意网页**可改配置、单点定位、甚至关机 | `core/server.py:678-769` |
另有 6 个中危、7 个低危缺陷,见第 2 节。
---
## 1. 独立复现的官方测试(先证明"绿")
| 命令 | 结果 | 备注 |
| --- | --- | --- |
| `./.venv/bin/python tests/selftest.py` | **通过 82 项,失败 0 项,用时 9.7s** | 自检自身用 8899 端口,未与 8787 冲突 |
| `./.venv/bin/python main.py --mock --no-browser --port 8877` | `{"ok":true,...,"mock":true,"device_count":1,...}` | 我自己起的实例 |
| `./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8877/ --out /tmp/ui-verify` | **通过 19 项,失败 0 项**(真实 headless Chrome,截图 `/tmp/ui-verify`) | 页面无 JS 报错 |
| `./.venv/bin/python tools/selftest_location.py` | **通过 36 项,失败 0 项** | 定位层离线自检 |
| `./.venv/bin/python tools/screenshot.py http://127.0.0.1:8877/ /tmp/verify-shot.png --wait 8` | 退出码 0,`/tmp/verify-shot.png` 1920 KB | 但见 D18-g(成功时也打印"Chrome 超时被终止") |
图形界面人工核验(截图 2880×1800):顶部设备卡 + 深色专业风 + 自绘 canvas 高德底图(北京中文标注)+ 左侧轨迹设置 + 绘制工具条 + 底部播放条 + 右侧日志面板,**界面确实存在且可用**,满足"图形化界面"要求。
---
## 2. 缺陷清单(按严重程度,全部含复现命令与原始输出)
> 全部复现均在自建 8877 实例上完成;`curl` 示例可直接复制(把 8877 换成你自己的端口)。
### D1(高)`play.seek(progress=NaN)` → 非法 JSON 推流 + 引擎进度 NaN 化
- 位置:`core/playback.py:154-173`(`seek`)、`core/protocol.py:71-76`(`clamp` 不处理 NaN)、`core/protocol.py:83-84`(`json.dumps` 默认 `allow_nan=True`)
- 复现(自写 RFC6455 客户端,`/tmp/verify_adv_ws2.py` B10):
```
w.send('{"type":"play.seek","progress":NaN,"id":"nan1"}') # python json 接受 NaN 字面量
→ ack: {"type":"ack","id":"nan1","cmd":"play.seek","status":{"playing":true,"progress":nan,...}} ← 没有报错
→ 之后 3 秒内 33 帧里有 18 帧含 NaN 字面量:
{"type":"location","lng":NaN,"lat":NaN,"source":"play","progress":NaN,"index":0,"speed":13.9,"sent_at":1791982831.97}
```
- 期望:`progress` 非有限数应回 `400 {"ok":false,"error":"进度必须是 0~1 的数字"}`;且任何出站 JSON 都不允许出现 `NaN`/`Infinity`。
- 影响:`clamp(NaN,0,1)` 返回 NaN(`NaN<0`、`NaN>1` 都是 False),于是 `self.distance = total*NaN = NaN`,此后 `interpolate()` 产出 NaN 坐标、`status.progress=NaN`。浏览器端 `ws.js:131-141` 的 `JSON.parse` 对 `NaN` 字面量**直接抛 SyntaxError**,每帧丢弃并报「服务端消息不是合法 JSON」;`status` 帧同样失效,界面进度/坐标停摆。
另:`POST /api/config {"send_hz":NaN}` 走同一条链(见 D2),说明该问题在**序列化边界**上普遍存在。
- 最小修复:`protocol.clamp` 前加 `if not math.isfinite(value): raise AppError(...)`(或 `seek` 里显式 `math.isfinite` 校验);`protocol.dumps` 改 `json.dumps(..., allow_nan=False)` 作为兜底。
- 附带证据:真实设备路径上 `RealLocationProvider.set_location` 会因 `not (-90<=NaN<=90)` 为真而抛中文错误(`core/location_provider.py:520-521`),即真机不会下发 NaN,但会**静默把播放停掉**;mock 路径则把 NaN 原样记日志并推送。
### D2(高)`POST /api/config` 无类型/范围校验 → 瓦片接口永久 500、welcome 变非法 JSON
- 位置:`core/config_store.py:120-142`(`update()` 只校验 key 白名单,不校验 value 类型)、`core/server.py:699-703`
- 复现与原始输出:
```
$ curl -s -X POST --data '{"tile_sources":"x"}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":true,"config":{"tile_source":"amap",...,"tile_sources":"x",...}} ← 接受了
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
HTTP 500
$ curl -s http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
{"ok":false,"error":"服务端错误:string indices must be integers, not 'str'","code":"internal"} ← 地图此后永久空白
$ curl -s -X POST --data '{"send_hz":NaN}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":true,...} ← 接受
$ curl -s http://127.0.0.1:8877/api/config | head -c 120
{"ok":true,"config":{...,"send_hz":NaN,...}} ← 非法 JSON 已进入运行态
$ # 新客户端握手时收到的 welcome 里同样带 NaN:
welcome 帧片段: ..."tile_cache_max_mb":64,"send_hz":NaN,"default_speed_scale":1.0,...
含 NaN 字面量: True → 浏览器 JSON.parse 抛异常,welcome(含 config/设备快照)整帧丢失
```
- 期望:`/api/config` 局部更新必须做**类型 + 范围**校验(`tile_sources` 必须是对象、`send_hz` 必须是 1–20 的有限数、`tile_cache_max_mb` 必须是非负整数…),非法值回 `400 + 中文`,绝不允许写入运行态。
- 备注:`_merge()`(`config_store.py:92-101`)只在 value 是 dict 时做合并,字符串会**整体替换** `tile_sources`,所以 `tile_source()`(:147-152)随后 `"amap" not in "x"` → `"x"["amap"]` → TypeError。
- 交互影响:D5 说明这个接口可被任意网页调用,因此 D2 是可被外部触发的持久 DoS。
### D3(高)播放线程僵尸化 → 两个 worker 同时驱动设备
- 位置:`core/playback.py:144-152`(`stop_locked` 里 `self._thread = None` 后 `join(timeout=2.5)`,超时即放弃但不作废线程)、`core/playback.py:75-106`(`start()` 里 `self._stop_flag.clear()` 后新建线程)
- 复现:`/tmp/verify_playback_race.py`(用真实 `core/playback.py` + 模拟"真机下发耗时 3s"的 provider,runner 语义与 `core/server.py:30-64 AsyncRunner` 一致)
```
路线1 1403 点 2803 m(北京);路线2 1464 点 2926 m(上海)
[0.30s] 播放线程 A = playback, active=True
[0.30s] 第二次 start(模拟用户再次点播放 / 前端重发 play.start)…
[2.81s] 新播放线程 = playback
[2.81s] 当前存活的 playback 线程:['playback', 'playback'] ← 两个线程同时存在
下发时存活的 playback 线程数 -> 下发记录:
存活 1 个 playback 线程: 1 次 -> [(3.0, 39.908, 116.397)]
存活 2 个 playback 线程: 4 次 -> [(5.81, 31.23, 121.47), (6.06, 39.923156, 116.412156),
(8.87, 31.25, 121.49), (9.11, 39.928, 116.417)]
```
- 期望:`stop` 必须保证旧线程**真的结束**(或在旧线程存活期间拒绝新的 `start`)。
- 影响:真机上定位会在**北京 ↔ 上海**两条轨迹间来回跳(上表就是交替下发),且 `active` 属性、`sent_count`、进度全部错乱。触发条件现实存在:`stop` 时若有一次 `set_location` 在途且耗时 > 2.5s(RSD 抖动、设备卡顿、隧道半断),紧接着用户再点「播放」(前端命令超时后重试也会重发 `play.start`)。
- 最小修复:给 `_loop` 传一个"代次 token"(`start()` 时自增,线程每轮校验自己的代次),或在 `stop_locked` 里放大 join 超时并在 `start()` 检测 `thread.is_alive()` 时拒绝启动。
### D4(高)前端心跳只认 `pong` → 慢隧道下 `device.connect` 结果丢失
- 位置:`web/js/ws.js:163-167`(只有 `type === 'pong'` 才刷新 `lastPongAt`/清 `missedPings`)、`web/js/ws.js:246-262`(`missedPings >= 3` 就地 `sock.close()`)、`web/js/app.js:845`(`device.connect` 超时给到 90s)
- 复现:用 **node 直接跑真实的 `web/js/ws.js`**,连一个 `device.connect` 会阻塞 60s 的假服务端(模拟 iOS 17+ native 隧道建立;服务端每 2s 还持续推 `log` 帧,和真实的 `tunnel-reader` 线程行为一致):
```
$ node /tmp/ws_client_stall_test.js # 服务端 /tmp/stall_server.py,阻塞 60s 且持续推 log
[0.0s] open / welcome
[1.5s] -> 发送 device.connect(服务端将阻塞 60s)
[40.1s] error: 心跳超时,正在重建连接 ← 客户端单方面判定连接已死
[91.5s] device.connect 失败: code=timeout msg=命令超时(device.connect) ← 服务端 61.5s 已经回了 ack
```
- 期望:只要**任何**入站消息到达就说明链路活着(`log`/`status`/`location` 都应刷新存活时间);长命令执行期间不应被心跳掐断。
- 影响:README §4 自己写「iOS 17+ 会自动建隧道,**最长约 1 分钟**」。真机首次连接时服务端 WS 线程被 `tunnel.start()`(`core/tunnel.py:140-157`,最长 75s)+ DVT connect 阻塞,期间只有 tunnel reader 线程广播的 `log`;前端 40s 就强制断线重连,`device.connect` 的 ack 发给已断开的 socket,界面最终弹「命令超时」,而服务端其实已经连上(`provider_for` 已置 `simulating=True`)。用户需要**再点一次**「连接设备」才成功。
- 最小修复:`_dispatch` 里对任何消息都刷新 `lastPongAt`/`missedPings`(或至少统计"最近 N 秒内是否收到过任意帧");同时给 `device.connect` 这类长命令做"命令执行中暂停心跳判定"。
### D5(高)本机服务无 Origin/Host 校验、无鉴权 → 任意网页可控设备
- 位置:`core/server.py:614-769`(所有 REST,含 `POST /api/location`、`/api/config`、`/api/devices/connect`、`/api/shutdown`),无任何 Origin/Host/CSRF token 检查
- 复现(带恶意 Origin 的**简单请求** `text/plain`,浏览器跨站发这种请求**不触发预检**):
```
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
--data '{"send_hz":3}' http://127.0.0.1:8877/api/config -D - -o /dev/null | head -1
HTTP/1.1 200 OK
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
--data '{"lat":39.9,"lng":116.4}' http://127.0.0.1:8877/api/location
{"ok":true} ← 手机被单点钉住
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
--data '{}' http://127.0.0.1:8877/api/shutdown
{"ok":true} → 2 秒后 curl /api/health 返回 HTTP 000,进程退出
```
- 期望:拒绝带外部 `Origin` 的请求(仅允许同源/无 Origin),或给每个实例生成随机 token 并要求携带;`/api/shutdown` 至少要求 token。
- 影响:用户只要在浏览器里打开一个恶意/被挂马的页面,它就能在后台把用户手机的 GPS 钉到任意坐标(且是"虚拟定位"类工具,用户很难察觉),或直接关掉服务。风险等级与"本机 127.0.0.1 服务"并不相称。
- 附带:`Host` 头未校验,存在 DNS rebinding 面。
### D6(中)瓦片全部回源失败时返回 **HTTP 200**,与契约「404 + JSON 错误」不符,且前端失败告警路径变成死代码
- 位置:`core/server.py:779-784`(两个分支都写死 `200`)、`core/tilecache.py:167-176`(返回 `status=404` + 占位 PNG)、契约 `docs/PROTOCOL.md:26`
- 复现:
```
$ curl -s -o /dev/null -m 6 -w "直连 OSM: %{http_code}\n" https://tile.openstreetmap.org/1/0/0.png
直连 OSM: 000 ← 本机不可达
$ curl -s -o /tmp/osm_tile.bin -w "经服务端: HTTP %{http_code} type=%{content_type} size=%{size_download}\n" \
http://127.0.0.1:8877/api/tiles/osm/1/0/0.png
经服务端: HTTP 200 type=image/png size=69 ← 契约要求 404
$ xxd /tmp/osm_tile.bin | head -1
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR ← 1x1 透明占位图
```
- 期望:失败时按契约返回 `404 + JSON`(占位图不必返回)。
- 影响:前端 `web/js/map.js:430-470` 依靠 `img.onerror` 做重试与「瓦片加载失败」提示;现在失败瓦片也走 `onload` 并标记 `state='ok'`,**重试与告警永远不会触发**,用户只看到地图空白且没有任何提示(README §6 却说"换底图/走缓存")。
### D7(中)`play.stop` 之后再 `play.seek` 会重新下发模拟定位(设备被重新钉住,界面显示"未播放")
- 位置:`core/playback.py:154-173`(`seek()` 不检查 `playing`,`_route` 在 `stop_locked` 后仍保留)、`core/server.py:342-344`
- 复现(`/tmp/verify_adv_ws3.py` C1,用服务端日志计数):
```
停止后累计下发次数 N1 = 120 最后一条: [模拟模式] 下发定位 lat=39.908036, lon=116.397072(第 64 次,模拟)
停止后 seek(0.5) 之后 N2 = 121 最后一条: [模拟模式] 下发定位 lat=39.917949, lon=116.416899(第 65 次,模拟)
seek 是否重新下发定位: True
ack.status: {'playing': False, 'paused': False, 'progress': 0.5, 'distance_done': 2025.92}
```
- 期望:未播放(`playing=false`)时 `play.seek` 应回 `ack ok:false, error:"当前没有正在播放的轨迹"`(`seek` 里 `route is None` 才报错,而 route 一直保留)。
- 影响:真机上「停止 → 手机恢复真实定位」之后,任何客户端(包括误触、脚本)调 `play.seek` 都会把手机重新钉在轨迹中途,而 UI 仍然显示"未播放/未模拟"。前端把进度条置灰了(`app.js:1562`),所以 GUI 正常操作不会触发;但这是协议层的语义漏洞。
### D8(中)三处非中文/500 报错,违反契约「400 + 中文」
```
$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=abc"
{"ok":false,"error":"服务端错误:invalid literal for int() with base 10: 'abc'","code":"internal"} -> 500
$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=1e9"
{"ok":false,"error":"服务端错误:invalid literal for int() with base 10: '1e9'","code":"internal"} -> 500
$ curl -s -X POST --data '{"send_hz":"abc"}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":false,"error":"服务端错误:could not convert string to float: 'abc'","code":"internal"} -> 500
# WS 侧同源问题:
C2 log.list limit=abc -> ok=False err=内部错误:ValueError: invalid literal for int() with base 10: 'abc'
```
- 位置:`core/server.py:670`(`int(limit)` 无 try)、`core/config_store.py:131-135`(`float(send_hz)` 无 try)
- 期望:非法参数 → `400 + code=bad_request/bad_limit` 的中文提示,且响应里不出现 Python 异常类名。
- 说明:这是**契约符合性**问题,不是安全漏洞(未泄漏栈,仅异常字符串)。
### D9(中)`--mock` 模式设备卡显示「隧道 空闲」,与 PROTOCOL §4(mock 设备 `tunnel="connected"`)矛盾;根因是共享对象被就地改写
- 位置:`web/js/app.js:400`(`welcome` → `applySessionInfo`)与 `:410`(hello 的 ack → 又一次 `applySessionInfo`)**同一条消息被处理两次**;`:461-465` 就地改 `S.device.tunnel`,`:466` 之后 `applyDevices()` 又把**同一个被改过的对象**装回去
- 复现(CDP 读真实 DOM + 钩 WebSocket 消息):
```
WS 消息流水: [{"type":"log"},
{"type":"ack","cmd":"hello","id":"c1-kcdo","tunnel":"idle","devTunnel":"connected"},
{"type":"ack","cmd":"devices.refresh","id":"c2-kf5l","devTunnel":"connected"},
{"type":"ack","cmd":"log.list","id":"c3-4zak"},
{"type":"ack","cmd":"devices.refresh","id":"c4-2ql9","devTunnel":"connected"}, ← 第二次 hello 副作用
{"type":"ack","cmd":"log.list","id":"c5-lbk2"}]
卡片隧道行: 隧道空闲 ← 服务端 /api/devices 明明返回 "tunnel":"connected"
```
- 期望:mock 模式下设备卡显示「隧道 已连接」(PROTOCOL.md:153),并且同一条消息只处理一次。
- 附带影响:每次页面加载会多发一次 `devices.refresh` + `log.list`(上表 c1/c2 与 c4/c5 成对出现)。
- 最小修复:`applySessionInfo` 加"同一条消息只处理一次"的幂等保护(例如按 `msg.id` 去重),`S.device.tunnel` 改成从服务端设备对象回读后再渲染。
### D10(中)`_kill_stale_tunnels()` 会杀掉**任何**`pymobiledevice3 … start-tunnel` 进程(含其它实例 / 用户手工建的),注释与实现不符
- 位置:`core/tunnel.py:172-196`(注释写"只清理本工具启动的同类进程")、`core/tunnel.py:323-347`(按 `ps` 命令行匹配 `pymobiledevice3` + `remote start-tunnel`/`lockdown start-tunnel` 即 `SIGTERM`/`SIGKILL`)
- 复现(纯读代码即可判定;进程匹配逻辑不含 PID 文件、不含本工具标识):
```
$ ./.venv/bin/python - <<'PY'
import sys; sys.path.insert(0,'.')
from core import tunnel
print(tunnel._find_stale_tunnel_pids())
PY
# 若此刻有任何人(其它实例、用户手动)跑着 pymobiledevice3 remote start-tunnel,PID 就会出现在这里 → 被 kill
```
- 期望:要么只清理自己记录在案的子进程(PID 文件/父子关系),要么在界面上明确提示"将清理所有第三方隧道进程"并二次确认。
- 影响:双开本工具、或用 `tools/probe_location.py --rsd` 手工建过隧道时,新实例会把别人的隧道掐断(iOS 17+ 只有一条 RSD 连接,表现为"隧道建好了定位立刻失效",README §6 已把它列为已知现象,但没说是**本工具自己**干的)。
### D11(中)手写 WebSocket 的 3 处协议符合性问题
| 项 | 证据 | 期望(RFC 6455) |
| --- | --- | --- |
| 接受**未掩码**的客户端帧 | `B4 未掩码帧 -> {'type': 'ack', 'id': 'u1', 'cmd': 'ping', ...}` | §5.1 服务端必须 `1002` 关闭 |
| `Sec-WebSocket-Version: 8` 仍返回 101 | `B3 Sec-WebSocket-Version: 8 -> HTTP/1.1 101 Switching Protocols` | §4.4 回 `426 Upgrade Required` |
| 分片消息总量无上限 | 单帧限 8MB(实测 `B6 声明 9MB 的帧 -> {'__close__': True, 'code': 1009}` ✅),但 `B5c` 用 12 个 1MB 分片拼出 12MB 消息被完整接收处理 | 建议对累计分片长度也设上限 |
- 位置:`core/ws.py:130-162`(不校验 `masked`)、`core/ws.py:225-260`(`handshake` 不校验 Version)、`core/ws.py:185-200`(`_frag_data` 只受单帧限制)
- 影响:仅本机监听,属健壮性/合规问题;正常浏览器不受影响。
### D12(低)`/api/gpx/import` 不支持 multipart,契约却写了
```
$ # multipart/form-data 上传同一个 GPX
multipart GPX 导入 -> 400 {"ok":false,"error":"GPX 文件解析失败,请确认文件格式","code":"bad_gpx"}
text/plain -> 200 {"ok":true,...,"count":2}
application/gpx+xml -> 200 {"ok":true,...,"count":2}
```
- 位置:`core/server.py:727-741`(直接 `raw.decode()` 后 `ET.fromstring`)、契约 `docs/PROTOCOL.md:29`
- 建议:要么按契约解析 multipart(提取 part body),要么把契约改成"仅 text/plain 与 application/gpx+xml"。
### D13(低)`DeviceManager.refresh()` 同步阻塞,且被 WS 命令线程与 3 秒监控线程调用
- 位置:`core/device.py:132-177` + `:307`(`asyncio.run(_collect())`,每个设备一次 `create_using_usbmux(..., autopair=True, pair_timeout=12.0)`)、`core/server.py:242-244`(**每次 hello 都 refresh**)、`core/device.py:123-129`(监控线程每 3s refresh)
- 实测(只读枚举,未建 lockdown 连接、未配对、未碰定位):
```
第1次枚举: 139.3 ms, 设备数=1
第2次枚举: 16.3 ms, 设备数=1
第3次枚举: 17.5 ms, 设备数=1
serial=00008030-001551840A02402E connection_type=USB is_usb=True
```
- 判断:健康设备下可接受(几十~几百毫秒);但**未配对/被锁设备**上 `pair_timeout=12s` 会逐台阻塞,而 `hello` 的前端超时只有 8s(`app.js` 传入 `req(...)` 默认 8000ms),监控线程也会被拖慢。建议把设备发现移到后台线程/AsyncRunner,`hello` 只回缓存快照。
### D14(低)`sanitize_route_payload` 没有点数上限
```
B14 20 万点 play.start -> 3.07s ok=None
B15 path 100 万个点 -> ConnectionResetError(单帧 > 8MB,服务端按 1009 断开)
```
- 位置:`core/route_engine.py:511-544`(`parse_waypoints` 直接吃下整个数组,只在 `RouteEngine._densify` 里有 `MAX_PATH_POINTS=60000`)
- 建议:在 `sanitize_route_payload` 里对 `len(path)` 设上限(例如 20 万)并回中文错误,避免一次请求吃掉数百 MB。
### D15(低)`speeds` 里的 NaN 被静默接受
```
B15 speeds 含 NaN -> ok=None(成功)
```
- 位置:`core/route_engine.py:533-540`:`max(0.05, min(400.0, float('nan')))` → Python 的 `min(400.0, nan)` 返回 `400.0`,NaN 被静默当成 400 m/s。
- 建议:`math.isfinite` 校验,非法则走 `compute_speeds`。
### D16(低)瓦片缓存 `_inflight` 只增不减
- 位置:`core/tilecache.py:57, 120-126`(每个瓦片 key 一个 `threading.Lock`,永不清理)。长时间浏览大量瓦片会缓慢增长内存。建议用 `WeakValueDictionary` 或定期清理。
### D17(低)多实例共享同一份 `config.json`(后写覆盖)
- 证据:lead 的 8787 实例与我 8877 实例读写同一个 `/Users/jbjf/Desktop/location/config.json`;我在 8877 上改配置后文件被改写(20:58、21:08 两次 mtime 变化)。
- 影响:团队/多开场景下配置互相覆盖;`--port` 并未隔离数据目录。建议按端口派生配置文件或加文件锁。
### D18(低)文档与实现不一致(逐条)
| # | 文档 | 实现/事实 |
| --- | --- | --- |
| a | `README.md:144` "离线自检:**80** 项单元 + 端到端" | 实际 **82** 项(`tests/selftest.py` 输出) |
| b | `docs/DEVICE_NOTES.md:736,758-762` 结论为"iOS 26.6.1 那台**开发者模式未开、无镜像**,真机端到端现在**无法跑通**",并要求用户先做两步手工操作 | 交付结论与 task 描述是"真机链路已实测跑通(RSD native 隧道 + DVT + 轨迹播放)";且现在 `usbmux list` 只有 1 台 USB 设备(`00008030-001551840A02402E`),文档里的第二台 Network 设备已不在列。**DEVICE_NOTES §8 已过时**,与最终结论冲突 |
| c | `docs/PROTOCOL.md:83-86` 示例瓦片源含 `tencent`、amap `style=8` | 实现 `core/config_store.py:13-46` 是 `amap(style=7)/amap_sat/arcgis_sat/osm`,**没有 tencent** |
| d | `web/tile_sources.json` 注释"仅作为前端离线兜底" | 全仓库无任何代码读取它(`grep -rn tile_sources.json` 仅命中自身) |
| e | `docs/PROTOCOL.md:21` `/api/devices/connect` 示例 tunnel 含 `"ios_major":26` | 实际返回 `{"state":"idle","message":...,"rsd":null,"mode":null,"udid":null}`,无 `ios_major`;且 **mock 模式下这里返回 `idle`**,与 §4"mock 设备 tunnel=connected"自相矛盾(前端因此显示"隧道 空闲",见 D9) |
| f | `docs/PROTOCOL.md:20` `device.select` "回 `device` + `tunnel`" | WS ack 只有 `device` |
| g | `README.md:151` 截图命令 | 命令可用,但 `tools/screenshot.py` 在**成功**时也打印 `Chrome 超时被终止`(`--virtual-time-budget` + `--screenshot` 模式本就不会自己退出,被 `timeout=max(60, wait*6)` 杀掉后才拿到 PNG),易被误判为失败 |
| h | `README.md:132-154` 命令行工具清单 | 未提及 `tests/real_gui_acceptance.py`;`scripts/run.sh` 在 `--mock` 下仍会执行一段 usbmux 枚举(README 未说明) |
| i | `README.md`/task-3 提到 `resources/` | 目录已不存在(任务书把它列为交付范围,但无内容) |
---
## 3. 真机路径:与 pymobiledevice3 11.29.1 源码的逐条核对
全部在 `/Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages/pymobiledevice3/` 内核对(未改动设备):
| `core/*.py` 主张 | 源码出处 | 结论 |
| --- | --- | --- |
| `RemoteServiceDiscoveryService((host, port))` | `remote/remote_service_discovery.py:103-110`(`address: tuple[str,int]` 位置参数) | ✅ 一致 |
| `rsd.product_version` / `rsd.udid` | `:179-185`(property,需 connect 后)、`:318`(connect 里赋值) | ✅ 一致(provider 也在 connect 之后才读,`location_provider.py:403-408`) |
| `DvtProvider(rsd)` / `DvtProvider(lockdown)` 自动选服务名 | `services/dvt/instruments/dvt_provider.py:33-35`、`dtx_service_provider.py:80-97, 99-111` | ✅ 一致 |
| `await dvt.connect()` / `await dvt.close()` 长连接 | `dtx_service_provider.py:149-171` | ✅ 一致 |
| `LocationSimulation(dvt)` + `await sim.connect()` | `services/dvt/instruments/location_simulation.py:17-47`、`dtx_service.py:140-151` | ✅ 一致 |
| `set(latitude, longitude)` / `clear()` | `location_simulation.py:30-47`(selector `simulateLocationWithLatitude:longitude:` / `stopLocationSimulation`) | ✅ 一致,**不是** `simulate_location()` |
| `DtSimulateLocation`(iOS<17 兜底) | `services/simulate_location.py:8-40`;`SERVICE_NAME` 在 :17;每次 set/clear 新开服务(:13-14 注释) | ✅ 一致;provider 确实在 connect 阶段真开一次服务校验(`location_provider.py:486-494`),避免"假成功" |
| `lockdown.start_lockdown_developer_service(name)` | `lockdown_service_provider.py:162` | ✅ 存在 |
| tunnel 输出解析 `HOST PORT` | `cli/remote.py:297-323`:`--script-mode` 时 `print(f"{address} {port}")`;非 script-mode 才打印 `--rsd addr port` | ✅ `core/tunnel.py:284-306` 两种格式都覆盖,与实现一致 |
| `_kill_stale_tunnels` 只清自己的进程 | —— | ❌ 见 D10 |
| 设备发现 `MuxDevice.serial` / `.connection_type`("USB"/"Network") | `usbmux.py:129-133`;`list_devices()` 为 `async`(`:456-463`) | ✅ `core/device.py:243-265` 用法正确(且字符串大小写按源码用 `"USB"`/`"Network"`) |
**异常路径的中文化**:`core/location_provider.py:155-193` 的 `_wrap()` 覆盖了 `StartServiceError`(其 `str()` 为空,需读 `service_name/message`)、`InvalidService`、开发者模式、未配对、连接被拒、超时;`tools/selftest_location.py` 36 项里明确断言"错误必须是 `LocationProviderError` 且含中文"。我另做了静态抽查:`set_location` 越界(`location_provider.py:520-521`)、未连接(:522-523)、RSD 端口不可达(`_connect_dvt_rsd` → `_wrap`)都会给出中文信息,**未发现会抛 `AttributeError` 的路径**。
唯一"非中文"来自上层:`App.dispatch`(`core/server.py:206-209`)把任意异常包成 `"内部错误:{类名}: {str}"`,于是 D8 的三处报错会带英文原文(有中文前缀,不算裸栈)。
---
## 4. 交付完备性核对
| 交付项 | 命令 | 结果 |
| --- | --- | --- |
| `scripts/run.sh` | `bash scripts/run.sh --mock --no-browser --port 8888` | ✅ 6 秒后 `/api/health` → HTTP 200(依赖检查、Python 检查、设备提示均正常输出) |
| `Location.app` | 读 `Info.plist` + `Contents/MacOS/launch` | ✅ 启动器逻辑自洽:从 `.app` 位置回退到 `~/Desktop/location`,日志写 `~/Library/Logs/ios-location-app.log`;`CFBundleExecutable=launch` 且可执行位正确(`-rwx--x--x`)。未实际双击(会拉起真实设备会话) |
| `tools/probe_device.py` | `--help` | ✅ 参数与 README:136-137 一致(`--probe/--rsd/--udid/--json`) |
| `tools/probe_location.py` | `--help` | ✅ 与 README:140-142 一致(`--lat/--lon/--clear/--rsd/--duration`);`scripts/test-location.sh 39.9088 116.3975` 参数顺序正确 |
| `tools/screenshot.py` | `... http://127.0.0.1:8877/ /tmp/verify-shot.png --wait 8` | ✅ 退出码 0、产出 1920 KB PNG;⚠️ 见 D18-g |
| `tools/selftest_location.py` | 直接运行 | ✅ 36/36 |
| `tests/selftest.py`、`tests/ui_acceptance.py` | 见第 1 节 | ✅ 82/82、19/19 |
| `README.md` 其余命令 | 逐条比对参数 | ✅ 与脚本一致(`--mock/--port/--no-browser/--window`、`mounter list/auto-mount`、`remote start-tunnel --native --script-mode`) |
| 无外网依赖约束 | `grep` 全部 `web/` 资源引用 | ✅ 只有本地 `coord.js/ws.js/map.js/app.js/style.css` + `data:` SVG favicon,无 CDN/字体外链 |
| 纯 stdlib 约束(后端) | `grep '^import|^from' core/*.py main.py` | ✅ 除 `pymobiledevice3`(懒加载)外全为标准库 |
---
## 5. 验证过但**没有**问题的点(覆盖面说明)
- **坐标转换方向正确**(用 node 直接跑 `web/js/coord.js`):
`wgs84ToGcj02(116.3975, 39.9088) = [116.403744, 39.910204]`,与基准 116.4038/39.9101 相差 5.6e-5/1.0e-4 度;`gcj02ToWgs84` 回代误差 **0**(`[116.3975, 39.9088]`)。`outOfChina` 边界、`toDisplay/fromDisplay` 的 datum 分支均正确。
- **静态文件穿越防护**:7 种载荷(`/../../../../etc/passwd`、`%2e%2e%2f`、`..%2f`、`/web/../core/server.py`、`/....//....//etc/passwd` …)全部未泄漏文件,返回 200(index.html 回落)/404/400,`leaked=False`。
- **瓦片参数校验**:`z=30`、`x` 越界、非整数坐标 → `400 + 中文`;`POST` 体 >64MB → `413`。
- **HTTP 对抗输入**:非法 JSON/数组/缺字段/NaN/Infinity/负数倍速/非法 mode/1MB 级 waypoints/畸形 name/超大 trkpt(10 万点)/XXE 外部实体(`ET` 拒绝,未泄漏 `/etc/passwd`)/空 GPX → 全部 `400 + 中文`,**无 500、无栈泄漏**(除 D8 的三处)。
- **WS 帧层**:两段分片、分片中间夹控制帧、粘包(两个独立帧)、close 握手回显 1000、>8MB 帧回 `close 1009`、非法 JSON 回中文 `bad_json`、未知命令(有/无 id 分别回 `ack ok:false`/`error`)、`play.speed`/`location.set`/`log.list` 边界值校验,均符合契约。
- **并发广播不串包**:2 个客户端 + 30x 播放 4 秒,收帧 a=44/b=46,`严格 JSON 解析失败 = 0`,`status.seq` 单调递增。
- **播放引擎正常路径**:暂停后不再下发、继续恢复、倍速生效、seek 后位置过半、播放到终点、loop 回到起点、停止后推 `source:"cleared"`;单点轨迹(0 长度)不会崩(`loop=false` 时正常结束并下发 1 个点)。
- **前端 XSS 静态审查**:4 处 `innerHTML` 汇点(设备卡、日志行、途经点行、modal)中,用户可控数据(设备名、GPS/GPX 名称、坐标)要么经 `esc()` 转义(`app.js:749-753`),要么用 `textContent`/`.value`;`pushLog`/`toast` 用 `textContent`。**未发现可利用的注入点**。
- **瓦片 Image 释放**:`map.js:340-470` 有 LRU(`CACHE_LIMIT`)、淘汰时 `onload/onerror=null` + `img.src=''`、`_clearTiles()` 全量释放、连续失败会暂停加载并给出告警(告警路径因 D6 无法触发,但代码逻辑本身正确)。
- **设备发现字段用法**与 11.29.1 源码一致(见第 3 节表格)。
- **mock 契约字段**:`/api/devices` 返回 `MOCK-0001 / 模拟设备 (iPhone 17 Pro) / iOS 26.0 / state=connected / tunnel=connected`,与 PROTOCOL §4 一致(UI 显示问题见 D9)。
---
## 6. 未能验证的点与原因
1. **真机端到端(RSD 隧道 + DVT 下发 + 轨迹播放)**:任务要求"绝不设置定位或改变设备状态",故**未运行** `tests/real_device_e2e.py --confirm`、`tests/real_gui_acceptance.py --confirm`、`tools/probe_location.py`,也未启动任何隧道。真机部分只做了(a)源码 API 逐条核对、(b)只读 `usbmux.list_devices()` 计时。**"真机已跑通"这一结论我无法独立背书**,只能确认代码与 11.29.1 源码的接口完全吻合。同理,`legacy`(iOS<17)路径只做了代码审查,未实测。
2. **iOS<17 经典通道**:本机两台设备里那台 iOS 16.5(Network)已不在 `usbmux list` 中,无法核对 `dvt-lockdown`/`legacy` 的真实行为。
3. **动态 XSS 注入**:只做了静态审查,未构造恶意 GPX 名称驱动真实浏览器验证 DOM 结果。
4. **长时间稳定性**:瓦片缓存淘汰(1GB 上限)、`_inflight` 增长、WS 连接长期挂机、设备监控线程 3s 轮询的累积开销均未做小时级压测。
5. **非 Chrome 浏览器**:前端只在 Chrome(headless 与 app 模式)验证;Safari/Firefox 未测(`ws.js` 里有针对子协议的兼容分支,但未实测)。
6. **管理员隧道**:`sudo ./scripts/run.sh` / osascript 提权路径需要密码且会建隧道,未实测(只核对了 `_build_command` 生成的命令字符串)。
7. **`/api/shutdown` 之后再启动**:我验证了关机接口可被跨站调用,但未验证重启后的状态一致性。
---
## 7. 给 lead 的修复优先级建议
1. **D2 + D5 一起修**(配置校验 + Origin/token 校验)——最小改动、直接消除"一次请求打挂地图/钉住手机"。
2. **D1**(`isfinite` 校验 + `allow_nan=False`)——三行以内,消除非法 JSON 推流。
3. **D3**(播放线程代次 token)——真机定位跳变的主因,改动集中在 `core/playback.py`。
4. **D4**(任何入站消息都算存活)——一行改动,解决慢隧道下"连接设备"假超时。
5. **D6**(瓦片失败按契约回 404)——注意会同时激活前端的失败重试/告警路径,改完建议重跑 `tests/ui_acceptance.py`。
6. **D7/D8/D9** 作为第二梯队;**D18** 的文档同步建议在最终提交前一并处理(尤其 DEVICE_NOTES §8 与最终结论的矛盾)。
> 复现脚本:`/tmp/verify_adv_http.py`、`/tmp/verify_adv_ws.py`、`/tmp/verify_adv_ws2.py`、`/tmp/verify_adv_ws3.py`、`/tmp/wsclient.py`、`/tmp/verify_playback_race.py`、`/tmp/stall_server.py`、`/tmp/ws_client_stall_test.js`、`/tmp/verify_coord.js`(均在 `/tmp`,不影响交付目录)。
> 本报告只写 `docs/VERIFY_REPORT.md` 一个文件;`config.json` 因对抗性测试被 API 改动过,已用同一接口恢复为默认值(`send_hz=5.0`、`tile_source=amap`、`tile_cache_max_mb=1024`)。
---
# 复验(第二轮)
- 触发:lead 针对 D1–D11 做了修复并 reopen task-4(revision 4→5),要求逐条复验 + 用对抗性思路找新引入的问题。
- 复验基线:`selftest 83/83`、`ui_acceptance 20/20`(本人独立复跑,见下);真机部分仍只做代码级/只读核对。
- 复验方式:**全部用第一轮的同一批脚本 + 新增针对性脚本复跑**,不采信 lead 的验证结论。
- 自建实例:8877(mock);`--host 0.0.0.0` 场景另起 8888;D4 假服务端 8897;验证结束后全部关闭。
- 副作用说明:CDP 底图切换测试把 `config.json` 的 `tile_source` 改成了 `osm`(前端会自动持久化),已用 `POST /api/config {"tile_source":"amap"}` 恢复默认值。
## 复验总览
| 编号 | 一轮结论 | 二轮复验 | 证据位置 |
| --- | --- | --- | --- |
| D1 NaN→非法 JSON | 高 | ✅ **已关闭** | §R2-1 |
| D2 config 无校验 | 高 | ✅ **已关闭** | §R2-1 |
| D3 播放线程僵尸化 | 高 | ✅ **已关闭** | §R2-2 |
| D4 心跳只认 pong | 高 | ✅ **已关闭** | §R2-3 |
| D5 无 Origin/Host 校验 | 高 | ✅ **已关闭** | §R2-4 |
| D6 瓦片失败回 200 | 中 | ✅ **已关闭**(前端告警路径实测被激活) | §R2-5 |
| D7 stop 后 seek 重下发 | 中 | ✅ **已关闭** | §R2-6 |
| D8 三处非中文/500 | 中 | ⚠️ **部分关闭**(REST 已修,WS `log.list` 仍泄漏英文异常) | §R3-1 |
| D9 mock 显示「隧道 空闲」 | 中 | ❌ **仍存在**(修的是前端根本不用的 REST 路径,真实原因链未动) | §R3-2 |
| D10 误杀他人隧道 | 中 | ✅ **已关闭**(动态构造伪造进程验证) | §R2-7 |
| D11 WS 协议细节 | 中 | ✅ **已关闭**(1002 / 426 / 8MB 分片上限 1009) | §R2-8 |
| D12 multipart GPX | 低 | ❌ 仍 400(未声称修复) | §R3-3 |
| D13 refresh 阻塞 | 低 | ✅ pair_timeout 12s→3s;`hello` 仍同步 refresh(可接受) | §R3-3 |
| D14 点数无上限 | 低 | ✅ 5000/20 万双上限,413 中文;20 万点 path 仍可用 | §R2-9 |
| D15 speeds NaN | 低 | ✅ 回退为基准速度 13.9 | §R2-9 |
| D16 `_inflight` 只增不减 | 低 | ❌ 仍在(未声称修复) | §R3-3 |
| D17 多实例共享 config | 低 | ❌ 仍在(未声称修复,属设计取舍) | §R3-3 |
| D18 文档不一致 | 低 | ✅ README 83 项、DEVICE_NOTES §8.2 已加"被后续进展取代";❌ PROTOCOL 仍列 tencent、connect 的 tunnel 仍无 `ios_major` | §R3-3 |
| 新增 N1–N5 | — | ⚠️ 2 个需要判断(clear 顺序、配置合并校验),3 个纯提示 | §R4 |
**复验结论:D1–D7、D10、D11 确认关闭;D8 只关了一半;D9 用户可见症状仍在;另有 1 个"顺序未被显式保证"的加固项(N1)。仍不建议直接交付,但剩余问题已从"高危"降级为"1 个中危 + 2 个局部问题"。**
---
## R1. 独立复跑的基线测试
| 命令 | 结果 |
| --- | --- |
| `./.venv/bin/python tests/selftest.py` | **通过 83 项,失败 0 项,用时 9.8s** ✅(我独立复跑,不是采信) |
| `./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8877/ --out /tmp/ui-verify2` | **通过 20 项,失败 0 项** ✅(含新增的「点击播放后进入播放态」) |
| `tools/selftest_location.py`(一轮已跑 36/36) | 未改动该模块,未复跑 |
---
## R2. 已关闭项的证据(逐条可复现)
### R2-1 D1 + D2
```
$ python /tmp/verify2_ws.py # R4/R5/R9/R11 段
R4 seek(NaN) ack -> {'type':'ack','id':'nan1','cmd':'play.seek','ok':False,
'error':'进度不是有限数(不允许 NaN / Infinity)','code':'bad_number'}
R4 之后 3 秒:含 NaN 帧=0 严格解析失败=0
R5 progress=NaN/Infinity/-Infinity/"abc"/null/{}/[] -> 全部 ok=False 中文
R11 speeds=[NaN,1] -> ok=None route.speeds=[13.9, 1.0] ← NaN 回退基准速度
```
D2(`config_store._validate` + `_normalize`,用**临时配置文件**测,不碰项目 `config.json`):
```
=== 非法配置必须全部被拒 === 非法用例被接受:0 / 25
✓ send_hz NaN -> send_hz不是有限数(不允许 NaN / Infinity)
✓ tile_sources 字符串 -> tile_sources 必须是「源名称 → 配置」的对象
✓ cache 63 -> 瓦片缓存上限需在 64 ~ 102400 MB 之间
✓ tile_sources url 缺 {z} -> 瓦片源 bad 的 url 必须包含 {z}/{x}/{y} 占位符
…(22 条同类)
=== 合法配置是否被误拒 === 合法用例被误拒:1 / 25
✓ 默认 4 个瓦片源整体提交 / tile_source 取 4 个源 / send_hz 1~20 / cache 64~102400
✓ window 200~10000 / mode_defaults 四模式 / 自定义瓦片源 / 多键合法组合 …(24 条)
✗ 误拒:{"tile_sources":{"my2":…},"tile_source":"my2"} 同一请求 -> 未知的瓦片源:my2 ← 见 N2
=== 坏配置文件自愈 === 4/4 全部回退默认值并把 tile_source 归位 amap
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
200 ← 修复后瓦片接口不再 500
```
### R2-2 D3 代次机制(用真实 `core/playback.py`)
脚本 `/tmp/verify2_playback2.py`(慢 provider 每次下发 3s,模拟真机卡顿):
```
S1' 慢 provider(3s) + 0.3s 后第二次 start
第二次 start 时存活线程: ['playback-4'](应为 1) ← 不再双线程
9 秒后存活线程: [](应为 0) ← 旧线程自行退出
下发序列: [('set',39.908,3.2), ('set',31.23,4.12), ('set',31.2445,7.18), ('set',31.25,10.23)]
北京点(旧代次)= 1 次;上海点(新代次)= 3 次 ← 旧代次只完成"在途那一次"
→ ✅ 通过:不再出现北京/上海交替下发
S3 seek 与 stop 并发 ×200:clear 之后又 set 的次数 = 0 ← 未复现残留竞态
```
> 对照第一轮:同样是这个脚本,第一轮实测 `当前存活的 playback 线程:['playback','playback']` 且定位在北京/上海之间来回跳。
### R2-3 D4 心跳(node 直接跑真实 `web/js/ws.js`)
假服务端 `/tmp/verifier2_stall.py`(8897):`device.connect` 阻塞 60s,期间每 2s 推一条 `log`(模拟 tunnel-reader 线程)。
```
[0.0s] open / welcome
[1.5s] -> 发送 device.connect(服务端将阻塞 60s,不回 pong)
[61.5s] device.connect 成功: {"type":"ack","id":"c2-jttw","cmd":"device.connect","device":{"udid":"FAKE"},...}
[100.0s] 结束观察(pending=0)
```
✅ 全程 **0 次"心跳超时"、0 次重连**,60s 的命令拿到了 ack。(第一轮同一测试在 40.0s 报「心跳超时,正在重建连接」,91.5s 报命令超时。)
### R2-4 D5 Origin/Host(含"会不会误伤正常用法")
```
1 无 Origin(curl 正常用法) -> HTTP 200
2 同源 Origin http://127.0.0.1:8877 -> HTTP 200
3 Origin http://localhost:8877 -> HTTP 200
4 Origin https://evil.example -> HTTP 403 已拒绝来自 https://evil.example 的跨站请求…
5 Origin: null(沙箱/file://) -> HTTP 403 已拒绝来自沙箱页面…
7 Host: evil.com(DNS rebinding) -> HTTP 403 已拒绝 Host: evil.com 的请求…
9 Host: localhost:8877 -> HTTP 200
10 Host: [::1]:8877 -> HTTP 200
POST /api/location|/api/shutdown|/api/config|/api/devices/connect (Origin: evil) -> 全部 403
POST /api/location (同源 Origin,正常用法) -> 200 {"ok":true}
WS 握手:同源 Origin -> 101;evil -> 403;Origin: null -> 403;无 Origin(原生客户端)-> 101
```
`--host 0.0.0.0` 场景(本人起 8888 实例):`app.host` 由 `serve()` 赋值(server.py:876 `app.host = host`),因此
`Host: 192.168.1.50:8888` / `mymac.local:8888` / LAN 浏览器真实场景(Host+Origin 都是局域网 IP)**全部 200** ✅,
而同一实例上 `Host: 192.168.1.50 + Origin: https://evil.example` 仍 403 ✅。**没有误伤。**
### R2-5 D6 瓦片 404 + 前端告警路径
```
$ curl -s -o /dev/null -w "osm -> HTTP %{http_code} 用时 %{time_total}s\n" -m 60 http://127.0.0.1:8877/api/tiles/osm/3/6/3.png
osm -> HTTP 404 用时 12.009s ← 契约要求 404,已符合
```
前端(真实 Chrome + CDP Network 域,切换到本机不可达的 OSM 源并驱动地图重绘):
```
tile 请求(按源): {'osm': 12}
tile 响应(源,状态): {('osm', 404): 12}
告警 class: map-warn ← 不再 hidden
告警文本: 瓦片加载失败 2 张 ← onerror → 重试/告警路径确实被激活
```
### R2-6 D7 停止后 seek
```
R6 停止后 seek -> ok=False err=当前没有正在播放的轨迹(请先点播放) 下发次数 3 -> 4(隔离复测为 +0)
# 隔离复测(单独脚本,stop 后统计 /api/logs 里 "下发定位 lat=" 条数):
seek -> False 当前没有正在播放的轨迹(请先点播放)
seek 之后累计下发 7 次(差值 0)
```
### R2-7 D10 隧道进程清理(动态构造伪造进程)
```
D10-A 没有 pid 文件时:调用 _kill_stale_tunnels() 后伪造的 "pymobiledevice3 remote start-tunnel" 进程仍存活 = True → ✅ 未误杀
D10-B pid 文件记录该 PID:进程被清理(存活=False),pid 文件被删除 → ✅
D10-C pid 文件记录的是普通 sleep 进程:仍存活 = True → ✅ 未误杀
D10-D pid 文件里是已消失的 PID:无异常,文件被清理 → ✅
```
(另核对:`tunnel.py:138` 在启动隧道时 `_write_pidfile([process.pid])`、189/294 清理,机制不是死代码。)
### R2-8 D11 WebSocket 协议
```
未掩码帧 -> {'__close__': True, 'code': 1002} ✅ RFC §5.1
Sec-WebSocket-Version: 8 / 0 -> HTTP/1.1 426 Upgrade Required ✅(第一轮是 101)
分片累计 >8MB -> 服务端 close 帧 op=8 code=1009 reason=b'fragmented message too large' ✅
单帧 9MB -> close 1009 ✅(一轮已验)
并发广播 4s a=28/b=30 帧,严格 JSON 解析失败 0 ✅;close 回显 1000 ✅;粘包 ✅
```
### R2-9 低危附带修复
```
route/build 6000 waypoints -> HTTP 413 {"error":"坐标点过多(6000 个,上限 5000 个):请减少途经点或改用 GPX 导入"}
route/build 100 waypoints -> HTTP 200
play.start 20 万点 path -> 2.55s 成功(GPX 大轨迹仍可播放)
```
---
## R3. 仍未关闭 / 未修的问题
### R3-1(中)D8 只关了一半:WS `log.list` 仍泄漏英文异常
- 位置:`core/server.py:355-356` `_cmd_log_list` 仍是 `int(message.get("limit") or 200)`(REST 的 `/api/logs` 已在 `:713-719` 加了 try/except,WS 命令路径漏了)。
- 复现与原始输出:
```
$ # 用自写 RFC6455 客户端发 {"type":"log.list","limit":"abc"}
limit='abc' -> [{'type':'log',...},
{'type':'error','error':"内部错误:ValueError: invalid literal for int() with base 10: 'abc'",'code':'internal'}]
limit='1e9' -> 同上('1e9')
limit='abc123' -> 同上
$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=abc"
{"ok":false,"error":"limit 必须是整数","code":"bad_request"} -> HTTP 400 ← REST 已修好
```
- 期望:WS 侧同样回中文(`{"ok":false,"error":"limit 必须是整数","code":"bad_request"}`)。
- 影响:前端固定传数字,只有 API 客户端能触发;属契约/健壮性问题(低危),但属于"说好修了却没修全",建议一并处理(3 行)。
- 顺带:`recent_logs()` 自身也仍然直接 `int(limit)`(`core/server.py:108`)。
### R3-2(中)D9 仍存在:mock 模式设备卡依然显示「隧道 空闲」
- 现状(CDP 读真实 DOM,页面刚加载、未点任何按钮):
```
设备卡: ["型号=iPhone18,1","系统=iOS 26.0","连接=模拟","设备状态=已连接",
"隧道=空闲","虚拟定位=未模拟","开发者模式=已开启"]
```
- 服务端侧确实修好了(`POST /api/devices/connect`、`POST /api/tunnel` 都返回 `state:"connected"` / "模拟模式:无需建立 RSD 隧道"),**但前端不调用 `/api/tunnel`**(`grep -rn "api/tunnel" web/` 无结果),而页面加载走的是 hello→welcome,这条链没改:
- `core/server.py:133` `welcome_payload()` 仍返回 `self.tunnel.snapshot()` → mock 下就是 `{"state":"idle","message":"隧道未建立(iOS 17 及以上需要)",…}`(实测 hello ack:`tunnel = {"state":"idle",…}`,而同帧里 `devices[0].tunnel = connected`);
- `web/js/app.js:461-465` `applySessionInfo()` 仍就地把 `S.device.tunnel = msg.tunnel.state`(=idle),紧接着 `applyDevices()` 又把**同一个被改过的对象**装回 `S.device`;而且同一条 hello ack 被处理两次(`:400` 的 welcome 事件 + `:410` 的 ack 分支)。
- 期望:mock 下设备卡显示「已连接」(PROTOCOL.md:153),或至少不要用隧道快照覆盖设备对象的权威字段。
- 最小修复(任一即可,优先第 1 条):
1. 服务端 `welcome_payload()` 复用 mock-aware 的隧道语义(与 `_cmd_tunnel_ensure` 同一处实现),使 hello ack 的 `tunnel.state == "connected"`;
2. 前端 `applySessionInfo` 不再用 `msg.tunnel.state` 覆盖 `S.device.tunnel`,并按 `msg.id` 去重避免同一消息处理两次。
- 附:`GET /api/tunnel` 在 mock 下仍返回 `idle`(与 `POST` 不一致)。前端未使用,属契约一致性小问题。
### R3-3(低)一轮中未声称修复、复验确认仍在的项
| 项 | 复验输出 | 说明 |
| --- | --- | --- |
| D12 multipart GPX | `multipart -> 400 {"error":"GPX 文件解析失败…"}`;`text/plain -> 200` | PROTOCOL.md:29 写了支持 multipart,仍未支持 |
| D16 `tilecache._inflight` | `grep _inflight` 仍只有 `get`/赋值,无清理 | 长跑内存缓慢增长 |
| D17 多实例共享 `config.json` | 本轮测试再次改写同一文件(前端自动持久化) | 设计取舍,未改 |
| D18-c PROTOCOL 瓦片源示例 | 仍含 `tencent`(实现里没有) | 文档示例与实现不一致 |
| D18-e `/api/devices/connect` 的 tunnel | `tunnel keys = ['message','mode','rsd','state','udid']`,无 `ios_major` | 与 PROTOCOL:21 示例不一致 |
| D13 `refresh` 同步阻塞 | `pair_timeout=3.0`(12→3 ✅);`hello` 仍同步 refresh | 健康设备下几十 ms,可接受 |
---
## R4. 新发现的问题(修复过程引入 / 本轮新观察到)
### N1(中低,真机语义,建议加固)`play.stop` 的"恢复真实定位"与"在途下发"的相对顺序**没有被显式保证**
- 位置:`core/playback.py:139-175`(`stop()` 里 join 超时从 2.5s 降到 0.6s,然后**立刻**起 `playback-clear` 线程调用 `clear_location()`)。
- 实验(同一脚本、只改"设备何时生效"的模型,模拟真机 DVT 的两种情况):
```
S2' stop 时的 set/clear 顺序(设备模型 effect_at=start,模拟真机:帧一发出设备就生效)
stop() 返回耗时 0.61s
事件序列: [('set', 39.908, 0.21), ('clear', None, 1.33)]
→ clear 之后的 set 次数 = 0 ✅ 设备最终已恢复
S2' stop 时的 set/clear 顺序(设备模型 effect_at=end,模拟 core/location_mock.py 的语义)
stop() 返回耗时 0.60s
事件序列: [('clear', None, 1.32), ('set', 39.908, 3.21)]
→ clear 之后的 set 次数 = 1 ⚠️ 设备最终停留在模拟位置
```
- 判断:真机走 DVT 时,`set` 的帧在 3 秒前就已经写出(`dtx/_sender.py:34-59` 的 `_send_lock` 串行化帧写入,`simulateLocationWithLatitude:longitude:` 发出后 await 回复),`clear` 的帧随后写入 → 设备按 set→clear 顺序处理 → **最终状态是已恢复**(`effect_at=start` 那一行)。但这依赖传输层时序,代码里没有任何"先等在途下发结束再 clear"的显式保证;而在 `--mock` 下(以及任何"回复到达才算生效"的实现下)可观测到 clear→set,最后停在模拟点。
- 影响:真机大概率没问题;风险在于①换传输/换 provider 时可能悄悄变成"停止后仍被钉住";②mock 的日志会显示"已停止模拟,iPhone 恢复真实定位",但内部最后状态仍是模拟点,容易误导后续验收。
- 最小修复:`_clear_provider_async` 里先 `old_thread.join(timeout=6)` 再 `clear_location()`(或把 clear 也塞进同一条串行下发队列),让顺序与传输无关。
### N2(低)D2 的校验顺序会把"新增瓦片源 + 同时切换过去"这种合法请求误拒
```
✗ 误拒:{"tile_sources":{"my2":{...}}, "tile_source":"my2"} -> 未知的瓦片源:my2
```
- 位置:`core/config_store.py:157`(`update()` 对 patch 里每个 key 用**当前** `self._data` 校验,此时新源还没合并)。
- 影响:前端只单独发 `{"tile_source": key}`(`app.js:663`),正常用法不受影响;属 API 边角。
- 建议:先合并 `tile_sources` 再校验 `tile_source`。
### N3(低)配置自动回退无任何提示
`ConfigStore._repaired` 被置位(`config_store.py:69/96/109/114/117`)但全仓库没有任何地方读取它 → 用户手改坏了 `config.json` 时会被静默重置。建议 `main.py` 启动时检测并 `app.log("warn", …)`。
### N4(低)`play.stop` 的 0.6s 是"必然耗满"的等待
`stop_locked()` 在**持有 `self._lock`** 的情况下 `thread.join(0.6s)`,而被 join 的播放线程此时通常正阻塞在 `stale()` 里 `with self._lock`(playback.py:233)→ 必然等满 0.6s 才继续。功能正确,若想让「停止」更快,可先释放锁再 join。
### N5(提示)一次「停止 + 退出」会重复 clear 三次
in-process 复验(`play.stop` 后立刻 `app.shutdown()`):
```
provider 调用序列: [('set',39.908),('set',39.908),('set',39.9081),('set',39.9083),
'clear','clear','clear','close']
clear 次数: 3 close 次数: 1
→ ✅ 停止+退出后设备仍被恢复
```
三次 clear 分别来自:后台 clear 线程、`shutdown()` 里的 `playback.stop()`、`provider.close()` 内部的 clear。幂等无害,但日志会重复三遍。
---
## R5. 二轮"验证过但没问题"的补充覆盖
- 播放/停止/暂停/继续/seek 的完整 UI 流程:`ui_acceptance 20/20`,新增的「点击播放后进入播放态」也通过。
- `play.stop` 的 ack 延迟从 2.2s 降到 **0.60s**(实测 3 次),且 clear 结果(成功/失败)都会写日志:成功 `已停止模拟,iPhone 恢复真实定位`,失败 `恢复真实定位失败:<中文>`(用会抛异常的 provider 实测到 warn)。
- `--host 0.0.0.0` 局域网访问 + Origin/Host 双校验组合:正常访问 200、恶意 Origin 403(见 R2-4)。
- 分片累计上限生效后,正常分片消息(两段、中间夹控制帧)依然可用。
- 20 万点超长播放列表仍可用(2.55s),6000 途经点按新上限回 413 中文。
- 隧道 pid 文件机制(写/读/清理)与"只杀自己记录的进程"行为(R2-7)。
## R6. 仍未验证的点(与一轮相同)
1. **真机端到端**:受"不设定位/不改设备状态"约束,未运行 `real_device_e2e.py`/`real_gui_acceptance.py`,未建隧道。N1 的顺序结论来自源码级分析(DTX `_send_lock` + `await` 语义)+ 两种设备模型仿真,**没有真机实测**。若 lead 要闭环这一条,建议在真机上做一次:`play.start` → 0.3s 后 `play.stop` → 立刻看手机是否回到真实定位(这条由 lead 那边跑,我不占用唯一的 RSD 连接)。
2. `Wi-Fi/Network` 设备与 iOS<17 `legacy` 路径(本机当前只有 1 台 USB 设备)。
3. 小时级稳定性(瓦片 LRU 淘汰、`_inflight` 增长、WS 长挂)。
4. Safari/Firefox(只测了 Chrome)。
5. 管理员隧道(`sudo`/osascript 提权路径)。
---
## R7. 复验用到的脚本(均在 /tmp,可重跑)
| 脚本 | 用途 |
| --- | --- |
| `/tmp/verify2_ws.py` | WS 侧全套(NaN seek、协议、上限、并发、粘包) |
| `/tmp/verify2_playback2.py` | D3 代次 + clear 顺序 + clear 失败可见性(真实 `core/playback.py`) |
| `/tmp/verifier2_stall.py` + `/tmp/verifier2_stall_test.js` | D4 心跳(node 跑真实 `web/js/ws.js`,端口 8897) |
| `/tmp/wsclient.py` | 自写 RFC6455 客户端(未复用项目 `core/ws.py`) |
| 内联脚本 | D2 配置校验 25+25 例、D5 Origin/Host 矩阵、D10 伪造进程、CDP 底图 404 告警 |