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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
# 虚拟定位轨迹工具 — 前后端接口契约(v1,冻结)
所有实现必须严格遵守本文件。**任何一方需要改动契约,必须先改本文件并通知对方。**
## 1. 进程与端口
- 单进程:Python HTTP/WebSocket 服务监听 `127.0.0.1:8787`(可用 `--port` 覆盖)。
- 静态前端由服务端从 `web/` 目录提供,入口 `GET /` → `web/index.html`。
- 无外部网络依赖(除地图瓦片与可选路线规划 OSRM)。
## 2. REST API
所有响应 `Content-Type: application/json; charset=utf-8`。失败统一返回:
`{"ok": false, "error": "<中文可读信息>", "code": "<machine_code>"}`,HTTP 状态码 400/404/409/500。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/health` | `{"ok":true,"version":"1.0.0","mock":false,"device_count":1}` |
| GET | `/api/devices` | 设备列表(见 2.1) |
| POST | `/api/devices/select` | body `{"udid":"..."}` → `{"ok":true,"device":<Device>}` |
| POST | `/api/devices/connect` | 建立隧道/DVT 会话 → `{"ok":true,"device":<Device>,"tunnel":{"state":"connected","ios_major":26}}` |
| POST | `/api/devices/disconnect` | 释放会话 → `{"ok":true}` |
| GET | `/api/config` | `{"ok":true,"config":{...}}` |
| POST | `/api/config` | 局部更新,body 为部分字段 → `{"ok":true,"config":{...}}` |
| GET | `/api/settings/tiles` | 瓦片源列表 + 当前选择 |
| GET | `/api/tiles/{source}/{z}/{x}/{y}.png` | 瓦片代理 + 本地磁盘缓存;命中/回源均返回图片字节;全部源失败 → 404 + JSON 错误 |
| POST | `/api/route/build` | body `{"waypoints":[[lng,lat],...],"mode":"driving"}` → `{"ok":true,"route":<Route>,"engine":"osrm"\|"straight"}` |
| POST | `/api/gpx/export` | body `{"route":<Route>,"name":"..."}` → 返回 GPX 文本(`application/gpx+xml`) |
| POST | `/api/gpx/import` | body 为 GPX 文本(`text/plain` 或 multipart)→ `{"ok":true,"waypoints":[[lng,lat],...],"path":[...],"points":[...],"name":"...","count":N}`。三个数组内容相同:**全量坐标、不抽稀**,前端用 `path` 保形重放 |
| POST | `/api/tunnel` | body `{}` 建隧道(可选 `{"sudo":true}` 走管理员);或 `{"host":"...","port":1234}` 手动指定 → `{"ok":true,"tunnel":{...}}` |
### 2.1 Device 对象
```json
{
"udid": "00008110-000A1B2C3D4E801E",
"name": "iPhone 15 Pro",
"product_type": "iPhone16,1",
"ios_version": "26.0",
"ios_major": 26,
"connection": "usb",
"state": "connected",
"tunnel": "idle",
"simulating": false,
"developer_mode": true,
"tunnel_hint": null,
"last_error": null
}
```
- `state`: `connected` | `unpaired` | `unavailable`
- `tunnel`: `idle` | `starting` | `connected` | `error` | `unsupported`
- `tunnel_hint`: iOS≥17 需要隧道而失败时的中文修复建议(例如「需要管理员权限建立隧道:sudo ./run.sh」),否则 `null`。
- `developer_mode`: iOS≥16 未开启开发者模式时为 `false`,前端需显著提示。
### 2.2 Route 对象
```json
{
"id": "r-1712345678901",
"name": "未命名轨迹",
"mode": "driving",
"waypoints": [[116.397, 39.908], [116.401, 39.912]],
"path": [[116.397, 39.908], [116.3971, 39.9082]],
"speeds": [8.3, 8.5],
"distance": 1234.5,
"duration": 148.6,
"engine": "osrm",
"loop": false,
"speed_scale": 1.0
}
```
- `waypoints` 用户途经点(含起点/终点),`path` 是实际驱动用的密集坐标序列(`[lng,lat]`,WGS84)。
- `speeds`:与 `path` 等长的瞬时速度 m/s(仅用于展示与导出);`speed_scale` 是播放倍速。
- `distance` 米;`duration` 秒(= 按 `speeds` 积分的结果)。
### 2.3 配置文件 `config.json`(与 `main.py` 同目录)
```json
{
"tile_source": "amap",
"tile_sources": {
"amap": {"name": "高德矢量", "url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}", "subdomains": ["1","2","3","4"], "max_zoom": 18, "attribution": "© 高德地图"},
"amap_sat":{"name": "高德卫星", "url": "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}", "subdomains": ["1","2","3","4"], "max_zoom": 18, "attribution": "© 高德地图"},
"tencent": {"name": "腾讯地图", "url": "https://rt{s}.map.gtimg.com/tile?z={z}&x={x}&y={y}&type=vector&styleid=3", "subdomains": ["0","1","2","3"], "max_zoom": 18, "attribution": "© 腾讯地图"},
"arcgis_sat": {"name": "全球卫星", "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", "subdomains": [], "max_zoom": 19, "attribution": "Esri"}
},
"tile_cache_dir": "~/.ios-location-tiles",
"tile_cache_max_mb": 1024,
"send_hz": 5,
"default_speed_scale": 1.0,
"mode_defaults": {"driving": 13.9, "cycling": 5.5, "walking": 1.4, "flight": 60.0},
"osrm_url": "https://router.project-osrm.org/route/v1"
}
```
**坐标系统一为 WGS84**。前端若使用高德瓦片,仅瓦片源不同(高德瓦片为 GCJ-02),显示用点必须做 WGS84→GCJ-02 转换后再落到瓦片上,反向转换用于取点。转换函数放在 `web/js/coord.js`,导出 `wgs84ToGcj02` / `gcj02ToWgs84`。**后端永远只接收/返回 WGS84。**
## 3. WebSocket API
- 地址:`ws://127.0.0.1:8787/ws`。UTF-8 文本帧,每条消息一个 JSON 对象。
- 客户端 → 服务端:`{"type": "<cmd>", "id": "<可选请求id>", ...}`
- 服务端 → 客户端:`{"type": "<event>", "id": "<回填请求id>", ...}`,`id` 存在时表示对某条命令的应答。
### 3.1 客户端命令
| type | 字段 | 说明 |
| --- | --- | --- |
| `hello` | `{"client":"web"}` | 连接后立即发送,服务端回 `welcome` |
| `ping` | `{}` | 回 `pong`,用于心跳(客户端每 10s 发一次) |
| `devices.refresh` | `{}` | 回 `devices` |
| `device.select` | `{"udid":""}` | 回 `device` + `tunnel` |
| `device.connect` | `{}` | 回 `device`/`tunnel`,可能先推多条 `log` |
| `device.disconnect` | `{}` | 回 `device`/`tunnel` |
| `location.set` | `{"lng":116.397,"lat":39.908}` | 单点定位,回 `ack`,随后推 `location` |
| `location.clear` | `{}` | 停止模拟、恢复真实定位,回 `ack` |
| `play.start` | `{"route":<Route>,"speed_scale":1.0,"loop":false,"start_index":0}` | 开始沿轨迹播放。**服务端以收到的 route 为准**(前端可只发 `path`/`speeds`)。回 `ack`;随后持续推 `status`/`location` |
| `play.pause` | `{}` | 回 `ack` |
| `play.resume` | `{}` | 回 `ack` |
| `play.stop` | `{}` | 停止播放并停止模拟(恢复真实定位),回 `ack` |
| `play.seek` | `{"progress":0.42}` | 0..1,回 `ack` 并推 `location` |
| `play.speed` | `{"speed_scale":2.0}` | 播放倍速 0.1..50,回 `ack` |
| `log.list` | `{"limit":200}` | 回 `logs` |
| `route.build` | `{"waypoints":[[lng,lat],...],"mode":"driving","name":"...","use_osrm":true,"loop":false,"speed_scale":1.0}` | 回 `ack{route,engine}`;便于前端在 WS 上一次完成规划 |
| `tunnel.ensure` | `{"sudo":false}` | 建立/复用隧道 → `ack{tunnel,need_tunnel}`。`need_tunnel:false` 表示该场景不需要隧道(mock 模式,或 iOS<17 走 usbmux 经典通道),前端应立即结束等待 |
| `tunnel.manual` | `{"host":"fd00::1","port":51234}` | 使用手动指定的 RSD → `ack{tunnel}` |
| `tiles.clear` | `{}` | 清空瓦片磁盘缓存 → `ack{removed:N}` |
| `config.update` | `{"config":{"send_hz":5,"tile_source":"amap"}}` | 局部更新配置 → `ack{config}` |
### 3.2 服务端事件
| type | 字段 | 说明 |
| --- | --- | --- |
| `welcome` | `{"version":"1.0.0","mock":false,"config":{...}}` | 连接就绪,附带当前配置与设备快照 |
| `pong` | `{"ts":1712345678.1}` | 心跳应答 |
| `ack` | `{"id":"<回填>","cmd":"play.start"}` 或 `{"id":"...","ok":false,"error":"..."}` | 命令应答 |
| `devices` | `{"devices":[<Device>...],"selected":"<udid\|null>"}` | 设备列表变化 |
| `device` | `{"device":<Device>\|null}` | 单设备状态变化 |
| `tunnel` | `{"state":"connected","ios_major":26,"message":"隧道已建立(RSD)","rsd":"fd00::1 端口 51234"}` | 隧道状态变化 |
| `location` | `{"lng":116.397,"lat":39.908,"source":"play"\|"manual","progress":0.42,"index":1234,"speed":8.31,"sent_at":1712345678.123}` | 每次实际下发定位后推送 |
| `status` | `{"playing":true,"paused":false,"progress":0.42,"index":1234,"total":3000,"elapsed":61.2,"remaining":88.0,"speed_scale":1.0,"loop":false,"distance_done":512.3,"lng":116.39,"lat":39.9,"seq":7}` | 播放状态,播放中约 2Hz 推送,状态变化时立即推送。`seq` 为自增序号,用于判断新旧状态 |
| `logs` | `{"lines":[{"ts":1712345678.1,"level":"info","msg":"..."}]}` | 日志回填 |
| `log` | `{"ts":...,"level":"info"\|"warn"\|"error"\|"success","msg":"..."}` | 实时日志 |
| `error` | `{"error":"中文信息","code":"..."}` | 服务端异常 |
### 3.3 语义约定
- `play.start` 时若尚未连接设备:立即回 `ack ok:false, error:"请先连接设备"`(mock 模式除外)。
- `play.stop` 与 `location.clear` 必须调用 DVT `clear_location`,让手机恢复真实定位,并额外推一条
`{"type":"location","source":"cleared","lng":null,"lat":null}`,前端应让地图上的定位点消失。
- 播放循环 `loop=true` 时到终点自动回到起点,`status.loop` 保持 `true`。
- 服务端播放引擎按 `send_hz`(默认 5Hz)下发;`play.speed` 只影响轨迹推进速率,不影响下发频率。
- 逐次下发的日志按 5 秒聚合成一条「正在按轨迹下发定位(已下发 N 次)」,不会 5Hz 刷屏。
- 所有 `lng/lat` 单位度,WGS84,保留 6 位小数。
- `hello` 的应答是 `ack`(`welcome` 的数据并进 ack,**没有独立的 `type:"welcome"` 消息**):
`{type:"ack", id, cmd:"hello", version, name, mock, config, devices, selected, tunnel, backend, pymobiledevice3}`。
- WebSocket 子协议:只有客户端在 `Sec-WebSocket-Protocol` 里请求了值时才回该头(RFC 6455 要求),
否则浏览器会判定握手失败。
## 4. 无设备模拟模式(验收必备)
启动参数 `--mock` 或环境变量 `IOSLOC_MOCK=1` 时:
- `/api/devices` 返回一台假设备:`udid="MOCK-0001"`, `name="模拟设备 (iPhone 17 Pro)"`, `ios_version="26.0"`, `ios_major=26`, `state="connected"`, `tunnel="connected"`。
- 定位下发不报错,仅记录日志并照常推送 `location` 事件。
- 其余行为(播放引擎、进度、GPX、瓦片缓存)与真机完全一致,用于端到端验证。
## 5. 模块与文件所有权(写作用域)
| 文件 | 负责人 |
| --- | --- |
| `core/protocol.py` | lead |
| `core/ws.py` | lead |
| `core/server.py` | lead |
| `core/route_engine.py` | lead |
| `core/tilecache.py` | lead |
| `core/device.py` | lead |
| `core/tunnel.py` | lead |
| `core/location_provider.py` | device-teammate |
| `core/location_mock.py` | device-teammate |
| `core/playback.py` | lead |
| `core/config_store.py` | lead |
| `main.py` | lead |
| `web/index.html`, `web/style.css` | web-teammate |
| `web/js/*.js` | web-teammate |
| `tools/*.py` | device-teammate |
| `scripts/*`, `README.md` | lead |
任何人不得直接改别人拥有的文件;需要改动就发消息商量。