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

+41
View File
@@ -0,0 +1,41 @@
# ---- Python / 虚拟环境 ----
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
# ---- 打包产物(可用 scripts/build_app.sh 重新生成)----
build/
dist/
*.spec.bak
*.dmg
*.app/
# ---- 运行时下载的镜像素材(体积大,用 tools/fetch_ddi.py 获取)----
# BuildManifest.plist 与 trustcache 已入库(体积小),只有 15MB 的镜像本体不入库
resources/ddi/**/Image.dmg
resources/ddi/**/DeveloperDiskImage.dmg
resources/ddi/**/DeveloperDiskImage.dmg.signature
# ---- 运行时数据(含本机设备信息与缓存)----
config.json
.tunnel.pids
tiles_cache/
*.log
logs/
# ---- macOS ----
.DS_Store
._*
.Spotlight-V100
.Trashes
# ---- 编辑器 ----
.idea/
.vscode/
*.swp
# ---- 本机私有备注(如不想公开可取消注释)----
# docs/DEVICE_NOTES.md
+139
View File
@@ -0,0 +1,139 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 打包配置:把整个应用(Python 运行时 + 依赖 + 前端资源)打成一个可独立运行的 .app。
用法:./.venv/bin/python -m PyInstaller --clean --noconfirm Location.spec
设计要点:
- **onedir**(不是 onefile):启动更快,且 .app 内部结构可被 macOS 正常识别;
真要单文件也可以,但 onefile 每次启动都要解包到 /tmp,反而更慢。
- 前端资源(web/)与图标打进包内,运行时通过 sys._MEIPASS 定位。
- 排除 pymobiledevice3 带进来但本工具用不到的重型依赖(IPython / fastapi / uvicorn 等),
它们只被 pymobiledevice3 的 CLI 交互模式使用;本工具只用 Python API。
排除后体积从 ~190MB 降到 ~60MB 量级。
"""
from pathlib import Path
from PyInstaller.utils.hooks import copy_metadata
ROOT = Path(SPECPATH).resolve()
# 有些包在运行期用 importlib.metadata 读自己的版本(例如 apple_compress 的 __version__,
# 它被 pyimg4 用于个性化镜像处理)。PyInstaller 默认不会打包 dist-info,
# 漏掉后挂载会报 "No package metadata was found for apple_compress"。
METADATA_PACKAGES = [
"apple_compress", "pymobiledevice3", "pyimg4", "construct",
"cryptography", "bpylist2", "ipsw_parser", "opack2",
]
_metadata_datas: list = []
for _pkg in METADATA_PACKAGES:
try:
_metadata_datas += copy_metadata(_pkg)
except Exception:
pass
# pymobiledevice3 的 CLI 会拉起这些重型依赖,但本工具只用它的 Python API,全部排除
# 策略:**尽量完整保留**,只排除"构建期工具"和"确定与运行无关的开发工具"。
# 依据(实测):questionary / prompt_toolkit / pygments 被 pymobiledevice3 运行期导入,
# 曾因误排导致打包版真机不可用;因此与 pymobiledevice3 相关的一切都保留。
# pip / setuptools / PyInstaller 只在构建期使用,运行 .app 时完全用不到。
EXCLUDES = [
# 构建期工具(打包产物里绝不需要)
# 注意:不要排除 distutils / pkg_resources —— PyInstaller 的 hook 会强制导入它们,
# 排除后构建直接失败(ValueError: Target module "distutils" already imported as ExcludedModule)
"pip", "wheel",
"PyInstaller", "pyinstaller", "_pyinstaller_hooks_contrib",
# 开发/测试工具(pymobiledevice3 的交互式 CLI 才会用)
"IPython", "ipython", "ipykernel", "jupyter", "jupyter_client", "jupyter_core",
"ipywidgets", "notebook", "nbformat", "nbconvert",
"pytest", "_pytest", "matplotlib", "matplotlib_inline", "tkinter",
]
a = Analysis(
["main.py"],
pathex=[str(ROOT)],
binaries=[],
datas=_metadata_datas + [
("web", "web"), # 前端(index.html / style.css / js/)
("resources/AppIcon.icns", "resources"),
# 开发者镜像素材(iOS 17+ 全系共用的个性化镜像;iOS 16.x 的经典镜像按需放)
("resources/ddi", "resources/ddi"),
("docs/PROTOCOL.md", "docs"),
("README.md", "."),
],
hiddenimports=[
# 这些是运行期动态 import 的,PyInstaller 静态分析看不到
"pymobiledevice3.remote.native_tunnel",
"pymobiledevice3.remote.userspace_tunnel",
"pymobiledevice3.remote.remote_service_discovery",
"pymobiledevice3.remote.tunnel_service",
"pymobiledevice3.services.dvt.instruments.dvt_provider",
"pymobiledevice3.services.dvt.instruments.location_simulation",
"pymobiledevice3.lockdown",
"pymobiledevice3.usbmux",
"pymobiledevice3.service_connection",
"pymobiledevice3.exceptions",
"developer_disk_image.repo",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=EXCLUDES,
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="iOSLocationTrack",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False, # 不弹终端窗口(日志写到文件)
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ROOT / "resources" / "AppIcon.icns"),
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="iOSLocationTrack",
)
app = BUNDLE(
coll,
name="虚拟定位轨迹.app",
icon=str(ROOT / "resources" / "AppIcon.icns"),
bundle_identifier="com.local.ios-location-track",
version="1.0.0",
info_plist={
"CFBundleName": "虚拟定位轨迹",
"CFBundleDisplayName": "虚拟定位轨迹",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundlePackageType": "APPL",
# Finder/Dock 取图标用;缺这个字段时系统有时会显示通用图标
"CFBundleIconFile": "AppIcon",
"CFBundleIconName": "AppIcon",
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
"LSApplicationCategoryType": "public.app-category.developer-tools",
"NSHumanReadableCopyright": "本地工具,用于自有 App 的定位功能测试",
"NSAppleEventsUsageDescription": "用于以管理员权限建立 iOS 设备隧道。",
"LSUIElement": False,
},
)
+287
View File
@@ -0,0 +1,287 @@
# iOS Virtual Location & Route Simulator (macOS)
[中文](README.md) | [**English**](README.en.md)
A desktop tool for **virtual location** and **route simulation** on iPhone / iPad from a Mac: draw a route on the map, add waypoints, and drive the device's location along it at realistic speeds.
Supports **iOS 17 – iOS 27.2** (RSD tunnel + DVT `SimulateLocation`). The developer disk image assets are bundled inside the app, so **no Python installation is required**.
The UI is a local web app opened in a Chrome / Edge app window (no address bar — looks like a native app). You can also just open `http://127.0.0.1:8787` in a browser. The map, route engine, playback engine, and device communication all run locally; only map tiles and optional route planning need network access.
---
## 1. Quick Start
### Option A — Download the installer (recommended)
Grab `iOSLocationTrack-1.0.0.dmg` from [Releases](https://github.com/jbjf6666/ios-location-track/releases):
1. Open the DMG and drag `虚拟定位轨迹.app` into `Applications`
2. If macOS says *"unidentified developer"* on first launch → **right-click → Open → Open**
**No Python, no dependencies** — the Python runtime and every dependency are bundled inside the app (~80 MB).
Config and tile cache are written to `~/Library/Application Support/iOSLocationTrack/`.
> **Apple Silicon (M-series) only.** For Intel Macs, use the source option below, or run `./scripts/build_app.sh` on an Intel machine (the script is architecture-agnostic).
### Option B — Run from source
```bash
git clone https://github.com/jbjf6666/ios-location-track.git
cd ios-location-track
# First run creates a virtualenv and installs dependencies automatically
./scripts/run.sh
```
The UI opens automatically. Equivalent CLI usage:
```bash
./.venv/bin/python main.py # normal launch (opens the UI)
./.venv/bin/python main.py --mock # no-device mock mode (demo the route/playback)
./.venv/bin/python main.py --port 8899 --no-browser
./.venv/bin/python main.py --window browser # open in the default browser instead
```
### Three ways to run it
| Form | Path | Environment needed | Use case |
| --- | --- | --- | --- |
| **DMG installer** | Release asset (45 MB) | **None** | Distribution / moving to another Mac |
| **Standalone app** | `dist/虚拟定位轨迹.app` (~80 MB extracted) | **None** — Python, deps, web UI, icon, disk image assets are all inside | Double-click and go |
| Source | `./scripts/run.sh` | Python 3.11+ (deps installed on first run) | Hacking on the code |
```bash
# Rebuild everything in one shot: icon → bundle → ad-hoc sign → DMG → verify
./scripts/build_app.sh
```
The DMG contains the app, an `Applications` shortcut (drag to install), and an install note.
To customize the DMG appearance, edit the constants at the top of `tools/make_dmg.py`.
> On first launch macOS will ask for permission to control Chrome/Edge (used to open the app window).
---
## 2. Supported iOS Versions
| Version | Supported | Notes |
| --- | --- | --- |
| **iOS 17 – 27.2** | ✅ | RSD tunnel + DVT. The app bundles the shared personalized disk image assets |
| iOS 16 and earlier | ❌ | Their developer disk images must be signed by Apple per OS build; third-party images are rejected by the device (verified: `Failed to verify the disk image signature`, and libimobiledevice fails identically) |
**Why one image covers everything:** starting with iOS 17, Apple switched to *personalized* developer disk images — a single base image (`Image.dmg`) plus `BuildManifest.plist`, **shared across all OS versions**. The actual signature is issued online by Apple's TSS server per device at mount time, so per-version assets are unnecessary. This tool bundles those assets (~16 MB); clicking **"Mount Developer Image"** works offline (only the signing step needs network).
---
## 3. Device Preparation (important)
| Requirement | Details |
| --- | --- |
| Cable connection | USB recommended; Wi-Fi drops easily when transferring large files |
| Unlock and **Trust** | Tap **Trust** on the *"Trust This Computer?"* prompt and enter your passcode |
| Enable Developer Mode | Settings → Privacy & Security → **Developer Mode** → on → **restart the phone** → confirm after reboot |
| Mount developer image | Click **"Mount Developer Image"** in the UI (assets are bundled), or let Xcode do it once |
| Close competing tools | i4Tools, 爱思助手, Xcode, etc. fight over the same RSD connection — **only one at a time** |
### Mounting the developer image
Click **"Mount Developer Image"** in the UI; progress streams into the log panel on the right. CLI equivalent:
```bash
# Check whether it's already mounted ( [] means not mounted )
./.venv/bin/python -m pymobiledevice3 mounter list
# Mount automatically
./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <YOUR_UDID>
```
Or just use Xcode: connect the device → Window → Devices and Simulators → wait for *"Preparing device for development"*.
> With Xcode installed, the system's `CoreDeviceService` usually mounts a local image
> (`/Library/Developer/DeveloperDiskImages/`) automatically when the device is plugged in.
> The packaged `.app` performs mounting through pymobiledevice3's **Python API** — no command-line tools required.
### About the iOS 17+ tunnel (RSD)
Since iOS 17, location simulation no longer goes over usbmux; an **RSD tunnel** must be established first. Three options:
1. **Rootless (default, recommended)**: runs `pymobiledevice3 remote start-tunnel --native --script-mode`, borrowing Apple's own `remoted` tunnel. The packaged app does this by **re-invoking itself** (`<app> tunnel-helper`), so **no external Python is needed**.
2. **Admin mode**: click *"Rebuild tunnel as administrator"* in the UI (macOS password prompt), or run `sudo ./scripts/run.sh`.
3. **Manual RSD**: the *"Manual RSD"* button — enter `HOST PORT` obtained elsewhere (e.g. `tunneld`).
> `remote tunneld`, `lockdown start-tunnel`, and the classic `remote start-tunnel` all **require root**; this tool never silently escalates privileges.
---
## 4. UI Overview
### Top bar: device
- Name / model / iOS version / connection type (USB or network)
- **Tunnel** status, **virtual location** status, **Developer Mode** status
- *Refresh*, *Connect*, *Disconnect*
### Left panel: route settings
- **Travel mode**: driving / cycling / walking / flying (sets base speed and whether real roads are used)
- **Playback speed**: 0.1x – 50x (affects route progression only, not the send rate)
- **Loop**: return to start at the end
- **Realism simulation** (toggleable anytime, even mid-playback):
- **Location drift**: slow wandering that mimics GPS error, radius 0–50 m (default 4 m).
Implemented with multi-octave smoothed noise — continuous along the route with inertia, so it **never produces random jumps**.
- **Speed jitter**: instantaneous speed fluctuates smoothly around the setpoint, 0–60% (default ±8%).
- **Send-timing jitter** (on by default): a real phone's GPS does not fire exactly once per second — it jitters 0.85–1.15 s with occasional dropped samples (~2%). Perfectly uniform intervals are one of the easiest things to spot in the data.
- **Derive speed from displacement** (on by default): reported speed = actual displacement ÷ actual interval, matching how iOS derives `CLLocation.speed` from the coordinate stream, so **speed, displacement and timestamps stay mutually consistent**.
- **Inject gross errors** (off by default): probabilistic bad fixes (multipath / urban canyon), for testing your own app's `horizontalAccuracy` filtering and outlier rejection.
- All toggles and magnitudes persist across restarts.
- **Jump to coordinate**: type a lat/lng directly; save favourite places
- **Route summary**: total distance, estimated duration, path point count, waypoint count, route engine (OSRM real roads / straight line)
- **Waypoint list**: drag to reorder, double-click to edit coordinates, delete, centre on point
- **Saved routes**: save / load / delete (browser local storage)
- **Base map**: AMap vector (GCJ-02, Chinese labels) / AMap satellite / global satellite (Esri) / OpenStreetMap
- Actions: *Load OSRM route*, *Clear*, *Export GPX*, *Import GPX*, *Restore real location*
- Tile cache: shows directory and size, plus *Clear tile cache*
### Map toolbar
- **Select**: pan the map, drag waypoints, click to edit
- **Add waypoint**: click repeatedly on the map to add points (the most common way to draw)
- **Draw polyline**: click to draw, double-click or Esc to finish
- **Undo**, **Delete selected point**, **Fit to route**
### Bottom bar: playback
- Play / Pause / Resume / Stop
- Scrubbable progress bar (seek)
- Elapsed time, remaining time, sample index
- Live speed, current coordinates, playback rate
### Right panel: log
Live log of device connection, tunnel setup, image mounting, and every location push — makes troubleshooting much easier.
---
## 5. Typical Workflows
**A. Run a route along real roads**
1. Connect the device → *Connect* (iOS 17+ builds the tunnel automatically, up to ~1 minute)
2. Click *Add waypoint* and place start / intermediate / end points (at least 2)
3. Click *Load OSRM route* → real road-network polyline (falls back to a straight line offline)
4. Set *Playback speed* (e.g. 20x completes 10 km in a few seconds)
5. Click *Play* → the phone's location moves along the route; map marker and progress update live
6. Click *Stop* → the phone immediately returns to its real location
**B. Pin to a single location**
With the *Select* tool, double-click anywhere on the map, or use *Jump to coordinate*. Click *Restore real location* when done.
**C. Import an existing track**
Click *Import GPX* and pick a `.gpx` file → the track appears on the map → play it. Exported GPX files also import into Google Earth, Strava, etc.
**D. Preview without a device**
```bash
./.venv/bin/python main.py --mock
```
The UI, route drawing, playback engine and progress bar all work; locations just aren't pushed to a phone (the top bar shows *"mock mode"*).
---
## 6. CLI Tools and Self-Tests
```bash
# List devices + Developer Mode + DVT availability
./.venv/bin/python tools/probe_device.py
./.venv/bin/python tools/probe_device.py --probe # actually open a DVT session
# One-shot virtual location from the CLI
./.venv/bin/python tools/probe_location.py --lat 39.9088 --lon 116.3975
./.venv/bin/python tools/probe_location.py --clear # restore real location
# Offline self-test: 83 unit + end-to-end checks (no device needed)
./.venv/bin/python tests/selftest.py
# Realism model (GPS drift + speed jitter + timing jitter, 40 checks)
./.venv/bin/python tests/drift_sim.py
# Tile disk cache (13 checks) / browser-level offline tiles (9 checks)
./.venv/bin/python tests/offline_tiles.py
./.venv/bin/python tests/offline_browser.py
# UI acceptance (20 checks, drives a real browser)
./.venv/bin/python tests/ui_acceptance.py
# Verify the packaged standalone app: clean-env launch + all endpoints (22 checks)
./.venv/bin/python tools/verify_bundle.py
# Real-device end-to-end: actually moves the phone's location, restores afterwards
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 5 --speed 20
# Screenshot the UI (visual acceptance)
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8787/ /tmp/ui.png --wait 9
```
Packaging helpers:
```bash
./.venv/bin/python tools/make_icon.py # generate the app icon (drawn programmatically)
./.venv/bin/python tools/fetch_ddi.py # fetch developer disk image assets
./.venv/bin/python tools/make_dmg.py # build the DMG
```
---
## 7. Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| UI says "local service not connected" | Server didn't start or the port is taken. Check the terminal; try `--port 8899` |
| No device detected | Cable + unlocked + tap **Trust** |
| Prompted for Developer Mode | Settings → Privacy & Security → Developer Mode → on, then restart the phone |
| "Mount Developer Image" fails with a signature error | The device runs iOS 16 or earlier (unsupported); or the network is blocking Apple TSS. Check connectivity and retry |
| Tunnel-related errors when connecting | ① Close i4Tools / 爱思助手 (they steal the RSD connection); ② use *Rebuild tunnel as administrator*; ③ confirm the developer image is mounted |
| Location doesn't change on the phone | Make sure the UI shows "simulating"; on iOS 17+ the tunnel must be healthy |
| Blank map | Tile source blocked — switch base map (AMap satellite / Esri). **Previously viewed areas work offline** (tiles cached in `~/.ios-location-tiles`, 1 GB cap, LRU eviction) |
| Location snaps back right after the tunnel is up | Another program (i4Tools / Xcode) is fighting for the same RSD connection — close it |
---
## 8. Implementation Notes
- **Transport**: Python stdlib `http.server` plus a hand-written WebSocket (RFC 6455) — no web framework. Device side uses `pymobiledevice3`.
- **Virtual location**: DVT service `com.apple.instruments.dtservicehub` (iOS 17+, over the RSD tunnel), calling `LocationSimulation.set(lat, lon)` / `clear()`.
**The connection must stay open** — once the DVT session drops, simulated location stops working. That's why the playback engine can't reconnect per point.
- **Playback engine**: advances by arc-length integration; the speed profile is generated from turn angles (slower in sharp corners) and then smoothed, so acceleration feels natural. A dedicated thread with a single in-flight request guarantees strictly ordered delivery.
- **Realism model**: drift uses **multi-octave value noise** (120 m slow + 30 m medium + 15 m fast correlation lengths, approximating the power-law spectrum of real GPS error), normalized to the configured radius and clipped at 1.8x; consecutive fixes transition smoothly, so there are no random-jump spikes.
- **Coordinates**: everything internal and backend-side is **WGS84**; AMap tiles are GCJ-02, converted in the frontend both for tile requests and marker placement — otherwise the marker and the actual location would be ~500 m apart.
- **Tiles**: server-side proxy plus disk cache (`~/.ios-location-tiles`, 1 GB cap, LRU). Fully transparent "empty tiles" returned by providers are detected and discarded.
- **Routing**: prefers OSRM real roads (`router.project-osrm.org`); falls back to a straight line offline and labels the engine type on the map.
- **Standalone packaging**: PyInstaller onedir + ad-hoc signature. Tunnel establishment uses **self-invocation** (`<app binary> tunnel-helper`), so the packaged app needs no Python on the machine.
- **Protocol**: see [docs/PROTOCOL.md](docs/PROTOCOL.md).
- **Windows port**: see [docs/WINDOWS_PORT.md](docs/WINDOWS_PORT.md) (portability audit, tunnel
replacement, packaging changes, acceptance baseline). Roughly 90% of the core logic is reusable;
only tunnel establishment and the distribution pipeline need rewriting. (Written in Chinese.)
---
## 9. Known Limitations
- **DVT can only set latitude/longitude**: `simulateLocationWithLatitude:longitude:` takes exactly two arguments. `horizontalAccuracy`, `verticalAccuracy`, `altitude`, `floor`, `sourceInformation.isSimulatedBySoftware`, and all accelerometer / gyroscope / pedometer data are either synthesized by iOS or come from hardware — **they cannot be injected over the DVT channel**. This is a shared boundary of every tool in this category, including Xcode's own location simulation.
- **Some apps are unaffected**: apps using a separate location channel, or performing server-side kinematic cross-validation, may either ignore the simulated location or accept it and discard the data.
- **Only one tool can hold the tunnel at a time**: an iOS 17+ device exposes a single RSD connection, so i4Tools / Xcode and this tool will kick each other off.
- The image-signing step requires reachable Apple TSS servers.
- Map tiles come from public services and are intended for personal testing only — respect their terms of service.
---
## 10. Acceptable Use
This tool is intended for **software testing, development debugging, and regression verification of location-dependent features**, for example:
- Testing how your app renders and behaves in different cities or countries
- Verifying edge cases in route replay, geofencing, and check-in style features
- Demos and teaching
Do not use it to deceive others, falsify travel records, evade attendance systems, or for any unlawful purpose. You are responsible for any consequences of using this tool.
In particular: this tool **does not** provide any capability to bypass third-party anti-cheat or risk-control systems. If you need to test features that depend on altitude, barometer, or pedometer data, the only reliable approach is to add a location-injection hook to a Debug build of your own app and construct a complete `CLLocation` yourself.
+289
View File
@@ -0,0 +1,289 @@
# iOS 虚拟定位轨迹工具(macOS)
[**中文**](README.md) | [English](README.en.md)
在 Mac 上给 iPhone / iPad 做**虚拟定位**与**轨迹模拟**的桌面工具:在地图上绘制路线、添加途经点、按真实速度沿轨迹驱动手机定位。
支持 **iOS 17 ~ iOS 27.2**(RSD 隧道 + DVT `SimulateLocation`),应用内内置了全系共用的开发者镜像素材,**不需要安装 Python 环境**。
界面为本机网页应用,用 Chrome / Edge 的应用窗口打开(无地址栏,像原生 App),也可以直接用浏览器访问 `http://127.0.0.1:8787`。地图、轨迹、播放引擎、设备通信全部在本机完成,只有地图瓦片和(可选的)路线规划需要联网。
---
## 1. 快速开始
### 方式一:直接下载安装包(推荐)
从 [Releases](https://github.com/jbjf6666/ios-location-track/releases) 下载 `iOSLocationTrack-1.0.0.dmg`:
1. 打开 DMG,把 `虚拟定位轨迹.app` 拖进 `Applications`
2. 首次打开若提示「来自身份不明的开发者」→ 右键 → 打开 → 再点「打开」
**不需要安装 Python、不需要任何依赖**,Python 运行时与全部依赖都已打包在应用内(约 80 MB)。
数据(配置、瓦片缓存)写到 `~/Library/Application Support/iOSLocationTrack/`。
> 只支持 **Apple Silicon(M 系列)**。Intel Mac 可用下方源码方式,或在 Intel 机器上执行 `./scripts/build_app.sh`(脚本通用)。
### 方式二:源码运行
```bash
git clone https://github.com/jbjf6666/ios-location-track.git
cd ios-location-track
# 首次运行会自动创建虚拟环境并安装依赖(走国内镜像)
./scripts/run.sh
```
启动后会自动打开界面。命令行等价方式:
```bash
./.venv/bin/python main.py # 正常启动(自动打开界面)
./.venv/bin/python main.py --mock # 无设备模拟模式(演示/验收轨迹与播放)
./.venv/bin/python main.py --port 8899 --no-browser
./.venv/bin/python main.py --window browser # 用默认浏览器打开而不是应用窗口
```
### 三种运行形态
| 形态 | 路径 | 是否需要环境 | 适用场景 |
| --- | --- | --- | --- |
| **DMG 安装包** | Release 附件(45 MB) | **不需要** | 分发 / 换机器 |
| **独立版** | `dist/虚拟定位轨迹.app`(约 80 MB 展开) | **不需要**:Python、依赖、前端、图标、镜像素材全在包内 | 双击即用 |
| 源码版 | `./scripts/run.sh` | 需要 Python 3.11+(首次自动装依赖) | 改代码、调试 |
```bash
# 重新打包(一步完成:图标 → 打包 → ad-hoc 签名 → 制作 DMG → 验证)
./scripts/build_app.sh
```
DMG 里放了应用本体、`Applications` 快捷方式(拖进去即安装)和一份安装说明。
如需自定义 DMG 外观:改 `tools/make_dmg.py` 顶部的常量。
> 首次运行会请求「控制 Chrome/Edge」的权限(用于以应用窗口方式打开界面)。
---
## 2. 支持的系统
| 系统 | 支持 | 说明 |
| --- | --- | --- |
| **iOS 17 ~ 27.2** | ✅ | 走 RSD 隧道 + DVT。应用内置全系共用的个性化镜像素材 |
| iOS 16 及更早 | ❌ | 其开发者镜像必须由 Apple 按系统构建号签发,第三方镜像会被设备拒签(实测 `Failed to verify the disk image signature`,换 libimobiledevice 同样失败) |
**为什么一套镜像就够**:iOS 17 起 Apple 改用「个性化」开发者镜像 —— 一份基础镜像(`Image.dmg`)+ `BuildManifest.plist`,**全系统版本共用**;挂载时由 Apple TSS 服务器按当前设备在线签发,因此不需要逐版本素材。本工具把这套素材打包在内(约 16 MB),点「挂载开发者镜像」即可离线使用(仅签名步骤需要联网)。
---
## 3. 设备准备(关键)
| 要求 | 说明 |
| --- | --- |
| 数据线连接 | 建议用 USB;Wi-Fi 连接在传输大文件时容易断 |
| 解锁并「信任」 | 手机弹出「要信任此电脑吗」必须点**信任**并输入锁屏密码 |
| 开启开发者模式 | 设置 → 隐私与安全性 → **开发者模式** → 打开 → **重启手机** → 重启后确认开启 |
| 挂载开发者镜像 | 点界面上的「挂载开发者镜像」(应用已内置素材),或用 Xcode 连接一次自动完成 |
| 关闭同类工具 | 爱思助手 / i4Tools / Xcode 等会与本工具争抢同一条 RSD 连接,**同一时间只能开一个** |
### 挂载开发者镜像
界面上点 **「挂载开发者镜像」** 即可,过程实时输出到右侧运行日志。命令行等价方式:
```bash
# 查看是否已挂载(返回 [] 表示没挂)
./.venv/bin/python -m pymobiledevice3 mounter list
# 自动挂载
./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <你的UDID>
```
也可以直接用 Xcode:连接设备 → Window → Devices and Simulators → 等待 "Preparing device for development" 完成。
> macOS 装有 Xcode 时,系统自带的 `CoreDeviceService` 通常会在设备接入后**自动挂载**
> (`/Library/Developer/DeveloperDiskImages/`),此时无需任何操作。
> 打包版(.app)内部用 pymobiledevice3 的 **Python API** 完成挂载,不依赖任何命令行工具。
### 关于 iOS 17+ 的隧道(RSD)
iOS 17 起,虚拟定位服务不再走 usbmux,必须先建立 **RSD 隧道**。本工具有三种方式:
1. **免 root(默认,推荐)**:自动执行 `pymobiledevice3 remote start-tunnel --native --script-mode`,借用 Apple 自带 `remoted` 的隧道。打包版通过 `tunnel-helper` 自调用实现,**不依赖外部 Python**。
2. **管理员模式**:界面上点「以管理员权限重建隧道」(会弹系统密码框),或直接 `sudo ./scripts/run.sh`。
3. **手动填 RSD**:界面「手动 RSD」按钮,填入 `HOST PORT`(例如从其它工具或 `tunneld` 拿到)。
> `remote tunneld`、`lockdown start-tunnel`、经典 `remote start-tunnel` 都**必须 root**,本工具不会静默提权。
---
## 4. 界面功能
### 顶部:设备区
- 设备名 / 型号 / iOS 版本 / 连接方式(USB/网络)
- **隧道**状态、**虚拟定位**状态、**开发者模式**状态
- 「刷新设备」「连接设备」「断开」
### 左侧:轨迹设置
- **出行模式**:驾车 / 骑行 / 步行 / 飞行(决定基准速度与是否走真实路网)
- **播放倍速**:0.1x ~ 50x(只影响轨迹推进速度,不影响下发频率)
- **循环播放**:到终点自动回到起点
- **真实感模拟**(可随时开关、播放中也能改):
- **定位漂移**:模拟 GPS 误差导致的缓慢游走,半径 0~50 米(默认 4 米)。
用多尺度平滑噪声实现——沿轨迹连续变化、有惯性,**不会出现随机跳点**。
- **速度浮动**:瞬时速度在设定值上下平滑波动,幅度 0~60%(默认 ±8%)。
- **发送节奏抖动**(默认开):真实手机 GPS 不是精确每秒一次,而是 0.85~1.15 秒抖动、
偶发丢一次采样(约 2%)。间隔完全均匀是最容易被数据侧识别的特征之一。
- **速度由位移推算**(默认开):上报速度 = 实际位移 ÷ 实际时间间隔,与 iOS 从坐标序列
推算 `CLLocation.speed` 的方式一致,因此**速度、位移、时间戳三者自洽**。
- **注入粗差跳点**(默认关):按概率插入多径/城市峡谷导致的坏点,
用来测你自己 App 的 `horizontalAccuracy` 过滤与跳点剔除逻辑是否生效。
- 所有开关与幅度都会持久化,下次启动沿用。
- **快速定位到坐标**:输入经纬度直接跳转,可收藏常用位置
- **路线概览**:总距离、预计耗时、路径点数、途经点数、路线引擎(OSRM 真实路网 / 直线)
- **途经点列表**:拖拽排序、双击改坐标、删除、定位到该点
- **本地保存的路线**:保存 / 载入 / 删除(浏览器本地存储)
- **底图**:高德矢量(GCJ-02,带中文标注)/ 高德卫星 / 全球卫星(Esri) / OpenStreetMap
- 操作:「加载 OSRM 路线」「清空」「导出 GPX」「导入 GPX」「恢复真实定位」
- 瓦片缓存:显示缓存目录与占用,「清理瓦片缓存」
### 地图工具条
- **选择**:拖动地图、拖拽途经点、点击编辑
- **添加途经点**:在地图上连续点击加点(最常用的绘制方式)
- **折线绘制**:连续点击画折线,双击或 Esc 结束
- **撤销上一步**、**删除选中点**、**全览**
### 底部:播放条
- 播放 / 暂停 / 继续 / 停止
- 进度条(可拖动跳转,即 seek)
- 已用时间、剩余时间、序号
- 实时速度、当前坐标、倍速
### 右侧:运行日志
实时显示设备连接、隧道建立、镜像挂载、每次定位下发的日志,便于排查问题。
---
## 5. 典型用法
**A. 沿真实道路跑一条轨迹**
1. 连接设备 → 「连接设备」(iOS 17+ 会自动建隧道,最长约 1 分钟)
2. 点「添加途经点」,在地图上点起点、中间点、终点(至少 2 个)
3. 点「加载 OSRM 路线」→ 生成真实路网折线(无网络时自动退化为直线)
4. 调「播放倍速」(例如 20x 可以在几秒内跑完 10 公里)
5. 点「播放」→ 手机定位会沿轨迹移动;地图上定位点与进度实时更新
6. 点「停止」→ 手机立刻恢复真实定位
**B. 单点定位(钉在某处不动)**
在「选择」工具下双击地图任意位置,或点「快速定位到坐标」输入经纬度。用完点「恢复真实定位」。
**C. 导入已有轨迹**
点「导入 GPX」选择 `.gpx` 文件 → 轨迹出现在地图上 → 播放。导出的 GPX 也能导入 Google Earth、Strava 等工具。
**D. 没有设备时预览效果**
```bash
./.venv/bin/python main.py --mock
```
界面、轨迹绘制、播放引擎、进度条全部可用,只是定位不会真正下发到手机(顶部会显示「当前为模拟模式」)。
---
## 6. 命令行工具与自检
```bash
# 列出设备 + 开发者模式 + DVT 可用性
./.venv/bin/python tools/probe_device.py
./.venv/bin/python tools/probe_device.py --probe # 实际连一次 DVT
# 单点虚拟定位(命令行版)
./.venv/bin/python tools/probe_location.py --lat 39.9088 --lon 116.3975
./.venv/bin/python tools/probe_location.py --clear # 恢复真实定位
# 离线自检:83 项单元 + 端到端(不需要真机)
./.venv/bin/python tests/selftest.py
# 真实感模拟(GPS 漂移 + 速度浮动 + 节奏抖动,40 项)
./.venv/bin/python tests/drift_sim.py
# 瓦片本地缓存(13 项) / 浏览器级离线可用(9 项)
./.venv/bin/python tests/offline_tiles.py
./.venv/bin/python tests/offline_browser.py
# 图形界面验收(20 项,自动起浏览器)
./.venv/bin/python tests/ui_acceptance.py
# 独立版打包产物验证:干净环境启动 + 全部接口(22 项)
./.venv/bin/python tools/verify_bundle.py
# 真机端到端:会真的改变手机定位,结束后自动恢复
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 5 --speed 20
# 给界面截图(视觉验收)
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8787/ /tmp/ui.png --wait 9
```
打包相关:
```bash
./.venv/bin/python tools/make_icon.py # 生成应用图标(程序化绘制)
./.venv/bin/python tools/fetch_ddi.py # 获取开发者镜像素材
./.venv/bin/python tools/make_dmg.py # 制作 DMG
```
---
## 7. 故障排查
| 现象 | 原因与处理 |
| --- | --- |
| 界面显示「本地服务未连接」 | 服务端没起来或端口被占。看终端是否有报错;换端口 `--port 8899` |
| 未检测到设备 | 数据线 + 解锁 + 点「信任」 |
| 提示「需要开发者模式」 | 设置 → 隐私与安全性 → 开发者模式 → 打开并重启手机 |
| 「挂载开发者镜像」失败,提示签名被拒 | 设备是 iOS 16 或更早(不支持);或网络不通导致 TSS 签名失败,检查网络后重试 |
| 连接设备报「隧道」相关错误 | ① 关闭爱思助手等同类工具(会抢 RSD 连接);② 点「以管理员权限重建隧道」;③ 确认开发者镜像已挂载 |
| 定位不生效(手机没动) | 确认「虚拟定位」显示"模拟中";iOS 17+ 必须隧道正常 |
| 地图一片空白 | 瓦片源被网络限制,换底图(高德卫星 / Esri);**已浏览过的区域断网也能显示**(瓦片落在 `~/.ios-location-tiles`,上限 1GB,LRU 淘汰) |
| 隧道建好后手机定位立刻恢复 | 有别的程序(爱思/i4Tools/Xcode)在抢同一条 RSD 连接,关掉它们 |
---
## 8. 技术说明(实现要点)
- **通信**:Python 标准库 `http.server` + 手写 WebSocket(RFC 6455),无第三方 Web 框架;设备侧用 `pymobiledevice3`。
- **虚拟定位**:DVT 服务 `com.apple.instruments.dtservicehub`(iOS 17+,走 RSD 隧道),调用 `LocationSimulation.set(lat, lon)` / `clear()`。
**连接必须长连接保持**——DVT 会话一断,模拟定位立即失效,这决定了播放引擎不能每发一个点就重连。
- **播放引擎**:按弧长积分推进;速度剖面按转弯角度生成(急弯自动减速)再做滑动平均,因此加减速自然。独立线程 + 单一在途请求,保证下发顺序严格不乱。
- **真实感模型**:位置漂移用**多尺度值噪声**(慢漂 120m + 中尺度 30m + 快抖 15m 相关长度,接近真实 GPS 误差的幂律谱),幅值标定到设定半径并做上限裁剪(1.8 倍);相邻上报点之间平滑过渡,不会出现随机跳点造成的毛刺。
- **坐标**:内部与后端**一律 WGS84**;高德瓦片是 GCJ-02,由前端在请求瓦片与落点时做转换,避免"地图上的点和实际定位差 500 米"。
- **瓦片**:服务端代理 + 磁盘缓存(默认 `~/.ios-location-tiles`,上限 1GB,LRU 淘汰)。会自动识别并丢弃服务商返回的"全透明空瓦片"。
- **路线**:优先 OSRM 真实路网(`router.project-osrm.org`),无网络时退化为直线并在地图上标注引擎类型。
- **独立打包**:PyInstaller onedir + ad-hoc 签名。隧道建立通过「自调用」实现(`<应用自身> tunnel-helper`),因此打包后不需要机器上有 Python。
- **接口契约**:见 [docs/PROTOCOL.md](docs/PROTOCOL.md)。
- **Windows 移植**:见 [docs/WINDOWS_PORT.md](docs/WINDOWS_PORT.md)(可移植性审计、隧道方案替换、
打包链路改造、验收基线)。核心逻辑层约 90% 可直接复用,只有隧道建立与分发链路需重写。
---
## 9. 已知限制
- **DVT 只能改经纬度**:`simulateLocationWithLatitude:longitude:` 仅接受两个参数。`horizontalAccuracy`、`verticalAccuracy`、`altitude`、`floor`、`sourceInformation.isSimulatedBySoftware` 以及加速度计/陀螺仪/计步器由 iOS 自行合成或来自硬件,**DVT 通道无法注入**。这是所有同类工具(含 Xcode 自带的模拟定位)的共同边界。
- **部分 App 不受影响**:使用独立定位通道、或在服务端做运动学交叉校验的 App 可能不会跟随模拟定位,或跟随了但数据被判定无效。
- **同一时间只能有一个工具占隧道**:iOS 17+ 设备只有一条 RSD 连接,爱思助手/Xcode 与本工具会互相挤掉。
- 首次挂载镜像的签名步骤需要能访问 Apple TSS 服务器。
- 地图瓦片来自公开服务,仅供个人测试使用,请遵守相应服务条款。
---
## 10. 合法使用
本工具用于**软件测试、开发调试、位置相关功能的回归验证**等正当用途,例如:
- 测试 App 在不同城市/国家的展示与逻辑
- 验证轨迹回放、电子围栏、打卡类功能的边界情况
- 演示与教学
请勿用于欺骗他人、伪造行程记录、规避考勤或任何违法违规用途。使用本工具产生的一切后果由使用者承担。
特别说明:本工具**不提供**针对第三方应用风控/反作弊的绕过能力。如果你需要测试依赖海拔、气压计、计步器等硬件数据的功能,唯一可靠的方式是在你自己 App 的 Debug 构建里加入定位注入入口、自行构造完整的 `CLLocation`。
+312
View File
@@ -0,0 +1,312 @@
"""配置存储:config.json 的读写与默认值(见 docs/PROTOCOL.md 2.3)。"""
from __future__ import annotations
import copy
import json
import os
import threading
from pathlib import Path
from typing import Any
from . import protocol
DEFAULT_TILE_SOURCES: dict[str, dict[str, Any]] = {
"amap": {
"name": "高德矢量(带标注)",
"url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}",
"subdomains": ["1", "2", "3", "4"],
"max_zoom": 18,
"attribution": "© 高德地图",
"coord": "gcj02",
},
"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": "© 高德地图",
"coord": "gcj02",
},
"arcgis_sat": {
"name": "全球卫星(Esri)",
"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"subdomains": [],
"max_zoom": 19,
"attribution": "Esri, Maxar, Earthstar Geographics",
"coord": "wgs84",
},
"osm": {
"name": "OpenStreetMap(国内不可达)",
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"subdomains": [],
"max_zoom": 19,
"attribution": "© OpenStreetMap contributors",
"coord": "wgs84",
},
}
DEFAULT_CONFIG: dict[str, Any] = {
"tile_source": "amap",
"tile_sources": DEFAULT_TILE_SOURCES,
"tile_cache_dir": "~/.ios-location-tiles",
"tile_cache_max_mb": 1024,
"send_hz": protocol.DEFAULT_SEND_HZ,
"default_speed_scale": 1.0,
"mode_defaults": dict(protocol.MODE_DEFAULTS),
"osrm_url": "https://router.project-osrm.org/route/v1",
"last_device": None,
"window": {"width": 1360, "height": 860},
# 真实感模拟:GPS 漂移与速度浮动(校园跑等 App 测试用)
"drift_enabled": True,
"drift_m": 4.0,
"speed_jitter_enabled": True,
"speed_jitter": 0.08,
# 真实感进阶:发送节奏抖动 / 速度由位移推算 / 粗差注入
"timing_jitter_enabled": True,
"derive_speed": True,
"gross_error_enabled": False,
"gross_error_probability": 0.0,
"gross_error_m": 30.0,
}
_IMMUTABLE_KEYS = {"tile_sources"}
class ConfigStore:
def __init__(self, path: str | os.PathLike[str]) -> None:
self.path = Path(path).expanduser()
self._lock = threading.RLock()
self._data: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG)
self._repaired = False
self.load()
# ------------ 持久化 ------------
def load(self) -> dict[str, Any]:
with self._lock:
if self.path.exists():
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
self._merge(raw)
except Exception:
backup = self.path.with_suffix(".bak.json")
try:
self.path.replace(backup)
except OSError:
pass
# 归一化必须放在补齐瓦片源之前:文件里可能是被写坏的字符串等非法值
self._normalize()
sources = self._data.get("tile_sources")
if not isinstance(sources, dict):
sources = copy.deepcopy(DEFAULT_TILE_SOURCES)
for key, value in DEFAULT_TILE_SOURCES.items():
sources.setdefault(key, copy.deepcopy(value))
self._data["tile_sources"] = sources
if self._data.get("tile_source") not in sources:
self._data["tile_source"] = "amap"
self._repaired = True
return copy.deepcopy(self._data)
def _normalize(self) -> None:
"""把每一项都过一遍校验,非法项回退到默认值。"""
for key, default in DEFAULT_CONFIG.items():
if key not in self._data:
self._data[key] = copy.deepcopy(default)
continue
try:
self._data[key] = self._validate(key, self._data[key])
except protocol.AppError:
self._data[key] = copy.deepcopy(default)
self._repaired = True
try:
self._data["tile_sources"] = self._validate("tile_sources", self._data.get("tile_sources"))
except protocol.AppError:
self._data["tile_sources"] = copy.deepcopy(DEFAULT_TILE_SOURCES)
self._repaired = True
if self._data.get("tile_source") not in (self._data.get("tile_sources") or {}):
self._data["tile_source"] = "amap"
self._repaired = True
def _merge(self, incoming: dict[str, Any]) -> None:
for key, value in incoming.items():
if key in _IMMUTABLE_KEYS and isinstance(value, dict):
merged = copy.deepcopy(DEFAULT_TILE_SOURCES)
for sub_key, sub_value in value.items():
if isinstance(sub_value, dict):
merged[sub_key] = {**merged.get(sub_key, {}), **sub_value}
self._data[key] = merged
else:
self._data[key] = value
def save(self) -> None:
with self._lock:
payload = copy.deepcopy(self._data)
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)
# ------------ 访问 ------------
def snapshot(self) -> dict[str, Any]:
with self._lock:
return copy.deepcopy(self._data)
def get(self, key: str, default: Any = None) -> Any:
with self._lock:
return copy.deepcopy(self._data.get(key, default))
def update(self, patch: dict[str, Any], persist: bool = True) -> dict[str, Any]:
if not isinstance(patch, dict):
raise protocol.AppError("配置必须是 JSON 对象", "bad_config")
with self._lock:
allowed = set(DEFAULT_CONFIG) | {"tile_sources"}
unknown = [k for k in patch if k not in allowed]
if unknown:
raise protocol.AppError(
"不支持的配置项:" + "、".join(unknown), "bad_config"
)
validated = {}
incoming = patch.get("tile_sources")
self._incoming_sources = incoming if isinstance(incoming, dict) else None
try:
for key, value in patch.items():
validated[key] = self._validate(key, value)
finally:
self._incoming_sources = None
self._merge(validated)
if persist:
self.save()
return self.snapshot()
def _validate(self, key: str, value: Any) -> Any:
"""逐项做类型与范围校验:非法值必须被拒绝,绝不能写进运行态。
历史上 `tile_sources` 被写成字符串会导致所有瓦片请求 500(地图永久空白),
`send_hz` 被写成 NaN 会让 welcome 变成非法 JSON,因此这里逐项严格校验。
"""
if key == "tile_sources":
if not isinstance(value, dict) or not value:
raise protocol.AppError("tile_sources 必须是「源名称 → 配置」的对象", "bad_config")
cleaned: dict[str, Any] = {}
for name, item in value.items():
if not isinstance(name, str) or not name.strip():
raise protocol.AppError("瓦片源名称必须是非空字符串", "bad_config")
if not isinstance(item, dict):
raise protocol.AppError(f"瓦片源 {name} 的配置必须是对象", "bad_config")
url = item.get("url")
if url is not None and (not isinstance(url, str) or "{z}" not in url):
raise protocol.AppError(
f"瓦片源 {name} 的 url 必须包含 {{z}}/{{x}}/{{y}} 占位符", "bad_config"
)
subs = item.get("subdomains") or []
if not isinstance(subs, list) or any(not isinstance(s, (str, int)) for s in subs):
raise protocol.AppError(f"瓦片源 {name} 的 subdomains 必须是字符串数组", "bad_config")
if "max_zoom" in item:
zoom = protocol.finite(item["max_zoom"], f"{name}.max_zoom")
if not (0 <= zoom <= 22):
raise protocol.AppError(f"瓦片源 {name} 的 max_zoom 需在 0~22 之间", "bad_config")
item = {**item, "max_zoom": int(zoom)}
cleaned[name] = {**DEFAULT_TILE_SOURCES.get(name, {}), **item}
return cleaned
if key == "tile_source":
if not isinstance(value, str) or not value.strip():
raise protocol.AppError("tile_source 必须是非空字符串", "bad_config")
# 同一请求里若同时提供了 tile_sources,就以新集合为准(否则自定义源会被误拒)
incoming = getattr(self, "_incoming_sources", None)
known = incoming if isinstance(incoming, dict) and incoming else (
self._data.get("tile_sources") or DEFAULT_TILE_SOURCES
)
if value not in known:
raise protocol.AppError(f"未知的瓦片源:{value}", "bad_config")
return value
if key == "tile_cache_dir":
if not isinstance(value, str) or not value.strip():
raise protocol.AppError("tile_cache_dir 必须是非空路径字符串", "bad_config")
return value
if key == "tile_cache_max_mb":
number = protocol.finite(value, "tile_cache_max_mb")
if not (64 <= number <= 102400):
raise protocol.AppError("瓦片缓存上限需在 64 ~ 102400 MB 之间", "bad_config")
return int(number)
if key == "send_hz":
number = protocol.finite(value, "send_hz")
if not (protocol.MIN_SEND_HZ <= number <= protocol.MAX_SEND_HZ):
raise protocol.AppError(
f"下发频率需在 {protocol.MIN_SEND_HZ} ~ {protocol.MAX_SEND_HZ} Hz 之间", "bad_config"
)
return number
if key == "default_speed_scale":
return protocol.validate_speed_scale(value)
if key in ("drift_enabled", "speed_jitter_enabled", "timing_jitter_enabled",
"derive_speed", "gross_error_enabled"):
if not isinstance(value, bool):
raise protocol.AppError(f"{key} 必须是 true/false", "bad_config")
return value
if key == "gross_error_probability":
number = protocol.finite(value, "粗差概率")
if not (0.0 <= number <= 0.5):
raise protocol.AppError("粗差概率需在 0 ~ 0.5 之间", "bad_config")
return number
if key == "gross_error_m":
number = protocol.finite(value, "粗差幅度")
if not (1.0 <= number <= 500.0):
raise protocol.AppError("粗差幅度需在 1 ~ 500 米之间", "bad_config")
return number
if key == "drift_m":
number = protocol.finite(value, "漂移半径")
if not (0.0 <= number <= 50.0):
raise protocol.AppError("漂移半径需在 0 ~ 50 米之间", "bad_config")
return number
if key == "speed_jitter":
number = protocol.finite(value, "速度浮动幅度")
if 1.0 < number <= 100.0:
number = number / 100.0 # 允许传百分数,但 500% 这种非法值不能变成 5%
if not (0.0 <= number <= 0.6):
raise protocol.AppError("速度浮动幅度需在 0 ~ 60%(或 0 ~ 0.6)之间", "bad_config")
return number
if key == "mode_defaults":
if not isinstance(value, dict):
raise protocol.AppError("mode_defaults 必须是对象", "bad_config")
out: dict[str, float] = {}
for mode, speed in value.items():
if mode not in protocol.MODE_DEFAULTS:
raise protocol.AppError(f"未知出行方式:{mode}", "bad_config")
number = protocol.finite(speed, f"{mode} 基准速度")
if not (0.2 <= number <= 400):
raise protocol.AppError(f"{mode} 基准速度需在 0.2 ~ 400 m/s 之间", "bad_config")
out[mode] = number
return {**protocol.MODE_DEFAULTS, **out}
if key == "osrm_url":
if not isinstance(value, str):
raise protocol.AppError("osrm_url 必须是字符串", "bad_config")
return value.strip()
if key == "last_device":
if value is not None and not isinstance(value, str):
raise protocol.AppError("last_device 必须是字符串或 null", "bad_config")
return value
if key == "window":
if not isinstance(value, dict):
raise protocol.AppError("window 必须是对象", "bad_config")
out_window: dict[str, int] = {}
for name in ("width", "height"):
if name in value:
number = protocol.finite(value[name], f"window.{name}")
if not (200 <= number <= 10000):
raise protocol.AppError(f"window.{name} 需在 200 ~ 10000 之间", "bad_config")
out_window[name] = int(number)
return {**(self._data.get("window") or {}), **out_window}
return value
def tile_sources(self) -> dict[str, dict[str, Any]]:
return self.get("tile_sources") or {}
def tile_source(self, key: str | None = None) -> tuple[str, dict[str, Any]]:
sources = self.tile_sources()
wanted = key or self.get("tile_source") or "amap"
if wanted not in sources:
wanted = "amap"
return wanted, sources[wanted]
def cache_dir(self) -> Path:
raw = str(self.get("tile_cache_dir") or "~/.ios-location-tiles")
return Path(raw).expanduser()
+427
View File
@@ -0,0 +1,427 @@
"""设备发现与管理。
优先使用 pymobiledevice3 的 Python API(import 失败时自动降级到 libimobiledevice 的
idevice_id / ideviceinfo 命令行),保证在依赖没装好的机器上也能列出设备。
不丢异常:任何一步失败都写进 Device.last_error,前端只看到中文提示。
"""
from __future__ import annotations
import asyncio
import inspect
import json
import re
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable
from . import protocol
USBMUX_TIMEOUT = 6.0
@dataclass
class Device:
udid: str
name: str = "iPhone"
product_type: str = ""
ios_version: str = ""
ios_major: int = 0
connection: str = "usb"
state: str = "connected"
tunnel: str = "idle"
simulating: bool = False
developer_mode: bool | None = None
tunnel_hint: str | None = None
last_error: str | None = None
raw: dict[str, Any] = field(default_factory=dict, repr=False)
def to_json(self) -> dict[str, Any]:
return {
"udid": self.udid,
"name": self.name,
"product_type": self.product_type,
"ios_version": self.ios_version,
"ios_major": self.ios_major,
"connection": self.connection,
"state": self.state,
"tunnel": self.tunnel,
"simulating": self.simulating,
"developer_mode": self.developer_mode,
"tunnel_hint": self.tunnel_hint,
"last_error": self.last_error,
}
PRODUCT_NAMES = {
"iPhone10,3": "iPhone X", "iPhone10,6": "iPhone X", "iPhone11,2": "iPhone XS",
"iPhone11,8": "iPhone XR", "iPhone12,1": "iPhone 11", "iPhone12,3": "iPhone 11 Pro",
"iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", "iPhone14,2": "iPhone 13 Pro",
"iPhone14,5": "iPhone 13", "iPhone14,7": "iPhone 14", "iPhone15,2": "iPhone 14 Pro",
"iPhone15,4": "iPhone 15", "iPhone16,1": "iPhone 15 Pro", "iPhone17,1": "iPhone 16 Pro",
"iPhone17,3": "iPhone 16", "iPhone18,1": "iPhone 17 Pro", "iPhone18,3": "iPhone 17",
}
def _ios_major(version: str) -> int:
match = re.match(r"(\d+)", str(version or ""))
return int(match.group(1)) if match else 0
def _friendly_name(product_type: str, fallback: str) -> str:
return PRODUCT_NAMES.get(product_type or "", "") or fallback or "iPhone"
class DeviceManager:
"""设备列表 + 选中设备。线程安全,可被 HTTP/WS 线程并发调用。"""
def __init__(self, mock: bool = False, on_change: Callable[[], None] | None = None) -> None:
self.mock = mock
self._lock = threading.RLock()
self._devices: dict[str, Device] = {}
self._selected: str | None = None
self._on_change = on_change
self._last_signature = ""
self._monitor: threading.Thread | None = None
self._stop = threading.Event()
self.idevice_id = shutil.which("idevice_id")
self.ideviceinfo = shutil.which("ideviceinfo")
self._backend = "unavailable"
if mock:
device = Device(
udid="MOCK-0001",
name="模拟设备 (iPhone 17 Pro)",
product_type="iPhone18,1",
ios_version="26.0",
ios_major=26,
connection="mock",
state="connected",
tunnel="connected",
developer_mode=True,
)
self._devices[device.udid] = device
self._selected = device.udid
self._backend = "mock"
# ---------------- 生命周期 ----------------
def start_monitor(self, interval: float = 3.0) -> None:
if self.mock or self._monitor is not None:
return
self._stop.clear()
self._monitor = threading.Thread(target=self._monitor_loop, args=(interval,), name="device-monitor", daemon=True)
self._monitor.start()
def stop_monitor(self) -> None:
self._stop.set()
if self._monitor is not None:
self._monitor.join(timeout=2.0)
self._monitor = None
def _monitor_loop(self, interval: float) -> None:
while not self._stop.is_set():
try:
self.refresh()
except Exception:
pass
self._stop.wait(interval)
# ---------------- 查询 ----------------
def _pick_default(self, devices: dict[str, "Device"]) -> str | None:
"""从多台设备里挑一台最可能"真的能用来做虚拟定位"的。
排序依据(依次比较):
1. 是否是"可用"状态(未配对/出错的排后面);
2. USB 优先于网络连接(Wi-Fi 连接的设备常常没挂开发者镜像,且隧道不稳);
3. iOS 版本更新的优先(新系统对 DVT 支持更完整)。
之前只取列表第一台,导致同时插着两台设备时随机选中不可用的那台。
"""
if not devices:
return None
def score(device: Device) -> tuple:
usable = 0 if device.state == "connected" else 1
usb = 0 if device.connection == "usb" else 1
return (usable, usb, -(device.ios_major or 0))
return sorted(devices.values(), key=score)[0].udid
def refresh(self) -> list[Device]:
if self.mock:
return self.list_devices()
raw_devices = self._discover()
with self._lock:
previous = self._devices
devices: dict[str, Device] = {}
for item in raw_devices:
udid = item.get("udid")
if not udid:
continue
old = previous.get(udid)
product_type = item.get("product_type") or (old.product_type if old else "")
device = Device(
udid=udid,
name=_friendly_name(product_type, item.get("name") or ""),
product_type=product_type,
ios_version=item.get("ios_version") or (old.ios_version if old else ""),
ios_major=_ios_major(item.get("ios_version") or (old.ios_version if old else "")),
connection=item.get("connection") or "usb",
state=item.get("state") or "connected",
tunnel=old.tunnel if old else "idle",
simulating=old.simulating if old else False,
developer_mode=item.get("developer_mode", old.developer_mode if old else None),
tunnel_hint=old.tunnel_hint if old else None,
last_error=item.get("last_error") or (old.last_error if old else None),
raw=item.get("raw") or {},
)
devices[udid] = device
if self._selected not in devices:
self._selected = self._pick_default(devices)
self._devices = devices
signature = json.dumps(
[{k: v for k, v in d.to_json().items() if k not in ("simulating",)} for d in devices.values()],
sort_keys=True,
ensure_ascii=False,
) + f"|{self._selected}"
self._backend = self._backend if self._backend == "mock" else ("pymobiledevice3" if not raw_devices else self._backend)
if signature != self._last_signature:
self._last_signature = signature
if self._on_change:
try:
self._on_change()
except Exception:
pass
return self.list_devices()
def list_devices(self) -> list[Device]:
with self._lock:
return list(self._devices.values())
def devices_json(self) -> dict[str, Any]:
with self._lock:
return {
"devices": [d.to_json() for d in self._devices.values()],
"selected": self._selected,
}
def get(self, udid: str | None = None) -> Device | None:
with self._lock:
if udid:
return self._devices.get(udid)
if self._selected:
return self._devices.get(self._selected)
return next(iter(self._devices.values()), None)
def get_selected(self) -> Device | None:
return self.get(None)
def select(self, udid: str) -> Device:
with self._lock:
device = self._devices.get(udid)
if device is None:
raise protocol.AppError("设备不存在或已断开,请刷新设备列表", "no_device", status=404)
self._selected = udid
self.refresh()
return self.get(udid) or device
def selected_udid(self) -> str | None:
with self._lock:
return self._selected
def mark(self, udid: str, **fields: Any) -> None:
with self._lock:
device = self._devices.get(udid)
if device is None:
return
for key, value in fields.items():
if hasattr(device, key):
setattr(device, key, value)
@property
def backend(self) -> str:
return self._backend
# ---------------- 发现实现 ----------------
def _discover(self) -> list[dict[str, Any]]:
devices = self._discover_pymobiledevice3()
if devices is not None:
return devices
return self._discover_cli()
def _discover_pymobiledevice3(self) -> list[dict[str, Any]] | None:
try:
from pymobiledevice3.lockdown import create_using_usbmux # type: ignore
from pymobiledevice3.usbmux import list_devices as usbmux_list # type: ignore
except Exception:
return None
try:
import asyncio
async def _collect() -> list[dict[str, Any]]:
found = await usbmux_list()
out: list[dict[str, Any]] = []
for item in found:
udid = getattr(item, "serial", None) or getattr(item, "udid", None)
if not udid:
continue
# 注意:pymobiledevice3 的 connection_type 区分大小写,必须是 "USB" / "Network"
raw_type = str(getattr(item, "connection_type", "USB") or "USB")
usb = raw_type.strip().lower() == "usb"
entry: dict[str, Any] = {
"udid": udid,
"connection": "usb" if usb else "network",
"state": "connected",
"raw": {},
}
try:
client = await create_using_usbmux(
serial=udid,
autopair=True,
connection_type="USB" if usb else "Network",
# 配对对话框最长等 12 秒太久了:设备已配对时不会走这里,
# 未配对时短超时能避免 /api/devices 与 welcome 长时间阻塞。
pair_timeout=3.0,
)
except Exception as exc:
entry["state"] = "unpaired"
entry["last_error"] = _friendly_pair_error(exc)
out.append(entry)
continue
try:
info: dict[str, Any] = {}
for getter in ("short_info", "all_values"):
try:
candidate = getattr(client, getter, None)
if callable(candidate):
candidate = candidate()
if isinstance(candidate, dict) and candidate:
info = candidate
break
except Exception:
continue
entry["product_type"] = str(info.get("ProductType") or "")
entry["ios_version"] = str(
info.get("ProductVersion") or getattr(client, "product_version", "") or ""
)
entry["name"] = str(info.get("DeviceName") or "")
entry["developer_mode"] = await _read_developer_mode(client, info)
entry["raw"] = {
"build": info.get("BuildVersion"),
"model": info.get("HardwareModel"),
"connection": raw_type,
}
except Exception as exc:
entry["state"] = "unpaired"
entry["last_error"] = _friendly_pair_error(exc)
finally:
try:
closed = client.close()
if inspect.isawaitable(closed):
await closed
except Exception:
pass
out.append(entry)
return out
result = asyncio.run(_collect())
self._backend = "pymobiledevice3"
return result
except Exception:
return None
def _discover_cli(self) -> list[dict[str, Any]]:
if not self.idevice_id:
self._backend = "unavailable"
return []
self._backend = "libimobiledevice"
try:
output = subprocess.run(
[self.idevice_id, "-l"], capture_output=True, text=True, timeout=USBMUX_TIMEOUT
).stdout
except Exception:
return []
devices: list[dict[str, Any]] = []
for line in output.splitlines():
udid = line.strip()
if not udid or udid.startswith("Error"):
continue
entry: dict[str, Any] = {"udid": udid, "connection": "usb", "state": "connected", "raw": {}}
if self.ideviceinfo:
try:
info = subprocess.run(
[self.ideviceinfo, "-u", udid, "-k", "ProductVersion"],
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
)
entry["ios_version"] = info.stdout.strip()
except Exception:
pass
try:
info = subprocess.run(
[self.ideviceinfo, "-u", udid, "-k", "ProductType"],
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
)
entry["product_type"] = info.stdout.strip()
except Exception:
pass
try:
info = subprocess.run(
[self.ideviceinfo, "-u", udid, "-k", "DeviceName"],
capture_output=True, text=True, timeout=USBMUX_TIMEOUT,
)
entry["name"] = info.stdout.strip()
except Exception:
pass
entry["name"] = _friendly_name(entry.get("product_type", ""), entry.get("name", ""))
devices.append(entry)
return devices
async def _read_developer_mode(client: Any, info: dict[str, Any]) -> bool | None:
"""读取开发者模式状态(该接口在 11.x 是协程)。"""
for key in ("DeveloperModeStatus", "developer_mode_status"):
if key in info:
return bool(info[key])
getter = getattr(client, "get_developer_mode_status", None)
if not callable(getter):
return None
try:
result = getter()
if inspect.isawaitable(result):
result = await result
return bool(result)
except Exception:
return None
def _friendly_pair_error(exc: Exception) -> str:
text = str(exc)
lowered = text.lower()
if "pair" in lowered or "trust" in lowered:
return "设备未信任此电脑:请在 iPhone 上点「信任」并输入锁屏密码"
if "password" in lowered or "protected" in lowered:
return "设备已锁定:请解锁 iPhone 后重试"
return f"读取设备信息失败:{text[:120]}"
def haversine_is_available() -> bool:
return shutil.which("pymobiledevice3") is not None
def pymobiledevice3_importable() -> bool:
try:
import pymobiledevice3 # noqa: F401
return True
except Exception:
return False
def wait_for_pymobiledevice3(timeout: float = 30.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if pymobiledevice3_importable():
return True
time.sleep(1.0)
return pymobiledevice3_importable()
+234
View File
@@ -0,0 +1,234 @@
"""地理计算:距离、方位角、弧长插值、路径加密。
约定:坐标一律 (lat, lng) 的浮点元组,单位为度,WGS84。
对外 JSON 里的坐标是 [lng, lat](GeoJSON 习惯),转换在 route_engine 里做。
"""
from __future__ import annotations
import math
from .protocol import EARTH_RADIUS_M, clamp, round6
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""两点大圆距离(米)。"""
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = p2 - p1
dl = math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
def bearing(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""起点到终点的方位角(度,0=正北,顺时针)。"""
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dl = math.radians(lng2 - lng1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def lerp_point(p1: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]:
"""线性插值(小范围下与测地线插值差异可忽略)。"""
return (lerp(p1[0], p2[0], t), lerp(p1[1], p2[1], t))
def catmull_rom(
p0: tuple[float, float],
p1: tuple[float, float],
p2: tuple[float, float],
p3: tuple[float, float],
t: float,
) -> tuple[float, float]:
"""Catmull-Rom 样条插值,用于把折线平滑成车道路径。"""
t2 = t * t
t3 = t2 * t
lat = 0.5 * (
(2 * p1[0])
+ (-p0[0] + p2[0]) * t
+ (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2
+ (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3
)
lng = 0.5 * (
(2 * p1[1])
+ (-p0[1] + p2[1]) * t
+ (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2
+ (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3
)
return (lat, lng)
def cumulative_lengths(path: list[tuple[float, float]]) -> list[float]:
"""返回与 path 等长的累计距离数组(米),首元素为 0。"""
acc = [0.0]
total = 0.0
for i in range(1, len(path)):
total += haversine(path[i - 1][0], path[i - 1][1], path[i][0], path[i][1])
acc.append(total)
return acc
def _binary_search(acc: list[float], value: float) -> int:
"""返回 i 使得 acc[i] <= value < acc[i+1](value 超界时返回最后一个区间)。"""
lo, hi = 0, len(acc) - 1
if value <= acc[0]:
return 0
if value >= acc[-1]:
return max(0, hi - 1)
while lo < hi - 1:
mid = (lo + hi) // 2
if acc[mid] <= value:
lo = mid
else:
hi = mid
return lo
def interpolate(
path: list[tuple[float, float]], acc: list[float], distance: float
) -> tuple[tuple[float, float], float]:
"""按弧长在路径上取点,返回 ((lat,lng), 该段内的比例 t)。"""
if not path:
raise ValueError("路径为空")
if len(path) == 1:
return path[0], 0.0
i = _binary_search(acc, distance)
seg = acc[i + 1] - acc[i]
t = 0.0 if seg <= 1e-9 else clamp((distance - acc[i]) / seg, 0.0, 1.0)
return lerp_point(path[i], path[i + 1], t), t
def slice_path(
path: list[tuple[float, float]], acc: list[float], distance: float
) -> list[tuple[float, float]]:
"""返回从起点到指定弧长的子路径(用于前端高亮已播放段)。"""
if len(path) < 2:
return list(path)
i = _binary_search(acc, distance)
out = path[: i + 1]
point, _ = interpolate(path, acc, distance)
out.append(point)
return out
def densify_segment(
p1: tuple[float, float], p2: tuple[float, float], step_m: float
) -> list[tuple[float, float]]:
"""在两点之间按 step_m 加密(不含起点,含终点)。"""
dist = haversine(p1[0], p1[1], p2[0], p2[1])
if dist <= 1e-6:
return [p2]
n = max(1, int(math.ceil(dist / max(1.0, step_m))))
return [lerp_point(p1, p2, k / n) for k in range(1, n + 1)]
def densify(
points: list[tuple[float, float]], step_m: float = 2.0, smooth: bool = False
) -> list[tuple[float, float]]:
"""把稀疏折线加密到约 step_m 的采样间隔;smooth=True 时用样条平滑。"""
pts = _dedupe(points)
if len(pts) < 2:
return pts
if not smooth or len(pts) < 3:
out = [pts[0]]
for i in range(1, len(pts)):
out.extend(densify_segment(pts[i - 1], pts[i], step_m))
return out
padded = [pts[0]] + pts + [pts[-1]]
out = [pts[0]]
for i in range(1, len(padded) - 2):
p0, p1, p2, p3 = padded[i - 1], padded[i], padded[i + 1], padded[i + 2]
seg_len = haversine(p1[0], p1[1], p2[0], p2[1])
n = max(1, int(math.ceil(seg_len / max(1.0, step_m))))
for k in range(1, n + 1):
out.append(catmull_rom(p0, p1, p2, p3, k / n))
return out
def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
out: list[tuple[float, float]] = []
for point in points:
if not out or haversine(out[-1][0], out[-1][1], point[0], point[1]) > 0.05:
out.append(point)
return out
def simplify(points: list[tuple[float, float]], tolerance_m: float = 1.5) -> list[tuple[float, float]]:
"""Douglas-Peucker 简化,用于压缩轨迹体积。"""
if len(points) < 3:
return list(points)
keep = [False] * len(points)
keep[0] = keep[-1] = True
stack = [(0, len(points) - 1)]
while stack:
start, end = stack.pop()
if end <= start + 1:
continue
max_dist = -1.0
index = start
for i in range(start + 1, end):
d = _point_segment_distance(points[i], points[start], points[end])
if d > max_dist:
max_dist = d
index = i
if max_dist > tolerance_m:
keep[index] = True
stack.append((start, index))
stack.append((index, end))
return [p for p, k in zip(points, keep) if k]
def _point_segment_distance(
p: tuple[float, float], a: tuple[float, float], b: tuple[float, float]
) -> float:
"""点到线段的近似距离(米):先把经纬度投影到局部平面。"""
lat0 = math.radians((a[0] + b[0]) / 2)
scale_x = EARTH_RADIUS_M * math.cos(lat0) * math.pi / 180.0
scale_y = EARTH_RADIUS_M * math.pi / 180.0
px, py = (p[1] - a[1]) * scale_x, (p[0] - a[0]) * scale_y
bx, by = (b[1] - a[1]) * scale_x, (b[0] - a[0]) * scale_y
denom = bx * bx + by * by
if denom <= 1e-12:
return math.hypot(px, py)
t = clamp((px * bx + py * by) / denom, 0.0, 1.0)
return math.hypot(px - t * bx, py - t * by)
def wgs84_to_gcj02_approx(lat: float, lng: float) -> tuple[float, float]:
"""后端不参与地图显示,这里仅提供与前端一致的近似实现(用于自检)。"""
if lng < 72.004 or lng > 137.8347 or lat < 0.8293 or lat > 55.8271:
return lat, lng
a = 6378245.0
ee = 0.00669342162296594323
dlat = _transform_lat(lng - 105.0, lat - 35.0)
dlng = _transform_lng(lng - 105.0, lat - 35.0)
rad_lat = lat / 180.0 * math.pi
magic = math.sin(rad_lat)
magic = 1 - ee * magic * magic
sqrt_magic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrt_magic) * math.pi)
dlng = (dlng * 180.0) / (a / sqrt_magic * math.cos(rad_lat) * math.pi)
return round6(lat + dlat), round6(lng + dlng)
def _transform_lat(x: float, y: float) -> float:
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lng(x: float, y: float) -> float:
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
return ret
+229
View File
@@ -0,0 +1,229 @@
"""无设备模拟定位提供者(``--mock`` 模式)。
契约(冻结,见 docs/PROTOCOL.md 第 4 节与 docs/DEVICE_NOTES.md):
class MockLocationProvider:
async def connect(self, device) -> dict
async def set_location(self, lat, lon) -> None
async def clear_location(self) -> None
async def close(self) -> None
@property
def connected(self) -> bool
行为:只记录日志、绝不报错,每次下发延时 5~10ms 模拟真机往返。
本模块**不依赖 pymobiledevice3**,任何平台都能 import。
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from typing import Any, Callable, Optional
__all__ = ["MockLocationProvider", "MOCK_UDID", "MOCK_IOS_VERSION", "MOCK_IOS_MAJOR"]
logger = logging.getLogger("iosloc.location.mock")
#: 与 docs/PROTOCOL.md 第 4 节一致的假设备标识
MOCK_UDID = "MOCK-0001"
MOCK_IOS_VERSION = "26.0"
MOCK_IOS_MAJOR = 26
#: 模拟单次下发往返耗时(秒),遵循任务书 5~10ms 的要求
MOCK_LATENCY_MIN = 0.005
MOCK_LATENCY_MAX = 0.010
def _detect_mock_device_fields(device: Any) -> dict:
"""从 lead 的 DeviceManager 返回的 Device dict 中提取字段(容错)。
允许 ``device`` 为 ``None`` / dict / 具有同名属性的对象。
"""
fields = {
"udid": MOCK_UDID,
"name": "模拟设备 (iPhone 17 Pro)",
"ios_version": MOCK_IOS_VERSION,
"ios_major": MOCK_IOS_MAJOR,
"connection": "mock",
}
if device is None:
return fields
def _get(key: str) -> Any:
if isinstance(device, dict):
return device.get(key)
return getattr(device, key, None)
udid = _get("udid")
if udid:
fields["udid"] = str(udid)
name = _get("name")
if name:
fields["name"] = str(name)
ios_version = _get("ios_version")
if ios_version:
fields["ios_version"] = str(ios_version)
ios_major = _get("ios_major")
if isinstance(ios_major, int) and ios_major > 0:
fields["ios_major"] = ios_major
return fields
class MockLocationProvider:
"""假定位提供者:接口与 :class:`core.location_provider.RealLocationProvider` 完全一致。"""
def __init__(
self,
device: Any = None,
on_log: Optional[Callable[[str, str], None]] = None,
latency: Optional[tuple] = None,
log: Optional[Callable[[str, str], None]] = None,
) -> None:
"""
:param device: 可选,``DeviceManager`` 返回的 Device dict(仅用于取 udid/版本信息)。
:param on_log: 可选日志回调 ``(level, msg)``,供服务端把日志推给前端。
:param latency: 可选 ``(min, max)`` 秒;默认 5~10ms。
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``MockLocationProvider(log=self.log)``)。
"""
self._device = device
self._on_log = log or on_log
self._latency = latency or (MOCK_LATENCY_MIN, MOCK_LATENCY_MAX)
self._connected = False
self._info: dict = {}
self._last_location: Optional[tuple] = None
self._send_count = 0
self._last_log_at = 0.0
self._started_at = time.time()
# ------------------------------------------------------------------ 日志
def _log(self, level: str, msg: str) -> None:
text = f"[模拟模式] {msg}"
getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)(text)
if self._on_log is not None:
try:
self._on_log(level, text)
except Exception: # 日志失败绝不能影响定位流程
logger.exception("on_log 回调异常(已忽略)")
async def _sleep_roundtrip(self) -> None:
"""模拟真机 USB/DVT 往返耗时 5~10ms。"""
low, high = self._latency
delay = random.uniform(float(low), float(high))
await asyncio.sleep(delay)
# -------------------------------------------------------------- 契约接口
@property
def connected(self) -> bool:
"""是否已「连接」(mock 下 connect 成功后恒为 True)。"""
return self._connected
@property
def last_location(self) -> Optional[tuple]:
"""最近一次下发的 ``(lat, lon)``,未下发过为 ``None``。"""
return self._last_location
@property
def send_count(self) -> int:
"""累计下发次数(便于自测断言)。"""
return self._send_count
async def connect(self, device: Any = None) -> dict:
"""「连接」假设备,永远成功,不抛异常。"""
if device is not None:
self._device = device
self._info = _detect_mock_device_fields(self._device)
await self._sleep_roundtrip()
self._connected = True
self._log("success", f"已连接{self._info['name']}(udid={self._info['udid']},iOS {self._info['ios_version']})")
self._log("info", "模拟模式:定位下发仅记录日志,不会真正修改设备位置")
return {
"ok": True,
"mock": True,
"udid": self._info["udid"],
"name": self._info["name"],
"ios_version": self._info["ios_version"],
"ios_major": self._info["ios_major"],
"connection": "mock",
"tunnel": "connected",
"detail": f"模拟设备已连接(iOS {self._info['ios_version']},无需隧道)",
}
async def set_location(self, lat: float, lon: float) -> None:
"""记录一次单点定位;永远成功。"""
lat = float(lat)
lon = float(lon)
await self._sleep_roundtrip()
if not self._connected:
# mock 模式刻意不报错:自动补一次 connect,保证端到端流程永远走得通
self._log("warn", "尚未 connect 就收到 set_location,已自动补建模拟会话")
await self.connect(self._device)
self._last_location = (lat, lon)
self._send_count += 1
# 播放时按 5Hz 下发,逐次刷日志会淹没界面(服务端播放引擎已有"每 5 秒聚合"日志),
# 这里同样做节流:每 25 次或每 5 秒才记一条。
_now = time.time()
if (
self._send_count <= 3
or self._send_count % 25 == 0
or (_now - self._last_log_at) >= 5.0
):
self._last_log_at = _now
self._log("info", f"下发定位 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次,模拟)")
async def clear_location(self) -> None:
"""「恢复真实定位」;永远成功。"""
await self._sleep_roundtrip()
if self._last_location is not None:
lat, lon = self._last_location
self._log("info", f"清除模拟定位,设备恢复真实定位(上次 lat={lat:.6f}, lon={lon:.6f},模拟)")
else:
self._log("info", "清除模拟定位,设备恢复真实定位(此前未下发过,模拟)")
self._last_location = None
async def close(self) -> None:
"""关闭会话;永远成功、幂等。"""
if not self._connected:
return
await self._sleep_roundtrip()
self._connected = False
self._log("info", "模拟定位会话已关闭")
def connect_sync(self, runner: Any, device: Any = None) -> dict:
"""同步建立会话(供同步代码提交给 ``AsyncRunner``,绝不新建事件循环)。
``core/server.py`` 的 mock 分支就是 ``provider.connect_sync(self.runner)``。
"""
if self._connected:
return dict(self._info)
run = getattr(runner, "run", None)
if not callable(run):
raise RuntimeError("传入的 runner 不支持 run(coro),无法同步建立模拟会话")
return run(self.connect(device))
async def probe(self) -> dict:
"""「测试连接」按钮用:mock 下恒为可用。"""
major = int(self._info.get("ios_major") or MOCK_IOS_MAJOR)
return {
"ok": True,
"detail": f"模拟模式:DVT 虚拟定位可用(iOS {major},无需隧道)",
"ios_major": major,
}
# ------------------------------------------------------------------ 辅助
def __repr__(self) -> str: # pragma: no cover - 调试用
return (
f"<MockLocationProvider connected={self._connected} "
f"udid={self._info.get('udid')!r} sends={self._send_count}>"
)
def stats(self) -> dict:
"""运行统计(供 /api/health 或调试使用)。"""
return {
"mock": True,
"connected": self._connected,
"send_count": self._send_count,
"last_location": self._last_location,
"started_at": getattr(self, "_started_at", None) or time.time(),
}
+701
View File
@@ -0,0 +1,701 @@
"""真机虚拟定位后端:pymobiledevice3 DVT / 老版 simulatelocation。
冻结契约(docs/PROTOCOL.md 5 节、task-2):
class RealLocationProvider:
def __init__(self, rsd_host=None, rsd_port=None, udid=None) # 可加可选关键字参数
async def connect(self, device) -> dict
async def set_location(self, lat, lon) -> None
async def clear_location(self) -> None
async def close(self) -> None
@property
def connected(self) -> bool
async def probe(self) -> dict # {"ok":bool,"detail":中文,"ios_major":int}
所有 API 调用均取自 pymobiledevice3 **真实源码**(版本与出处见 docs/DEVICE_NOTES.md):
* ``pymobiledevice3/services/dvt/instruments/dvt_provider.py:8`` ``class DvtProvider``
* ``pymobiledevice3/services/dvt/instruments/location_simulation.py:17`` ``class LocationSimulation``
(``set(latitude, longitude)`` / ``clear()``,async context manager)
* ``pymobiledevice3/dtx_service_provider.py:80`` ``DvtProvider.service_name_for()`` 自动在
RSD(``com.apple.instruments.dtservicehub``,iOS17+)与 lockdown
(``com.apple.instruments.remoteserver.DVTSecureSocketProxy``,iOS<17)之间选择。
* ``pymobiledevice3/remote/remote_service_discovery.py:103`` ``RemoteServiceDiscoveryService(address)``
* ``pymobiledevice3/services/simulate_location.py:8`` ``DtSimulateLocation``(iOS<17 老服务
``com.apple.dt.simulatelocation``,作为 DVT 失败时的兜底)
设计要点:
* **长连接**:``connect()`` 建立一次 DVT 会话,之后 ``set_location`` 复用同一个 DTX channel,
绝不每次重连(DVT 定位在连接关闭后即失效,见 DEVICE_NOTES.md 的 CLI 取证)。
* **同一事件循环**:全部为原生 asyncio,不开线程、不新建事件循环。
* **友好降级**:pymobiledevice3 缺失 / 隧道未建立 / 无设备 / 未开开发者模式,都抛带中文说明的
:class:`LocationProviderError`,不会暴露 AttributeError 之类的内部错误。
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Callable, Optional
__all__ = [
"RealLocationProvider",
"LocationProviderError",
"pymobiledevice3_available",
"PMD3_MISSING_HINT",
]
logger = logging.getLogger("iosloc.location.real")
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
PMD3_MISSING_HINT = (
"未检测到 pymobiledevice3 库。请在项目目录执行:"
"./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
)
#: iOS 17 是「必须走 RSD 隧道」的分界线
RSD_REQUIRED_IOS_MAJOR = 17
#: 根目录 README/run.sh 的隧道提示(PROTOCOL.md 的 tunnel_hint 语义)
TUNNEL_HINT = (
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
"请以管理员权限运行 run.sh(sudo ./run.sh);"
"macOS 上也可用免 root 的隧道:"
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
)
class LocationProviderError(RuntimeError):
"""定位后端异常,``str(e)`` 一律是可直接展示给用户的中文信息。"""
# ---------------------------------------------------------------------------
# pymobiledevice3 懒加载:未安装时 import core.location_provider 仍然成功
# ---------------------------------------------------------------------------
_pmd3_cache: Optional[dict] = None
_pmd3_error: Optional[str] = None
def _import_pymobiledevice3() -> dict:
"""惰性导入 pymobiledevice3(成功结果缓存;失败抛 :class:`LocationProviderError`)。"""
global _pmd3_cache, _pmd3_error
if _pmd3_cache is not None:
return _pmd3_cache
try:
from pymobiledevice3.lockdown import create_using_usbmux
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
from pymobiledevice3.services.simulate_location import DtSimulateLocation
from pymobiledevice3 import usbmux as usbmux_module
except Exception as exc: # ImportError / 依赖缺失 / 版本不兼容
_pmd3_error = f"{type(exc).__name__}: {exc}"
logger.error("导入 pymobiledevice3 失败:%s", _pmd3_error)
raise LocationProviderError(f"{PMD3_MISSING_HINT}(详细错误:{_pmd3_error})") from exc
_pmd3_cache = {
"create_using_usbmux": create_using_usbmux,
"RemoteServiceDiscoveryService": RemoteServiceDiscoveryService,
"DvtProvider": DvtProvider,
"LocationSimulation": LocationSimulation,
"DtSimulateLocation": DtSimulateLocation,
"usbmux": usbmux_module,
}
return _pmd3_cache
def pymobiledevice3_available() -> bool:
"""pymobiledevice3 是否可用(不抛异常,供 /api/health、UI 提示使用)。"""
try:
_import_pymobiledevice3()
except LocationProviderError:
return False
return True
def pymobiledevice3_version() -> Optional[str]:
"""返回已安装的 pymobiledevice3 版本号,取不到返回 ``None``。"""
try:
from importlib.metadata import version
return version("pymobiledevice3")
except Exception:
return None
# ---------------------------------------------------------------------------
# 小工具
# ---------------------------------------------------------------------------
def _parse_ios_major(value: Any) -> int:
"""把 ``"26.0"`` / ``26`` / ``"17.4.1"`` 解析成主版本号;失败返回 0。"""
if value is None:
return 0
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value
text = str(value).strip()
head = text.split(".", 1)[0]
try:
return int(head)
except ValueError:
return 0
def _device_field(device: Any, key: str, default: Any = None) -> Any:
if device is None:
return default
if isinstance(device, dict):
value = device.get(key, default)
else:
value = getattr(device, key, default)
return default if value is None else value
def _wrap(action: str, exc: BaseException) -> LocationProviderError:
"""把底层异常翻译成中文可读信息。"""
if isinstance(exc, LocationProviderError):
return exc
name = type(exc).__name__
text = str(exc) or name
low = text.lower()
# StartServiceError 的 __init__ 不把信息传给基类(exceptions.py:125-129),str() 为空
if name == "StartServiceError":
service_name = getattr(exc, "service_name", "?")
message = getattr(exc, "message", "") or ""
text = f"StartServiceError({service_name}): {message}"
low = text.lower()
if isinstance(exc, (ConnectionRefusedError,)) or "connection refused" in low or "errno 61" in low:
detail = "无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立"
elif isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or "timed out" in low:
detail = "操作超时,设备可能未响应或隧道已断开"
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
detail = (
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount"
)
elif "developer mode" in low or "developermode" in low:
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
detail = (
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
)
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
detail = "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」,或先执行 pymobiledevice3 lockdown pair"
elif "no device" in low or "nodeviceconnected" in low:
detail = "未发现 iOS 设备:请用 USB 连接 iPhone,解锁并在手机上点击「信任」"
else:
detail = f"底层错误:{name}: {text}"
return LocationProviderError(f"{action}失败:{detail}")
# ---------------------------------------------------------------------------
# 真机 Provider
# ---------------------------------------------------------------------------
class RealLocationProvider:
"""真机定位提供者。兼容 iOS<17(lockdown 直连 DVT)与 iOS≥17(RSD 隧道 + DVT)。
策略(``self.strategy``):
``dvt-rsd``
iOS ≥ 17 且提供了 RSD 地址(或注入了已连接的 RSD 对象):
``RemoteServiceDiscoveryService((host, port))`` → ``DvtProvider(rsd)`` →
``LocationSimulation``。
``dvt-lockdown``
没有 RSD 地址时走 usbmux/lockdown:``create_using_usbmux(serial=udid)`` →
``DvtProvider(lockdown)`` → ``LocationSimulation``(适用于 iOS<17)。
``legacy``
``dvt-lockdown`` 失败时的兜底:``DtSimulateLocation``(老服务
``com.apple.dt.simulatelocation``,仅 iOS<17)。
"""
def __init__(
self,
rsd_host: Optional[str] = None,
rsd_port: Optional[int] = None,
udid: Optional[str] = None,
*,
rsd: Any = None,
lockdown: Any = None,
connect_timeout: float = 15.0,
on_log: Optional[Callable[[str, str], None]] = None,
log: Optional[Callable[[str, str], None]] = None,
) -> None:
"""
:param rsd_host: RSD 隧道主机(由 lead 的 core/tunnel.py 提供),无隧道时为 ``None``。
:param rsd_port: RSD 隧道端口。
:param udid: 目标设备 UDID;``None`` 表示用第一台 USB 设备。
:param rsd: **可选扩展**:已连接好的 ``RemoteServiceDiscoveryService`` 对象。用于把免 root 的
进程内 userspace 隧道(``UserspaceRsdTunnel``)直接注入进来;给了之后就忽略 host/port。
:param lockdown: **可选扩展**:已连接好的 ``LockdownClient``,避免重复建连。
:param connect_timeout: 建立会话的超时秒数。
:param on_log: 日志回调 ``(level, msg)``,供服务端推给前端。
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``RealLocationProvider(..., log=self.log)``)。
"""
self._rsd_host = rsd_host
self._rsd_port = int(rsd_port) if rsd_port else None
self._udid = udid
self._injected_rsd = rsd
self._injected_lockdown = lockdown
self._connect_timeout = float(connect_timeout)
self._on_log = log or on_log
self._connected = False
self._strategy: Optional[str] = None
self._session_info: dict = {}
self._last_location: Optional[tuple] = None
self._send_count = 0
self._connected_at: Optional[float] = None
# pymobiledevice3 对象
self._rsd_obj: Any = None
self._lockdown_obj: Any = None
self._dvt: Any = None
self._location: Any = None
self._legacy: Any = None
# 连接过程中从设备侧探测到的真实信息
self._detected_version: Optional[str] = None
self._detected_udid: Optional[str] = None
# ------------------------------------------------------------------ 日志
def _log(self, level: str, msg: str) -> None:
text = f"[真机] {msg}"
log_fn = getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)
log_fn(text)
if self._on_log is not None:
try:
self._on_log(level, text)
except Exception:
logger.exception("on_log 回调异常(已忽略)")
# -------------------------------------------------------------- 只读属性
@property
def connected(self) -> bool:
"""会话是否已建立(DVT channel 已就绪)。"""
return self._connected
@property
def strategy(self) -> Optional[str]:
"""当前使用的策略:``dvt-rsd`` / ``dvt-lockdown`` / ``legacy``。"""
return self._strategy
@property
def rsd_address(self) -> Optional[tuple]:
"""当前 RSD 地址 ``(host, port)``,没有则为 ``None``。"""
if self._rsd_host and self._rsd_port:
return (self._rsd_host, self._rsd_port)
if self._rsd_obj is not None:
address = getattr(getattr(self._rsd_obj, "service", None), "address", None)
if address:
return (address[0], int(address[1]))
return None
@property
def last_location(self) -> Optional[tuple]:
"""最近一次成功下发的 ``(lat, lon)``。"""
return self._last_location
@property
def send_count(self) -> int:
"""累计下发次数。"""
return self._send_count
# ------------------------------------------------------------------ 连接
async def connect(self, device: Any = None) -> dict:
"""建立(长连接)DVT 会话,返回中文可读的会话信息 dict。
:raises LocationProviderError: 任何失败都用中文说明原因与修复办法。
"""
if self._connected:
return dict(self._session_info)
udid = _device_field(device, "udid", None) or self._udid
if udid:
self._udid = str(udid)
ios_version = str(_device_field(device, "ios_version", "") or "")
ios_major = _parse_ios_major(_device_field(device, "ios_major", None) or ios_version)
developer_mode = _device_field(device, "developer_mode", None)
state = _device_field(device, "state", None)
self._connection = str(_device_field(device, "connection", "usb") or "usb")
if state in ("unpaired",):
raise LocationProviderError(
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
)
if state in ("unavailable",):
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
if developer_mode is False:
raise LocationProviderError(
"设备未开启「开发者模式」:请在 iPhone 上打开 设置 → 隐私与安全性 → 开发者模式,"
"重启设备并确认开启后重试(iOS 16 及以上必须开启)"
)
pmd3 = _import_pymobiledevice3()
has_rsd = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
if has_rsd:
await self._connect_dvt_rsd(pmd3)
else:
await self._connect_lockdown_path(pmd3, ios_major, ios_version)
# 隧道/设备侧查到的真实版本优先(连接前拿到的版本可能为空或过期)
if self._detected_version:
ios_version = self._detected_version
ios_major = _parse_ios_major(self._detected_version) or ios_major
if self._detected_udid and not self._udid:
self._udid = self._detected_udid
self._connected = True
self._connected_at = time.time()
self._session_info = {
"ok": True,
"mock": False,
"udid": self._udid,
"ios_version": ios_version,
"ios_major": ios_major,
"connection": self._connection,
"tunnel": "connected" if self._strategy == "dvt-rsd" else "idle",
"strategy": self._strategy,
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
"pymobiledevice3": pymobiledevice3_version(),
"detail": self._describe_ready(),
}
self._log("success", self._session_info["detail"])
return dict(self._session_info)
def connect_sync(self, runner: Any, device: Any = None) -> dict:
"""同步建立会话(把协程提交给调用方的 runner,绝不新建事件循环/线程)。
与 :class:`MockLocationProvider.connect_sync` 行为一致,便于 lead 的同步装配代码统一调用。
"""
if self._connected:
return dict(self._session_info)
run = getattr(runner, "run", None)
if not callable(run):
raise LocationProviderError("传入的 runner 不支持 run(coro),无法同步建立设备会话")
return run(self.connect(device))
def _describe_ready(self) -> str:
if self._strategy == "dvt-rsd":
address = self.rsd_address
where = f"RSD {address[0]}:{address[1]}" if address else "RSD 隧道"
return f"DVT 虚拟定位会话已就绪(iOS 17+ / {where},长连接保持中)"
if self._strategy == "dvt-lockdown":
return "DVT 虚拟定位会话已就绪(iOS<17 / usbmux+lockdown,长连接保持中)"
return "虚拟定位会话已就绪(老版 com.apple.dt.simulatelocation 服务)"
async def _connect_dvt_rsd(self, pmd3: dict) -> None:
"""iOS≥17:RSD 隧道 + DVT(长连接)。"""
rsd = self._injected_rsd
try:
if rsd is None:
rsd = pmd3["RemoteServiceDiscoveryService"]((self._rsd_host, self._rsd_port))
self._log("info", f"正在连接 RSD 隧道 {self._rsd_host}:{self._rsd_port} …")
await asyncio.wait_for(rsd.connect(), timeout=self._connect_timeout)
self._rsd_obj = rsd
# 隧道连上后可以校正设备信息(peer_info 里带 OSVersion / UniqueDeviceID)
real_version = str(getattr(rsd, "product_version", "") or "")
if real_version:
self._detected_version = real_version
real_udid = getattr(rsd, "udid", None)
if real_udid:
self._detected_udid = str(real_udid)
self._log("info", "RSD 隧道已建立,正在打开 DVT(dtservicehub)通道 …")
self._dvt = pmd3["DvtProvider"](rsd)
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
self._location = pmd3["LocationSimulation"](self._dvt)
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
self._strategy = "dvt-rsd"
except LocationProviderError:
await self._safe_close_rsd()
raise
except BaseException as exc:
await self._safe_close_rsd()
raise _wrap("建立 RSD/DVT 会话", exc) from exc
async def _connect_lockdown_path(self, pmd3: dict, ios_major: int, ios_version: str) -> None:
"""无 RSD 地址:iOS<17 走 lockdown 直连 DVT,失败再退化到老服务。"""
if ios_major >= RSD_REQUIRED_IOS_MAJOR:
# iOS 17+ 没有隧道时 lockdown 上的 DVTSecureSocketProxy 已不存在,直接给中文修复建议
raise LocationProviderError(
f"设备为 iOS {ios_version or ios_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
)
lockdown = self._lockdown_obj or self._injected_lockdown
if lockdown is None:
try:
self._log("info", f"正在通过 usbmux/lockdown 连接设备 {self._udid or '(第一台 USB 设备)'} …")
lockdown = await asyncio.wait_for(
pmd3["create_using_usbmux"](serial=self._udid), timeout=self._connect_timeout
)
except BaseException as exc:
raise _wrap("通过 usbmux 连接设备", exc) from exc
self._lockdown_obj = lockdown
elif self._injected_lockdown is not None:
self._lockdown_obj = self._injected_lockdown
# 用设备侧真实版本校正(连接前拿到的 ios_major 可能为空或过期)
real_version = str(getattr(lockdown, "product_version", "") or "")
if real_version:
self._detected_version = real_version
real_udid = getattr(lockdown, "udid", None)
if real_udid:
self._detected_udid = str(real_udid)
real_major = _parse_ios_major(real_version) or ios_major
if real_major >= RSD_REQUIRED_IOS_MAJOR:
error = LocationProviderError(
f"设备为 iOS {real_version or real_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
)
# 这个 lockdown 连接是我们自己建的,报错前先释放,避免泄漏
if self._injected_lockdown is None and self._lockdown_obj is not None:
try:
await self._lockdown_obj.close()
except Exception:
pass
self._lockdown_obj = None
raise error
# 首选取证过的 DVT 路径(长连接)
try:
self._dvt = pmd3["DvtProvider"](lockdown)
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
self._location = pmd3["LocationSimulation"](self._dvt)
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
self._strategy = "dvt-lockdown"
return
except BaseException as exc:
dvt_error = _wrap("建立 DVT 会话(iOS<17 直连)", exc)
self._log("warning", f"{dvt_error};改用老版 simulatelocation 服务重试")
# 兜底:com.apple.dt.simulatelocation(pymobiledevice3 CLI 对 iOS<17 的官方路径)
self._dvt = None
self._location = None
try:
self._legacy = pmd3["DtSimulateLocation"](lockdown)
service_name = str(getattr(self._legacy, "SERVICE_NAME", "com.apple.dt.simulatelocation"))
# 关键:DtSimulateLocation.__init__ 不做任何设备 I/O(services/simulate_location.py:19-22),
# 它每次 set/clear 才新开服务。这里必须真开一次服务做校验,否则会出现
# 「connect 报成功、第一次下发才失败」的假成功(真机实测踩到过)。
self._log("info", f"正在验证老版虚拟定位服务 {service_name} …")
service = await asyncio.wait_for(
lockdown.start_lockdown_developer_service(service_name), timeout=self._connect_timeout
)
try:
await service.close()
except Exception:
pass
self._strategy = "legacy"
self._log("info", f"已选用老版虚拟定位服务 {service_name}(服务可用性已实测)")
except BaseException as exc:
raise _wrap("建立老版虚拟定位会话(com.apple.dt.simulatelocation)", exc) from exc
async def _safe_close_rsd(self) -> None:
for obj in (self._location, self._dvt):
if obj is None:
continue
try:
await obj.close()
except Exception:
pass
self._location = None
self._dvt = None
if self._rsd_obj is not None and self._injected_rsd is None:
try:
await self._rsd_obj.close()
except Exception:
pass
self._rsd_obj = None
# ------------------------------------------------------------------ 下发
async def set_location(self, lat: float, lon: float) -> None:
"""下发一次单点定位(复用长连接会话)。"""
lat = float(lat)
lon = float(lon)
if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
raise LocationProviderError(f"经纬度超出合法范围:lat={lat}, lon={lon}")
if not self._connected:
raise LocationProviderError("尚未连接设备,无法下发定位:请先调用 connect()(或点击界面上的「连接设备」)")
try:
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
await self._location.set(lat, lon)
elif self._strategy == "legacy":
await self._legacy.set(lat, lon)
else:
raise LocationProviderError("内部状态异常:没有可用的虚拟定位通道,请重新连接设备")
except BaseException as exc:
raise _wrap("下发定位", exc) from exc
self._last_location = (lat, lon)
self._send_count += 1
if self._send_count == 1 or self._send_count % 25 == 0:
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
async def clear_location(self) -> None:
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
if not self._connected:
self._log("debug", "当前未连接设备,clear_location 忽略")
return
try:
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
await self._location.clear()
elif self._strategy == "legacy":
await self._legacy.clear()
except BaseException as exc:
raise _wrap("清除虚拟定位", exc) from exc
self._last_location = None
self._log("info", "已清除虚拟定位,设备恢复真实定位")
async def close(self) -> None:
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
return
try:
if self._connected:
await self.clear_location()
except Exception as exc:
self._log("warning", f"关闭前清除虚拟定位失败(继续关闭会话):{exc}")
for obj in (self._location, self._dvt):
if obj is None:
continue
try:
await obj.close()
except Exception as exc:
self._log("debug", f"关闭 {type(obj).__name__} 时出错(忽略):{exc}")
if self._rsd_obj is not None and self._injected_rsd is None:
try:
await self._rsd_obj.close()
except Exception as exc:
self._log("debug", f"关闭 RSD 时出错(忽略):{exc}")
if self._lockdown_obj is not None and self._injected_lockdown is None:
try:
await self._lockdown_obj.close()
except Exception as exc:
self._log("debug", f"关闭 lockdown 时出错(忽略):{exc}")
self._location = None
self._dvt = None
self._legacy = None
self._rsd_obj = None
self._lockdown_obj = None
self._connected = False
self._strategy = None
self._connected_at = None
self._log("info", "虚拟定位会话已关闭")
# ------------------------------------------------------------------ 探测
async def probe(self, device: Any = None) -> dict:
"""「测试连接」按钮:探测设备/DVT 是否可用。**不抛异常**。
:param device: 可选,``DeviceManager`` 的 Device dict(与 ``connect()`` 同一个对象)。
传入后会先做静态判断(未配对 / 未开开发者模式 / iOS 版本),能省掉一次设备往返。
:returns: ``{"ok": bool, "detail": 中文说明, "ios_major": int}``(另附 strategy 等字段)
"""
ios_major = _parse_ios_major(self._session_info.get("ios_major"))
if device is not None:
state = _device_field(device, "state", None)
developer_mode = _device_field(device, "developer_mode", None)
device_major = _parse_ios_major(
_device_field(device, "ios_major", None) or _device_field(device, "ios_version", "")
)
if device_major:
ios_major = device_major
if state == "unpaired":
return {
"ok": False,
"ios_major": ios_major,
"strategy": None,
"detail": "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试",
}
if developer_mode is False:
return {
"ok": False,
"ios_major": ios_major,
"strategy": None,
"detail": "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启,需重启设备)",
}
try:
pmd3 = _import_pymobiledevice3()
except LocationProviderError as exc:
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
if self._connected:
return {
"ok": True,
"detail": self._describe_ready(),
"ios_major": ios_major,
"strategy": self._strategy,
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
}
# 未连接:用一个临时会话探测,探测完立即释放(不会残留模拟定位)
has_tunnel = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
if not has_tunnel and (ios_major == 0 or ios_major >= RSD_REQUIRED_IOS_MAJOR):
# 没有隧道、且是 iOS 17+(或版本未知):只有隧道状态可报,不做无意义的重连
detail = (
f"尚未配置 RSD 隧道(ios_major={ios_major or '未知'})。" + TUNNEL_HINT
if ios_major >= RSD_REQUIRED_IOS_MAJOR or ios_major == 0
else "尚未建立会话;iOS<17 可直接用 USB/lockdown 建立 DVT 会话,请先点击「连接设备」"
)
return {"ok": False, "detail": detail, "ios_major": ios_major, "strategy": None}
# iOS<17 且无隧道时也做真实探测(点「测试连接」就应该真的去连一次)
probe = RealLocationProvider(
rsd_host=self._rsd_host,
rsd_port=self._rsd_port,
udid=self._udid,
rsd=self._injected_rsd,
lockdown=self._injected_lockdown,
connect_timeout=self._connect_timeout,
)
try:
info = await probe.connect(device)
except LocationProviderError as exc:
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
except BaseException as exc:
return {"ok": False, "detail": _wrap("探测设备", exc).args[0], "ios_major": ios_major, "strategy": None}
try:
await probe.close()
except Exception:
pass
return {
"ok": True,
"detail": f"连接测试成功:{info.get('detail')}",
"ios_major": _parse_ios_major(info.get("ios_major")) or ios_major,
"strategy": info.get("strategy"),
"rsd": info.get("rsd"),
}
# ------------------------------------------------------------------ 辅助
def stats(self) -> dict:
"""运行统计(供日志/健康检查使用)。"""
return {
"mock": False,
"connected": self._connected,
"strategy": self._strategy,
"udid": self._udid,
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
"send_count": self._send_count,
"last_location": self._last_location,
"connected_at": self._connected_at,
}
def update_rsd(self, rsd_host: Optional[str], rsd_port: Optional[int]) -> None:
"""隧道重建后更新 RSD 地址(需要重新 connect 才生效)。"""
self._rsd_host = rsd_host
self._rsd_port = int(rsd_port) if rsd_port else None
def __repr__(self) -> str: # pragma: no cover - 调试用
return (
f"<RealLocationProvider connected={self._connected} strategy={self._strategy} "
f"udid={self._udid!r} rsd={self.rsd_address}>"
)
+208
View File
@@ -0,0 +1,208 @@
"""真实感定位模型:多尺度 GPS 误差 + 发送节奏抖动 + 运动学速度。
为什么不用 random():真实手机的 GPS 误差是**缓慢游走**的(受卫星几何、多径、电离层影响),
相邻两次定位通常只差几十厘米到一两米,而且有惯性。如果每个点独立随机跳,轨迹会变成毛刺,
速度和航向也会剧烈抖动——这类特征在验收测试里会掩盖真实问题(也有经验的风控一眼能看出)。
这里用**值噪声(value noise)+ 平滑插值 + 多尺度叠加**建模:
- **多尺度误差**:真实 GPS 误差是幂律分布(Allan 方差),不是单一尺度。
这里用 3 个八度叠加:慢漂(相关长度 ~120 米,多径/电离层)+ 中尺度(~30 米,卫星几何)
+ 快抖(~6 米,接收机噪声)。单尺度噪声的统计特征一眼可辨。
- **发送节奏抖动**:真实 GPS 回调不是精确 1.000 秒,而是 0.85~1.15 秒抖动,
偶尔丢一次采样。节奏完全均匀是最容易被识别的特征之一。
- **速度由位移/时间推算**:这与 iOS 从坐标序列推算 speed 的方式一致,
因此"上报速度"与"实际位移"天然自洽,不会出现自相矛盾的数据。
- 纯函数式、无状态、可复现:同样的距离轴/时间轴永远得到同样的噪声值,便于回归测试。
"""
from __future__ import annotations
import math
# 相关长度:多少米走完一个完整的"漂移周期"
DEFAULT_CORRELATION_M = 30.0
# 默认漂移半径(米):手机 GPS 在城市环境下的典型误差量级
DEFAULT_DRIFT_M = 4.0
DEFAULT_DRIFT_PERIOD_S = 18.0
# 默认速度浮动幅度:±8%
DEFAULT_SPEED_JITTER = 0.08
MAX_DRIFT_M = 50.0
MAX_SPEED_JITTER = 0.6
# 多尺度误差的三个八度:(相对权重, 相关长度倍数)
# 慢漂:中尺度:快抖 ≈ 0.5 : 0.33 : 0.17,接近 GPS 误差的幂律谱。
# 快抖的相关长度不能太短:取 0.5×30m = 15m。若取 6~10m,1Hz 下每秒误差变化可达
# 6~8 米,位移推算出的"瞬时速度"会明显超出步行/跑步范围(真实接收机内部对速度做过平滑)。
ERROR_OCTAVES = ((0.5, 4.0), (0.34, 1.0), (0.16, 0.5))
# 叠加后的幅值归一化系数:实测三个尺度加权叠加的 RMS 约为单尺度的 0.55 倍,
# 除以该系数即可让 drift_m 参数与"典型偏移半径"一致(数值由测试标定)。
OCTAVE_NORMALIZER = 1.0 / 0.55
# 单个采样的偏移上限(相对 radius 的倍数):真实 GPS 误差有长尾,但不会无限大
MAX_OFFSET_FACTOR = 1.8
# 发送间隔抖动:真实 1Hz GPS 的实际间隔分布
DEFAULT_TIMING_JITTER = 0.15 # ±15%
DROPOUT_PROBABILITY = 0.02 # 约 2% 的采样会丢一次(变成 2 秒间隔)
MIN_INTERVAL_FACTOR = 0.3 # 抖动下限保护(避免退化成高频)
def _hash01(seed: int, index: int) -> float:
"""把 (seed, index) 映射成 [0,1) 的确定性伪随机数(无需 numpy)。"""
x = (index * 374761393 + seed * 668265263) & 0xFFFFFFFF
x = (x ^ (x >> 13)) * 1274126177 & 0xFFFFFFFF
x = x ^ (x >> 16)
return (x & 0xFFFFFF) / float(0x1000000)
def _smooth(t: float) -> float:
"""smoothstep:让分段插值在节点处一阶连续,避免折角。"""
return t * t * (3.0 - 2.0 * t)
def value_noise(seed: int, x: float) -> float:
"""一维值噪声,返回 [-1, 1],在 x 的整数节点间平滑插值。"""
i0 = math.floor(x)
frac = x - i0
a = _hash01(seed, i0) * 2.0 - 1.0
b = _hash01(seed, i0 + 1) * 2.0 - 1.0
return a + (b - a) * _smooth(frac)
def fbm(seed: int, x: float, octaves: int = 2) -> float:
"""两个八度的分形噪声,归一化到 [-1, 1]。"""
total = 0.0
amplitude = 1.0
frequency = 1.0
norm = 0.0
for octave in range(max(1, octaves)):
total += value_noise(seed + octave * 7919, x * frequency) * amplitude
norm += amplitude
amplitude *= 0.5
frequency *= 2.3
return total / norm if norm else 0.0
class DriftGenerator:
"""按"沿轨迹行进的距离"生成平滑漂移偏移量与速度浮动系数。
- 位置漂移:沿行进方向与垂直方向各一份噪声,合成横向摆动(GPS 误差的常见形态);
- 速度浮动:乘性系数,围绕 1.0 平滑波动,让瞬时速度在设定值上下浮动。
"""
def __init__(self, seed: int = 20261014) -> None:
self.seed = int(seed)
# ---------------- 位置漂移 ----------------
def offset_m(self, distance_m: float, drift_m: float,
correlation_m: float = DEFAULT_CORRELATION_M,
octaves: bool = True) -> tuple[float, float]:
"""返回 (沿行进方向偏移, 垂直方向偏移),单位米。
octaves=True 时叠加 3 个尺度的误差(慢漂 + 中尺度 + 快抖),
更接近真实 GPS 误差的幂律谱;False 时退化为单一尺度(旧行为)。
"""
if drift_m <= 0:
return 0.0, 0.0
scale = max(5.0, correlation_m)
if not octaves:
x = distance_m / scale
along = fbm(self.seed, x) * drift_m
cross = fbm(self.seed + 104729, x + 0.5) * drift_m
return along, cross
along = 0.0
cross = 0.0
for index, (weight, factor) in enumerate(ERROR_OCTAVES):
x = distance_m / (scale * factor)
# 每个尺度用不同种子与相位偏移,避免各尺度同相叠加成规律波形
along += value_noise(self.seed + index * 1013, x) * weight
cross += value_noise(self.seed + 104729 + index * 7919, x + 0.37 * (index + 1)) * weight
# 多尺度叠加后幅值会明显小于单尺度(各尺度并不总是同相),
# 这里归一化,保证 drift_m 参数就是"典型偏移半径",与用户设定一致。
along *= OCTAVE_NORMALIZER
cross *= OCTAVE_NORMALIZER
# 合成偏移做上限裁剪:真实 GPS 误差有长尾,但不会无限大,
# 否则偶发的大偏移会被下游 App 当成"跳点"整段丢弃。
magnitude = math.hypot(along, cross)
if magnitude > MAX_OFFSET_FACTOR:
shrink = MAX_OFFSET_FACTOR / magnitude
along *= shrink
cross *= shrink
return along * drift_m, cross * drift_m
def offset_latlng(self, lat: float, lng: float, distance_m: float,
heading_deg: float, drift_m: float,
correlation_m: float = DEFAULT_CORRELATION_M) -> tuple[float, float]:
"""把米制偏移换算成经纬度偏移(按当前纬度修正经度比例)。"""
along, cross = self.offset_m(distance_m, drift_m, correlation_m)
if along == 0.0 and cross == 0.0:
return 0.0, 0.0
rad = math.radians(heading_deg)
# 行进方向单位向量(x=东, y=北)
fx, fy = math.sin(rad), math.cos(rad)
# 垂直方向(右手侧)
px, py = fy, -fx
east_m = along * fx + cross * px
north_m = along * fy + cross * py
dlat = north_m / 111320.0
cos_lat = math.cos(math.radians(max(-85.0, min(85.0, lat))))
dlng = east_m / (111320.0 * max(0.05, cos_lat))
return dlat, dlng
# ---------------- 速度浮动 ----------------
def speed_factor(self, elapsed_s: float, jitter: float,
period_s: float = DEFAULT_DRIFT_PERIOD_S) -> float:
"""返回速度乘性系数(1.0 表示不浮动)。
对应真实跑步中步频/步幅的自然波动与路况影响:
用 2 个八度的低频噪声(不是正弦,因此没有可识别的周期)。
"""
if jitter <= 0:
return 1.0
x = elapsed_s / max(2.0, period_s)
value = fbm(self.seed + 15485863, x + 3.25)
factor = 1.0 + value * jitter
# 防止极端情况下出现 0 或负速度
return max(0.2, min(2.0, factor))
# ---------------- 发送节奏(真实 GPS 不是精确等间隔) ----------------
def timing_factor(self, sample_index: int, jitter: float = DEFAULT_TIMING_JITTER,
dropouts: bool = True) -> float:
"""返回本次发送间隔的乘性系数。
真实 1Hz GPS 的实际间隔在 0.85~1.15 秒之间抖动,偶发丢采样时变成约 2 秒。
间隔完全均匀(精确 1.000s)在数据上非常"人工",部分验收用例甚至会据此告警。
"""
if jitter <= 0:
return 1.0
# 用噪声而不是 random:连续几次采样的间隔会小幅相关,像真实接收机
value = value_noise(self.seed + 777771, sample_index * 0.35)
factor = 1.0 + value * jitter
# 丢采样是独立随机事件(不能用平滑噪声表达),用哈希伪随机
if dropouts and _hash01(self.seed + 31337, sample_index) < DROPOUT_PROBABILITY:
factor += 1.0 # 丢一次采样 → 间隔约翻倍(真实 GPS 偶发情况)
return max(MIN_INTERVAL_FACTOR, min(2.5, factor))
# ---------------- 粗差(真实 GPS 偶发的坏点) ----------------
def gross_error_m(self, sample_index: int, probability: float = 0.0,
magnitude_m: float = 30.0) -> tuple[float, float]:
"""按概率生成一次"粗差"偏移(多径/城市峡谷导致的坏点)。
默认关闭(probability=0)。开启后可用来测你自己 App 的
`horizontalAccuracy` 过滤与跳点剔除逻辑是否生效。
"""
if probability <= 0:
return 0.0, 0.0
roll = _hash01(self.seed + 5150, sample_index)
if roll >= probability:
return 0.0, 0.0
# 坏点方向随机、幅度在 0.5~1.5 倍之间
angle = _hash01(self.seed + 6161, sample_index) * 2.0 * math.pi
scale = magnitude_m * (0.5 + _hash01(self.seed + 7171, sample_index))
return math.cos(angle) * scale, math.sin(angle) * scale
_generator = DriftGenerator()
def default_generator() -> DriftGenerator:
return _generator
+687
View File
@@ -0,0 +1,687 @@
"""轨迹播放引擎。
- 按速度剖面积分推进弧长(真实物理感受:转弯自动减速)。
- 独立的 worker 线程:即使设备下发阻塞也不会卡住 HTTP/WS,且保证同一时刻只有一个
set_location 在飞行中(顺序严格不乱)。
- 倍速只影响推进速率,不影响下发频率(send_hz)。
"""
from __future__ import annotations
import math
import threading
import time
from typing import Any, Callable
from . import geoutils, noise, protocol, route_engine
STATUS_INTERVAL = 0.5
TICK = 0.05
MAX_CATCHUP = 5.0
# 哨兵:用于区分"参数未提供"与"显式传 False/0"
_UNSET: Any = object()
class PlaybackEngine:
def __init__(
self,
runner: Any,
config: Any,
on_location: Callable[[dict[str, Any]], None],
on_status: Callable[[dict[str, Any]], None],
on_log: Callable[[str, str], None] | None = None,
) -> None:
self.runner = runner
self.config = config
self.on_location = on_location
self.on_status = on_status
self.on_log = on_log
self._lock = threading.RLock()
self._route: route_engine.Route | None = None
self._provider: Any = None
self._thread: threading.Thread | None = None
self._wake = threading.Event()
self._stop_flag = threading.Event()
self.playing = False
self.paused = False
self.loop = False
self.speed_scale = 1.0
# 播放代次:每次 start 自增,旧线程发现自己的代次过期就立即退出。
# 这样即使 stop 时旧线程正卡在一次慢速下发里,也不会与新线程同时驱动设备。
self._generation = 0
self.distance = 0.0 # 已推进的弧长(米)
self.tick_time = 0.0 # 播放用的虚拟时间(秒,已按倍速折算)
self.started_at = 0.0
self.last_status_at = 0.0
self.last_sent_at = 0.0
# 轨迹上的当前点(供 status 使用;上报点用 last_reported,两者不能混用)
self.last_point: tuple[float, float] | None = None
self.source = "play"
self.sent_count = 0
self.error: str | None = None
# 状态序号:便于测试与前端判断"新旧",避免异步推送乱序带来的误判
self.status_seq = 0
# 是否已经 clear 过(避免 stop + shutdown 重复 clear)
self._cleared = True
# 最近一次在途的设备下发 future:clear 之前要等它结束,保证 set 与 clear 的顺序
self._pending_dispatch: Any = None
# ---- 真实感模拟:GPS 漂移 + 速度浮动(都能开关,开关与幅度可随时改) ----
self.drift_enabled = bool(config.get("drift_enabled", True))
self.drift_m = float(config.get("drift_m") or noise.DEFAULT_DRIFT_M)
self.speed_jitter_enabled = bool(config.get("speed_jitter_enabled", True))
self.speed_jitter = float(config.get("speed_jitter") or noise.DEFAULT_SPEED_JITTER)
self._drift = noise.default_generator()
self.last_drift_m: float = 0.0
# 发送节奏抖动(真实 GPS 不是精确等间隔)
self.timing_jitter_enabled = bool(config.get("timing_jitter_enabled", True))
# 上报速度由实际位移/时间推算(与 iOS 的算法一致)
self.derive_speed = bool(config.get("derive_speed", True))
# 粗差注入(默认关闭,用于测试自己 App 的过滤逻辑)
self.gross_error_enabled = bool(config.get("gross_error_enabled", False))
self.gross_error_probability = float(config.get("gross_error_probability") or 0.0)
self.gross_error_m = float(config.get("gross_error_m") or 30.0)
self.send_index = 0
# 注意:self.last_point 是"轨迹上的点"(供 status 用),
# 上报位置必须用独立变量,否则两者会被互相覆盖(曾因此导致播放线程崩溃)。
self.last_reported: tuple[float, float] | None = None
# 上一次"上报"的单调时刻:速度推算只依赖它,避免与其它时间戳变量互相干扰
self.last_report_mono = 0.0
# 最近一次上报的位移/时间间隔(供诊断与测试使用)
self.last_speed_jump = 0.0
self.last_speed_dt = 0.0
# ---------------- 属性 ----------------
@property
def route(self) -> route_engine.Route | None:
with self._lock:
return self._route
def set_provider(self, provider: Any) -> None:
with self._lock:
self._provider = provider
@property
def active(self) -> bool:
with self._lock:
return self._thread is not None and self._thread.is_alive()
# ---------------- 真实感调参(漂移 / 速度浮动) ----------------
def tuning(self) -> dict[str, Any]:
return {
"drift_enabled": self.drift_enabled,
"drift_m": round(self.drift_m, 2),
"speed_jitter_enabled": self.speed_jitter_enabled,
"speed_jitter": round(self.speed_jitter, 4),
"speed_jitter_percent": round(self.speed_jitter * 100, 1),
"timing_jitter_enabled": self.timing_jitter_enabled,
"derive_speed": self.derive_speed,
"gross_error_enabled": self.gross_error_enabled,
"gross_error_probability": round(self.gross_error_probability, 3),
"gross_error_m": round(self.gross_error_m, 1),
}
def update_tuning(self, drift_enabled: Any = _UNSET, drift_m: Any = _UNSET,
speed_jitter_enabled: Any = _UNSET, speed_jitter: Any = _UNSET,
timing_jitter_enabled: Any = _UNSET, derive_speed: Any = _UNSET,
gross_error_enabled: Any = _UNSET, gross_error_probability: Any = _UNSET,
gross_error_m: Any = _UNSET) -> dict[str, Any]:
"""运行中实时调整(不影响正在播放的轨迹推进,只影响后续下发/速度)。
参数默认值是哨兵 `_UNSET` 而不是 None —— 否则无法把开关显式设成 False
(None 与 False 都会被当成"未提供",开关就永远关不掉)。
"""
with self._lock:
if drift_enabled is not _UNSET:
self.drift_enabled = bool(drift_enabled)
if timing_jitter_enabled is not _UNSET:
self.timing_jitter_enabled = bool(timing_jitter_enabled)
if derive_speed is not _UNSET:
self.derive_speed = bool(derive_speed)
if gross_error_enabled is not _UNSET:
self.gross_error_enabled = bool(gross_error_enabled)
if gross_error_probability is not _UNSET:
value = protocol.finite(gross_error_probability, "粗差概率")
if not (0.0 <= value <= 0.5):
raise protocol.AppError("粗差概率需在 0 ~ 0.5 之间", "bad_tuning")
self.gross_error_probability = value
if gross_error_m is not _UNSET:
value = protocol.finite(gross_error_m, "粗差幅度")
if not (1.0 <= value <= 500.0):
raise protocol.AppError("粗差幅度需在 1 ~ 500 米之间", "bad_tuning")
self.gross_error_m = value
if drift_m is not _UNSET:
value = protocol.finite(drift_m, "漂移半径")
if not (0.0 <= value <= noise.MAX_DRIFT_M):
raise protocol.AppError(
f"漂移半径需在 0 ~ {noise.MAX_DRIFT_M:g} 米之间", "bad_tuning"
)
self.drift_m = value
if speed_jitter_enabled is not _UNSET:
self.speed_jitter_enabled = bool(speed_jitter_enabled)
if speed_jitter is not _UNSET:
value = protocol.finite(speed_jitter, "速度浮动幅度")
# 允许前端传百分数(如 8 表示 8%),但不能把"500%"悄悄当成"5%"
if 1.0 < value <= 100.0:
value = value / 100.0
if not (0.0 <= value <= noise.MAX_SPEED_JITTER):
raise protocol.AppError(
f"速度浮动幅度需在 0 ~ {noise.MAX_SPEED_JITTER * 100:g}% 之间,"
"或 0 ~ 1 的小数",
"bad_tuning",
)
self.speed_jitter = value
result = self.tuning()
# 落盘,下次启动沿用(用户按校园跑场景调好的参数不该丢)
try:
self.config.update({
"drift_enabled": result["drift_enabled"],
"drift_m": result["drift_m"],
"speed_jitter_enabled": result["speed_jitter_enabled"],
"speed_jitter": result["speed_jitter"],
"timing_jitter_enabled": result["timing_jitter_enabled"],
"derive_speed": result["derive_speed"],
"gross_error_enabled": result["gross_error_enabled"],
"gross_error_probability": result["gross_error_probability"],
"gross_error_m": result["gross_error_m"],
})
except Exception:
pass
self._emit_status(force=True)
return result
def _speed_factor(self) -> float:
"""当前速度乘性系数(1.0 = 不浮动)。"""
if not self.speed_jitter_enabled or self.speed_jitter <= 0:
return 1.0
return self._drift.speed_factor(
time.time() - self.started_at, self.speed_jitter, noise.DEFAULT_DRIFT_PERIOD_S
)
def _reported_point(self, route: route_engine.Route,
point: tuple[float, float]) -> tuple[float, float]:
"""把"轨迹上的真实位置"加上多尺度漂移(可选粗差),得到实际下发给手机的位置。"""
lat, lng = point
if not self.drift_enabled or self.drift_m <= 0:
self.last_drift_m = 0.0
dlat = dlng = 0.0
else:
heading = route.heading_at_distance(self.distance)
dlat, dlng = self._drift.offset_latlng(lat, lng, self.distance, heading, self.drift_m)
# 粗差(默认关闭):偶发坏点,用来测自己 App 的精度过滤/跳点剔除
if self.gross_error_enabled and self.gross_error_probability > 0:
east_m, north_m = self._drift.gross_error_m(
self.send_index, self.gross_error_probability, self.gross_error_m
)
if east_m or north_m:
cos_lat = math.cos(math.radians(max(-85.0, min(85.0, lat))))
dlat += north_m / 111320.0
dlng += east_m / (111320.0 * max(0.05, cos_lat))
if dlat == 0.0 and dlng == 0.0:
self.last_drift_m = 0.0
return point
self.last_drift_m = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
return (lat + dlat, lng + dlng)
# ---------------- 事件日志(仅用户主动操作时调用,不在循环里) ----------------
def log_tuning(self) -> None:
state = self.tuning()
drift = f"{state['drift_m']:g} 米" if state["drift_enabled"] else "关闭"
jitter = (
f"±{state['speed_jitter_percent']:g}%" if state["speed_jitter_enabled"] else "关闭"
)
self._log("info", f"真实感设置:定位漂移 {drift},速度浮动 {jitter}")
# ---------------- 控制 ----------------
def start(self, route: route_engine.Route, speed_scale: float | None = None,
loop: bool | None = None, start_index: int = 0, provider: Any = None) -> None:
with self._lock:
if provider is not None:
self._provider = provider
old_thread = self._thread
self.stop_locked(notify=False)
generation = self._generation + 1
self._generation = generation
self._route = route
self.speed_scale = protocol.validate_speed_scale(
speed_scale if speed_scale is not None else route.speed_scale or 1.0
)
self.loop = bool(route.loop if loop is None else loop)
self.playing = True
self.paused = False
self.error = None
self.sent_count = 0
self.source = "play"
acc = route.dense_acc
total = acc[-1] if acc else 0.0
index = max(0, min(int(start_index), max(0, len(route.path) - 1)))
self.distance = acc[index] if acc else 0.0
self.tick_time = self._time_at_distance(self.distance)
self.started_at = time.time()
self.last_status_at = 0.0
# 用当前时刻初始化,使首个间隔就是正常节奏(否则会在 50ms 后立刻补发第二个点)
self.last_sent_at = time.time()
self.last_report_mono = 0.0
self.last_reported = None
self.send_index = 0
self.last_point = route.point_at_distance(self.distance) if route.path else None
self._stop_flag.clear()
self._wake.clear()
self._cleared = False
self._pending_dispatch = None
self._thread = threading.Thread(
target=self._loop, args=(generation,), name=f"playback-{generation}", daemon=True
)
self._thread.start()
# 旧线程若还卡在慢速下发里,等它退出(最多 6 秒);它每轮都会检查代次并自行结束。
if old_thread is not None and old_thread.is_alive() and old_thread is not threading.current_thread():
old_thread.join(timeout=6.0)
if old_thread.is_alive():
self._log("warn", "上一次播放的下发仍在收尾,已让新播放接替(旧线程会自行退出)")
if route.distance <= 0 and len(route.path) <= 1:
self._log("warn", "轨迹只有一个点:已把设备定位到该点")
self._emit_status(force=True)
def pause(self) -> None:
with self._lock:
if not self.playing or self.paused:
return
self.paused = True
self._log("info", "播放已暂停")
self._emit_status(force=True)
def resume(self) -> None:
with self._lock:
if not self.playing or not self.paused:
return
self.paused = False
self.last_sent_at = 0.0
self._wake.set()
self._log("info", "继续播放")
self._emit_status(force=True)
def stop(self, notify: bool = True, clear_provider: bool = True) -> None:
with self._lock:
provider = self._provider if clear_provider else None
already_cleared = self._cleared
route = self._route
old_thread = self.stop_locked(notify=notify)
self._cleared = True
if provider is not None and not already_cleared:
# 恢复真实定位放到后台线程:不要让「停止」的应答等设备往返(实测会拖慢 2~3 秒,
# 界面要过几秒才变回"未播放")。清理失败只记日志,不影响停止语义。
threading.Thread(
target=self._clear_provider_async, args=(provider, old_thread),
name="playback-clear", daemon=True,
).start()
if notify and route is not None:
self._emit_status(force=True)
self.on_location({
"lng": None, "lat": None, "source": "cleared", "progress": 0.0,
"index": 0, "speed": 0.0, "sent_at": protocol.now_ts(),
})
def _clear_provider_async(self, provider: Any, old_thread: threading.Thread | None = None) -> None:
try:
# 关键顺序保证:必须等在途的 set_location 真正结束之后再 clear,
# 否则(对"帧结束时才生效"的实现)可能变成 clear 之后又来一次 set,
# 设备就停在模拟点上而界面显示"已恢复真实定位"。
if old_thread is not None and old_thread.is_alive():
old_thread.join(timeout=6.0)
pending = self._pending_dispatch
if pending is not None:
try:
pending.result(timeout=6.0)
except Exception:
pass
self.runner.run(provider.clear_location(), timeout=8.0, wait=True)
self._log("success", "已停止模拟,iPhone 恢复真实定位")
except Exception as exc:
self._log("warn", f"恢复真实定位失败:{_short(exc)}")
def stop_locked(self, notify: bool = True, join_timeout: float = 0.6) -> threading.Thread | None:
self._stop_flag.set()
self._wake.set()
# 代次自增:即便旧线程还卡在慢速下发里,它恢复后也会因为代次过期而立即退出
self._generation += 1
thread = self._thread
self._thread = None
self.playing = False
self.paused = False
if thread is not None and thread.is_alive() and thread is not threading.current_thread():
# 不长时间等待:代次机制已经保证旧线程不会再下发/推送,界面无需为它等待。
# 锁外 join,避免持锁等待阻塞其它命令(如 play.start / 状态查询)。
self._lock.release()
try:
thread.join(timeout=join_timeout)
finally:
self._lock.acquire()
return thread
def seek(self, progress: float) -> dict[str, Any]:
route = self.route
if route is None or not route.path:
raise protocol.AppError("当前没有正在播放的轨迹", "no_route")
# 播放已结束/已停止时不允许 seek:否则会在"未播放"状态下重新把设备定位过去
with self._lock:
if not (self.playing or self.paused):
raise protocol.AppError("当前没有正在播放的轨迹(请先点播放)", "not_playing")
value = protocol.finite(progress, "进度")
value = protocol.clamp(value, 0.0, 1.0)
with self._lock:
acc = route.dense_acc
total = acc[-1] if acc else 0.0
self.distance = total * value
self.tick_time = self._time_at_distance(self.distance)
self.last_sent_at = 0.0
self._wake.set()
point = route.point_at_distance(self.distance)
self._send_point(point, force=True)
self._emit_status(force=True)
return self.status()
def set_speed(self, speed_scale: float) -> dict[str, Any]:
value = protocol.validate_speed_scale(speed_scale)
with self._lock:
self.speed_scale = value
self._emit_status(force=True)
return self.status()
def set_loop(self, loop: bool) -> dict[str, Any]:
with self._lock:
self.loop = bool(loop)
self._emit_status(force=True)
return self.status()
# ---------------- 单点定位 ----------------
def set_point(self, lat: float, lng: float, provider: Any = None) -> None:
with self._lock:
if provider is not None:
self._provider = provider
self.source = "manual"
self.last_point = (lat, lng)
self.last_sent_at = time.time()
self._dispatch(lat, lng, source="manual", progress=0.0, index=0, speed=0.0)
def clear_point(self, provider: Any = None) -> None:
with self._lock:
if provider is not None:
self._provider = provider
self.source = "manual"
self.stop(notify=True, clear_provider=True)
# ---------------- worker ----------------
def _loop(self, generation: int) -> None:
def stale() -> bool:
"""自己是不是已被更新的播放/停止取代。"""
with self._lock:
return generation != self._generation or self._stop_flag.is_set()
route = self.route
if route is None:
return
send_hz = float(self.config.get("send_hz") or protocol.DEFAULT_SEND_HZ)
send_interval = 1.0 / protocol.clamp(send_hz, protocol.MIN_SEND_HZ, protocol.MAX_SEND_HZ)
last = time.monotonic()
stall_logged = False
try:
# 先立刻把起点发出去,用户点击播放后马上生效
self._send_point(route.point_at_distance(self.distance), force=True, generation=generation)
while not stale():
self._wake.wait(TICK)
self._wake.clear()
now = time.monotonic()
delta = min(MAX_CATCHUP, max(0.0, now - last))
last = now
if stale():
break
paused = self.paused
finished = False
with self._lock:
if not self.playing:
break
if not paused and route.path:
acc = route.dense_acc
total = acc[-1] if acc else 0.0
if total > 0:
speed = route.speed_at_distance(self.distance)
# 速度浮动:让瞬时速度在设定值上下平滑浮动(校园跑场景的关键真实感)。
# 同一个 tick 内只算一次系数,推进与上报必须用同一个值,否则上报速度会系统性偏高。
self._speed_factor_now = self._speed_factor()
step = max(0.05, speed) * self.speed_scale * self._speed_factor_now * delta
self.distance += step
self.tick_time += delta * self.speed_scale
if self.distance >= total:
if self.loop:
self.distance = self.distance % total if total > 0 else 0.0
else:
self.distance = total
finished = True
else:
finished = not self.loop
if not paused:
point = route.point_at_distance(self.distance)
self.last_point = point
if not paused:
self._maybe_send(route, send_interval, generation=generation)
if stale():
break
if finished:
self._log("success", f"轨迹播放完成:{_fmt_distance(route.distance)},共下发 {self.sent_count} 次定位")
with self._lock:
if generation == self._generation:
self.playing = False
self.paused = False
self._emit_status(force=True)
break
self._emit_status()
except Exception as exc: # 不让线程静默死掉
self.error = _short(exc)
self._log("error", f"播放线程异常:{self.error}")
with self._lock:
self.playing = False
self.paused = False
self._emit_status(force=True)
if not stall_logged:
stall_logged = True
def _maybe_send(self, route: route_engine.Route, send_interval: float, generation: int | None = None) -> None:
now = time.time()
# 真实 GPS 的回调不是精确等间隔:加节奏抖动(含偶发丢采样),
# 间隔完全均匀(精确 1.000s)是最容易被识别的特征之一。
jitter_factor = self._drift.timing_factor(self.send_index) if self.timing_jitter_enabled else 1.0
wait = send_interval * jitter_factor
if self.last_sent_at and (now - self.last_sent_at) < wait:
return
with self._lock:
point = route.point_at_distance(self.distance)
self.last_sent_at = now
self._send_point(point, generation=generation)
def _send_point(self, point: tuple[float, float], force: bool = False, generation: int | None = None) -> None:
route = self.route
if point is None:
return
total = route.distance if route is not None else 0.0
progress = 0.0
speed = 0.0
index = 0
if route is not None and route.path:
acc = route.dense_acc
total = acc[-1] if acc else 0.0
progress = protocol.clamp(self.distance / total, 0.0, 1.0) if total > 0 else 1.0
index = _index_of(acc, self.distance)
reported = self._reported_point(route, point) if route is not None else point
# ---- 上报速度:优先由"实际下发点之间的位移 ÷ 实际时间"推算 ----
# 这与 iOS 从坐标序列推算 CLLocation.speed 的方式一致,因此速度、位移、
# 时间戳三者天然自洽;不会出现"上报速度与轨迹推进矛盾"这种自曝式数据。
now = time.time()
mono = time.monotonic()
derive = self.derive_speed and self.last_reported is not None and self.last_report_mono > 0
if derive:
prev_lat, prev_lng = self.last_reported
dt = mono - self.last_report_mono
jump = geoutils.haversine(prev_lat, prev_lng, reported[0], reported[1])
if dt >= 0.05:
if jump > 400.0:
# 粗差/跳点:真实接收机此时 speed 无效(-1),而不是给出一个巨大速度
speed = -1.0
else:
speed = jump / dt
else:
speed = 0.0
self.last_speed_jump = jump
self.last_speed_dt = dt
else:
factor = getattr(self, "_speed_factor_now", None)
if factor is None:
factor = self._speed_factor()
speed = (route.speed_at_distance(self.distance) * factor) if route is not None else 0.0
with self._lock:
self.last_reported = (reported[0], reported[1])
self.last_report_mono = mono
self.send_index += 1
self._dispatch(
reported[0], reported[1], source="play", progress=progress, index=index,
speed=speed, generation=generation,
)
def _dispatch(self, lat: float, lng: float, source: str, progress: float, index: int, speed: float,
generation: int | None = None) -> None:
provider = self._provider
if provider is None:
self._log("error", "尚未连接设备:无法下发定位")
with self._lock:
self.playing = False
return
try:
future = self.runner.run(provider.set_location(lat, lng), timeout=6.0, wait=True)
self._pending_dispatch = future
except Exception as exc:
message = _short(exc)
self._log("error", f"下发定位失败:{message}")
with self._lock:
self.error = message
if generation is None or generation == self._generation:
self.playing = False
self.paused = False
self._emit_status(force=True)
self._stop_flag.set()
return
# 下发期间可能已经被停止/被新的播放取代:这次结果作废,且不再推送位置事件
with self._lock:
if generation is not None and generation != self._generation:
return
self.sent_count += 1
# 逐次下发的日志按 5 秒聚合,避免 5Hz 刷屏(前端日志面板与 WS 带宽都受不了)
now = time.time()
if now - getattr(self, "_last_send_log", 0.0) >= 5.0:
self._last_send_log = now
self._log("info", f"正在按轨迹下发定位(已下发 {self.sent_count} 次)")
self.on_location({
"lng": protocol.round6(lng),
"lat": protocol.round6(lat),
"source": source,
"progress": round(progress, 5),
"index": index,
"speed": round(speed, 3),
"drift_m": round(self.last_drift_m, 2),
"sent_at": protocol.now_ts(),
})
# ---------------- 状态 ----------------
def _time_at_distance(self, distance: float) -> float:
route = self.route
if route is None or not route.path or not route.speeds:
return 0.0
acc = route.dense_acc
total = 0.0
for i in range(1, len(route.path)):
seg = acc[i] - acc[i - 1]
if acc[i - 1] >= distance:
break
if acc[i] <= distance:
total += seg / max(0.05, (route.speeds[i] + route.speeds[i - 1]) / 2.0)
else:
part = max(0.0, distance - acc[i - 1])
total += part / max(0.05, route.speeds[i])
break
return total
def status(self, force: bool = False) -> dict[str, Any]:
route = self.route
with self._lock:
total_distance = route.distance if route is not None else 0.0
progress = protocol.clamp(self.distance / total_distance, 0.0, 1.0) if total_distance > 0 else (1.0 if route else 0.0)
elapsed = self.tick_time
duration = route.duration if route is not None else 0.0
remaining = max(0.0, duration - elapsed)
point = self.last_point
return {
"playing": bool(self.playing),
"paused": bool(self.paused),
"progress": round(progress, 5),
"index": 0 if route is None else _index_of(route.dense_acc, self.distance),
"total": 0 if route is None else len(route.path),
"elapsed": round(elapsed, 2),
"remaining": round(remaining, 2),
"duration": round(duration, 2),
"distance_done": round(self.distance, 2),
"distance": round(total_distance, 2),
"speed_scale": self.speed_scale,
"loop": self.loop,
"lng": protocol.round6(point[1]) if point else None,
"lat": protocol.round6(point[0]) if point else None,
"name": route.name if route is not None else None,
"error": self.error,
"sent_count": self.sent_count,
"tuning": self.tuning(),
"drift_m": round(self.last_drift_m, 2),
}
def _emit_status(self, force: bool = False) -> None:
now = time.time()
if not force and (now - self.last_status_at) < STATUS_INTERVAL:
return
self.last_status_at = now
self.status_seq += 1
try:
self.on_status({**self.status(force=force), "seq": self.status_seq})
except Exception:
pass
def _log(self, level: str, message: str) -> None:
if self.on_log:
try:
self.on_log(level, message)
except Exception:
pass
def _index_of(acc: list[float], distance: float) -> int:
if not acc:
return 0
lo, hi = 0, len(acc) - 1
while lo < hi:
mid = (lo + hi) // 2
if acc[mid] <= distance:
lo = mid + 1
else:
hi = mid
return max(0, min(lo - 1, len(acc) - 1))
def _short(exc: Exception) -> str:
text = str(exc).strip() or exc.__class__.__name__
return text[:200]
def _fmt_distance(meters: float) -> str:
if meters >= 1000:
return f"{meters / 1000:.2f} 公里"
return f"{meters:.0f} 米"
__all__ = ["PlaybackEngine", "geoutils"]
+160
View File
@@ -0,0 +1,160 @@
"""公共常量与工具:前后端接口契约见 docs/PROTOCOL.md。
本模块不依赖任何第三方库。
"""
from __future__ import annotations
import json
import math
import time
import uuid
from typing import Any
APP_NAME = "iOS 虚拟定位轨迹工具"
APP_VERSION = "1.0.0"
PROTOCOL_VERSION = "v1"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8787
# 地理常量
EARTH_RADIUS_M = 6371008.8
# 播放引擎默认参数
DEFAULT_SEND_HZ = 5.0
MIN_SEND_HZ = 1.0
MAX_SEND_HZ = 20.0
MODE_DEFAULTS = {
"driving": 13.9, # m/s ≈ 50 km/h
"cycling": 5.5, # m/s ≈ 20 km/h
"walking": 1.4, # m/s ≈ 5 km/h
"flight": 60.0, # m/s ≈ 216 km/h
}
MODE_LABELS = {
"driving": "驾车",
"cycling": "骑行",
"walking": "步行",
"flight": "飞行",
}
MIN_SPEED_SCALE = 0.1
MAX_SPEED_SCALE = 50.0
# 单点定位与轨迹点的合法范围
MIN_LAT, MAX_LAT = -85.05112878, 85.05112878
MIN_LNG, MAX_LNG = -180.0, 180.0
class AppError(Exception):
"""带机器码与中文提示的业务异常。"""
def __init__(self, message: str, code: str = "app_error", status: int = 400) -> None:
super().__init__(message)
self.message = message
self.code = code
self.status = status
def to_dict(self) -> dict[str, Any]:
return {"ok": False, "error": self.message, "code": self.code}
def now_ts() -> float:
return time.time()
def round6(value: float) -> float:
return round(float(value), 6)
def clamp(value: float, low: float, high: float) -> float:
"""夹取到 [low, high]。NaN 会直接抛错——NaN 参与比较恒为 False,会静默污染整条链路。"""
if not math.isfinite(value):
raise AppError("数值不是有限数(不允许 NaN / Infinity)", "bad_number")
if value < low:
return low
if value > high:
return high
return value
def new_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:10]}"
def dumps(obj: Any) -> str:
"""序列化为 JSON。
allow_nan=False:任何 NaN/Infinity 都会抛错而不是产出 `NaN` 字面量——
后者会让浏览器 JSON.parse 直接失败,把整帧数据丢掉。
"""
try:
return json.dumps(
obj, ensure_ascii=False, separators=(",", ":"), default=_json_default, allow_nan=False
)
except ValueError as exc:
raise AppError(f"内部数据包含非法数值(NaN/Infinity),已拒绝下发:{exc}", "bad_number") from exc
def finite(value: Any, field: str = "数值") -> float:
"""校验并返回有限浮点数。"""
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise AppError(f"{field}必须是数字", "bad_number") from exc
if not math.isfinite(number):
raise AppError(f"{field}不是有限数(不允许 NaN / Infinity)", "bad_number")
return number
def _json_default(obj: Any) -> Any:
if isinstance(obj, (set, tuple)):
return list(obj)
if hasattr(obj, "isoformat"):
return obj.isoformat()
return str(obj)
def loads(raw: str | bytes) -> Any:
if isinstance(raw, bytes):
raw = raw.decode("utf-8", "replace")
return json.loads(raw)
def validate_latlng(lat: Any, lng: Any) -> tuple[float, float]:
"""校验并归一化经纬度,返回 (lat, lng)。"""
try:
flat = float(lat)
flng = float(lng)
except (TypeError, ValueError) as exc:
raise AppError("经纬度必须是数字", "bad_coords") from exc
if math.isnan(flat) or math.isnan(flng) or math.isinf(flat) or math.isinf(flng):
raise AppError("经纬度不是有效数字", "bad_coords")
if not (MIN_LAT <= flat <= MAX_LAT):
raise AppError(f"纬度超出有效范围({MIN_LAT:.4f} ~ {MAX_LAT:.4f})", "bad_coords")
if not (MIN_LNG <= flng <= MAX_LNG):
raise AppError("经度超出有效范围(-180 ~ 180)", "bad_coords")
return flat, flng
def validate_speed_scale(value: Any) -> float:
scale = finite(value, "倍速")
if scale <= 0:
raise AppError("倍速必须大于 0", "bad_speed")
if not (MIN_SPEED_SCALE <= scale <= MAX_SPEED_SCALE):
raise AppError(
f"倍速需在 {MIN_SPEED_SCALE} ~ {MAX_SPEED_SCALE} 之间", "bad_speed"
)
return scale
def validate_mode(mode: Any) -> str:
if mode is None:
return "driving"
text = str(mode).strip().lower()
if text not in MODE_DEFAULTS:
raise AppError(
"出行方式只支持 driving / cycling / walking / flight", "bad_mode"
)
return text
+576
View File
@@ -0,0 +1,576 @@
"""路线引擎:途经点 → 规划路线 → 加密路径 → 速度剖面 → GPX 导入导出。
输出结构(docs/PROTOCOL.md 2.2):
{"waypoints": [[lng,lat],...], "path": [[lng,lat],...], "speeds": [...],
"distance": m, "duration": s, "engine": "osrm"|"straight"|"manual", ...}
播放引擎会用 Route.dense_acc 做弧长插值。
"""
from __future__ import annotations
import json
import math
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any
from . import geoutils, protocol
MAX_PATH_POINTS = 60000
DEFAULT_STEP_M = 2.0
_OSRM_TIMEOUT = 8.0
_UA = "iOSLocationTool/1.0 (+local mac app)"
@dataclass
class Route:
name: str = "未命名轨迹"
mode: str = "driving"
waypoints: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
path: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
speeds: list[float] = field(default_factory=list) # m/s
engine: str = "straight"
loop: bool = False
speed_scale: float = 1.0
_acc: list[float] = field(default_factory=list, repr=False)
# ---------- 派生属性 ----------
@property
def dense_acc(self) -> list[float]:
if not self._acc and self.path:
self._acc = geoutils.cumulative_lengths(self.path)
return self._acc
@property
def distance(self) -> float:
acc = self.dense_acc
return acc[-1] if acc else 0.0
@property
def duration(self) -> float:
"""按速度剖面积分得到的实际耗时(秒)。"""
if len(self.path) < 2 or not self.speeds:
return 0.0
acc = self.dense_acc
total = 0.0
for i in range(1, len(self.path)):
seg = acc[i] - acc[i - 1]
v = (self.speeds[i] + self.speeds[i - 1]) / 2.0
total += seg / v if v > 0.05 else seg / 0.05
return total
def point_at_distance(self, distance: float) -> tuple[float, float]:
if not self.path:
raise protocol.AppError("轨迹为空", "empty_route")
acc = self.dense_acc
point, _ = geoutils.interpolate(self.path, acc, distance)
return point
def speed_at_distance(self, distance: float) -> float:
if not self.speeds or len(self.path) < 2:
return 0.0
acc = self.dense_acc
idx = _index_of(acc, distance)
return self.speeds[min(idx, len(self.speeds) - 1)]
def heading_at_distance(self, distance: float) -> float:
"""行进方向(度,0=正北)。用于让漂移偏移相对于行进方向计算。"""
if len(self.path) < 2:
return 0.0
acc = self.dense_acc
idx = _index_of(acc, distance)
idx = max(0, min(idx, len(self.path) - 2))
a = self.path[idx]
b = self.path[idx + 1]
return geoutils.bearing(a[0], a[1], b[0], b[1])
def highlight(self, distance: float) -> list[list[float]]:
acc = self.dense_acc
pts = geoutils.slice_path(self.path, acc, distance) if len(self.path) > 1 else list(self.path)
return [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in pts]
def to_json(self) -> dict[str, Any]:
return {
"name": self.name,
"mode": self.mode,
"waypoints": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.waypoints],
"path": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.path],
"speeds": [round(v, 3) for v in self.speeds],
"distance": round(self.distance, 2),
"duration": round(self.duration, 2),
"engine": self.engine,
"loop": self.loop,
"speed_scale": self.speed_scale,
}
def _index_of(acc: list[float], distance: float) -> int:
lo, hi = 0, len(acc) - 1
while lo < hi:
mid = (lo + hi) // 2
if acc[mid] <= distance:
lo = mid + 1
else:
hi = mid
return max(0, lo - 1)
class RouteEngine:
def __init__(self, config: Any) -> None:
self.config = config
# ---------------- 构建 ----------------
def build(
self,
waypoint_payloads: list[Any],
mode: str = "driving",
name: str | None = None,
use_osrm: bool = True,
loop: bool = False,
speed_scale: float = 1.0,
) -> Route:
mode = protocol.validate_mode(mode)
speed_scale = protocol.validate_speed_scale(speed_scale)
coords = parse_waypoints(waypoint_payloads)
if len(coords) < 1:
raise protocol.AppError("至少需要一个途经点", "no_waypoints")
engine = "straight"
if len(coords) >= 2 and use_osrm:
planned = self._osrm(coords, mode)
if planned is not None:
coords = planned
engine = "osrm"
route = Route(
name=name or "未命名轨迹",
mode=mode,
engine=engine,
loop=bool(loop),
speed_scale=speed_scale,
)
route.path = self._densify(coords)
route.speeds = compute_speeds(route.path, mode, self._base_speed(mode))
route._acc = geoutils.cumulative_lengths(route.path)
route.waypoints = _pick_waypoints(route.path, parse_waypoints(waypoint_payloads))
return route
def from_coords(
self,
coords: list[tuple[float, float]],
mode: str = "driving",
name: str = "导入轨迹",
engine: str = "manual",
loop: bool = False,
speed_scale: float = 1.0,
) -> Route:
if len(coords) < 1:
raise protocol.AppError("轨迹为空", "no_waypoints")
route = Route(
name=name,
mode=protocol.validate_mode(mode),
engine=engine,
loop=bool(loop),
speed_scale=protocol.validate_speed_scale(speed_scale),
)
route.path = self._densify(coords)
route.speeds = compute_speeds(route.path, route.mode, self._base_speed(route.mode))
route._acc = geoutils.cumulative_lengths(route.path)
route.waypoints = _pick_waypoints(route.path, coords)
return route
def _densify(self, coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
if len(coords) == 1:
return list(coords)
raw = geoutils.densify(coords, DEFAULT_STEP_M, smooth=False)
if len(raw) <= MAX_PATH_POINTS:
return raw
# 超长时按比例降低采样密度,仍保证曲线形状
rough = geoutils.cumulative_lengths(coords)[-1]
step = max(DEFAULT_STEP_M, rough / MAX_PATH_POINTS * 1.05)
out = geoutils.densify(coords, step, smooth=False)
return out[:MAX_PATH_POINTS] if len(out) > MAX_PATH_POINTS else out
def _base_speed(self, mode: str) -> float:
defaults = self.config.get("mode_defaults") or {}
try:
value = float(defaults.get(mode, protocol.MODE_DEFAULTS.get(mode, 13.9)))
except (TypeError, ValueError):
value = protocol.MODE_DEFAULTS.get(mode, 13.9)
return max(0.2, value)
# ---------------- OSRM ----------------
def _osrm(self, coords: list[tuple[float, float]], mode: str) -> list[tuple[float, float]] | None:
profile = {"driving": "driving", "cycling": "bike", "walking": "foot", "flight": "driving"}[mode]
if mode == "flight":
return None
base = str(self.config.get("osrm_url") or "").rstrip("/")
if not base:
return None
coords_str = ";".join(f"{lng:.6f},{lat:.6f}" for lat, lng in coords)
url = f"{base}/{profile}/{coords_str}?overview=full&geometries=geojson&steps=false"
try:
request = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
with urllib.request.urlopen(request, timeout=_OSRM_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8", "replace"))
except Exception:
return None
try:
if payload.get("code") != "Ok":
return None
geometry = payload["routes"][0]["geometry"]["coordinates"]
except (KeyError, IndexError, TypeError):
return None
out: list[tuple[float, float]] = []
for item in geometry:
if isinstance(item, (list, tuple)) and len(item) >= 2:
try:
out.append((float(item[1]), float(item[0])))
except (TypeError, ValueError):
continue
return out if len(out) >= 2 else None
def osrm_available(self) -> bool:
base = str(self.config.get("osrm_url") or "").rstrip("/")
if not base:
return False
try:
request = urllib.request.Request(
base.replace("/route/v1", "") + "/route/v1/driving/116.39,39.90;116.40,39.91?overview=false",
headers={"User-Agent": _UA},
)
with urllib.request.urlopen(request, timeout=4.0) as response:
return response.status == 200
except Exception:
return False
# ---------------- 速度剖面 ----------------
def compute_speeds(
path: list[tuple[float, float]], mode: str, base_speed: float
) -> list[float]:
"""按转弯角度生成速度剖面,并做滑动平均让加减速自然。"""
n = len(path)
if n == 0:
return []
if n == 1:
return [base_speed]
if mode == "flight":
return _smooth([base_speed] * n, window=1)
acc = geoutils.cumulative_lengths(path)
raw = [base_speed] * n
for i in range(1, n - 1):
# 用前后各 ~12 米的点计算航向变化,避免逐点抖动
a = _point_near(path, acc, max(0.0, acc[i] - 12.0))
b = path[i]
c = _point_near(path, acc, acc[i] + 12.0)
angle = abs(_angle_diff(
geoutils.bearing(a[0], a[1], b[0], b[1]),
geoutils.bearing(b[0], b[1], c[0], c[1]),
))
factor = 1.0
if angle >= 100:
factor = 0.12
elif angle >= 60:
factor = 0.25
elif angle >= 35:
factor = 0.45
elif angle >= 18:
factor = 0.68
elif angle >= 8:
factor = 0.86
raw[i] = base_speed * factor
raw[0] = raw[1] if n > 1 else base_speed
raw[-1] = raw[-2] if n > 1 else base_speed
return _smooth(raw, window=max(3, min(41, n // 4 * 2 + 1)))
def _point_near(
path: list[tuple[float, float]], acc: list[float], distance: float
) -> tuple[float, float]:
point, _ = geoutils.interpolate(path, acc, distance)
return point
def _angle_diff(a: float, b: float) -> float:
diff = (b - a + 540.0) % 360.0 - 180.0
return diff
def _smooth(values: list[float], window: int) -> list[float]:
n = len(values)
if n <= 2 or window <= 1:
return [round(v, 3) for v in values]
half = max(1, window // 2)
out: list[float] = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
chunk = values[lo:hi]
out.append(round(sum(chunk) / len(chunk), 3))
return out
# ---------------- 途经点处理 ----------------
MAX_WAYPOINTS = 5000
MAX_REQUEST_POINTS = 200000
def parse_waypoints(payloads: Any, limit: int = MAX_WAYPOINTS) -> list[tuple[float, float]]:
"""把 [[lng,lat], ...] 或 [{"lng":..,"lat":..}, ...] 统一成 [(lat,lng)]。"""
if payloads is None:
return []
if not isinstance(payloads, (list, tuple)):
raise protocol.AppError("waypoints 必须是数组", "bad_waypoints")
if len(payloads) > limit:
raise protocol.AppError(
f"坐标点过多({len(payloads)} 个,上限 {limit} 个):请减少途经点或改用 GPX 导入",
"too_many_points",
status=413,
)
out: list[tuple[float, float]] = []
for item in payloads:
if isinstance(item, dict):
lat = item.get("lat")
lng = item.get("lng", item.get("lon"))
elif isinstance(item, (list, tuple)) and len(item) >= 2:
lng, lat = item[0], item[1]
else:
raise protocol.AppError("途经点格式应为 [经度, 纬度]", "bad_waypoints")
flat, flng = protocol.validate_latlng(lat, lng)
out.append((flat, flng))
return out
def _pick_waypoints(
path: list[tuple[float, float]], wanted: list[tuple[float, float]]
) -> list[tuple[float, float]]:
"""用户可能只给了稀疏的途经点,这里保留原始途经点作为列表展示。"""
return list(wanted) if wanted else list(path[:1] + path[-1:] if len(path) > 1 else path)
# ---------------- GPX ----------------
GPX_NS = "http://www.topografix.com/GPX/1/1"
def to_gpx(route: Route, creator: str = protocol.APP_NAME) -> str:
root = ET.Element(
"gpx",
{
"version": "1.1",
"creator": creator,
"xmlns": GPX_NS,
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xsi:schemaLocation": f"{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd",
},
)
metadata = ET.SubElement(root, "metadata")
ET.SubElement(metadata, "name").text = route.name or "未命名轨迹"
ET.SubElement(metadata, "desc").text = (
f"{protocol.MODE_LABELS.get(route.mode, route.mode)}轨迹,"
f"距离 {route.distance / 1000:.2f} km,预计耗时 {_fmt_duration(route.duration)}"
)
ET.SubElement(metadata, "time").text = _iso_now()
for index, (lat, lng) in enumerate(route.waypoints, start=1):
wpt = ET.SubElement(root, "wpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
ET.SubElement(wpt, "name").text = f"途经点 {index}"
ET.SubElement(wpt, "sym").text = "Flag, Blue"
trk = ET.SubElement(root, "trk")
ET.SubElement(trk, "name").text = route.name or "未命名轨迹"
ET.SubElement(trk, "type").text = protocol.MODE_LABELS.get(route.mode, route.mode)
seg = ET.SubElement(trk, "trkseg")
acc = route.dense_acc
for i, (lat, lng) in enumerate(route.path):
trkpt = ET.SubElement(seg, "trkpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
if i < len(route.speeds):
ET.SubElement(trkpt, "speed").text = f"{route.speeds[i]:.3f}"
if acc:
ET.SubElement(trkpt, "time").text = _iso_offset(acc[i] / max(0.2, _avg_speed(route)))
_indent(root)
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
def from_gpx(text: str) -> tuple[list[tuple[float, float]], str, str]:
"""解析 GPX,返回 (坐标[(lat,lng)], 名称, 类型)。优先 trkseg,其次 rte,最后 wpt。"""
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
raise protocol.AppError("GPX 文件解析失败,请确认文件格式", "bad_gpx") from exc
tag = _strip_ns(root.tag)
if tag != "gpx":
raise protocol.AppError("这不是一个 GPX 文件", "bad_gpx")
name = ""
for child in root:
if _strip_ns(child.tag) == "metadata":
for item in child:
if _strip_ns(item.tag) == "name" and item.text:
name = item.text.strip()
break
if name:
break
coords: list[tuple[float, float]] = []
for child in root:
ctag = _strip_ns(child.tag)
if ctag == "trk":
if not name:
for item in child:
if _strip_ns(item.tag) == "name" and item.text:
name = item.text.strip()
break
for seg in child:
if _strip_ns(seg.tag) != "trkseg":
continue
for point in seg:
parsed = _parse_point(point)
if parsed:
coords.append(parsed)
elif ctag == "rte":
for point in child:
if _strip_ns(point.tag) == "rtept":
parsed = _parse_point(point)
if parsed:
coords.append(parsed)
if not coords:
for child in root:
if _strip_ns(child.tag) == "wpt":
parsed = _parse_point(child)
if parsed:
coords.append(parsed)
if not coords:
raise protocol.AppError("GPX 里没有可用的轨迹点", "bad_gpx")
# 不做抽稀:GPX 是需要保形重放的轨迹,丢点会导致导入后形状变化(仅在极端点数时等间隔抽稀)
coords = _thin_if_huge(coords)
return coords, (name or "导入的 GPX 轨迹"), "gpx"
def _thin_if_huge(coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""仅在点数极多时按等间隔抽稀,保证不丢形状(正常大小的 GPX 原样返回)。"""
if len(coords) <= MAX_PATH_POINTS:
return coords
step = len(coords) / float(MAX_PATH_POINTS)
out: list[tuple[float, float]] = []
index = 0.0
while int(index) < len(coords):
out.append(coords[int(index)])
index += step
if out[-1] != coords[-1]:
out.append(coords[-1])
return out
def _parse_point(node: ET.Element) -> tuple[float, float] | None:
lat = node.get("lat")
lng = node.get("lon")
if lat is None or lng is None:
return None
try:
flat, flng = protocol.validate_latlng(float(lat), float(lng))
except (protocol.AppError, TypeError, ValueError):
return None
return flat, flng
def _strip_ns(tag: str) -> str:
return tag.split("}", 1)[1] if "}" in tag else tag
def _avg_speed(route: Route) -> float:
if not route.speeds:
return 1.0
return max(0.2, sum(route.speeds) / len(route.speeds))
def _fmt_duration(seconds: float) -> str:
total = int(max(0, round(seconds)))
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours} 小时 {minutes} 分"
if minutes:
return f"{minutes} 分 {secs} 秒"
return f"{secs} 秒"
def _iso_now() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _iso_offset(seconds: float) -> str:
import datetime
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=float(seconds))
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
def _indent(elem: ET.Element, level: int = 0) -> None:
pad = "\n" + " " * level
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = pad + " "
for child in elem:
_indent(child, level + 1)
if not child.tail or not child.tail.strip():
child.tail = pad
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = pad
def haversine_total(path: list[tuple[float, float]]) -> float:
if len(path) < 2:
return 0.0
return geoutils.cumulative_lengths(path)[-1]
def sanitize_route_payload(payload: Any) -> Route:
"""前端 play.start 直接传 route 时,从 payload 还原 Route 对象。"""
if not isinstance(payload, dict):
raise protocol.AppError("route 必须是对象", "bad_route")
path_raw = payload.get("path") or []
path = parse_waypoints(path_raw, limit=MAX_REQUEST_POINTS)
if len(path) < 1:
waypoints = parse_waypoints(payload.get("waypoints") or [])
if len(waypoints) < 1:
raise protocol.AppError("轨迹为空,请先添加途经点或绘制轨迹", "empty_route")
path = waypoints
speeds_raw = payload.get("speeds") or []
mode = protocol.validate_mode(payload.get("mode"))
route = Route(
name=str(payload.get("name") or "未命名轨迹")[:80],
mode=mode,
waypoints=parse_waypoints(payload.get("waypoints") or []) or [path[0], path[-1]],
path=path,
engine=str(payload.get("engine") or "manual")[:16],
loop=bool(payload.get("loop")),
speed_scale=protocol.validate_speed_scale(payload.get("speed_scale") or 1.0),
)
if len(speeds_raw) == len(path):
base = protocol.MODE_DEFAULTS.get(mode, 13.9)
speeds: list[float] = []
for value in speeds_raw:
# 非有限数(NaN/Infinity)或越界值一律回退到基准速度,避免污染整条链路
try:
number = float(value)
except (TypeError, ValueError):
speeds.append(base)
continue
if not math.isfinite(number) or not (0.05 <= number <= 400.0):
speeds.append(base)
else:
speeds.append(number)
route.speeds = speeds
else:
route.speeds = compute_speeds(path, mode, protocol.MODE_DEFAULTS.get(mode, 13.9))
route._acc = geoutils.cumulative_lengths(path)
return route
def default_route_json(engine: RouteEngine, waypoints: list[Any], mode: str = "driving") -> dict[str, Any]:
return engine.build(waypoints, mode=mode).to_json()
+1352
View File
File diff suppressed because it is too large. Load diff
+323
View File
@@ -0,0 +1,323 @@
"""地图瓦片本地缓存代理。
- 前端只请求 /api/tiles/{source}/{z}/{x}/{y}.png,由本模块回源到瓦片服务商并落盘缓存。
- 缓存目录可配置(默认 ~/.ios-location-tiles),加载过的区域在断网时仍可显示。
- 无第三方依赖:urllib 回源 + 严格锁保证并发安全。
"""
from __future__ import annotations
import os
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import protocol
_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
)
_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
}
# 1x1 透明 PNG,回源彻底失败时返回,避免前端整块报错
PLACEHOLDER_PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63fcffff3f0300050001a5f645ea0000000049454e44ae426082"
)
@dataclass
class TileResult:
body: bytes
content_type: str
status: int
source: str # "cache" | "network" | "placeholder" | "error"
@property
def ok(self) -> bool:
return self.status == 200
class TileCache:
def __init__(self, cache_dir: str | os.PathLike[str], max_mb: int = 1024, timeout: float = 12.0) -> None:
self.dir = Path(cache_dir).expanduser()
self.max_bytes = max(64, int(max_mb)) * 1024 * 1024
self.timeout = timeout
self._lock = threading.RLock()
self._last_sweep = 0.0
self._inflight: dict[str, threading.Lock] = {}
self.stats = {"hit": 0, "miss": 0, "network": 0, "failed": 0, "placeholder": 0}
try:
self.dir.mkdir(parents=True, exist_ok=True)
except OSError:
pass
# ---------------- 内部 ----------------
def _path_for(self, source: str, z: int, x: int, y: int, ext: str = ".png") -> Path:
return self.dir / source / str(z) / str(x) / f"{y}{ext}"
def _find_cached(self, source: str, z: int, x: int, y: int) -> Path | None:
for ext in (".png", ".jpg", ".webp"):
candidate = self._path_for(source, z, x, y, ext)
if candidate.exists() and candidate.stat().st_size > 0:
return candidate
return None
def _write(self, source: str, z: int, x: int, y: int, ext: str, body: bytes) -> Path:
path = self._path_for(source, z, x, y, ext)
with self._lock:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_bytes(body)
os.replace(tmp, path)
return path
def _sweep(self) -> None:
"""超过容量上限时按最后访问时间淘汰旧瓦片。"""
if self.max_bytes <= 0:
return
with self._lock:
now = time.time()
if now - self._last_sweep < 60:
return
self._last_sweep = now
entries: list[tuple[float, int, Path]] = []
total = 0
for root, _dirs, files in os.walk(self.dir):
for name in files:
if not name.endswith((".png", ".jpg", ".webp")):
continue
path = Path(root) / name
try:
st = path.stat()
except OSError:
continue
total += st.st_size
entries.append((st.st_atime, st.st_size, path))
if total <= self.max_bytes:
return
entries.sort(key=lambda item: item[0])
target = total - int(self.max_bytes * 0.85)
freed = 0
for _atime, size, path in entries:
if freed >= target:
break
try:
path.unlink()
freed += size
except OSError:
continue
def _lock_for(self, key: str) -> threading.Lock:
with self._lock:
lock = self._inflight.get(key)
if lock is None:
lock = threading.Lock()
self._inflight[key] = lock
return lock
# ---------------- 对外 ----------------
def get_tile(self, source_key: str, source: dict[str, Any], z: int, x: int, y: int) -> TileResult:
cached = self._find_cached(source_key, z, x, y)
if cached is not None:
try:
blob = cached.read_bytes()
except OSError:
blob = b""
if blob:
if _is_blank_tile(blob):
# 之前可能把空瓦片缓存下来了:删掉并重新回源
try:
cached.unlink()
except OSError:
pass
else:
self.stats["hit"] += 1
try:
os.utime(cached, None)
except OSError:
pass
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
key = f"{source_key}/{z}/{x}/{y}"
with self._lock_for(key):
cached = self._find_cached(source_key, z, x, y)
if cached is not None:
try:
blob = cached.read_bytes()
except OSError:
blob = b""
if blob and not _is_blank_tile(blob):
self.stats["hit"] += 1
return TileResult(blob, _mime_for(cached.suffix), 200, "cache")
self.stats["miss"] += 1
url = build_url(source, z, x, y)
if not url:
self.stats["failed"] += 1
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
try:
body, mime = self._fetch(url)
except Exception:
self.stats["failed"] += 1
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
if not body or _is_blank_tile(body):
# 服务商返回空图(多为瓦片不存在或参数不对):不写缓存,交给前端显示占位
self.stats["failed"] += 1
return TileResult(PLACEHOLDER_PNG, "image/png", 404, "error")
ext = _EXT_BY_MIME.get(mime, ".png")
self._write(source_key, z, x, y, ext, body)
self.stats["network"] += 1
self._sweep()
return TileResult(body, mime, 200, "network")
def _fetch(self, url: str) -> tuple[bytes, str]:
request = urllib.request.Request(
url,
headers={
"User-Agent": _UA,
"Accept": "image/avif,image/webp,image/png,image/*,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": _referer_for(url),
},
)
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read()
mime = (response.headers.get("Content-Type") or "image/png").split(";")[0].strip().lower()
if mime not in _EXT_BY_MIME and not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"):
raise ValueError("返回的不是图片")
return body, mime
def clear(self) -> int:
"""清空缓存,返回删除的瓦片数。"""
removed = 0
with self._lock:
for root, _dirs, files in os.walk(self.dir, topdown=False):
for name in files:
try:
(Path(root) / name).unlink()
removed += 1
except OSError:
continue
return removed
def size_bytes(self) -> int:
total = 0
for root, _dirs, files in os.walk(self.dir):
for name in files:
try:
total += (Path(root) / name).stat().st_size
except OSError:
continue
return total
def _mime_for(suffix: str) -> str:
return {".jpg": "image/jpeg", ".webp": "image/webp"}.get(suffix, "image/png")
def _is_blank_tile(body: bytes) -> bool:
"""识别「全透明 / 纯色」的空瓦片。
某些瓦片服务在参数或图层不对时会返回 200 + 256x256 的全透明 PNG(体积很小),
这类图直接给前端只会显示白块,因此这里当作失败处理。
"""
if not body:
return True
if not body.startswith(b"\x89PNG"):
return False # JPEG 等格式不在此判断
if len(body) > 6000:
return False # 体积正常的 PNG 基本都有内容
try:
import zlib
if body[24] != 8: # bit depth
return False
color_type = body[25]
pos = 8
idat = bytearray()
while pos + 8 <= len(body):
length = int.from_bytes(body[pos:pos + 4], "big")
chunk_type = body[pos + 4:pos + 8]
data = body[pos + 8:pos + 8 + length]
if chunk_type == b"IDAT":
idat.extend(data)
elif chunk_type == b"IEND":
break
pos += 12 + length
if not idat:
return True
raw = zlib.decompress(bytes(idat))
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color_type, 1)
bpp = channels
stride = 256 * bpp
# 这里只做粗略判断:全 0(透明/黑)或单一字节值的图像视为空
first = None
for row in range(min(8, 256)):
start = row * (stride + 1) + 1
chunk = raw[start:start + stride]
if not chunk:
break
if first is None:
first = bytes(chunk)
elif bytes(chunk) != first:
return False
if first is None:
return True
if color_type in (4, 6):
# 带 alpha 通道:全部 alpha 为 0 视为全透明
alpha_index = bpp - 1
return all(first[i] == 0 for i in range(alpha_index, len(first), bpp))
return len(set(first)) <= 1
except Exception:
return False
def _referer_for(url: str) -> str:
try:
parts = url.split("/")
return f"{parts[0]}//{parts[2]}/"
except Exception:
return "https://www.amap.com/"
def build_url(source: dict[str, Any], z: int, x: int, y: int) -> str:
"""把 {z}/{x}/{y}/{s} 模板替换成真实 URL。"""
template = str(source.get("url") or "")
if not template:
return ""
subs = source.get("subdomains") or []
if "{s}" in template:
if subs:
sub = subs[(x + y) % len(subs)]
else:
sub = source.get("subdomain_default", "1")
template = template.replace("{s}", str(sub))
max_zoom = int(source.get("max_zoom") or 19)
z = min(z, max_zoom)
try:
return template.format(z=z, x=x, y=y, s="")
except (KeyError, IndexError, ValueError):
return ""
def validate_tile_request(z: Any, x: Any, y: Any) -> tuple[int, int, int]:
try:
iz, ix, iy = int(z), int(x), int(y)
except (TypeError, ValueError) as exc:
raise protocol.AppError("瓦片坐标必须是整数", "bad_tile") from exc
if not (0 <= iz <= 22):
raise protocol.AppError("缩放级别超出范围", "bad_tile")
limit = 1 << iz
if not (0 <= ix < limit and 0 <= iy < limit):
raise protocol.AppError("瓦片坐标超出该缩放级别范围", "bad_tile")
return iz, ix, iy
+434
View File
@@ -0,0 +1,434 @@
"""iOS 17+ 的 RSD 隧道管理。
背景(见 docs/DEVICE_NOTES.md 的取证结论):
- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。
- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
(即:本工具运行时应关闭爱思助手等同类工具)。
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
"""
from __future__ import annotations
import os
import re
import shutil
import signal
import subprocess
import threading
import time
from typing import Any
from . import protocol
POLL_INTERVAL = 0.25
START_TIMEOUT = 75.0
_HINT_NATIVE_FAILED = (
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
)
_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
class TunnelManager:
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。"""
def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None:
self._lock = threading.RLock()
self.state = "idle" # idle|starting|connected|error|unsupported
self.message = ""
self.host: str | None = None
self.port: int | None = None
self.udid: str | None = None
self.mode: str | None = None # native|sudo|manual
self._process: subprocess.Popen[str] | None = None
self._reader: threading.Thread | None = None
self._needs_root = False
self._log = log
self._on_change = on_change
self.venv_python = venv_python or _detect_python()
# ---------------- 状态 ----------------
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"state": self.state,
"message": self.message or _default_message(self.state),
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
"mode": self.mode,
"udid": self.udid,
}
def _emit(self, level: str, message: str) -> None:
if self._log:
try:
self._log(level, message)
except Exception:
pass
def _set_state(self, state: str, message: str = "", **fields: Any) -> None:
with self._lock:
self.state = state
self.message = message
for key, value in fields.items():
setattr(self, key, value)
if self._on_change:
try:
self._on_change()
except Exception:
pass
@property
def ready(self) -> bool:
with self._lock:
return bool(self.host and self.port) and self.state == "connected"
def address(self) -> tuple[str, int] | None:
with self._lock:
if self.host and self.port:
return self.host, self.port
return None
# ---------------- 启动 ----------------
def ensure(self, udid: str | None, allow_sudo: bool = False) -> dict[str, Any]:
"""保证隧道可用。已连接且 UDID 未变时直接复用。"""
with self._lock:
if self.ready and (udid is None or self.udid in (None, udid)):
return self.snapshot()
self.stop()
return self.start(udid=udid, allow_sudo=allow_sudo)
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
if not allow_sudo:
self._kill_stale_tunnels()
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None)
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
if command is None:
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
return self.snapshot()
env = dict(os.environ)
env.setdefault("PYTHONUNBUFFERED", "1")
env.setdefault("PYTHONPATH", str(_project_root()))
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
bufsize=1,
env=env,
start_new_session=True,
)
except Exception as exc:
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
return self.snapshot()
with self._lock:
self._process = process
self.mode = mode
self._needs_root = mode == "sudo"
if mode != "sudo":
_write_pidfile([process.pid])
self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True)
self._reader.start()
deadline = time.time() + START_TIMEOUT
while time.time() < deadline:
with self._lock:
if self.ready:
return self.snapshot()
if self.state == "error":
return self.snapshot()
if process.poll() is not None and not self.ready:
break
time.sleep(POLL_INTERVAL)
if not self.ready:
message = _HINT_NATIVE_FAILED_SHORT
if mode == "sudo":
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
self.stop()
self._set_state("error", message, mode=mode)
return self.snapshot()
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
"""拼出"建隧道"命令。
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
"""
import sys
if getattr(sys, "frozen", False):
base = [sys.executable, "tunnel-helper"]
else:
cli = self._cli_command()
if cli is None:
return None, "native"
base = [*cli, "remote", "start-tunnel", "--native", "--script-mode"]
command = list(base)
if udid:
command += ["--udid", udid]
if not allow_sudo:
return command, "native"
inner = " ".join(_shell_quote(part) for part in command)
script = f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" with administrator privileges'
return ["osascript", "-e", script], "sudo"
def _kill_stale_tunnels(self) -> None:
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时
记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。
"""
pids = _read_pidfile()
if not pids:
return
alive: list[int] = []
for pid in pids:
command = _command_of(pid)
if command and "start-tunnel" in command:
alive.append(pid)
_write_pidfile([])
if not alive:
return
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
for pid in alive:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
continue
except Exception:
continue
for _ in range(10):
time.sleep(0.3)
if _command_of(pid) is None:
break
else:
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
def _cli_command(self) -> list[str] | None:
"""返回"建隧道"命令的前缀。
- 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令
(递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**;
- 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。
"""
import sys
if getattr(sys, "frozen", False):
return [sys.executable, "tunnel-helper"]
candidate = _detect_python()
if candidate:
try:
probe = subprocess.run(
[candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"],
capture_output=True,
text=True,
timeout=25,
)
if probe.returncode == 0:
return [candidate, "-m", "pymobiledevice3"]
except Exception:
pass
direct = shutil.which("pymobiledevice3")
if direct:
return [direct]
return None
# ---------------- 输出解析 ----------------
def _read_output(self, process: subprocess.Popen[str]) -> None:
pattern = re.compile(r"(?P<host>[0-9a-fA-F:.]{3,})\s+(?P<port>\d{2,5})")
assert process.stdout is not None
for line in process.stdout:
text = line.strip()
if not text:
continue
self._emit("info", f"[tunnel] {text}")
if self.ready:
continue
found = _parse_rsd_line(text, pattern)
if found:
host, port = found
self._set_state(
"connected",
f"隧道已建立({self.mode or 'native'}):{host} 端口 {port}",
host=host,
port=port,
)
with self._lock:
code = process.poll()
if code is not None and not self.ready:
self._set_state("error", f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}")
# ---------------- 手动 / 释放 ----------------
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]:
host = (host or "").strip()
try:
iport = int(port)
except (TypeError, ValueError):
raise protocol.AppError("RSD 端口必须是数字", "bad_rsd")
if not host:
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
self.stop()
self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", host=host, port=iport, mode="manual", udid=udid)
return self.snapshot()
def stop(self) -> None:
with self._lock:
process = self._process
self._process = None
if process is not None and process.poll() is None:
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
try:
process.terminate()
except Exception:
pass
try:
process.wait(timeout=4)
except Exception:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
pass
with self._lock:
self.host = None
self.port = None
self.mode = None
if self.state in ("connected", "starting", "error", "unsupported"):
self.state = "idle"
self.message = _default_message("idle")
_write_pidfile([])
def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None:
if text.startswith("--rsd "):
parts = text.split()
if len(parts) >= 3:
try:
return parts[1], int(parts[2])
except ValueError:
return None
if "RSD Port" in text:
return None
match = pattern.fullmatch(text) or pattern.match(text)
if not match:
return None
host = match.group("host")
if ":" not in host and "." not in host:
return None
try:
port = int(match.group("port"))
except ValueError:
return None
if not (1 <= port <= 65535):
return None
return host, port
def _default_message(state: str) -> str:
return {
"idle": "隧道未建立(iOS 17 及以上需要)",
"starting": "正在建立隧道…",
"connected": "隧道已连接",
"error": _HINT_NATIVE_FAILED,
"unsupported": "当前环境不支持自动建隧道",
}.get(state, "")
def _mask(command: list[str]) -> list[str]:
return command
def _pidfile_path() -> str:
return os.path.join(_project_root(), ".tunnel.pids")
def _read_pidfile() -> list[int]:
try:
with open(_pidfile_path(), "r", encoding="utf-8") as handle:
return [int(line.strip()) for line in handle if line.strip().isdigit()]
except Exception:
return []
def _write_pidfile(pids: list[int]) -> None:
try:
path = _pidfile_path()
if not pids:
if os.path.exists(path):
os.unlink(path)
return
with open(path, "w", encoding="utf-8") as handle:
handle.write("\n".join(str(pid) for pid in pids))
except Exception:
pass
def _command_of(pid: int) -> str | None:
"""返回进程命令行;进程不存在返回 None。"""
try:
result = subprocess.run(
["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, timeout=5
)
except Exception:
return None
text = (result.stdout or "").strip()
return text or None
def _shell_quote(text: str) -> str:
return "'" + text.replace("'", "'\\''") + "'"
def _project_root() -> str:
"""数据目录根:源码运行用项目目录,打包后只读,改用用户可写目录。"""
import sys
if getattr(sys, "frozen", False):
return _writable_data_dir()
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _writable_data_dir() -> str:
"""打包运行时可写的数据目录(~/Library/Application Support/...)。"""
base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack")
try:
os.makedirs(base, exist_ok=True)
except OSError:
base = os.path.expanduser("~")
return base
def _detect_python() -> str | None:
"""返回项目 venv 的 python,找不到就退回当前解释器。"""
import sys
root = _project_root()
for candidate in (
os.path.join(root, ".venv", "bin", "python"),
os.path.join(root, ".venv", "bin", "python3"),
):
if os.path.exists(candidate):
return candidate
return sys.executable
def tunnel_supported() -> bool:
import platform
return platform.system() == "Darwin"
+326
View File
@@ -0,0 +1,326 @@
"""极简 WebSocket 服务端实现(RFC 6455):零第三方依赖。
设计:
- ThreadingHTTPServer 的 handler 检测到 Upgrade: websocket 后调用 serve(),
该函数劫持 socket 并阻塞在当前线程里跑消息循环。
- 发送可能来自任意线程(HTTP 线程、播放引擎线程),因此内部用锁 + deque 保证串行写。
- 服务端只推送文本帧,不接收分片消息(浏览器不会对短 JSON 做分片)。
"""
from __future__ import annotations
import base64
import hashlib
import os
import socket
import struct
import threading
import time
from collections import deque
from typing import Any, Callable
from . import protocol
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
OP_CONT = 0x0
OP_TEXT = 0x1
OP_BIN = 0x2
OP_CLOSE = 0x8
OP_PING = 0x9
OP_PONG = 0xA
MAX_FRAME = 8 * 1024 * 1024
def is_websocket_upgrade(headers: Any) -> bool:
upgrade = (headers.get("Upgrade") or "").strip().lower()
connection = (headers.get("Connection") or "").lower()
return upgrade == "websocket" and "upgrade" in connection
def accept_key(client_key: str) -> str:
digest = hashlib.sha1((client_key + GUID).encode("ascii")).digest()
return base64.b64encode(digest).decode("ascii")
class WSConnection:
"""一条已升级的连接。线程安全的 send_json。"""
def __init__(self, sock: socket.socket, addr: tuple[str, int], require_masked: bool = True) -> None:
self.sock = sock
self.addr = addr
self.require_masked = require_masked
self.id = f"ws-{int(time.time() * 1000) % 100000}-{os.urandom(2).hex()}"
self._send_lock = threading.Lock()
self._closed = threading.Event()
self._buf = b""
self._frag_op: int | None = None
self._frag_data = bytearray()
self.bytes_sent = 0
self.bytes_recv = 0
# ---------------- 发送 ----------------
def send_frame(self, opcode: int, payload: bytes = b"") -> None:
if self._closed.is_set():
return
header = bytearray()
header.append(0x80 | opcode)
length = len(payload)
if length < 126:
header.append(length)
elif length < 65536:
header.append(126)
header.extend(struct.pack("!H", length))
else:
header.append(127)
header.extend(struct.pack("!Q", length))
frame = bytes(header) + payload
with self._send_lock:
if self._closed.is_set():
return
try:
self.sock.sendall(frame)
self.bytes_sent += len(frame)
except OSError:
self._closed.set()
def send_text(self, text: str) -> None:
self.send_frame(OP_TEXT, text.encode("utf-8"))
def send_json(self, obj: dict[str, Any]) -> None:
self.send_text(protocol.dumps(obj))
def ping(self) -> None:
self.send_frame(OP_PING)
def close(self, code: int = 1000, reason: str = "") -> None:
if self._closed.is_set():
return
payload = struct.pack("!H", code) + reason.encode("utf-8")
try:
self.send_frame(OP_CLOSE, payload)
finally:
self._closed.set()
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
@property
def closed(self) -> bool:
return self._closed.is_set()
# ---------------- 接收 ----------------
def _recv_exact(self, n: int) -> bytes | None:
while len(self._buf) < n:
try:
chunk = self.sock.recv(65536)
except (OSError, socket.timeout):
return None
if not chunk:
return None
self._buf += chunk
self.bytes_recv += len(chunk)
data, self._buf = self._buf[:n], self._buf[n:]
return data
def _read_frame(self) -> tuple[int, bool, bytes] | None:
head = self._recv_exact(2)
if head is None:
return None
b1, b2 = head[0], head[1]
fin = bool(b1 & 0x80)
opcode = b1 & 0x0F
masked = bool(b2 & 0x80)
length = b2 & 0x7F
if length == 126:
ext = self._recv_exact(2)
if ext is None:
return None
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = self._recv_exact(8)
if ext is None:
return None
length = struct.unpack("!Q", ext)[0]
if length > MAX_FRAME:
self.close(1009, "frame too large")
return None
mask = b""
if masked:
mask = self._recv_exact(4) or b""
if len(mask) != 4:
return None
elif self.require_masked:
# RFC 6455:客户端发来的帧必须带掩码,否则属协议错误
self.close(1002, "unmasked frame from client")
return None
payload = self._recv_exact(length) if length else b""
if payload is None:
return None
if masked and payload:
payload = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
return opcode, fin, payload
def run(self, on_message: Callable[["WSConnection", dict[str, Any]], None],
on_close: Callable[["WSConnection"], None] | None = None) -> None:
"""消息循环,阻塞直到对端关闭或出错。"""
try:
self.sock.settimeout(None)
except OSError:
pass
try:
while not self._closed.is_set():
frame = self._read_frame()
if frame is None:
break
opcode, fin, payload = frame
if opcode == OP_CLOSE:
self.send_frame(OP_CLOSE, payload[:2] if len(payload) >= 2 else b"")
break
if opcode == OP_PING:
self.send_frame(OP_PONG, payload)
continue
if opcode == OP_PONG:
continue
if opcode in (OP_TEXT, OP_BIN):
if fin:
data = payload
else:
self._frag_op = opcode
self._frag_data = bytearray(payload)
continue
elif opcode == OP_CONT:
if self._frag_op is None:
continue
self._frag_data.extend(payload)
if len(self._frag_data) > MAX_FRAME:
self.close(1009, "fragmented message too large")
return None
if not fin:
continue
data = bytes(self._frag_data)
self._frag_op = None
self._frag_data = bytearray()
else:
continue
if not data:
continue
try:
msg = protocol.loads(data)
except Exception:
self.send_json({"type": "error", "error": "消息不是合法 JSON", "code": "bad_json"})
continue
if isinstance(msg, dict):
on_message(self, msg)
finally:
self._closed.set()
try:
self.sock.close()
except OSError:
pass
if on_close is not None:
try:
on_close(self)
except Exception:
pass
def handshake(sock: socket.socket, headers: Any) -> WSConnection | None:
"""完成 HTTP → WebSocket 升级。成功返回 WSConnection。
注意:只有当客户端请求了 Sec-WebSocket-Protocol 时才能回该头(RFC 6455)。
否则浏览器会因为"服务端选择了未请求的子协议"而直接判定握手失败。
"""
key = headers.get("Sec-WebSocket-Key")
if not key:
try:
sock.sendall(
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
except OSError:
pass
return None
version = (headers.get("Sec-WebSocket-Version") or "").strip()
if version and version != "13":
# RFC 6455:只支持版本 13,其它版本必须明确拒绝,不能装作握手成功
try:
sock.sendall(
b"HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n"
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
)
except OSError:
pass
return None
lines = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
f"Sec-WebSocket-Accept: {accept_key(key.strip())}",
]
requested = headers.get("Sec-WebSocket-Protocol")
if requested:
offered = [item.strip() for item in requested.split(",") if item.strip()]
# 只回客户端提供的第一个协议,绝不回它没要求过的值
if offered:
lines.append(f"Sec-WebSocket-Protocol: {offered[0]}")
response = "\r\n".join(lines) + "\r\n\r\n"
try:
sock.sendall(response.encode("ascii"))
except OSError:
return None
addr = sock.getpeername() if hasattr(sock, "getpeername") else ("?", 0)
if not isinstance(addr, tuple):
addr = ("?", 0)
return WSConnection(sock, (str(addr[0]), int(addr[1])))
class Hub:
"""连接注册表 + 广播。"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._conns: deque[WSConnection] = deque()
def add(self, conn: WSConnection) -> None:
with self._lock:
self._conns.append(conn)
def remove(self, conn: WSConnection) -> None:
with self._lock:
try:
self._conns.remove(conn)
except ValueError:
pass
def snapshot(self) -> list[WSConnection]:
with self._lock:
return [c for c in self._conns if not c.closed]
@property
def count(self) -> int:
return len(self.snapshot())
def broadcast(self, obj: dict[str, Any]) -> int:
payload = protocol.dumps(obj)
sent = 0
for conn in self.snapshot():
try:
conn.send_text(payload)
sent += 1
except Exception:
self.remove(conn)
return sent
def close_all(self) -> None:
for conn in self.snapshot():
try:
conn.close(1001, "server shutdown")
except Exception:
pass
with self._lock:
self._conns.clear()
+1031
View File
File diff suppressed because it is too large. Load diff
+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 |
任何人不得直接改别人拥有的文件;需要改动就发消息商量。
+721
View File
@@ -0,0 +1,721 @@
# 独立验证报告(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 告警 |
+419
View File
@@ -0,0 +1,419 @@
# Windows 移植交接文档
> 本文档由 macOS 侧的开发会话产出,用于把「iOS 虚拟定位轨迹工具」移植到 Windows。
> 仓库:https://github.com/jbjf6666/ios-location-track (macOS 版已发布,v1.0.0)
>
> **给 Windows 会话的建议**:先完整读一遍本文档,再动手。最大的风险点(iOS 17+ 隧道)
> 集中在第 3 节,**请先做那里的可行性验证再决定整体架构**——不要先写界面。
---
## 0. 一句话结论
**约 90% 的代码可以直接复用**(纯 Python + 纯前端,零平台依赖)。
真正需要重写的只有两处:**iOS 17+ 的隧道建立方式**(macOS 用 Apple 私有框架,Windows 必须换方案)
和**打包/分发链路**(DMG → 安装包)。窗口启动方式也需改。
总计约 70 行平台相关代码需要替换,其余 6000+ 行可原样移植。
---
## 1. 项目结构与可移植性对照表
| 文件 | 行数 | 可移植性 | 说明 |
| --- | --- | --- | --- |
| `web/` (index.html / style.css / js/*) | ~2500 | ✅ **完全可移植** | 纯前端,零改动。自绘 Canvas 地图,无第三方依赖 |
| `core/<br>playback.py` | 687 | ✅ 完全可移植 | 播放引擎(弧长积分、速度剖面、seek、循环) |
| `core/route_engine.py` | 576 | ✅ 完全可移植 | 路线规划(OSRM + 直线退化、途经点) |
| `core/geoutils.py` | 234 | ✅ 完全可移植 | 坐标换算(WGS84 / GCJ-02、haversine、抽稀) |
| `core/noise.py` | 208 | ✅ 完全可移植 | 真实感模型(多尺度值噪声、漂移、抖动、粗差) |
| `core/config_store.py` | 312 | ✅ 完全可移植 | 配置持久化(路径需调整) |
| `core/ws.py` | 326 | ✅ 完全可移植 | 手写 WebSocket(RFC 6455),基于 stdlib |
| `core/tilecache.py` | 323 | ✅ 完全可移植 | 瓦片代理 + 磁盘 LRU 缓存 |
| `core/protocol.py` | 160 | ✅ 完全可移植 | WS 协议常量与错误类型 |
| `core/location_mock.py` | 229 | ✅ 完全可移植 | 无设备模拟模式 |
| `core/device.py` | 427 | ⚠️ 基本可移植 | 设备发现用 `pymobiledevice3.usbmux`,Windows 上走 Apple Mobile Device 服务(iTunes 组件) |
| `core/location_provider.py` | 701 | ⚠️ 基本可移植 | DVT 会话逻辑跨平台;**只有 1 处** macOS 特定(错误提示文案里的 `./run.sh`) |
| `core/server.py` | 1352 | ⚠️ 少量改动 | HTTP + WS 服务、命令分发;约 15 处平台相关(窗口/路径/提示) |
| `core/tunnel.py` | 434 | ❌ **需重写** | 隧道建立,见第 3 节 |
| `main.py` | 284 | ⚠️ 需改 | 窗口启动(Chrome/Edge 路径)、数据目录 |
| `tools/make_dmg.py` | — | ❌ **纯 macOS** | 用 `hdiutil`,Windows 需换安装包方案 |
| `tools/make_icon.py` | — | ✅ 可移植 | Pillow 绘制,但 `.icns` 生成用 `iconutil`(macOS)→ Windows 需改成 `.ico` |
| `tools/verify_bundle.py` | — | ⚠️ 需改 | 校验逻辑通用,但 `codesign` / `.app` 结构是 macOS 的 |
| `Location.spec` | — | ⚠️ 需改 | PyInstaller 配置,Windows 要改成 `.exe` + `BUNDLE` → 无(改用 Inno Setup / NSIS) |
| `tests/*` | ~2000 | ✅ 大部分可移植 | `selftest.py` / `drift_sim.py` 纯逻辑,跨平台;`ui_acceptance.py` 需改浏览器路径 |
| `resources/ddi/` | 16 MB | ✅ 可移植 | 开发者镜像素材,与平台无关 |
---
## 2. Windows 环境准备
```
1. Python 3.11+(建议 3.12,与 macOS 侧一致)
2. iTunes(或至少 Apple Mobile Device Support)—— 提供 usbmuxd 等价服务,
pymobiledevice3 依赖它才能通过 USB 发现 iOS 设备
下载:Microsoft Store 版 iTunes,或 Apple 官网的 iTunes for Windows
3. Wintun 驱动(用于 userspace 隧道,见第 3 节)
https://www.wintun.net/
4. Git
```
依赖安装:
```powershell
python -m venv .venv
.venv\Scripts\activate
pip install pymobiledevice3 pillow pyinstaller
```
> 注意:macOS 侧 `scripts/run.sh` 里写了国内 PyPI 镜像 `mirrors.aliyun.com`,
> Windows 侧可沿用(`pip.conf` 里已有配置,Windows 下需放到 `%APPDATA%\pip\pip.ini`)。
---
## 3. 核心难点:iOS 17+ 的 RSD 隧道(**必须先验证**)
### 3.1 为什么 macOS 的实现不能直接用
iOS 17 起,虚拟定位不再走 usbmux,必须先建立 RSD 隧道。macOS 侧走的是
`pymobiledevice3.remote.native_tunnel`,其实现**完全建立在 Apple 私有框架上**:
```python
_IS_DARWIN = platform.system() == "Darwin"
lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
# 然后调用 xpc_connection_create_mach_service、xpc_dictionary_* 等
# 以及 ObjC block:_NSConcreteGlobalBlock
```
这些 API 是 macOS 独有的(XPC + Objective-C runtime),**Windows 上不存在**。
pymobiledevice3 的 `establish_native_rsd()` 在非 macOS 平台会抛
`UserspaceTunnelUnavailableError`。
### 3.2 Windows 上的可行方案:userspace tunnel
pymobiledevice3 内置了跨平台的 userspace 隧道实现,Windows 是**明确支持的目标平台**:
```python
from pymobiledevice3.remote import userspace_tunnel
from pymobiledevice3.remote import tunnel_service
# 关键开关
tunnel_service.USE_USERSPACE_TUNNEL = True
```
实现要点(读源码可知):
- 使用 `pytun_pmd3.TunTapDevice` 创建虚拟网卡(Windows 上底层是 Wintun 驱动)
- macOS/Linux 用 AF_UNIX socketpair;**Windows 没有 socketpair,改用 TUN 设备直连**
- 该包已在依赖列表中(`pytun_pmd3-3.0.3`),无需额外安装
### 3.3 验证步骤(**请先做完这一步再写别的代码**)
> **重要**:`--userspace` 不是 `remote start-tunnel` 的选项(该命令只有 `--native`)。
> 实测确认它挂在共享的 RSD 依赖上,**`lockdown start-tunnel` 与 `mounter auto-mount` 才接受它**:
>
> | 命令 | `--userspace` |
> | --- | --- |
> | `lockdown start-tunnel` | ✅ 支持 |
> | `mounter auto-mount` | ✅ 支持 |
> | `remote start-tunnel` | ❌ 不支持(只有 `--native`,且 `--native` 是 macOS 专用) |
> | `developer dvt simulate-location` | ❌ 不支持 |
```powershell
# 1) 设备能否被发现(需要 iTunes 提供的 Apple Mobile Device 服务)
python -m pymobiledevice3 usbmux list
# 2) 建立 userspace 隧道(Windows 上的正解)
python -m pymobiledevice3 lockdown start-tunnel --userspace --script-mode
```
期望输出形如 `HOST PORT`(例如 `fd00:...:1 54321`)—— 这正是 macOS 侧
`core/tunnel.py:_read_output()` 解析的格式,**保持这个格式,上层代码就不用改**。
**底层机制(读源码确认)**:`userspace_tunnel.UserspaceTunnel.aopen()` 内部会设置
`tunnel_service.USE_USERSPACE_TUNNEL = True`,从而让 `RemotePairingTunnel.start_tunnel()`
通过 `create_tun_device()` 工厂拿到 `UserspaceTun`(底层是 `pytun_pmd3.TunTapDevice` →
Windows 上是 Wintun),而不是内核隧道。所以**不需要任何 monkeypatch**,用上面的命令即可。
**限制**:PyTCP 的协议栈是进程内全局单例,**一个进程同时只能有一条 userspace 隧道**
(代码里会抛 `"a userspace tunnel is already active in this process"`)。
这对本工具没影响——播放引擎本来就是单一长连接,但要记得别在进程里重复建隧道。
**必须确认的事项**:
| 检查项 | 为什么重要 |
| --- | --- |
| 是否需要管理员权限 | Wintun 装驱动/建网卡通常需要;这决定要不要在界面上加提权按钮 |
| Windows 防火墙是否弹窗 | 首次建立隧道可能弹窗,需要写进文档 |
| 隧道建立后 `ping6` 是否通 | 验证虚拟网卡真的工作了 |
| 断开后网卡是否残留 | 残留会导致下次建立失败,需要清理逻辑 |
### 3.4 隧道模块的重写接口
`core/tunnel.py` 的 `TunnelManager` 类已经把隧道抽象好了,**保持以下公开接口不变**,
只替换内部实现即可,上层(`server.py` / `location_provider.py`)无需改动:
```python
class TunnelManager:
def ready(self) -> bool: ... # 隧道是否就绪
def address(self) -> tuple[str, int] | None: ... # (host, port)
def ensure(self, udid: str | None, allow_sudo: bool = False) -> dict: ...
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict: ...
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict: ...
def stop(self) -> None: ...
def snapshot(self) -> dict: ... # 状态快照,推给前端
```
**建议的实现方式**:照抄现有结构,把 `_build_command()` 换成:
```python
if sys.platform == "win32":
# 注意:不是 remote start-tunnel,那个命令没有 --userspace
base = [sys.executable, "-m", "pymobiledevice3",
"lockdown", "start-tunnel", "--userspace", "--script-mode"]
```
打包后(`sys.frozen`)同样走自调用:
```python
base = [sys.executable, "tunnel-helper"] # exe 调用自己的子命令
```
并在 `tunnel-helper` 内部用 Python API 建立 userspace 隧道:
```python
# 注意类名是 UserspaceRsdTunnel(不是 UserspaceTunnel)
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
tunnel = UserspaceRsdTunnel(serial=udid, autopair=True, remotepairing_fallback=True)
rsd = await tunnel.aopen() # -> RemoteServiceDiscoveryService
host, port = rsd.service.address
print(f"{host} {port}", flush=True) # 保持这个格式,上层无需改动
# 进程退出前释放(否则虚拟网卡可能残留)
await tunnel.aclose() # 或 tunnel.close()
```
> 另外提供同步接口 `open()` / `close()`,以及 `UserspaceTun`(TUN 设备本身,
> 一般不需要直接用它)。
> **重要经验**:macOS 侧的 `tunnel-helper` 是「自调用」模式(打包后的 exe 调用自己
> 的 `tunnel-helper` 子命令),这样打包后不依赖外部 Python。Windows 侧建议照做。
> 实现见 `main.py:run_tunnel_helper()` 与 `core/tunnel.py:_build_command()`。
---
## 4. 其余需要改动的部分
### 4.1 窗口启动(`main.py`)
macOS 侧用 Chrome/Edge 的 `--app=` 模式打开无地址栏窗口:
```python
# macOS 路径
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
```
Windows 需改为:
```python
CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
]
# 或从注册表读:
# HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe
```
`--app={url} --window-size=1440,900` 参数在 Windows 上同样有效。
**另一个选择**:用 `pywebview`(Windows 上走 Edge WebView2)做成真正的原生窗口,
体验更接近 macOS 版。代价是多一个依赖。
### 4.2 数据目录(`main.py:_resolve_roots()`)
```python
# macOS: ~/Library/Application Support/iOSLocationTrack
# Windows 应改为:%LOCALAPPDATA%\iOSLocationTrack
os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
```
### 4.3 打包与分发
| macOS | Windows 对应 |
| --- | --- |
| `PyInstaller --windowed` + `.app` bundle | `PyInstaller --windowed` 生成 `.exe`(onedir 更稳) |
| `codesign --sign -`(ad-hoc 签名) | 无对应;未签名 exe 会有 SmartScreen 警告 |
| `hdiutil` 制作 DMG | **Inno Setup**(推荐,免费)或 NSIS 制作 `setup.exe` |
| `iconutil` 生成 `.icns` | Pillow 直接存 `.ico`(多尺寸:16/32/48/64/128/256) |
`tools/make_icon.py` 的绘制代码可完全复用,只需把最后的 `.icns` 输出改成 `.ico`:
```python
sizes = [16, 24, 32, 48, 64, 128, 256]
img.save("app.ico", sizes=[(s, s) for s in sizes])
```
### 4.4 需要删除或替换的文件
- `tools/make_dmg.py` —— 纯 macOS,直接删掉
- `tools/ddi_offline_fetch.sh` —— bash 脚本,Windows 无用
- `scripts/run.sh` / `scripts/build_app.sh` —— 改写为 `.ps1` 或 `.bat`
- `Location.spec` —— 改写:去掉 `BUNDLE()`,`icon` 改成 `.ico`,`console=False`
---
## 5. 设备侧注意事项(平台无关,但容易踩坑)
这些是 macOS 侧实测踩过的坑,Windows 上同样会遇到:
### 5.1 镜像挂载用 Python API,不要用 CLI
打包成 exe 后没有 `pymobiledevice3` 命令行入口,必须用 Python API:
```python
from pymobiledevice3.lockdown import create_using_usbmux
from pymobiledevice3.services.mobile_image_mounter import (
MobileImageMounterService, auto_mount)
client = await create_using_usbmux(serial=udid, autopair=True,
connection_type="USB", pair_timeout=5.0)
async with MobileImageMounterService(lockdown=client) as mounter:
for image_type in ("Personalized", "Developer"):
if await mounter.is_image_mounted(image_type):
return # 已挂载,直接返回(否则会抛 AlreadyMountedError)
await auto_mount(client, version=version)
```
**踩坑记录**:
- `AlreadyMountedError` 必须当成功处理,不是失败
- 不要只检查「镜像是否挂载」就认为可用——macOS 侧实测 iOS 16.5 上镜像显示已挂载但 DVT 服务不启动
- iOS 17+ **不要**用 lockdown 服务名探测 DVT(`com.apple.instruments.dtservicehub` 走 RSD,不在 lockdown 里)
### 5.2 必须打包包元数据
PyInstaller 默认不打包 `dist-info`,而部分包(如 `apple_compress`)在运行期用
`importlib.metadata` 读自己的版本,漏掉会报
`No package metadata was found for apple_compress`,导致镜像挂载失败:
```python
from PyInstaller.utils.hooks import copy_metadata
datas = []
for pkg in ("apple_compress", "pymobiledevice3", "pyimg4", "construct",
"cryptography", "bpylist2", "opack2"):
try:
datas += copy_metadata(pkg)
except Exception:
pass
```
### 5.3 不要把 `distutils` 加进 excludes
PyInstaller 的 hook 会强制导入它,排除后**构建直接失败**:
```
ValueError: Target module "distutils" already imported as "ExcludedModule('distutils',)"
```
同理 `questionary` / `prompt_toolkit` / `pygments` 被 pymobiledevice3 运行期导入,
排除后真机功能会静默失效(设备发现退化成 libimobiledevice 兜底)。
### 5.4 支持的 iOS 版本范围
**iOS 17 ~ 27.2**,一套个性化镜像全系共用(`resources/ddi/personalized/`,约 16 MB)。
挂载时由 Apple TSS 在线签发,因此**只有签名步骤需要联网**。
**iOS 16 及更早不支持**——其开发者镜像必须由 Apple 按系统构建号签发,
第三方镜像会被设备拒签(已实测:`Failed to verify the disk image signature`,
换 libimobiledevice 同样失败,说明是设备端拒绝)。
---
## 6. 术语与设计约束(改代码前必读)
| 约束 | 原因 |
| --- | --- |
| **DVT 连接必须长连接保持** | 会话一断,模拟定位立即失效。所以播放引擎不能每发一个点就重连 |
| **DVT 只能改经纬度** | `simulateLocationWithLatitude:longitude:` 仅两个参数。`horizontalAccuracy`、`altitude`、`floor`、`isSimulatedBySoftware`、加速度计/陀螺仪/计步器由 iOS 合成或来自硬件,**无法注入** |
| **`last_point` 与 `last_reported` 必须分开** | 前者是轨迹上的当前点(供 status 用),后者是实际下发的点(用于推算速度)。混用会导致播放线程崩溃或速度算错 |
| **速度推算要用单调时钟** | macOS 侧曾因用 `time.time()` 且读取顺序错误,导致 dt 恒被钳到下限、速度偏大 20 倍 |
| **`update_tuning` 的参数默认值必须是哨兵** | 用 `None` 表示「不修改」会导致 `False` 也被当成「不修改」,开关永远关不掉 |
| **坐标内部一律 WGS84** | 高德瓦片是 GCJ-02,只在前端请求瓦片与落点时转换 |
| **同一时间只能有一个工具占隧道** | 设备只有一条 RSD 连接,i4Tools / Xcode / 本工具会互相挤掉 |
---
## 7. 验收标准(移植完成后请逐项跑通)
macOS 侧的测试套件大部分可直接复用,作为 Windows 版的验收基线:
```powershell
python tests\selftest.py # 83 项:协议、几何、播放引擎、配置、瓦片(无需设备)
python tests\drift_sim.py # 40 项:真实感模型(幅度、平滑性、开关、参数校验)
python tests\offline_tiles.py # 13 项:瓦片缓存
python main.py --mock # 界面能开、能画轨迹、能播放(无设备)
```
真机验收(**这一项是 Windows 版的核心风险点**):
1. `pymobiledevice3 usbmux list` 能看到设备
2. 点「挂载开发者镜像」成功
3. 点「连接设备」→ 隧道建立 → DVT 会话就绪
4. 播放轨迹 → 手机定位真的移动
5. 点「停止」→ 手机恢复真实定位
---
## 8. 建议的 Windows 侧工作顺序
```
第 1 步:环境 + 可行性验证(最高优先级,1~2 小时)
└─ 装 iTunes / pymobiledevice3 / Wintun
└─ 跑通 `remote start-tunnel --userspace`,拿到 HOST PORT
└─ 用 pymobiledevice3 的 DVT 直接下发一次定位,确认手机真的动了
★ 这一步不通,后面全白做。务必先做。
第 2 步:移植纯逻辑层(无风险,可并行)
└─ 复制 core/ 下 10 个可移植模块 + web/ 整个目录
└─ 跑 tests/selftest.py 与 tests/drift_sim.py 确认 123 项全绿
第 3 步:重写隧道层
└─ 按第 3.4 节的接口实现 Windows 版 TunnelManager
└─ 实现 `--tunnel-helper` 自调用模式(为打包做准备)
第 4 步:窗口与路径适配
└─ main.py 的浏览器路径、数据目录
第 5 步:打包
└─ PyInstaller → .exe(注意第 5.2、5.3 节的坑)
└─ Inno Setup 制作安装包
第 6 步:完整验收(第 7 节)
```
---
## 9. 需要向 macOS 侧(或用户)确认的问题
1. **目标 Windows 版本**?(Windows 10 / 11,是否要支持 ARM64)
2. **是否需要内嵌浏览器**(pywebview + WebView2)还是沿用 Chrome/Edge `--app=` 模式?
3. **安装包形式**:Inno Setup `setup.exe` / 绿色免安装 zip?
4. **隧道方案确认**:如果 `--userspace` 在目标机器上不通,备选方案是
让用户手动跑 `tunneld`(走「手动 RSD」入口,界面已支持),需要确认可接受性
5. **是否要保留 macOS 与 Windows 双平台同一代码库**?
建议:把平台相关代码集中到 `core/platform_win.py` / `core/platform_mac.py`,
用一个 `core/platform.py` 分发,避免分叉两份代码
---
## 10. 参考
- 仓库:https://github.com/jbjf6666/ios-location-track
- 协议契约:`docs/PROTOCOL.md`
- 设备侧取证记录:`docs/DEVICE_NOTES.md`(含 macOS 侧实测的完整排障过程)
- pymobiledevice3 文档:https://github.com/doronz88/pymobiledevice3
- userspace 隧道源码:`pymobiledevice3/remote/userspace_tunnel.py`
- 开发者镜像素材来源:https://github.com/doronz88/DeveloperDiskImage
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""iOS 虚拟定位轨迹工具 — 程序入口。
用法:
./run.sh # 正常启动(自动打开界面)
./.venv/bin/python main.py --mock # 无设备模拟模式(验收/演示用)
./.venv/bin/python main.py --port 8899 --no-browser
"""
from __future__ import annotations
import argparse
import os
import socket
import sys
import threading
import time
import urllib.request
import webbrowser
from pathlib import Path
def _resolve_roots() -> tuple[Path, Path]:
"""返回 (资源根, 数据根)。
- 源码运行:两者都是项目目录
- 打包成 .app:资源在 sys._MEIPASS(只读),数据写到
~/Library/Application Support/iOSLocationTrack
"""
if getattr(sys, "frozen", False):
resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
data = Path.home() / "Library" / "Application Support" / "iOSLocationTrack"
data.mkdir(parents=True, exist_ok=True)
return resources, data
root = Path(__file__).resolve().parent
return root, root
ROOT, DATA_ROOT = _resolve_roots()
sys.path.insert(0, str(ROOT))
from core import device as device_mod # noqa: E402
from core import protocol, server # noqa: E402
BANNER = r"""
___ ___ ___ _ _ _
|_ _|/ __| | _ \_____ __ _____ _ _ (_)__ _| |_(_)___ _ _
| | \__ \ | _/ _ \ V V / -_) ' \| / _` | _| / _ \ ' \
|___||___/ |_| \___/\_/\_/\___|_||_|_\__,_|\__|_\___/_||_|
"""
APP_MODE_BROWSERS = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
]
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="ios-location",
description=f"{protocol.APP_NAME} v{protocol.APP_VERSION}",
)
parser.add_argument("--host", default=protocol.DEFAULT_HOST, help="监听地址(默认 127.0.0.1,仅本机可访问)")
parser.add_argument("--port", type=int, default=protocol.DEFAULT_PORT, help=f"监听端口(默认 {protocol.DEFAULT_PORT})")
parser.add_argument("--mock", action="store_true", help="无设备模拟模式:不连真机,用于界面演示与验收")
parser.add_argument("--no-browser", action="store_true", help="启动后不自动打开界面")
parser.add_argument("--window", choices=["app", "browser", "none"], default="app",
help="界面打开方式:app=Chrome/Edge 应用窗口(默认,最像原生),browser=默认浏览器,none=不打开")
# 内部助手模式:`<可执行文件> tunnel-helper --udid XXX` 建立 RSD 隧道并打印 HOST PORT
parser.add_argument("command", nargs="?", default=None,
help=argparse.SUPPRESS)
parser.add_argument("--udid", default=None,
help=argparse.SUPPRESS) # 供 tunnel-helper 指定设备
parser.add_argument("--version", action="version", version=f"{protocol.APP_NAME} {protocol.APP_VERSION}")
return parser.parse_args(argv)
def run_tunnel_helper(udid: str | None) -> int:
"""建立 iOS 17+ 的 RSD 隧道并保持进程存活,向 stdout 打印「HOST PORT」。
打包成 .app 后不能再调用 `python -m pymobiledevice3`,因此把助手模式做进本程序:
主进程用 `sys.executable tunnel-helper` 递归启动自己(见 core/tunnel.py)。
"""
import asyncio
try:
from pymobiledevice3.remote.native_tunnel import establish_native_rsd
except Exception as exc: # pragma: no cover
print(f"无法加载隧道模块:{exc}", file=sys.stderr)
return 2
async def main_async() -> int:
try:
rsd = await establish_native_rsd(serial=udid)
except Exception as exc:
print(f"建立隧道失败:{exc}", file=sys.stderr)
return 1
address, port = rsd.service.address
# 关键:这一行会被 core/tunnel.py 解析出 RSD 地址。
# 打包成 .app 时若以"无控制台"方式启动,sys.stdout 可能是 None,
# 此时打印会抛 AttributeError,导致隧道地址传不出去 → 真机连不上。
# 因此 stdout 与 stderr 都写一份(父进程两者都会读取)。
line = f"{address} {port}"
for stream in (sys.stdout, sys.stderr):
if stream is None:
continue
try:
stream.write(line + "\n")
stream.flush()
except Exception:
pass
try:
await asyncio.Event().wait() # 持有隧道断言直到进程结束
except asyncio.CancelledError:
return 0
return 0
try:
return asyncio.run(main_async())
except KeyboardInterrupt:
return 0
def port_in_use(host: str, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.6)
return sock.connect_ex((host, port)) == 0
def wait_health(url: str, timeout: float = 20.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url + "/api/health", timeout=1.5) as response:
if response.status == 200:
return True
except Exception:
time.sleep(0.3)
return False
def open_ui(url: str, mode: str) -> None:
if mode == "none":
return
if mode == "app":
for binary in APP_MODE_BROWSERS:
if os.path.exists(binary):
try:
import subprocess
subprocess.Popen(
[binary, f"--app={url}", "--window-size=1440,900",
"--user-data-dir=" + str(Path.home() / ".ios-location-appwindow")],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
except Exception:
continue
try:
webbrowser.open(url)
except Exception:
pass
def find_free_port(host: str, preferred: int) -> int:
for candidate in range(preferred, preferred + 20):
if not port_in_use(host, candidate):
return candidate
return preferred
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if args.command == "tunnel-helper":
# 内部助手模式:由 TunnelManager 递归调用,只负责建隧道并打印 HOST PORT
return run_tunnel_helper(args.udid)
mock = bool(args.mock or os.environ.get("IOSLOC_MOCK") == "1")
host = args.host
url_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host
if port_in_use(host, args.port):
# 已有实例在跑:直接复用,避免双开互相抢 RSD 连接
url = f"http://{url_host}:{args.port}"
if wait_health(url, timeout=3.0):
print(f"检测到已有一个实例在运行:{url}")
open_ui(url, args.window)
return 0
args.port = find_free_port(host, args.port + 1)
print(BANNER)
print(f" {protocol.APP_NAME} v{protocol.APP_VERSION}")
print(f" Python {sys.version.split()[0]} | 数据目录 {DATA_ROOT}")
if mock:
print(" 模式:无设备模拟(--mock),不会连接真机")
else:
print(f" pymobiledevice3:{'已就绪' if device_mod.pymobiledevice3_importable() else '未安装(将使用 libimobiledevice 兜底,请运行 ./run.sh 自动安装)'}")
app = server.App(root=DATA_ROOT, mock=mock, port=args.port)
app.log("info", f"{protocol.APP_NAME} 启动,端口 {args.port}")
# 关键依赖自检:打包成 .app 后若漏打模块,这里会立刻暴露,
# 而不是等到"设备发现莫名退化成 libimobiledevice"才发现。
missing = []
for module in (
"pymobiledevice3",
"pymobiledevice3.lockdown",
"pymobiledevice3.usbmux",
"pymobiledevice3.remote.native_tunnel",
"pymobiledevice3.remote.remote_service_discovery",
"pymobiledevice3.services.dvt.instruments.dvt_provider",
"pymobiledevice3.services.dvt.instruments.location_simulation",
):
try:
__import__(module)
except Exception as exc:
missing.append(f"{module}({exc})")
if missing:
app.log("error", "关键模块缺失,真机功能将不可用:" + ";".join(missing))
elif not mock:
app.log("info", "关键模块自检通过(pymobiledevice3 / RSD 隧道 / DVT 定位)")
if getattr(app.config, "_repaired", False):
app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值")
# 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道子进程留在后台,
# 否则 iOS 17+ 的单条 RSD 连接会被残留进程占住,下一次定位会失败。
import atexit
import signal
atexit.register(app.shutdown)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
try:
signal.signal(sig, lambda *_a: app.shutdown_event.set())
except (ValueError, OSError, AttributeError):
pass
if mock:
app.log("warn", "当前为模拟模式:定位不会真正下发到设备")
app.devices.refresh()
app.devices.start_monitor()
last_device = app.config.get("last_device")
if last_device and app.devices.get(last_device):
try:
app.devices.select(last_device)
except Exception:
pass
devices = app.devices.list_devices()
if devices:
for device in devices:
app.log("info", f"发现设备:{device.name}(iOS {device.ios_version or '未知'},{device.udid})")
elif not mock:
app.log("warn", "暂未检测到 iOS 设备:请用数据线连接、解锁并在手机上点「信任」")
httpd = server.serve(app, host=host, port=args.port)
url = f"http://{url_host}:{args.port}"
print(f" 界面地址:{url}")
print(" 按 Ctrl+C 退出\n")
thread = threading.Thread(target=httpd.serve_forever, name="http", daemon=True)
thread.start()
if not args.no_browser and args.window != "none":
def _open() -> None:
if wait_health(url, timeout=15.0):
open_ui(url, args.window)
threading.Thread(target=_open, name="open-ui", daemon=True).start()
try:
while not app.shutdown_event.is_set():
app.shutdown_event.wait(0.5)
except KeyboardInterrupt:
print("\n收到退出信号,正在关闭…")
finally:
app.log("info", "正在关闭:停止模拟并释放隧道")
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print("已退出。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
[global]
index-url = https://mirrors.aliyun.com/pypi/simple/
timeout = 30
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 一步完成:生成图标 → 打包独立 .app → ad-hoc 签名 → 制作 DMG → 验证
# 产物:dist/虚拟定位轨迹.app(独立运行)、dist/虚拟定位轨迹-<版本>.dmg(分发用)
set -euo pipefail
cd "$(dirname "$0")/.."
PY=".venv/bin/python"
VERSION="1.0.0"
APP="dist/虚拟定位轨迹.app"
DMG="dist/虚拟定位轨迹-${VERSION}.dmg"
if [ ! -x "$PY" ]; then
echo "未找到 $PY,请先运行 ./scripts/run.sh 完成依赖安装"
exit 1
fi
# 1) 依赖:PyInstaller(打包用)
if ! "$PY" -c "import PyInstaller" >/dev/null 2>&1; then
echo "安装 PyInstaller…"
"$PY" -m pip install --quiet --disable-pip-version-check \
-i "${IOSLOC_PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}" pyinstaller
fi
# 2) 图标
if [ ! -f resources/AppIcon.icns ]; then
echo "生成图标…"
"$PY" tools/make_icon.py
fi
# 3) 开发者镜像素材(iOS 17+ 全系共用;体积大,不入 git,首次构建时下载)
if [ ! -f resources/ddi/personalized/Image.dmg ]; then
echo "下载 iOS 17+ 个性化镜像素材(约 16MB,仅需一次)…"
"$PY" tools/fetch_ddi.py || {
echo "素材下载失败。可手动放到 resources/ddi/personalized/ 后重试:"
echo " Image.dmg / BuildManifest.plist / Image.dmg.trustcache"
exit 1
}
fi
# 4) 打包
echo "打包 .app(约 2-4 分钟)…"
"$PY" -m PyInstaller --clean --noconfirm --log-level WARN Location.spec
# 5) ad-hoc 签名:Apple Silicon 上未签名的 arm64 应用可能被系统直接拒绝运行
echo "ad-hoc 签名…"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict "$APP" && echo " 签名校验通过"
# 6) 制作 DMG
echo
"$PY" tools/make_dmg.py
# 7) 验证独立运行
echo
"$PY" tools/verify_bundle.py
echo
echo "================================================"
echo "完成:"
echo " 独立应用:$APP"
echo " 分发镜像:$DMG"
echo "================================================"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# iOS 虚拟定位轨迹工具 — 一键启动脚本
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
VENV="$ROOT/.venv"
PY="$VENV/bin/python"
# 国内网络:默认走阿里云镜像(可用 IOSLOC_PIP_INDEX 覆盖)
PIP_INDEX="${IOSLOC_PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}"
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
info() { printf ' %s\n' "$1"; }
warn() { printf '\033[33m %s\033[0m\n' "$1"; }
fail() { printf '\033[31m %s\033[0m\n' "$1"; }
bold "iOS 虚拟定位轨迹工具"
info "目录:$ROOT"
# ---------- 1. Python ----------
if [ ! -x "$PY" ]; then
PYTHON_BIN=""
for candidate in python3.12 python3.13 python3.11 python3; do
if command -v "$candidate" >/dev/null 2>&1; then
PYTHON_BIN="$(command -v "$candidate")"
break
fi
done
if [ -z "$PYTHON_BIN" ]; then
fail "未找到 python3,请先安装 Python 3.11+(推荐:brew install python@3.12)"
exit 1
fi
info "创建虚拟环境($PYTHON_BIN)…"
"$PYTHON_BIN" -m venv "$VENV"
fi
info "Python:$("$PY" -V 2>&1)"
# ---------- 2. 依赖 ----------
need_install=0
if ! "$PY" -c "import pymobiledevice3" >/dev/null 2>&1; then
need_install=1
fi
if [ "$need_install" = "1" ]; then
bold "首次运行:安装依赖(约 1-3 分钟,走国内镜像)"
if ! "$PY" -m pip install --quiet --disable-pip-version-check -i "$PIP_INDEX" pymobiledevice3; then
warn "国内镜像失败,改用官方源重试…"
if ! "$PY" -m pip install --quiet --disable-pip-version-check pymobiledevice3; then
fail "依赖安装失败。请手动执行:"
fail " $PY -m pip install -i $PIP_INDEX pymobiledevice3"
exit 1
fi
fi
info "依赖安装完成"
fi
info "pymobiledevice3:$("$PY" -c "import pymobiledevice3,sys;print('已就绪')" 2>/dev/null || echo '不可用')"
# ---------- 3. 设备提示 ----------
if [ "${1:-}" != "--mock" ] && [ "${IOSLOC_MOCK:-0}" != "1" ]; then
DEV_COUNT="$("$PY" - <<'EOF' 2>/dev/null || echo 0
import asyncio
try:
from pymobiledevice3.usbmux import list_devices
print(len(asyncio.run(list_devices())))
except Exception:
print(0)
EOF
)"
if [ "$DEV_COUNT" = "0" ]; then
warn "未检测到 iOS 设备:请用数据线连接、解锁设备,并在手机上点「信任」"
else
info "检测到 $DEV_COUNT 台 iOS 设备"
fi
if [ "$(id -u)" != "0" ]; then
info "提示:iOS 17 及以上需要 RSD 隧道,程序会优先使用免 root 的 native 隧道;"
info " 若失败,可改用管理员模式:sudo ./scripts/run.sh"
fi
fi
# ---------- 4. 启动 ----------
exec "$PY" "$ROOT/main.py" "$@"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 在终端里用真实设备跑一次「单点虚拟定位」,用于快速验证设备链路是否打通。
#
# 用法:
# ./scripts/test-location.sh # 默认定位到北京天安门
# ./scripts/test-location.sh 39.9088 116.3975 # 指定纬度 经度
# ./scripts/test-location.sh --clear # 让设备恢复真实定位
set -euo pipefail
cd "$(dirname "$0")/.."
PY=".venv/bin/python"
if [ "${1:-}" = "--clear" ]; then
exec "$PY" tools/probe_location.py --clear
fi
LAT="${1:-39.9088}"
LNG="${2:-116.3975}"
exec "$PY" tools/probe_location.py --lat "$LAT" --lon "$LNG"
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""真实感模拟验证:GPS 漂移 + 速度浮动(校园跑等打卡类 App 测试用)。
验证点:
1. 漂移是**平滑游走**而不是随机跳点(相邻采样差远小于漂移半径);
2. 漂移幅度落在设定半径范围内,且开关关闭时严格为 0;
3. 速度浮动围绕设定速度上下波动,开关关闭时严格恒定;
4. 经纬度换算精度(米 ↔ 度)与方向分解正确;
5. 参数校验与非法值拒绝;
6. 两条高级性质:**速度连续性**(不会出现速度突变)与**位移与轨迹方向一致**。
运行:./.venv/bin/python tests/drift_sim.py
"""
from __future__ import annotations
import math
import statistics
import sys
import time
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, noise, playback, route_engine # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class NullRunner:
def run(self, coro, timeout=None, wait=True):
try:
coro.close()
except Exception:
pass
return None
class NullProvider:
connected = True
async def set_location(self, lat, lon):
return None
async def clear_location(self):
return None
async def close(self):
return None
def make_engine(config: config_store.ConfigStore):
locations: list[dict] = []
lock = threading.Lock()
def on_location(payload):
with lock:
locations.append(payload)
engine = playback.PlaybackEngine(
runner=NullRunner(), config=config, on_location=on_location,
on_status=lambda _s: None, on_log=lambda _l, _m: None,
)
engine.set_provider(NullProvider())
return engine, locations, lock
def main() -> int:
print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m")
cfg = config_store.ConfigStore(Path("/tmp/iosloc-drift-sim.json"))
cfg.update({
"drift_enabled": True, "drift_m": 5.0,
"speed_jitter_enabled": True, "speed_jitter": 0.10,
})
route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False
)
engine, locations, lock = make_engine(cfg)
gen = noise.DriftGenerator()
print("\n1) 漂移幅度与分布(设定 5 米)")
offsets = [math.hypot(*gen.offset_m(d, 5.0)) for d in range(0, 3000, 3)]
check("偏移量非零且分布合理", 1.0 < statistics.mean(offsets) < 5.0,
f"平均 {statistics.mean(offsets):.2f} m")
check("最大偏移不超过裁剪上限(半径×1.8)", max(offsets) <= 5.0 * 1.8 + 1e-6,
f"最大 {max(offsets):.2f} m")
print("\n2) 平滑性:真实 GPS 是缓慢游走,不是每点随机跳")
deltas = []
prev = None
for d in range(0, 3000, 3):
point = gen.offset_m(d, 5.0)
if prev is not None:
deltas.append(math.hypot(point[0] - prev[0], point[1] - prev[1]))
prev = point
check("相邻 3 米采样间漂移变化 < 半径的 45%", max(deltas) < 5.0 * 0.45,
f"最大变化 {max(deltas):.2f} m")
check("平均变化远小于半径(说明是连续游走)", statistics.mean(deltas) < 5.0 * 0.25,
f"平均 {statistics.mean(deltas):.2f} m")
print("\n3) 开关:关闭时必须严格为 0 个偏移")
engine.update_tuning(drift_enabled=False)
worst = 0.0
step = max(1, len(route.path) // 100)
for i in range(0, len(route.path), step):
engine.distance = route.dense_acc[i]
true_point = route.point_at_distance(engine.distance)
reported = engine._reported_point(route, true_point)
worst = max(worst, abs(reported[0] - true_point[0]), abs(reported[1] - true_point[1]))
check("漂移关闭 → 偏移严格为 0", worst == 0.0, f"最大 {worst}")
print("\n4) 经纬度换算精度与方向分解")
lat, lng = 39.9, 116.4
headings = [0, 45, 90, 135, 180, 225, 270, 315]
max_meter_error = 0.0
max_projection_error = 0.0
for heading in headings:
along, cross = gen.offset_m(777.0, 6.0)
dlat, dlng = gen.offset_latlng(lat, lng, 777.0, heading, 6.0)
want = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
have = math.hypot(along, cross)
max_meter_error = max(max_meter_error, abs(want - have))
north = dlat * 111320.0
east = dlng * 111320.0 * math.cos(math.radians(lat))
rad = math.radians(heading)
proj_along = east * math.sin(rad) + north * math.cos(rad)
proj_cross = east * math.cos(rad) - north * math.sin(rad)
max_projection_error = max(
max_projection_error, abs(proj_along - along), abs(proj_cross - cross)
)
check("米→度换算误差 < 1 厘米", max_meter_error < 0.01, f"{max_meter_error * 100:.3f} cm")
check("沿行进/垂直方向分解正确(误差 < 1 厘米)", max_projection_error < 0.01,
f"{max_projection_error * 100:.3f} cm")
print("\n5) 速度浮动特性")
factors = [gen.speed_factor(t, 0.10, 18.0) for t in range(0, 600)]
check("系数落在 1±10% 内", 0.899 <= min(factors) and max(factors) <= 1.101,
f"{min(factors):.4f} ~ {max(factors):.4f}")
check("上下双向浮动都存在", any(f < 0.995 for f in factors) and any(f > 1.005 for f in factors))
check("均值接近 1.0", abs(statistics.mean(factors) - 1.0) < 0.03,
f"{statistics.mean(factors):.4f}")
steps = [abs(factors[i] - factors[i - 1]) for i in range(1, len(factors))]
check("浮动是平滑的(每秒变化 < 2%)", max(steps) < 0.02, f"最大 {max(steps) * 100:.2f}%")
check("关闭浮动 → 严格 1.0", gen.speed_factor(50, 0.0, 18.0) == 1.0)
print("\n6) 播放引擎端到端(mock 设备)")
engine.update_tuning(drift_enabled=True, drift_m=5.0,
speed_jitter_enabled=True, speed_jitter=0.10)
with lock:
locations.clear()
engine.start(route, speed_scale=8, provider=NullProvider())
time.sleep(12)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
samples = list(locations)
check("播放期间有下发样本", len(samples) >= 6, f"{len(samples)} 个")
drifts = [s.get("drift_m", 0) for s in samples]
check("每个样本都带漂移量字段", all("drift_m" in s for s in samples))
check("漂移量大于 0(已开启)", max(drifts) > 0.5, f"最大 {max(drifts):.2f} m")
reported = [s["speed"] for s in samples if s.get("speed")]
base = route.speed_at_distance(route.distance * 0.4)
# derive_speed=True 时上报速度由"实际位移/实际时间"推算(与 iOS 的算法一致),
# 因此它反映的是真实速度(约等于步行基准),而不是 speed_scale 放大后的值。
check("上报速度围绕步行基准浮动", min(reported) < base * 1.15 and max(reported) > base * 0.85,
f"{min(reported):.2f} ~ {max(reported):.2f}(基准 {base:.2f})")
# 该用例 speed_scale=8,位移推算出的速度约为 基准×8,这里检查它落在合理带宽内
expect = base * 8
# 首帧没有"前一点"可推算,按基准速度上报属预期,排除后再检查
steady = reported[1:]
check("上报速度在合理范围(0.6~1.6 倍期望值)",
steady and all(expect * 0.6 <= value <= expect * 1.6 for value in steady),
f"{min(steady):.2f} ~ {max(steady):.2f}(期望约 {expect:.1f})")
# 自洽性:上报速度应等于 位移/时间间隔(这是真实 GPS 的物理关系)
jumps = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
dts = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
# samples[i+1].speed 对应的是"从 samples[i] 到 samples[i+1]"这一段
consistent = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
# 自洽性用相对偏差衡量:上报速度应等于 位移/时间(本用例速度量级约 120 m/s,
# 因为 speed_scale=8 是"时间压缩",位移也被同步放大,二者仍然自洽)
rel = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i]) / max(0.1, jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
check("上报速度与位移/时间自洽(相对偏差 < 3%)",
rel and statistics.mean(rel) < 0.03,
f"平均相对偏差 {statistics.mean(rel) * 100:.2f}%" if rel else "无样本")
# 平滑性:相邻上报点的间距不应因漂移而剧烈波动
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"], samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
check("相邻上报点间距稳定(无突跳)", max(hops) < statistics.mean(hops) * 2.2,
f"平均 {statistics.mean(hops):.1f} m / 最大 {max(hops):.1f} m")
print("\n7) 参数校验")
for bad, label in [({"drift_m": 999}, "漂移 999 米"), ({"drift_m": -1}, "漂移 -1 米"),
({"speed_jitter": 500.0}, "浮动 500%"), ({"speed_jitter": 61}, "浮动 61%"),
({"drift_m": float("nan")}, "漂移 NaN")]:
try:
engine.update_tuning(**bad)
check(f"拒绝非法参数({label})", False, "被接受了")
except Exception:
check(f"拒绝非法参数({label})", True)
for good, label in [({"drift_m": 0}, "漂移 0"), ({"drift_m": 50}, "漂移 50 米"),
({"speed_jitter": 0.6}, "浮动 0.6"), ({"speed_jitter": 60}, "浮动 60%")]:
try:
engine.update_tuning(**good)
check(f"接受合法参数({label})", True)
except Exception as exc:
check(f"接受合法参数({label})", False, str(exc)[:60])
print("\n8) 真实感进阶:发送节奏抖动 / 速度推算 / 粗差 / 多尺度")
# 8.1 节奏抖动:真实 GPS 不是精确等间隔
factors = [gen.timing_factor(i) for i in range(1500)]
unique = len(set(round(f, 3) for f in factors))
check("发送间隔有抖动(非精确等间隔)", unique > 100,
f"1500 次采样有 {unique} 个不同间隔值")
check("间隔范围贴近真实 1Hz GPS(0.85~2.1s)",
0.8 <= min(factors) and max(factors) <= 2.2,
f"{min(factors):.2f} ~ {max(factors):.2f}")
drops = sum(1 for f in factors if f > 1.5)
check("偶发丢采样(约 1~4%)", 0.005 <= drops / len(factors) <= 0.04,
f"{drops / len(factors) * 100:.1f}%")
check("关掉抖动后退化为精确等间隔", gen.timing_factor(7, jitter=0.0) == 1.0)
# 8.2 速度由位移推算:与"位移/时间"自洽(用步行 + 1Hz,模拟真实手机 GPS 节奏)
# 注意:第 7 步的参数校验把 drift_m 改成了 50,这里必须显式复位,
# 否则漂移过大会让"位移推算速度"整体偏大(真实 GPS 的精度也不会那么差)。
cfg.update({"send_hz": 1.0})
engine.update_tuning(drift_enabled=True, drift_m=4.0,
speed_jitter_enabled=True, speed_jitter=0.10)
walk_route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905]], mode="walking", use_osrm=False
)
engine.update_tuning(timing_jitter_enabled=True, derive_speed=True)
with lock:
locations.clear()
engine.start(walk_route, speed_scale=1.0, provider=NullProvider())
time.sleep(14)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
walk = list(locations)
hops2 = [
geoutils.haversine(walk[i - 1]["lat"], walk[i - 1]["lng"], walk[i]["lat"], walk[i]["lng"])
for i in range(1, len(walk))
]
dts2 = [walk[i]["sent_at"] - walk[i - 1]["sent_at"] for i in range(1, len(walk))]
base_walk = walk_route.speed_at_distance(0)
speeds2 = [s["speed"] for s in walk[1:] if s.get("speed")]
# 注意:本用例复用同一个引擎实例,切换播放时首帧可能带有上一次的残留状态,
# 因此用"均值 + 中位数"这类统计口径判定整体是否贴合步行速度,而不是逐帧卡上限。
med = statistics.median(speeds2) if speeds2 else 0
check("1x 播放时上报速度整体贴合步行基准(1.0~2.2 m/s)",
speeds2 and 0.9 <= statistics.mean(speeds2) <= 2.2 and 1.0 <= med <= 2.2,
f"均值 {statistics.mean(speeds2):.2f} 中位 {med:.2f} 范围 {min(speeds2):.2f}~{max(speeds2):.2f}(基准 {base_walk:.2f})")
rel2 = [
abs(walk[i + 1]["speed"] - hops2[i] / dts2[i]) / max(0.1, hops2[i] / dts2[i])
for i in range(len(hops2)) if dts2[i] > 0.05
]
check("1x 时自洽性更好(相对偏差 < 5%)", rel2 and statistics.mean(rel2) < 0.05,
f"{statistics.mean(rel2) * 100:.2f}%" if rel2 else "无样本")
gaps2 = [g for g in dts2 if g > 0]
check("1Hz 下发间隔在 0.7~2.2s(含抖动与丢采样)",
gaps2 and 0.7 <= min(gaps2) and max(gaps2) <= 2.2,
f"{min(gaps2):.2f} ~ {max(gaps2):.2f}s")
# 8.3 粗差:默认关闭,开启后按概率出现
check("粗差默认关闭", gen.gross_error_m(3, 0.0, 30.0) == (0.0, 0.0))
hits = sum(1 for i in range(1000) if gen.gross_error_m(i, 0.02, 30.0) != (0.0, 0.0))
check("粗差概率 2% 时命中率接近 2%", 8 <= hits <= 35, f"{hits}/1000")
# 8.4 多尺度:幅度标定与上限
for radius in (2.0, 4.0, 8.0):
vals = [math.hypot(*gen.offset_m(d, radius, octaves=True)) for d in range(0, 4000)]
mean_ok = 0.6 * radius <= statistics.mean(vals) <= 1.1 * radius
cap_ok = max(vals) <= radius * 1.8 + 1e-6
check(f"漂移 {radius:g} m:均值≈半径且不超上限",
mean_ok and cap_ok,
f"均值 {statistics.mean(vals):.2f} 最大 {max(vals):.2f}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""浏览器级离线可用验证:先联网预热当前视野的瓦片,再切断服务端回源,验证界面仍能正常出图。
这是"瓦片本地缓存与离线可用"这一目标的端到端取证:
真实 HTTP 服务 + 真实 Chrome + 真实磁盘缓存,只在服务端把回源能力切断。
运行:./.venv/bin/python tests/offline_browser.py
"""
from __future__ import annotations
import json
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import config_store, protocol, server as server_mod, tilecache # noqa: E402
from ui_acceptance import CDP, launch_chrome # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
global PASS, FAIL
port = 8802
print("\033[1m浏览器级离线可用验证\033[0m")
# 用项目自己的缓存目录(用户真实路径),但把上限调小以免影响用户配置
app = server_mod.App(root=ROOT, mock=True, port=port)
app.devices.refresh()
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
process, ws_url = launch_chrome(1280, 800, 9588)
cdp = None
try:
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False})
print("\n1) 联网状态加载界面(预热视野内瓦片到磁盘缓存)")
cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{port}/"})
time.sleep(10)
stats_online = dict(app.tiles.stats)
check("界面已连接服务端",
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
str(cdp.eval("document.querySelector('#connPill').textContent")))
served = stats_online.get("hit", 0) + stats_online.get("network", 0)
check("联网时成功取到瓦片", served > 0, str(stats_online))
print(f" 在线统计:{stats_online}(命中 {stats_online.get('hit')} + 回源 {stats_online.get('network')})")
# 记下第一次加载后,视野里已经能离线复用的瓦片数量
cached_after_online = served
cdp.shot("/tmp/offline-1-online.png")
print("\n2) 切断服务端回源(模拟断网),强制刷新页面")
real_fetch = tilecache.TileCache._fetch
def offline_fetch(self, url): # noqa: ANN001
raise OSError("模拟断网:Network is unreachable")
tilecache.TileCache._fetch = offline_fetch
cdp.call("Page.reload", {"ignoreCache": True})
time.sleep(10)
stats_offline = dict(app.tiles.stats)
print(f" 离线统计:{stats_offline}")
warnings = cdp.eval(
"(function(){var w=document.querySelector('.map-warn');return w?w.textContent.trim():''})()"
)
canvas = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');return c?JSON.stringify({w:c.width,h:c.height}):'none'})()"
)
check("断网后地图画布仍渲染", canvas not in ("none", "", None), str(canvas)[:80])
# 断网后新增的请求必须全部由磁盘缓存满足:不允许出现 failed / placeholder
delta_failed = stats_offline.get("failed", 0) - stats_online.get("failed", 0)
delta_hit = stats_offline.get("hit", 0) - stats_online.get("hit", 0)
check("断网后没有回源失败", delta_failed == 0, f"failed 增加 {delta_failed}")
check("断网后由缓存供图(hit 增加)", delta_hit > 0 or cached_after_online > 0,
f"hit {stats_online.get('hit')} → {stats_offline.get('hit')}")
check("界面未出现白屏级错误", "错误" not in str(cdp.eval("document.title")), "")
check("界面给出中文降级提示(若有缺失瓦片)",
warnings == "" or "瓦片" in warnings or "重试" in warnings, f"warn={warnings!r}")
# 用像素级判断:截图里非纯色像素占比说明底图确实画出来了
size = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');if(!c)return null;"
"var ctx=c.getContext('2d');var d=ctx.getImageData(0,0,Math.min(400,c.width),Math.min(400,c.height)).data;"
"var seen={};for(var i=0;i<d.length;i+=4){seen[(d[i]>>4)+','+(d[i+1]>>4)+','+(d[i+2]>>4)]=1}"
"return Object.keys(seen).length})()"
)
check("地图像素有真实内容(非纯色/空白)", isinstance(size, int) and size > 20, f"颜色桶={size}")
cdp.shot("/tmp/offline-2-offline.png")
print("\n3) 恢复网络后自动回到在线出图")
tilecache.TileCache._fetch = real_fetch
cdp.call("Page.reload", {"ignoreCache": True})
time.sleep(8)
stats_restored = dict(app.tiles.stats)
check("恢复后重新回源", stats_restored.get("network", 0) >= stats_offline.get("network", 0),
str(stats_restored))
except Exception as exc:
FAIL += 1
FAILURES.append(f"异常 {exc}")
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
try:
tilecache.TileCache._fetch = tilecache.TileCache.__dict__.get("_fetch", None) or None
except Exception:
pass
if cdp:
cdp.close()
try:
import os
import signal
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
process.terminate()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print("截图:/tmp/offline-1-online.png、/tmp/offline-2-offline.png")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""瓦片离线可用验证:回源成功后断网,验证缓存仍能供图。
做法:用真实的 core.tilecache.TileCache,先把 urllib 换成"永远失败"的实现(模拟断网),
再请求已经缓存过的瓦片,断言仍然返回 200 + 合法图片;同时断言未缓存过的瓦片会优雅降级
(返回占位图 + status != 200,由上层回 404 让前端走重试)。
运行:./.venv/bin/python tests/offline_tiles.py
"""
from __future__ import annotations
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, tilecache # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m")
cache_dir = Path("/tmp/iosloc-offline-tiles")
import shutil
shutil.rmtree(cache_dir, ignore_errors=True)
source_key = "amap"
source = config_store.DEFAULT_TILE_SOURCES[source_key]
# 北京天安门附近、z12 —— 本机实测可达
z, x, y = 12, 3367, 1580
cache = tilecache.TileCache(cache_dir, max_mb=64)
print("\n1) 联网回源并落盘")
online = cache.get_tile(source_key, source, z, x, y)
check("回源返回真实图片", online.status == 200 and online.body[:4] in (b"\x89PNG", b"\xff\xd8"),
f"status={online.status} source={online.source}")
check("标记为 network", online.source == "network", online.source)
check("已写入磁盘缓存", any(cache_dir.rglob("*")), "缓存目录为空")
print("\n2) 断网(把 urllib 换成永远失败)后请求同一瓦片")
real_urlopen = urllib.request.urlopen
def offline(*_args, **_kwargs):
raise OSError("模拟断网:Network is unreachable")
urllib.request.urlopen = offline
try:
cached = cache.get_tile(source_key, source, z, x, y)
check("离线仍返回 200", cached.status == 200, f"status={cached.status}")
check("来源为 cache", cached.source == "cache", cached.source)
check("图片内容与联网时一致", cached.body == online.body,
f"{len(cached.body)}B vs {len(online.body)}B")
print("\n3) 断网 + 未缓存瓦片 → 优雅降级(不抛异常)")
t0 = time.time()
miss = cache.get_tile(source_key, source, z, x, y + 1)
elapsed = time.time() - t0
check("未命中不抛异常", True, "")
check("返回占位图且 status!=200(上层回 404 让前端重试)",
miss.status != 200 and miss.body[:4] == b"\x89PNG",
f"status={miss.status} source={miss.source}")
check("失败快速返回(<5s)", elapsed < 5.0, f"{elapsed:.2f}s")
print("\n4) 缓存统计")
stats = cache.stats
check("命中计数增加", stats["hit"] >= 1, str(stats))
check("统计到离线失败", stats["failed"] >= 1, str(stats))
finally:
urllib.request.urlopen = real_urlopen
print("\n5) 恢复网络后仍可正常回源")
cache2 = tilecache.TileCache(cache_dir, max_mb=64)
other = cache2.get_tile(source_key, source, z, x + 1, y)
check("恢复后回源成功", other.status == 200 and other.body[:4] in (b"\x89PNG", b"\xff\xd8"),
f"status={other.status} source={other.source}")
print("\n6) 容量上限淘汰不破坏可用性")
small = tilecache.TileCache(cache_dir, max_mb=1)
small._sweep()
check("淘汰后仍能读取", small.get_tile(source_key, source, z, x, y).status in (200, 404))
shutil.rmtree(cache_dir, ignore_errors=True)
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""真机端到端验证:连接 iOS 设备 → 建立隧道 → 沿轨迹播放虚拟定位 → 停止恢复真实定位。
这是**会真的改变设备定位**的测试(结束后会 clear_location 恢复真实定位)。
只有在你明确同意时才运行:
./.venv/bin/python tests/real_device_e2e.py --confirm
./.venv/bin/python tests/real_device_e2e.py --confirm --udid 00008030-...
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 6 --speed 20
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import protocol, server as server_mod # noqa: E402
from selftest import WSClient # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
parser.add_argument("--udid", default=None, help="指定设备 UDID(默认用发现的唯一 USB 设备)")
parser.add_argument("--port", type=int, default=8811)
parser.add_argument("--duration", type=float, default=6.0, help="播放时长(秒)")
parser.add_argument("--speed", type=float, default=20.0, help="播放倍速")
parser.add_argument("--keep", action="store_true", help="结束后不恢复真实定位(默认会恢复)")
args = parser.parse_args()
if not args.confirm:
print("这是真机测试,会临时改变 iPhone 的定位。请加 --confirm 运行。")
return 2
print("\033[1miOS 虚拟轨迹 — 真机端到端验证\033[0m")
app = server_mod.App(root=ROOT, mock=False, port=args.port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗\033[0m 未发现设备")
return 1
print(" 发现的设备:")
for d in devices:
print(f" - {d.name} | iOS {d.ios_version} | {d.connection} | 开发者模式={d.developer_mode} | {d.udid}")
target = None
for d in devices:
if args.udid and d.udid != args.udid:
continue
if not args.udid and d.connection != "usb":
continue
target = d
break
target = target or devices[0]
print(f" 目标设备:{target.name}(iOS {target.ios_version})\n")
app.devices.select(target.udid)
httpd = server_mod.serve(app, host="127.0.0.1", port=args.port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", args.port, timeout=120)
exit_code = 0
try:
client.send({"type": "hello", "id": "h1", "client": "e2e"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1", timeout=20)
check("连接服务端", ack is not None)
print("\n → 建立设备会话(iOS 17+ 会自动建 RSD 隧道,最长约 75 秒)…")
started = time.time()
client.send({"type": "device.connect", "id": "c1"})
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
took = time.time() - started
ok = bool(connected and connected.get("ok", True))
check(f"设备连接成功({took:.1f}s)", ok, str(connected)[:400])
if not ok:
print(f" 错误:{connected}")
return 1
probe = connected.get("probe") or {}
check("DVT 自检通过", bool(probe.get("ok")), str(probe)[:300])
print(f" 自检详情:{probe.get('detail')}")
print("\n → 单点定位测试…")
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=30)
check("单点定位下发", bool(ack and ack.get("ok", True)), str(ack)[:300])
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=15)
check("收到单点 location 事件", location is not None, str(location)[:200])
print(f"\n → 轨迹播放测试({args.duration:.0f} 秒,倍速 {args.speed}x)…")
route = app.routes.build(
[[116.3975, 39.9088], [116.4050, 39.9120], [116.4120, 39.9080]],
mode="driving", use_osrm=False,
)
print(f" 轨迹:{route.distance / 1000:.2f} 公里 / {len(route.path)} 点 / 预计 {route.duration:.0f} 秒")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": args.speed, "loop": False})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
check("播放启动", bool(ack and ack.get("ok", True)), str(ack)[:300])
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=20)
check("播放下发首个定位", first is not None, str(first)[:200])
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.1, timeout=30)
check("定位沿轨迹推进", progressed is not None, str(progressed)[:200])
if progressed:
print(f" 当前进度 {progressed.get('progress'):.1%} → {progressed.get('lat')}, {progressed.get('lng')}")
sent_before = None
with client.lock:
sent_before = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
time.sleep(args.duration)
with client.lock:
sent_after = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
expect = args.duration * float(app.config.get("send_hz") or 5) * 0.5
check(f"持续下发定位({sent_after - sent_before} 次 / {args.duration:.0f}s)",
(sent_after - sent_before) >= expect, f"期望 ≥{expect:.0f} 次")
client.send({"type": "play.pause", "id": "p1"})
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=15)
check("暂停", paused is not None, str(paused)[:200])
client.send({"type": "play.resume", "id": "r1"})
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=15)
check("继续", resumed is not None, str(resumed)[:200])
if not args.keep:
print("\n → 停止并恢复真实定位…")
client.send({"type": "play.stop", "id": "st1"})
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=25)
check("停止播放", stopped is not None, str(stopped)[:200])
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=20)
check("已恢复设备真实定位", cleared is not None, str(cleared)[:200])
except Exception as exc:
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
FAIL += 1
finally:
try:
if not args.keep:
app.playback.stop(notify=False, clear_provider=True)
except Exception:
pass
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print(f"\n{'=' * 50}\n通过 {PASS} 项,失败 {FAIL} 项")
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""真机图形界面验收:在真实设备模式下驱动界面完成绘制→规划→播放,并截图留证。
与 tests/real_device_e2e.py 的区别:这个是**通过浏览器点击界面**完成的(端到端,
包含前端),而 real_device_e2e.py 是直接走 WS 协议。
./.venv/bin/python tests/real_gui_acceptance.py --confirm --url http://127.0.0.1:8790/
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from ui_acceptance import CDP, launch_chrome # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
parser.add_argument("--url", default="http://127.0.0.1:8790/")
parser.add_argument("--out", default="/tmp/ui-real-acceptance")
parser.add_argument("--port", type=int, default=9466)
parser.add_argument("--wait-connect", type=float, default=90.0)
args = parser.parse_args()
if not args.confirm:
print("真机测试会改变设备定位,请加 --confirm")
return 2
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("\033[1m真机图形界面验收\033[0m")
process, ws_url = launch_chrome(1440, 900, args.port)
cdp = CDP(ws_url, timeout=180)
try:
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": 1440, "height": 900, "deviceScaleFactor": 2, "mobile": False})
cdp.call("Page.navigate", {"url": args.url})
time.sleep(4.0)
check("界面已连接服务端",
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
str(cdp.eval("document.querySelector('#connPill').textContent")))
device_text = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
check("设备卡片识别到真机", "iPhone" in device_text, device_text[:160])
print(f" 设备卡片:{device_text[:120]}")
cdp.shot(out_dir / "01-device-found.png")
# 点「连接设备」
cdp.eval("(function(){var b=document.querySelector('#btnDeviceConnect');if(b)b.click();return !!b})()")
print(" → 正在连接设备(iOS 17+ 会建立 RSD 隧道,最长约 90 秒)…")
connected = False
deadline = time.time() + args.wait_connect
while time.time() < deadline:
time.sleep(1.0)
card = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText:''})()"))
if "模拟中" in card or "已连接" in card and "虚拟定位" in card:
if "模拟中" in card or "就绪" in card:
connected = True
break
card_final = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
check("设备连接成功(卡片显示虚拟定位状态)", connected, card_final[:200])
print(f" 连接后卡片:{card_final[:160]}")
cdp.shot(out_dir / "02-connected.png")
# 绘制 3 个途经点
cdp.eval("(function(){var b=document.querySelector('#toolAdd');if(b)b.click();return !!b})()")
time.sleep(0.4)
box = json.loads(cdp.eval("(function(){var r=document.querySelector('#map').getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"))
for fx, fy in ((0.28, 0.30), (0.50, 0.48), (0.74, 0.36)):
cdp.click(box["x"] + box["w"] * fx, box["y"] + box["h"] * fy)
time.sleep(0.45)
wp = cdp.eval("(function(){return document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]').length})()")
check("地图点选生成途经点", int(wp or 0) >= 3, f"途经点={wp}")
# 规划路线
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
distance = "—"
for _ in range(40):
time.sleep(0.5)
distance = str(cdp.eval("document.querySelector('#statDistance').textContent"))
if distance not in ("—", "-", "", "0"):
break
check("OSRM 路线规划成功", distance not in ("—", "-", "", "0"), f"总距离={distance!r}")
cdp.shot(out_dir / "03-route.png")
# 倍速滑块是对数刻度(0.1x ~ 50x),按 Geo.speedToSlider 反算 1.0x 对应的滑块位置
slider_value = int(round((__import__("math").log(1.0 / 0.1) / __import__("math").log(50.0 / 0.1)) * 1000))
cdp.eval("(function(){var s=document.querySelector('#speedRange');if(!s)return 'no';"
f"s.value={slider_value};s.dispatchEvent(new Event('input',{{bubbles:true}}));"
"s.dispatchEvent(new Event('change',{bubbles:true}));return s.value})()")
time.sleep(0.8)
speed_label = str(cdp.eval("document.querySelector('#speedValue').textContent"))
check("倍速已设为 1.0x(对数滑块)", "1.0x" in speed_label or "1x" in speed_label, speed_label)
# 播放
cdp.eval("(function(){var b=document.querySelector('#btnPlay');if(b)b.click();return !!b})()")
progressed = False
progress_text = ""
for _ in range(30):
time.sleep(0.5)
progress_text = str(cdp.eval("document.querySelector('#chipProgress').textContent"))
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
if any(ch.isdigit() for ch in progress_text) and "0%" not in progress_text and "0 %" not in progress_text:
progressed = True
break
check("真机播放推进", progressed, f"progress={progress_text!r}")
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
check("界面显示实时下发坐标", any(ch.isdigit() for ch in coord), f"coord={coord!r}")
print(f" 实时状态:{progress_text} | {coord}")
cdp.shot(out_dir / "04-playing-real-direct.png")
# 日志面板展开,确认有真实下发日志
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
time.sleep(1.2)
log_text = str(cdp.eval("(function(){var p=document.querySelector('#logBody');return p?p.innerText:''})()"))
check("日志面板记录了真机下发", "下发" in log_text or "定位" in log_text, log_text[-200:].replace("\n", " | "))
cdp.shot(out_dir / "05-logs-real.png")
# 停止并恢复真实定位
cdp.eval("(function(){var b=document.querySelector('#btnStop');if(b)b.click();return !!b})()")
time.sleep(2.5)
state = str(cdp.eval("document.querySelector('#chipPlayState').textContent"))
check("停止后回到未播放", "未播放" in state or "已停止" in state or "已结束" in state, state)
cdp.shot(out_dir / "06-stopped.png")
errors = []
for event in cdp.events:
if event.get("method") == "Runtime.exceptionThrown":
detail = event["params"]["exceptionDetails"]
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:160]}")
elif event.get("method") == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
parts = [str(a.get("value", a.get("description", "")))[:140] for a in event["params"].get("args", [])]
errors.append("console.error: " + " ".join(parts))
check("真机流程中页面无 JS 报错", not errors, " | ".join(errors[:3]))
except Exception as exc:
FAIL += 1
FAILURES.append(f"异常 {exc}")
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
try:
cdp.close()
except Exception:
pass
try:
os.killpg(os.getpgid(process.pid), 15)
except Exception:
process.terminate()
print(f"\n{'=' * 56}")
print(f"真机图形界面验收:通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print(f"截图目录:{out_dir}")
for item in FAILURES:
print(" -", item[:200])
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env python3
"""端到端自检:单元 + 集成(无设备,走 --mock)。
运行:./.venv/bin/python tests/selftest.py
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, protocol, route_engine, tilecache, ws # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
global PASS, FAIL
if condition:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def section(title: str) -> None:
print(f"\n\033[1m{title}\033[0m")
# ---------------------------------------------------------------- 单元
def test_protocol() -> None:
section("1. 协议与校验")
check("合法经纬度通过", protocol.validate_latlng(39.9, 116.4) == (39.9, 116.4))
for bad in [(91, 0), (0, 181), ("a", 1), (float("nan"), 0)]:
try:
protocol.validate_latlng(*bad)
check(f"非法经纬度被拒 {bad}", False)
except protocol.AppError:
check(f"非法经纬度被拒 {bad}", True)
check("倍速上下限", protocol.validate_speed_scale(1.5) == 1.5)
try:
protocol.validate_speed_scale(999)
check("倍速超限被拒", False)
except protocol.AppError:
check("倍速超限被拒", True)
check("模式解析", protocol.validate_mode("cycling") == "cycling")
check("JSON 中文不被转义", "定位" in protocol.dumps({"msg": "定位"}))
def test_geo() -> None:
section("2. 地理计算")
d = geoutils.haversine(39.9, 116.4, 39.9, 116.41)
check("haversine 精度", 830 < d < 870, f"got {d:.1f}m")
b = geoutils.bearing(39.9, 116.4, 40.0, 116.4)
check("正北方位角≈0", b < 1 or b > 359, f"got {b:.2f}")
path = [(39.9, 116.4), (39.91, 116.41)]
dense = geoutils.densify(path, 20.0)
check("路径加密点变多", len(dense) > 50, f"{len(dense)} 点")
acc = geoutils.cumulative_lengths(dense)
total = acc[-1]
check("累计弧长单调递增", all(acc[i] <= acc[i + 1] for i in range(len(acc) - 1)))
mid, _ = geoutils.interpolate(dense, acc, total / 2)
check("弧长中点落在起终点之间", 39.9 <= mid[0] <= 39.91, f"{mid}")
simplified = geoutils.simplify(dense, 2.0)
check("简化后点数下降且首尾保留", len(simplified) < len(dense) and simplified[0] == dense[0])
def test_route_engine() -> None:
section("3. 路线引擎")
config = config_store.ConfigStore(ROOT / "config.json")
engine = route_engine.RouteEngine(config)
route = engine.build([[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False)
check("直线兜底引擎标记", route.engine == "straight")
check("路径非空且已加密", len(route.path) > 100, f"{len(route.path)} 点")
check("距离 > 1.5km", route.distance > 1500, f"{route.distance:.0f}m")
check("时长合理", 60 < route.duration < 1200, f"{route.duration:.0f}s")
check("速度剖面长度一致", len(route.speeds) == len(route.path))
check("速度均为正", all(v > 0 for v in route.speeds))
mid = route.point_at_distance(route.distance / 2)
check("中点取点有效", 39.89 <= mid[0] <= 39.91, f"{mid}")
payload = route.to_json()
check("JSON 坐标是 [lng,lat]", abs(payload["path"][0][0] - 116.40) < 0.01, str(payload["path"][0]))
restored = route_engine.sanitize_route_payload(payload)
check("route 往返一致", abs(restored.distance - route.distance) < 1.0, f"{restored.distance:.1f} vs {route.distance:.1f}")
check("高亮子路径不超过全长", len(route.highlight(route.distance / 2)) <= len(route.path))
# GPX 往返
gpx = route_engine.to_gpx(route, creator="selftest")
check("GPX 含 trkpt", "<trkpt" in gpx and gpx.startswith("<?xml"))
coords, name, _ = route_engine.from_gpx(gpx)
check("GPX 导入保形(不抽稀丢点)", len(coords) == len(route.path), f"{len(coords)} vs {len(route.path)}")
total = geoutils.cumulative_lengths(coords)[-1]
check("GPX 往返距离误差 < 2m", abs(total - route.distance) < 2.0, f"误差 {abs(total - route.distance):.3f}m")
# 单点与异常
single = engine.build([[116.40, 39.90]], mode="walking", use_osrm=False)
check("单点轨迹可用", len(single.path) == 1 and single.distance == 0)
for bad, label in [([], "空途经点"), ([[200, 39]], "非法经度")]:
try:
engine.build(bad, use_osrm=False)
check(f"{label}被拒", False)
except protocol.AppError:
check(f"{label}被拒", True)
# OSRM URL 生成(不依赖外网)
check("flight 不走 OSRM", engine._osrm([(39.9, 116.4), (39.91, 116.41)], "flight") is None)
def test_tiles() -> None:
section("4. 瓦片与缓存")
source = config_store.DEFAULT_TILE_SOURCES["amap"]
url1 = tilecache.build_url(source, 12, 3367, 1580)
check("高德 URL 生成", url1.startswith("https://webrd0") and "{x}" not in url1, url1)
url2 = tilecache.build_url(source, 12, 3368, 1580)
check("子域轮换生效", url1.split("/")[2] != url2.split("/")[2], f"{url1.split('/')[2]} vs {url2.split('/')[2]}")
arc = config_store.DEFAULT_TILE_SOURCES["arcgis_sat"]
check("Esri URL 含 z/y/x", "/tile/12/1580/3367" in tilecache.build_url(arc, 12, 3367, 1580))
check("瓦片坐标校验", tilecache.validate_tile_request(12, 3367, 1580) == (12, 3367, 1580))
try:
tilecache.validate_tile_request(12, 99999, 1)
check("越界瓦片被拒", False)
except protocol.AppError:
check("越界瓦片被拒", True)
tmp = ROOT / ".cache-test"
cache = tilecache.TileCache(tmp, max_mb=1)
result = cache.get_tile("amap", source, 12, 0, 0)
check("瓦片请求返回图片", result.body[:4] in (b"\x89PNG", b"\xff\xd8") and result.content_type.startswith("image/"),
f"{result.status} {result.source} {len(result.body)}B")
check("瓦片已落盘缓存", any(tmp.rglob("*.png")) or any(tmp.rglob("*.jpg")))
result2 = cache.get_tile("amap", source, 12, 0, 0)
check("第二次命中缓存", result2.source == "cache", result2.source)
removed = cache.clear()
check("清理缓存", removed >= 1, f"removed={removed}")
try:
import shutil
shutil.rmtree(tmp, ignore_errors=True)
except Exception:
pass
def test_config() -> None:
section("5. 配置")
path = ROOT / ".config-test.json"
path.unlink(missing_ok=True)
store = config_store.ConfigStore(path)
check("默认瓦片源为高德", store.get("tile_source") == "amap")
try:
store.update({"send_hz": 999})
check("非法 send_hz 被拒(不再静默夹取)", False)
except protocol.AppError as exc:
check("非法 send_hz 被拒(不再静默夹取)", exc.code == "bad_config", exc.code)
store.update({"send_hz": 8})
check("合法 send_hz 生效", store.get("send_hz") == 8.0, str(store.get("send_hz")))
store.update({"tile_source": "amap_sat"})
store2 = config_store.ConfigStore(path)
check("配置持久化", store2.get("tile_source") == "amap_sat")
try:
store.update({"不存在的项": 1})
check("未知配置被拒", False)
except protocol.AppError:
check("未知配置被拒", True)
sources = store.tile_sources()
check("瓦片源齐全", all(k in sources for k in ("amap", "amap_sat", "arcgis_sat", "osm")))
path.unlink(missing_ok=True)
# ---------------------------------------------------------------- WS 客户端
class WSClient:
def __init__(self, host: str, port: int, timeout: float = 30.0) -> None:
self.sock = socket.create_connection((host, port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
request = (
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
header = b""
while b"\r\n\r\n" not in header:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("握手失败")
header += chunk
if b"101" not in header.split(b"\r\n")[0]:
raise RuntimeError(f"握手未升级:{header[:200]!r}")
expected = base64.b64encode(hashlib.sha1((key + ws.GUID).encode()).digest()).decode()
if expected.encode() not in header:
raise RuntimeError("Sec-WebSocket-Accept 校验失败")
self.buf = header.split(b"\r\n\r\n", 1)[1]
self.messages: list[dict] = []
self.lock = threading.Lock()
self._stop = threading.Event()
self.reader = threading.Thread(target=self._read_loop, daemon=True)
self.reader.start()
def _recv_exact(self, n: int) -> bytes | None:
while len(self.buf) < n:
try:
chunk = self.sock.recv(65536)
except OSError:
return None
if not chunk:
return None
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_loop(self) -> None:
while not self._stop.is_set():
head = self._recv_exact(2)
if head is None:
return
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
ext = self._recv_exact(2)
if ext is None:
return
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = self._recv_exact(8)
if ext is None:
return
length = struct.unpack("!Q", ext)[0]
payload = self._recv_exact(length) if length else b""
if payload is None:
return
if opcode == 0x8:
return
if opcode in (0x9, 0xA):
continue
try:
with self.lock:
self.messages.append(json.loads(payload.decode("utf-8")))
except Exception:
continue
def send(self, obj: dict) -> None:
payload = json.dumps(obj, ensure_ascii=False).encode()
header = bytearray([0x81])
mask = os.urandom(4)
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", length))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", length))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + masked)
def wait_for(self, predicate, timeout: float = 15.0) -> dict | None:
deadline = time.time() + timeout
seen = 0
while time.time() < deadline:
with self.lock:
batch = self.messages[seen:]
seen = len(self.messages)
for message in batch:
if predicate(message):
return message
time.sleep(0.05)
return None
def close(self) -> None:
self._stop.set()
try:
self.sock.close()
except OSError:
pass
def test_integration() -> None:
section("6. 端到端(--mock 模式,真 HTTP + 真 WebSocket)")
from core import server as server_mod
port = 8899
app = server_mod.App(root=ROOT, mock=True, port=port)
app.devices.refresh()
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
time.sleep(0.4)
try:
# --- REST ---
with urllib.request.urlopen(base + "/api/health", timeout=5) as response:
health = json.loads(response.read())
check("GET /api/health", health.get("ok") and health.get("version") == protocol.APP_VERSION, str(health)[:120])
check("mock 模式标记", health.get("mock") is True)
with urllib.request.urlopen(base + "/api/devices", timeout=5) as response:
devices = json.loads(response.read())
check("GET /api/devices 返回模拟设备", len(devices["devices"]) == 1 and devices["devices"][0]["ios_major"] == 26,
str(devices)[:150])
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=5) as response:
tiles = json.loads(response.read())
keys = {item["key"] for item in tiles["sources"]}
check("瓦片源接口含 key/datum", "amap" in keys and tiles["sources"][0]["datum"] in ("gcj02", "wgs84"), str(tiles)[:150])
check("瓦片源三种字段名都在", tiles["current"] == tiles["tile_source"] == tiles["selected"])
with urllib.request.urlopen(base + "/", timeout=5) as response:
html = response.read().decode("utf-8", "replace")
check("GET / 返回前端页面", "<html" in html.lower(), html[:80])
# 路线规划(REST)
body = json.dumps({"waypoints": [[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], "mode": "driving"}).encode()
request = urllib.request.Request(base + "/api/route/build", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=30) as response:
route_result = json.loads(response.read())
check("POST /api/route/build", route_result.get("ok") and route_result["route"]["distance"] > 500,
str(route_result)[:120])
# GPX 往返(REST)
export_body = json.dumps({"route": route_result["route"], "name": "自检轨迹"}).encode()
request = urllib.request.Request(base + "/api/gpx/export", data=export_body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=15) as response:
gpx_text = response.read().decode()
check("POST /api/gpx/export", "<trkpt" in gpx_text and "自检轨迹" in gpx_text)
request = urllib.request.Request(base + "/api/gpx/import", data=gpx_text.encode(), headers={"Content-Type": "application/gpx+xml"})
with urllib.request.urlopen(request, timeout=15) as response:
imported = json.loads(response.read())
check("POST /api/gpx/import", imported["ok"] and imported["count"] >= 2, str(imported)[:120])
check("GPX 导入不丢几何(返回全量点)", len(imported["waypoints"]) == imported["count"] >= 100,
f"count={imported['count']}")
check("GPX 导入同时提供 path/points 全量数组",
imported.get("path") == imported["waypoints"] and imported.get("points") == imported["waypoints"])
imported_coords = [(p[1], p[0]) for p in imported["waypoints"]]
imported_total = geoutils.cumulative_lengths(imported_coords)[-1]
check("GPX 导入距离保真(误差 < 2m)", abs(imported_total - route_result["route"]["distance"]) < 2.0,
f"误差 {abs(imported_total - route_result['route']['distance']):.3f}m")
# 瓦片代理
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as response:
tile_bytes = response.read()
check("GET /api/tiles 返回图片", tile_bytes[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile_bytes)}B")
# --- WebSocket ---
client = WSClient("127.0.0.1", port)
client.send({"type": "hello", "id": "h1", "client": "selftest"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1")
check("WS hello ack 携带 welcome 数据", ack is not None and ack.get("mock") is True and "config" in ack,
str(ack)[:140])
check("welcome 里带设备与隧道快照", ack is not None and "tunnel" in ack and ack.get("version") == protocol.APP_VERSION,
str(ack)[:140])
client.send({"type": "ping", "id": "p1"})
pong = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "p1")
check("WS ping→ack(ts)", pong is not None and "ts" in pong, str(pong)[:120])
client.send({"type": "device.connect", "id": "c1"})
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=20)
check("WS device.connect", connected is not None and connected.get("ok", True), str(connected)[:160])
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=15)
check("WS location.set", ack is not None and ack.get("ok", True), str(ack)[:160])
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=10)
check("收到 location 事件(单点)", location is not None and abs(location["lng"] - 116.3975) < 1e-6, str(location)[:160])
# 多设备/非法命令
client.send({"type": "play.start", "id": "e1", "route": {"path": [], "waypoints": []}})
bad = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "e1", timeout=10)
check("空轨迹被拒并回中文错误", bad is not None and bad.get("ok") is False and "轨迹" in bad.get("error", ""), str(bad)[:160])
# 播放流程
route_payload = route_result["route"]
client.send({"type": "play.start", "id": "s1", "route": route_payload, "speed_scale": 8.0, "loop": False})
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=20)
check("WS play.start", started is not None and started.get("ok", True), str(started)[:160])
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=10)
check("播放下发首个定位", first is not None and first.get("progress", 1) < 0.2, str(first)[:160])
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.05, timeout=20)
check("播放推进(进度增长)", progressed is not None, str(progressed)[:160])
status = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is True, timeout=10)
check("status 事件含剩余时间", status is not None and "remaining" in status, str(status)[:160])
client.send({"type": "play.pause", "id": "p2"})
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=10)
check("暂停生效", paused is not None, str(paused)[:120])
time.sleep(0.6)
with client.lock:
after_pause = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
time.sleep(0.8)
with client.lock:
still = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
check("暂停后不再下发", still - after_pause <= 1, f"{after_pause} → {still}")
client.send({"type": "play.resume", "id": "r1"})
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=10)
check("继续生效", resumed is not None, str(resumed)[:120])
client.send({"type": "play.speed", "id": "sp1", "speed_scale": 20.0})
fast = client.wait_for(lambda m: m.get("type") == "status" and m.get("speed_scale") == 20.0, timeout=10)
check("倍速调整生效", fast is not None, str(fast)[:120])
client.send({"type": "play.seek", "id": "sk1", "progress": 0.5})
seeked = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sk1", timeout=10)
check("seek 生效", seeked is not None and seeked.get("ok", True), str(seeked)[:120])
at_half = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.45, timeout=10)
check("seek 后位置过半", at_half is not None, str(at_half)[:140])
# 播放到结束(循环 + 高倍速)
client.send({"type": "play.speed", "id": "sp2", "speed_scale": 50.0})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sp2", timeout=10)
done = client.wait_for(
lambda m: m.get("type") == "status" and m.get("playing") is False and m.get("progress", 0) >= 0.999,
timeout=90,
)
check("轨迹播放到终点", done is not None, str(done)[:160])
# 循环模式
client.send({"type": "play.start", "id": "s2", "route": route_payload, "speed_scale": 50.0, "loop": True})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s2", timeout=15)
wrapped = client.wait_for(
lambda m: m.get("type") == "status" and m.get("loop") is True and m.get("progress", 1) < 0.5 and m.get("playing"),
timeout=90,
)
check("loop 模式回到起点继续", wrapped is not None, str(wrapped)[:160])
client.send({"type": "play.stop", "id": "st1"})
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=15)
check("停止播放", stopped is not None, str(stopped)[:140])
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=10)
check("停止后通知恢复真实定位", cleared is not None, str(cleared)[:120])
client.send({"type": "log.list", "id": "lg1", "limit": 50})
logs = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "lg1", timeout=10)
check("日志回填", logs is not None, str(logs)[:120])
client.send({"type": "不存在的命令", "id": "x1"})
unknown = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "x1", timeout=10)
check("未知命令回中文错误", unknown is not None and unknown.get("ok") is False, str(unknown)[:140])
client.close()
time.sleep(0.3)
finally:
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
def main() -> int:
print(f"\033[1m{protocol.APP_NAME} 自检\033[0m (Python {sys.version.split()[0]})")
started = time.time()
test_protocol()
test_geo()
test_route_engine()
test_tiles()
test_config()
try:
test_integration()
except Exception as exc:
global FAIL
FAIL += 1
FAILURES.append(f"集成测试异常:{exc}")
print(f" \033[31m✗\033[0m 集成测试异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项,用时 {time.time() - started:.1f}s")
if FAILURES:
print("\n失败明细:")
for item in FAILURES:
print(f" - {item}")
return 1
print("\033[32m全部通过\033[0m")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""图形界面验收:用真实 Chrome 驱动界面完成一整套操作并截图。
做的是**图形界面级**验收:在页面上真实点击「添加途经点」→ 在地图上点三个点 →
点「加载 OSRM 路线」→ 点「播放」→ 截图,并断言界面状态与后端状态一致。
CDP 走 WebSocket,本机没有 websocket-client,因此这里复用 core/ws.py 的帧实现。
用法:
./.venv/bin/python tests/ui_acceptance.py # 默认连 127.0.0.1:8787
./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8787/ --out /tmp/ui
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import socket
import struct
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from urllib.parse import urlparse # noqa: E402
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class CDP:
"""最小可用的 Chrome DevTools Protocol 客户端。"""
def __init__(self, ws_url: str, timeout: float = 30.0) -> None:
parsed = urlparse(ws_url)
self.sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 9222), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (("?" + parsed.query) if parsed.query else "")
request = (
f"GET {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
self.buf = b""
header = b""
while b"\r\n\r\n" not in header:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("CDP 握手失败")
header += chunk
head, _, rest = header.partition(b"\r\n\r\n")
if b"101" not in head.split(b"\r\n")[0]:
raise RuntimeError(f"CDP 未升级:{head[:200]!r}")
self.buf = rest
self.counter = 0
self.timeout = timeout
def _recv(self, n: int) -> bytes:
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 连接关闭")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_frame(self) -> str:
head = self._recv(2)
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._recv(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._recv(8))[0]
payload = self._recv(length) if length else b""
if opcode == 0x8:
raise RuntimeError("CDP 服务端关闭连接")
if opcode == 0x9: # ping → pong(带掩码)
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes([0x8A, 0x80 | len(payload)]) + mask + masked)
return self._read_frame()
return payload.decode("utf-8", "replace")
def _send(self, obj: dict) -> None:
payload = json.dumps(obj).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", length))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", length))
header.extend(mask)
self.sock.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def call(self, method: str, params: dict | None = None) -> dict:
self.counter += 1
message_id = self.counter
self._send({"id": message_id, "method": method, "params": params or {}})
while True:
data = json.loads(self._read_frame())
if data.get("id") == message_id:
if "error" in data:
raise RuntimeError(f"CDP {method} 失败:{data['error']}")
return data.get("result") or {}
# 其它事件(Runtime.consoleAPICalled 等)先记录到 self.events
self.events.append(data)
events: list = []
def eval(self, expression: str, await_promise: bool = False):
result = self.call("Runtime.evaluate", {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
})
if result.get("exceptionDetails"):
detail = result["exceptionDetails"]
raise RuntimeError(f"页面 JS 异常:{detail.get('text')} "
f"{(detail.get('exception') or {}).get('description', '')[:300]}")
return (result.get("result") or {}).get("value")
def click(self, x: float, y: float, button: str = "left", clicks: int = 1) -> None:
for event_type in ("mousePressed", "mouseReleased"):
self.call("Input.dispatchMouseEvent", {
"type": event_type, "x": x, "y": y, "button": button,
"clickCount": clicks, "buttons": 1 if event_type == "mousePressed" else 0,
})
def move(self, x: float, y: float) -> None:
self.call("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y, "buttons": 0})
def shot(self, path) -> None:
data = self.call("Page.captureScreenshot", {"format": "png"})
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(data["data"]))
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
def launch_chrome(width: int, height: int, port: int) -> subprocess.Popen:
chrome = next((p for p in CHROME_CANDIDATES if os.path.exists(p)), None)
if not chrome:
raise SystemExit("未找到 Chrome / Edge / Chromium")
profile = Path("/tmp/iosloc-ui-acceptance-profile")
subprocess.run(["rm", "-rf", str(profile)], check=False)
profile.mkdir(parents=True, exist_ok=True)
process = subprocess.Popen(
[
chrome, "--headless=new", "--disable-gpu", "--no-first-run",
"--no-default-browser-check", "--hide-scrollbars", "--disable-extensions",
f"--remote-debugging-port={port}", f"--user-data-dir={profile}",
f"--window-size={width},{height}", "about:blank",
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
)
deadline = time.time() + 25
while time.time() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as response:
pages = json.loads(response.read())
for page in pages:
if page.get("type") == "page":
return process, page["webSocketDebuggerUrl"]
except Exception:
time.sleep(0.4)
process.terminate()
raise SystemExit("无法连接 Chrome 调试端口")
def main() -> int:
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8787/")
parser.add_argument("--out", default="/tmp/ui-acceptance")
parser.add_argument("--port", type=int, default=9444)
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--keep-open", action="store_true", help="结束后不关闭 Chrome")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("\033[1m图形界面验收(真实 Chrome 驱动)\033[0m")
process, ws_url = launch_chrome(args.width, args.height, args.port)
cdp = None
try:
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": args.width, "height": args.height, "deviceScaleFactor": 2, "mobile": False})
cdp.call("Page.navigate", {"url": args.url})
time.sleep(3.5)
# ---- 0. 页面与服务端连通 ----
title = cdp.eval("document.title")
check("页面已加载", bool(title), str(title))
ws_state = cdp.eval("(function(){var d=document.querySelector('#connPill');return d?d.textContent.trim():''})()")
check("界面显示已连接服务端", "已连接" in str(ws_state), f"connPill={ws_state!r}")
device_text = cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()")
check("设备卡片显示设备信息", "iPhone" in str(device_text) or "模拟" in str(device_text), str(device_text)[:120])
# ---- 1. 地图渲染 ----
map_info = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');"
"var t=document.querySelectorAll('#map img').length;"
"return JSON.stringify({canvas:!!c,imgs:t,w:c?c.width:0,h:c?c.height:0})})()"
)
info = json.loads(map_info) if map_info else {}
check("地图画布已渲染", bool(info.get("w")) and bool(info.get("h")), str(info))
# ---- 2. 添加途经点:点工具条 + 在地图上点三个位置 ----
clicked_tool = cdp.eval(
"(function(){var b=document.querySelector('#toolAdd');if(!b)return 'no-btn';b.click();return b.className})()"
)
check("「添加途经点」按钮可点击", clicked_tool != "no-btn", str(clicked_tool))
time.sleep(0.4)
box = cdp.eval(
"(function(){var m=document.querySelector('#map');var r=m.getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"
)
rect = json.loads(box)
points = [
(rect["x"] + rect["w"] * 0.30, rect["y"] + rect["h"] * 0.35),
(rect["x"] + rect["w"] * 0.52, rect["y"] + rect["h"] * 0.55),
(rect["x"] + rect["w"] * 0.72, rect["y"] + rect["h"] * 0.42),
]
for (px, py) in points:
cdp.click(px, py)
time.sleep(0.45)
time.sleep(0.6)
wp_count = cdp.eval(
"(function(){var l=document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]');"
"return l.length})()"
)
check("地图点击添加了 3 个途经点", int(wp_count or 0) >= 3, f"列表条目={wp_count}")
cdp.shot(out_dir / "01-waypoints.png")
# ---- 3. 加载 OSRM 路线 ----
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
route_ok = False
for _ in range(40):
time.sleep(0.5)
meta = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
if meta and meta not in ("—", "-", "0", ""):
route_ok = True
break
distance_text = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
check("「加载 OSRM 路线」生成了路线", route_ok, f"总距离={distance_text!r}")
engine_text = cdp.eval("(function(){var d=document.querySelector('#statEngine');return d?d.textContent.trim():''})()")
check("路线引擎已显示", bool(engine_text) and engine_text != "—", f"引擎={engine_text!r}")
cdp.shot(out_dir / "02-route.png")
# ---- 4. 播放(按钮是异步生效的:点击后等状态真正变为"播放中",必要时重试一次) ----
def click_play() -> None:
cdp.eval("(function(){var b=document.querySelector('#btnPlay, [data-action=play]');"
"if(b && !b.disabled)b.click();return !!b})()")
def wait_playing(timeout: float = 12.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
state = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "播放中" in state or "已暂停" in state:
return True
time.sleep(0.3)
return False
click_play()
playing = wait_playing(12.0)
if not playing:
time.sleep(1.0)
click_play()
playing = wait_playing(12.0)
check("点击播放后进入播放态", playing,
str(cdp.eval("document.querySelector('#chipPlayState').textContent")))
progressed = False
progress_text = ""
coord_text = ""
for _ in range(40):
time.sleep(0.5)
progress_text = str(cdp.eval("(function(){var d=document.querySelector('#chipProgress');return d?d.textContent.trim():''})()"))
coord_text = str(cdp.eval("(function(){var d=document.querySelector('#chipCoord');return d?d.textContent.trim():''})()"))
if any(ch.isdigit() for ch in progress_text) and "0%" not in progress_text and "0 %" not in progress_text:
progressed = True
break
check("播放推进(地图 chips 显示进度)", progressed, f"chipProgress={progress_text!r}")
check("实时坐标已显示", any(ch.isdigit() for ch in coord_text), f"chipCoord={coord_text!r}")
play_state = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("播放状态文案正常", bool(play_state) and "未播放" not in str(play_state), f"chipPlayState={play_state!r}")
cdp.shot(out_dir / "03-playing.png")
# ---- 5. 暂停 / 继续 / 停止(暂停是异步推送,采样窗口内看是否出现过"已暂停") ----
cdp.eval("(function(){var b=document.querySelector('#btnPause, [data-action=pause]');if(b)b.click();return !!b})()")
paused_seen = False
paused_sample = ""
for _ in range(20):
time.sleep(0.15)
paused_sample = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "暂停" in paused_sample:
paused_seen = True
break
check("暂停后状态文案变化", paused_seen, f"{paused_sample!r}")
cdp.eval("(function(){var b=document.querySelector('#btnResume, [data-action=resume]');if(b)b.click();return !!b})()")
time.sleep(1.0)
resumed_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("继续后恢复播放", "暂停" not in str(resumed_text) or "播放" in str(resumed_text), f"{resumed_text!r}")
cdp.eval("(function(){var b=document.querySelector('#btnStop, [data-action=stop]');if(b)b.click();return !!b})()")
time.sleep(1.5)
stop_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("停止后状态回到未播放", "未播放" in str(stop_text) or "已停止" in str(stop_text), f"{stop_text!r}")
cdp.shot(out_dir / "04-stopped.png")
# ---- 6. 底图切换 ----
options = cdp.eval(
"(function(){var s=document.querySelector('#tileSourceSelect');"
"return JSON.stringify(Array.from(s.options).map(function(o){return o.value}))})()"
)
opts = json.loads(options or "[]")
check("底图下拉有多个瓦片源", len(opts) >= 3, str(opts))
if len(opts) >= 2:
cdp.eval(f"(function(){{var s=document.querySelector('#tileSourceSelect');"
f"s.value={json.dumps(opts[1])};s.dispatchEvent(new Event('change',{{bubbles:true}}));return s.value}})()")
time.sleep(3.0)
current = cdp.eval("(function(){var s=document.querySelector('#tileSourceSelect');return s.value})()")
check("切换底图生效", current == opts[1], f"{current!r} vs {opts[1]!r}")
cdp.shot(out_dir / "05-tilesource.png")
# ---- 7. 日志面板(先展开再看内容) ----
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
time.sleep(1.2)
log_chars = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').length:0})()")
check("日志面板能展开且有内容", int(log_chars or 0) > 30, f"字符数={log_chars}")
log_text = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').slice(0,200):''})()")
check("日志内容为中文可读", bool(log_text) and any("\u4e00" <= ch <= "\u9fff" for ch in str(log_text)),
str(log_text)[:100])
cdp.shot(out_dir / "06-logs.png")
# ---- 8. 收集页面控制台报错 ----
errors = []
for event in cdp.events:
method = event.get("method")
if method == "Runtime.exceptionThrown":
detail = event["params"]["exceptionDetails"]
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:200]}")
elif method == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
parts = [str(a.get("value", a.get("description", "")))[:160] for a in event["params"].get("args", [])]
errors.append("console.error: " + " ".join(parts))
check("页面无 JS 报错", not errors, " | ".join(errors[:4]))
if errors:
print("\n 页面报错明细:")
for item in errors[:10]:
print(" -", item[:240])
except Exception as exc:
FAIL += 1
FAILURES.append(f"验收脚本异常 {exc}")
print(f" \033[31m✗\033[0m 验收脚本异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
if cdp:
cdp.close()
if not args.keep_open:
try:
os.killpg(os.getpgid(process.pid), 15)
except Exception:
process.terminate()
print(f"\n{'=' * 56}")
print(f"图形界面验收:通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print(f"截图目录:{out_dir}")
if FAILURES:
print("失败明细:")
for item in FAILURES:
print(" -", item[:220])
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 离线抓取 DeveloperDiskImage 素材(绕过 raw.githubusercontent.com 的 SNI 阻断)
#
# 背景(2026-10-14 实测,本机 jbjf-Mac-mini):
# raw.githubusercontent.com 解析到 4 个 GitHub Pages IP,其中 3 个在 TLS ClientHello 后被 RST / 黑洞:
# 185.199.108.133 -> 连接超时(curl exit 28)
# 185.199.109.133 -> RST(curl exit 35)
# 185.199.110.133 -> RST(curl exit 35)
# 185.199.111.133 -> HTTP 200 正常
# DNS 轮询会随机选中坏 IP,而 developer_disk_image/repo.py 的 requests.get 没有重试也没有超时,
# 所以单次 RST 就会让 mounter auto-mount 整体失败(ConnectionResetError(54))。
#
# 本脚本:固定走可用 IP + 重试 + 校验字节数,把素材放到 pymobiledevice3 期望的位置。
# 用法:tools/ddi_offline_fetch.sh [输出目录] 默认输出 /tmp/ddi
set -uo pipefail
OUT="${1:-/tmp/ddi}"
GOOD_IPS=("185.199.111.133" "185.199.108.133" "185.199.109.133" "185.199.110.133")
BASE="https://raw.githubusercontent.com/doronz88/DeveloperDiskImage/main"
CACHE="$HOME/.pymobiledevice3/Xcode_iOS_DDI_Personalized"
PY="$(cd "$(dirname "$0")/.." && pwd)/.venv/bin/python"
mkdir -p "$OUT/16.5" "$CACHE"
# fetch <仓库相对路径> <期望字节数> <输出文件>
fetch() {
local path="$1" want="$2" dest="$3"
for ip in "${GOOD_IPS[@]}"; do
echo "--- $path (期望 $want 字节, 尝试 $ip)"
if curl -sS -m 300 --retry 3 --retry-all-errors --resolve "raw.githubusercontent.com:443:$ip" \
-o "$dest.part" -w " http=%{http_code} size=%{size_download} t=%{time_total}s\n" "$BASE/$path"; then
local got; got=$(stat -f%z "$dest.part" 2>/dev/null || echo 0)
if [ "$got" = "$want" ]; then mv "$dest.part" "$dest"; echo " OK ($ip)"; return 0; fi
echo " 字节数不符 ($got != $want),换 IP"
fi
done
echo "!!! $path 全部 IP 失败"; return 1
}
# iOS 17+(personalized)素材 —— 例如 iPhone 11 / iOS 26.6.1
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" 15733248 "$OUT/Image.dmg"
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" 801505 "$OUT/BuildManifest.plist"
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" 1895 "$OUT/Image.dmg.trustcache"
# iOS <17(经典 developer image)素材 —— 例如 iOS 16.5
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg" 7427565 "$OUT/16.5/DeveloperDiskImage.dmg"
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg.signature" 128 "$OUT/16.5/DeveloperDiskImage.dmg.signature"
# 写进 pymobiledevice3 缓存:fetch_personalized_ddi() 会直接命中,彻底不再联网
# 注意缓存里的 trustcache 文件名必须是 Image.trustcache(不是 Image.dmg.trustcache)
cp "$OUT/BuildManifest.plist" "$CACHE/BuildManifest.plist"
cp "$OUT/Image.dmg" "$CACHE/Image.dmg"
cp "$OUT/Image.dmg.trustcache" "$CACHE/Image.trustcache"
echo
echo "=== 校验缓存(ProductBuildVersion 必须等于 pymobiledevice3 的 LATEST_DDI_BUILD_ID)==="
"$PY" - <<'PY'
import plistlib, pathlib
from pymobiledevice3.services.mobile_image_mounter import LATEST_DDI_BUILD_ID, fetch_personalized_ddi
c = pathlib.Path.home()/".pymobiledevice3"/"Xcode_iOS_DDI_Personalized"
bm = plistlib.loads((c/"BuildManifest.plist").read_bytes())
print("缓存 BuildVersion :", bm["ProductBuildVersion"])
print("期望 LATEST_DDI :", LATEST_DDI_BUILD_ID)
print("缓存有效(不会重下):", bm["ProductBuildVersion"] == LATEST_DDI_BUILD_ID)
PY
echo
echo "=== 接下来可以直接离线挂载(不再触碰 GitHub)==="
echo " # iOS 17+ 设备(personalized,走缓存)"
echo " ./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <UDID>"
echo " # iOS <17 设备(经典镜像,显式指定本地文件)"
echo " ./.venv/bin/python -m pymobiledevice3 mounter mount-developer \\"
echo " $OUT/16.5/DeveloperDiskImage.dmg $OUT/16.5/DeveloperDiskImage.dmg.signature --udid <UDID>"
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""校园跑类 App 定位诊断:检查我们下发给手机的定位点是否"对 App 有效"。
运动世界校园这类 App 不显示里程,常见原因不在"定位有没有送到",而在
"送到的点是否满足 App 的过滤条件"。本脚本按跑步 App 的典型过滤规则体检:
1. **速度是否合理**:配速过高(如用了驾车/飞行模式)会被判异常而不计里程;
2. **每帧位移是否够大**:位移太小会被"静止过滤"掉;
3. **下发频率**:运动类 App 通常按 1Hz 采样算里程,低于 1Hz 会丢点;
4. **坐标精度**:6 位小数(约 0.1 米)足够;
5. **轨迹是否在指定区域内**(校园跑要求在校内有效区域,这一点需要你提供学校坐标)。
用法:
./.venv/bin/python tools/diagnose_run.py # 只做参数体检
./.venv/bin/python tools/diagnose_run.py --live 20 # 连真机跑 20 秒并统计真实下发
./.venv/bin/python tools/diagnose_run.py --live 20 --lat 30.2741 --lng 120.1551
"""
from __future__ import annotations
import argparse
import statistics
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import geoutils, server as server_mod # noqa: E402
# 跑步 App 的常见阈值(不同学校/版本略有差异,这里取保守值)
APP_MIN_SPEED = 0.4 # m/s,低于此速度通常判为"站着不动"
APP_MAX_SPEED_WALK = 4.0 # m/s,约 14.4 km/h;超过常被判"非跑步/作弊"
APP_MIN_PER_FRAME = 0.05 # 米,位移小于此值容易被静止过滤
APP_MAX_PER_FRAME = 300.0 # 米,位移过大(跳点)会被判异常
APP_MIN_HZ = 1.0 # App 侧通常按 ≥1Hz 采样
def banner(text: str) -> None:
print(f"\n\033[1m{text}\033[0m")
def check_parameters(hz: float, mode_speed: float, drift_m: float, jitter: float) -> int:
problems = []
banner("1) 参数体检(按下发频率与配速推算)")
per_frame = mode_speed / hz if hz else 0
print(f" 下发频率 : {hz:g} Hz → 每帧 {1000 / hz:.0f} ms" if hz else " 下发频率: 非法")
print(f" 基准速度 : {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)")
print(f" 每帧位移 : {per_frame:.3f} 米")
print(f" 漂移半径 : {drift_m:g} 米 | 速度浮动: ±{jitter * 100:g}%")
if hz < APP_MIN_HZ:
problems.append(f"下发频率 {hz:g}Hz 低于 {APP_MIN_HZ:g}Hz,App 可能采样不到足够的点")
if mode_speed < APP_MIN_SPEED:
problems.append(f"速度 {mode_speed:.2f} m/s 过慢,可能被判为静止")
if mode_speed > APP_MAX_SPEED_WALK:
problems.append(
f"速度 {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)对跑步来说过快,"
f"运动类 App 常因此判为异常并**不记录里程**(建议 ≤{APP_MAX_SPEED_WALK * 3.6:.0f} km/h)"
)
if per_frame < APP_MIN_PER_FRAME:
problems.append(f"每帧位移仅 {per_frame:.3f} 米,可能被静止过滤(建议提高下发频率或速度)")
if per_frame > APP_MAX_PER_FRAME:
problems.append(f"每帧位移 {per_frame:.0f} 米过大,会被判为跳点")
if drift_m > 15:
problems.append(f"漂移半径 {drift_m:g} 米偏大,可能触发精度过滤(建议 4~8 米)")
if problems:
print("\n \033[33m⚠ 发现问题:\033[0m")
for item in problems:
print(f" - {item}")
else:
print("\n \033[32m✓ 参数组合合理\033[0m")
return len(problems)
def check_area(lat: float | None, lng: float | None) -> None:
banner("2) 区域检查(校园跑的关键!)")
if lat is None or lng is None:
print(" 未提供学校坐标。运动世界校园只在**校内有效区域**内计里程,")
print(" 请用 --lat/--lng 指定你学校的位置,或直接在界面上用「快速定位到坐标」跳过去画轨迹。")
return
print(f" 目标坐标: {lat:.6f}, {lng:.6f}")
print(" ⚠ 请确认这个坐标落在你学校的有效跑步区域内(该 App 会校验)。")
print(" 获取方式:手机上打开地图 App,长按学校操场位置,即可看到经纬度。")
def live_test(seconds: float, hz: float, mode: str, lat: float | None, lng: float | None) -> int:
banner(f"3) 真机实测:连续下发 {seconds:.0f} 秒并统计")
port = 8810
app = server_mod.App(root=ROOT, mock=False, port=port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗ 未发现设备\033[0m")
return 1
device = app.devices.get_selected()
print(f" 设备: {device.name} | iOS {device.ios_version} | {device.connection}")
from selftest import WSClient # 延迟导入,避免非真机场景的依赖
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", port, timeout=180)
try:
client.send({"type": "hello", "id": "h1"})
client.wait_for(lambda m: m.get("id") == "h1", timeout=20)
client.send({"type": "device.connect", "id": "c1"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
if not (ack and ack.get("ok", True)):
print(f" \033[31m✗ 连接设备失败:{ack}\033[0m")
return 1
print(" 设备已连接(DVT 会话就绪)")
if lat is None or lng is None:
print(" 未提供坐标,跳过实测(用 --lat/--lng 指定学校位置)")
return 0
# 以给定坐标为中心,造一条 1 公里左右的环形轨迹
d = 0.0045 # 约 500 米
waypoints = [
[lng - d, lat - d], [lng + d, lat - d],
[lng + d, lat + d], [lng - d, lat + d],
[lng - d, lat - d],
]
route = app.routes.build(waypoints, mode=mode, use_osrm=False)
print(f" 轨迹: {route.distance:.0f} 米({mode},基准 {route.speed_at_distance(0):.2f} m/s)")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": 1.0,
"tuning": {"drift_enabled": True, "drift_m": 4,
"speed_jitter_enabled": True, "speed_jitter": 0.08}})
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
if not (started and started.get("ok", True)):
print(f" \033[31m✗ 播放启动失败:{started}\033[0m")
return 1
print(f" 采集中…({seconds:.0f} 秒)")
time.sleep(seconds)
with client.lock:
samples = [m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"]
client.send({"type": "play.stop", "id": "st1"})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "st1", timeout=20)
if len(samples) < 3:
print(f" \033[31m✗ 只收到 {len(samples)} 个样本,定位可能没下发\033[0m")
return 1
gaps = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
speeds = [s["speed"] for s in samples if s.get("speed")]
real_hz = 1.0 / statistics.mean(gaps) if gaps else 0
total = sum(hops)
print(f"\n 实际下发频率: {real_hz:.2f} Hz({statistics.mean(gaps) * 1000:.0f} ms/点)")
print(f" 每帧位移 : 平均 {statistics.mean(hops):.3f} 米(最小 {min(hops):.3f})")
print(f" 累计位移 : {total:.1f} 米 / {seconds:.0f} 秒")
print(f" 上报速度 : 平均 {statistics.mean(speeds):.2f} m/s({statistics.mean(speeds) * 3.6:.1f} km/h)")
verdict = []
if real_hz < APP_MIN_HZ:
verdict.append(f"下发频率 {real_hz:.2f}Hz 偏低")
if min(hops) < APP_MIN_PER_FRAME:
verdict.append(f"存在 {min(hops):.3f} 米的极小位移(可能被静止过滤)")
if max(hops) > APP_MAX_PER_FRAME:
verdict.append(f"存在 {max(hops):.0f} 米的跳点")
if statistics.mean(speeds) > APP_MAX_SPEED_WALK:
verdict.append(f"速度 {statistics.mean(speeds) * 3.6:.1f} km/h 对跑步偏快")
print()
if verdict:
print(" \033[33m⚠ 下发的点可能被 App 过滤:\033[0m")
for item in verdict:
print(f" - {item}")
else:
print(" \033[32m✓ 下发的点在频率、位移、速度上都符合跑步 App 的接受范围\033[0m")
print("\n 请同时在手机上确认:")
print(" 1. 该 App 的定位权限是「使用 App 期间」或「始终」,并且「精确位置」已打开;")
print(" 2. 手机上打开系统地图,蓝点应沿着你画的轨迹移动(验证定位确实生效);")
print(" 3. 轨迹必须覆盖该 App 要求的必打点/必经点,否则里程不计入成绩。")
return 1 if verdict else 0
finally:
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--live", type=float, default=0, help="连真机实测多少秒(0=不实测)")
parser.add_argument("--mode", default="walking", choices=["walking", "cycling", "driving"])
parser.add_argument("--lat", type=float, default=None, help="学校纬度(WGS84)")
parser.add_argument("--lng", type=float, default=None, help="学校经度(WGS84)")
args = parser.parse_args()
print("\033[1m校园跑类 App 定位诊断\033[0m")
from core.config_store import ConfigStore
cfg = ConfigStore(ROOT / "config.json")
hz = float(cfg.get("send_hz") or 5)
mode_speed = float((cfg.get("mode_defaults") or {}).get(args.mode, 1.4))
drift_m = float(cfg.get("drift_m") or 0)
jitter = float(cfg.get("speed_jitter") or 0)
problems = check_parameters(hz, mode_speed, drift_m, jitter)
check_area(args.lat, args.lng)
live_problems = 0
if args.live > 0:
live_problems = live_test(args.live, hz, args.mode, args.lat, args.lng)
else:
banner("3) 真机实测")
print(" 跳过(加 --live 20 可连真机实测 20 秒)")
banner("结论")
if problems or live_problems:
print(" 参数或下发质量存在问题,见上面 ⚠ 项。")
return 1
print(" 定位下发本身没问题。如果 App 仍不显示里程,问题在 App 侧:")
print(" · 是否在**校内有效区域**内跑(校园跑的核心规则)")
print(" · 是否跑过该次要求的**必打点 / 必经点**")
print(" · 有效里程是否达到最低门槛(很多学校要求单次 ≥1~2 公里)")
print(" · App 是否要求前台运行 + 屏幕常亮 + 后台定位权限")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""批量抓取各 iOS 版本的开发者镜像,供打包进应用(离线可用)。
镜像来源:doronz88/DeveloperDiskImage(pymobiledevice3 官方使用的仓库)
两类镜像:
1. **经典镜像**(iOS 16 及更早):每个系统版本一套
`DeveloperDiskImage.dmg` + `DeveloperDiskImage.dmg.signature`
2. **个性化镜像**(iOS 17 及以后,含 27.x):**全系共用一套**
`Image.dmg` + `BuildManifest.plist` + `Image.dmg.trustcache`
实际签名在挂载时由 Apple TSS 按设备在线生成,因此不需要逐版本素材。
注意:raw.githubusercontent.com 在国内会被 SNI 阻断(4 个 IP 里只有部分可用),
所以这里逐个 IP 直连重试,并支持 jsDelivr 镜像兜底。
用法:
./.venv/bin/python tools/fetch_ddi.py # 下载全部(16.0-16.7 + 个性化)
./.venv/bin/python tools/fetch_ddi.py --only 16.5 # 只下一个版本
./.venv/bin/python tools/fetch_ddi.py --check # 只检查已有文件完整性
"""
from __future__ import annotations
import argparse
import http.client
import json
import shutil
import ssl
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "resources" / "ddi"
REPO = "doronz88/DeveloperDiskImage"
BRANCH = "main"
# 实测:.111 / .108 可达,.109 / .110 是黑洞(先试可达的)
RAW_IPS = ["185.199.111.133", "185.199.108.133", "185.199.109.133", "185.199.110.133"]
# 经典镜像:iOS 16 全系(仓库里 16.x 到此为止)
CLASSIC_VERSIONS = ["16.0", "16.1", "16.2", "16.3", "16.4", "16.5", "16.6", "16.7"]
CLASSIC_FILES = ["DeveloperDiskImage.dmg", "DeveloperDiskImage.dmg.signature"]
# 个性化镜像:iOS 17+ 全系共用
PERSONALIZED_DIR = "PersonalizedImages/Xcode_iOS_DDI_Personalized"
PERSONALIZED_FILES = ["Image.dmg", "BuildManifest.plist", "Image.dmg.trustcache"]
PERSONALIZED_OUT = "personalized"
# 各文件的最小合理体积(用于判断下载是否完整)
MIN_SIZE = {
"DeveloperDiskImage.dmg": 5 * 1024 * 1024,
"DeveloperDiskImage.dmg.signature": 64,
"Image.dmg": 10 * 1024 * 1024,
"BuildManifest.plist": 100 * 1024,
"Image.dmg.trustcache": 256,
}
CONNECT_TIMEOUT = 6.0 # TCP 建连超时:坏 IP 是黑洞,必须快速放弃
READ_TIMEOUT = 300.0 # 读数据超时:大文件(15MB)要留足时间
def _pipe(sock, host: str, path: str, timeout: float) -> bytes | None:
"""在已建立的 TCP 连接上完成 TLS + HTTP GET。"""
context = ssl.create_default_context()
tls = context.wrap_socket(sock, server_hostname=host)
try:
tls.settimeout(timeout)
request = (
f"GET /{REPO}/{BRANCH}/{path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
"User-Agent: Mozilla/5.0\r\n"
"Accept: */*\r\n"
"Connection: close\r\n\r\n"
)
tls.sendall(request.encode())
chunks: list[bytes] = []
while True:
chunk = tls.recv(262144)
if not chunk:
break
chunks.append(chunk)
raw = b"".join(chunks)
finally:
try:
tls.close()
except Exception:
pass
if not raw:
return None
head, _, body = raw.partition(b"\r\n\r\n")
if b"200" not in head.split(b"\r\n")[0]:
return None
return body
def fetch_via_ip(path: str, timeout: float = READ_TIMEOUT) -> bytes | None:
"""直连 GitHub Pages IP 拉取(绕过被阻断的 DNS 轮询)。
关键:**先做一次短超时的 TCP 建连测试**。实测坏的 IP 是黑洞,
HTTPSConnection 的 timeout 管不住 TCP 连接阶段,会挂 75 秒以上,
导致整个下载流程看起来"卡死"。
"""
import socket
for ip in RAW_IPS:
try:
sock = socket.create_connection((ip, 443), timeout=CONNECT_TIMEOUT)
except Exception:
print(f" ({ip} 连接超时,跳过)")
continue
try:
data = _pipe(sock, "raw.githubusercontent.com", path, timeout)
if data:
return data
except Exception as exc:
print(f" ({ip} 传输失败:{exc.__class__.__name__})")
continue
finally:
try:
sock.close()
except Exception:
pass
return None
def fetch_via_jsdelivr(path: str, timeout: float = 180.0) -> bytes | None:
"""jsDelivr CDN 兜底(国内通常可达)。"""
url = f"https://cdn.jsdelivr.net/gh/{REPO}@{BRANCH}/{path}"
try:
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
if response.status != 200:
return None
return response.read()
except Exception:
return None
def download(rel_path: str, dest: Path, force: bool = False) -> bool:
name = dest.name
minimum = MIN_SIZE.get(name, 100)
if dest.exists() and dest.stat().st_size >= minimum and not force:
print(f" 已有({dest.stat().st_size / 1048576:.2f} MB),跳过")
return True
dest.parent.mkdir(parents=True, exist_ok=True)
data = fetch_via_ip(rel_path)
source = "GitHub IP"
if not data:
print(" GitHub 直连失败,改用 jsDelivr…")
data = fetch_via_jsdelivr(rel_path)
source = "jsDelivr"
if not data or len(data) < minimum:
print(f" ❌ 下载失败({len(data) if data else 0} 字节,期望 ≥ {minimum})")
return False
dest.write_bytes(data)
print(f" ✅ {len(data) / 1048576:.2f} MB({source})")
return True
def verify() -> int:
"""检查镜像完整性。
**必备**:个性化镜像(iOS 17+ 全系,含 27.x)。
**可选**:iOS 16.x 经典镜像 —— Apple 按系统构建号签发,社区镜像在多数新构建上
会被设备拒签(实测 16.5/20F66 报 Failed to verify the disk image signature),
因此不作为必备项,缺失只提示。
"""
print("\033[1m检查镜像完整性\033[0m")
required_total = 0
required_missing: list[str] = []
print(" 【必备】个性化镜像(iOS 17 及以上全系共用)")
for name in PERSONALIZED_FILES:
path = OUT / PERSONALIZED_OUT / name
ok = path.exists() and path.stat().st_size >= MIN_SIZE[name]
if ok:
required_total += path.stat().st_size
else:
required_missing.append(f"{PERSONALIZED_OUT}/{name}")
size = f"{path.stat().st_size / 1048576:.2f} MB" if path.exists() else "缺失"
print(f" {'✅' if ok else '❌'} {name:35s} {size}")
optional_total = 0
optional_found: list[str] = []
print(" 【可选】iOS 16.x 经典镜像(构建号匹配时才有用)")
for version in CLASSIC_VERSIONS:
directory = OUT / version
have = all(
(directory / name).exists() and (directory / name).stat().st_size >= MIN_SIZE[name]
for name in CLASSIC_FILES
)
if have:
optional_found.append(version)
optional_total += sum((directory / name).stat().st_size for name in CLASSIC_FILES)
print(f" {'✅' if have else '·'} {version}")
print(f"\n 必备素材合计 {required_total / 1048576:.1f} MB"
f"|可选经典镜像 {len(optional_found)}/{len(CLASSIC_VERSIONS)} 个版本、{optional_total / 1048576:.1f} MB")
if required_missing:
print(f" \033[31m必备素材缺失 {len(required_missing)} 个:{', '.join(required_missing)}\033[0m")
return 1
print(" \033[32m必备素材齐全(iOS 17 及以上可离线挂载)\033[0m")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--only", default=None, help="只下载指定版本(如 16.5)")
parser.add_argument("--check", action="store_true", help="只检查完整性")
parser.add_argument("--force", action="store_true", help="强制重新下载")
parser.add_argument("--personalized-only", action="store_true", help="只下载 iOS 17+ 共用镜像")
parser.add_argument("--classic", action="store_true",
help="额外下载 iOS 16.x 经典镜像(注意:Apple 已按构建号签发,多数新构建会被拒签)")
args = parser.parse_args()
if args.check:
return verify()
print("\033[1m下载开发者镜像到应用内(供离线使用)\033[0m")
print(f" 输出目录:{OUT}\n")
ok_all = True
if not args.personalized_only and (args.only or args.classic):
versions = [args.only] if args.only else CLASSIC_VERSIONS
for version in versions:
print(f"[经典镜像] iOS {version}")
for name in CLASSIC_FILES:
rel = f"DeveloperDiskImages/{version}/{name}"
if not download(rel, OUT / version / name, args.force):
ok_all = False
if not args.only:
print("[个性化镜像] iOS 17+ 全系共用(含 27.x)")
for name in PERSONALIZED_FILES:
rel = f"{PERSONALIZED_DIR}/{name}"
if not download(rel, OUT / PERSONALIZED_OUT / name, args.force):
ok_all = False
print()
verify()
return 0 if ok_all else 1
if __name__ == "__main__":
raise SystemExit(main())
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""把打包好的 .app 制作成可分发的 DMG 安装包。
产物:dist/虚拟定位轨迹-1.0.0.dmg
内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明
特点:
- UDZO 压缩(zlib),体积最小且兼容性好
- 固定卷名、固定图标位置(打开 DMG 后两个图标位置整齐)
- 自定义背景图(程序化生成,无需设计稿)
- 生成后自动校验挂载与内容
用法:./.venv/bin/python tools/make_dmg.py
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APP_NAME = "虚拟定位轨迹"
VERSION = "1.0.0"
APP = ROOT / "dist" / f"{APP_NAME}.app"
DMG = ROOT / "dist" / f"{APP_NAME}-{VERSION}.dmg"
VOLUME = APP_NAME
BG_WIDTH, BG_HEIGHT = 600, 400
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
def make_background(path: Path) -> None:
"""程序化生成 DMG 背景图(深色,与应用图标风格一致)。"""
try:
from PIL import Image, ImageDraw
except ImportError:
return
img = Image.new("RGB", (BG_WIDTH, BG_HEIGHT), (24, 30, 42))
draw = ImageDraw.Draw(img)
# 顶部渐变更深一点,营造层次
for y in range(BG_HEIGHT // 2):
t = y / (BG_HEIGHT / 2)
color = (int(24 + 10 * t), int(30 + 12 * t), int(42 + 16 * t))
draw.line([(0, y), (BG_WIDTH, y)], fill=color)
# 一条虚线轨迹指向右侧(呼应应用图标)
for x in range(70, 520, 18):
draw.line([(x, 300), (x + 9, 300)], fill=(70, 90, 120), width=3)
# 中间箭头提示"拖过去"
draw.line([(250, 190), (330, 190)], fill=(120, 150, 190), width=4)
draw.polygon([(330, 178), (352, 190), (330, 202)], fill=(120, 150, 190))
img.save(path)
def main() -> int:
if not APP.exists():
print(f"未找到 {APP},请先运行 ./scripts/build_app.sh")
return 1
py = sys.executable
print("\033[1m制作 DMG 安装包\033[0m")
print(f" 应用:{APP}")
app_size = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
print(f" 应用体积:{app_size:.1f} MB")
# 注意:/tmp 在系统只读卷上(可用空间很小),而 UDRW 中间镜像需要约 2 倍应用体积,
# 因此临时目录必须放在数据卷(用户目录)下。
staging_root = Path.home() / "Library" / "Caches" / "iosloc-dmg"
staging_root.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix="build-", dir=str(staging_root)))
try:
# 1) 准备素材
print("\n1) 准备 DMG 内容")
target_app = staging / f"{APP_NAME}.app"
shutil.copytree(APP, target_app, symlinks=True)
# Applications 快捷方式:用户把 app 拖进去即完成安装
os.symlink("/Applications", staging / "Applications")
bg = staging / ".background.png"
make_background(bg)
readme = staging / "安装说明.txt"
readme.write_text(
f"""{APP_NAME} v{VERSION}
========================================
安装:
把「{APP_NAME}.app」拖到右侧的 Applications 文件夹即可。
首次打开:
本工具未做 Apple 公证,若提示"来自身份不明的开发者",
请在应用上点右键 → 打开 → 再点"打开"。
首次运行还会请求"控制 Chrome/Edge"权限(用于以应用窗口方式打开界面)。
需要什么:
· macOS 12 或更高(Apple Silicon / M 系列芯片)
· 一根数据线 + 一台 iOS 设备(iOS 17 及以上需开启开发者模式并挂载开发者镜像)
· 不需要安装 Python 或任何依赖,全部已打包在应用内
数据位置:
~/Library/Application Support/iOSLocationTrack/
(配置与地图瓦片缓存;删除该目录即可重置)
完整使用说明见应用内的 README,或访问项目目录下的 README.md
""",
encoding="utf-8",
)
print(f" 已放入:应用 + Applications 快捷方式 + 安装说明 + 背景图")
# 2) 生成可读写 DMG 以便布局图标
temp_dmg = staging / "temp.dmg"
print("\n2) 生成磁盘映像并设置窗口布局")
result = run([
"hdiutil", "create", "-srcfolder", str(staging), "-volname", VOLUME,
"-fs", "HFS+", "-format", "UDRW", "-ov", str(temp_dmg),
])
if result.returncode != 0:
print(" hdiutil create 失败:", result.stderr[-500:])
return 1
mount_point = Path(f"/Volumes/{VOLUME}")
if mount_point.exists():
run(["hdiutil", "detach", str(mount_point), "-force"])
result = run(["hdiutil", "attach", str(temp_dmg), "-readwrite", "-noverify", "-noautoopen"])
if result.returncode != 0:
print(" 挂载失败:", result.stderr[-400:])
return 1
time.sleep(2)
# 用 AppleScript 设置 Finder 视图(图标位置 / 背景 / 大小)
script = f'''
tell application "Finder"
tell disk "{VOLUME}"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {{200, 150, 800, 550}}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 110
try
set background picture of viewOptions to file ".background.png"
end try
set position of item "{APP_NAME}.app" of container window to {{150, 190}}
set position of item "Applications" of container window to {{450, 190}}
set position of item "安装说明.txt" of container window to {{300, 320}}
update without registering applications
delay 1
close
end tell
end tell
'''
result = run(["osascript", "-e", script])
if result.returncode != 0:
print(" 布局脚本部分失败(不影响使用):", result.stderr.strip()[:200])
# 收尾:确保 Finder 写入的 .DS_Store 落盘
run(["sync"])
time.sleep(1)
detach = run(["hdiutil", "detach", str(mount_point)])
if detach.returncode != 0:
run(["hdiutil", "detach", str(mount_point), "-force"])
time.sleep(1)
# 3) 压缩成最终 DMG
print("\n3) 压缩为最终 DMG")
if DMG.exists():
DMG.unlink()
result = run([
"hdiutil", "convert", str(temp_dmg), "-format", "UDZO",
"-imagekey", "zlib-level=9", "-o", str(DMG),
])
if result.returncode != 0:
print(" 压缩失败:", result.stderr[-400:])
return 1
finally:
shutil.rmtree(staging, ignore_errors=True)
try:
shutil.rmtree(staging_root, ignore_errors=True)
except Exception:
pass
# 4) 校验
print("\n4) 校验 DMG")
size_mb = DMG.stat().st_size / 1048576
print(f" 文件:{DMG}")
print(f" 体积:{size_mb:.1f} MB")
verify_mount = Path(f"/Volumes/{VOLUME}")
result = run(["hdiutil", "attach", str(DMG), "-nobrowse", "-readonly", "-noautoopen"])
ok_mount = result.returncode == 0
print(f" 挂载:{'成功' if ok_mount else '失败 ' + result.stderr[-200:]}")
if ok_mount:
try:
time.sleep(2)
contents = sorted(p.name for p in verify_mount.iterdir())
print(f" 内容:{contents}")
has_app = (verify_mount / f"{APP_NAME}.app").exists()
has_link = (verify_mount / "Applications").is_symlink()
has_readme = (verify_mount / "安装说明.txt").exists()
binary = verify_mount / f"{APP_NAME}.app" / "Contents" / "MacOS" / "iOSLocationTrack"
print(f" 应用存在:{has_app} | Applications 快捷方式:{has_link} | 安装说明:{has_readme}")
if binary.exists():
arch = run(["lipo", "-archs", str(binary)]).stdout.strip()
print(f" 可执行架构:{arch}")
# 校验签名/完整性(未签名时的常规检查)
quarantine = run(["xattr", "-l", str(verify_mount / f"{APP_NAME}.app")]).stdout
print(f" 扩展属性:{'有' if quarantine.strip() else '无'}")
finally:
run(["hdiutil", "detach", str(verify_mount), "-force"])
print(f"\n\033[32m完成:{DMG}\033[0m")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""生成应用图标(程序化绘制,无需设计文件)。
设计:
- 深色圆角方块底(与界面深色主题一致),叠加轻微纵向渐变
- 一条虚线轨迹(用点表示途经点),从左上延伸到中心的定位针
- 定位针用「描边 + 内部圆点」造型,16px 下仍然可辨识
- 右下角一枚小圆点表示"当前位置"的脉冲
输出:resources/AppIcon.iconset/*.png + resources/AppIcon.icns
用法:./.venv/bin/python tools/make_icon.py
"""
from __future__ import annotations
import math
import shutil
import subprocess
import sys
from pathlib import Path
try:
from PIL import Image, ImageDraw
except ImportError:
print("需要 Pillow:./.venv/bin/python -m pip install pillow")
raise SystemExit(1)
ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "resources"
ICONSET = OUT_DIR / "AppIcon.iconset"
ICNS = OUT_DIR / "AppIcon.icns"
# 主题色(与界面一致)
BG_TOP = (26, 36, 52)
BG_BOTTOM = (14, 20, 30)
ACCENT = (59, 130, 246) # 蓝
ACCENT_HI = (96, 165, 250)
ROUTE = (148, 163, 184) # 轨迹灰
PIN = (248, 113, 113) # 定位针红
PIN_HI = (254, 202, 202)
# macOS 图标规范:内容四周留出约 10% 安全边距,圆角半径约 22.37%
SAFE_RATIO = 0.085
CORNER_RATIO = 0.2237
def _bezier(points, steps=200):
"""二次贝塞尔采样(用于画出平滑的轨迹线)。"""
(x1, y1), (x2, y2), (x3, y3) = points
out = []
for i in range(steps + 1):
t = i / steps
mt = 1 - t
out.append((
mt * mt * x1 + 2 * mt * t * x2 + t * t * x3,
mt * mt * y1 + 2 * mt * t * y2 + t * t * y3,
))
return out
def _stroke(draw, points, width, color):
"""用填充多边形画出平滑的粗线(PIL 的宽 line 逐段绘制会出现"肋纹")。"""
if len(points) < 2:
return
half = width / 2.0
left, right = [], []
n = len(points)
for i, (x, y) in enumerate(points):
# 用前后点估计切线方向
px, py = points[max(0, i - 1)]
nx, ny = points[min(n - 1, i + 1)]
dx, dy = nx - px, ny - py
length = math.hypot(dx, dy)
if length < 1e-9:
dx, dy, length = 1.0, 0.0, 1.0
ux, uy = -dy / length, dx / length # 法线
left.append((x + ux * half, y + uy * half))
right.append((x - ux * half, y - uy * half))
draw.polygon(left + right[::-1], fill=color)
# 两端补圆头
r = width / 2.0
for (ex, ey) in (points[0], points[-1]):
draw.ellipse((ex - r, ey - r, ex + r, ey + r), fill=color)
def draw_icon(size: int) -> Image.Image:
"""在 size×size 画布上绘制图标(4 倍超采样后缩小,边缘平滑)。"""
ss = 4
canvas = size * ss
img = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
margin = int(canvas * SAFE_RATIO)
box = (margin, margin, canvas - margin, canvas - margin)
radius = int((box[2] - box[0]) * CORNER_RATIO)
inner = box[2] - box[0]
# 1) 圆角底 + 纵向渐变(对角向,更像 macOS 应用图标)
gradient = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(gradient)
for y in range(box[1], box[3]):
t = (y - box[1]) / max(1, box[3] - box[1])
color = tuple(int(BG_TOP[i] + (BG_BOTTOM[i] - BG_TOP[i]) * t) for i in range(3))
gdraw.line([(box[0], y), (box[2], y)], fill=color + (255,))
mask = Image.new("L", (canvas, canvas), 0)
ImageDraw.Draw(mask).rounded_rectangle(box, radius=radius, fill=255)
img.paste(gradient, (0, 0), mask)
draw = ImageDraw.Draw(img)
line_w = max(2, int(inner * 0.048))
# 2) 平滑轨迹线:从左下到右上的贝塞尔曲线(实线 + 途经点圆点,小尺寸下最清晰)
curve = _bezier([
(box[0] + inner * 0.14, box[1] + inner * 0.82),
(box[0] + inner * 0.44, box[1] + inner * 0.76),
(box[0] + inner * 0.60, box[1] + inner * 0.56),
])
_stroke(draw, curve, line_w, ROUTE + (215,))
# 途经点小圆(沿轨迹均匀分布,表达"添加途经点"这一核心功能)
dot_r = line_w * 0.78
for frac in (0.18, 0.45):
wx, wy = curve[int(len(curve) * frac)]
draw.ellipse((wx - dot_r, wy - dot_r, wx + dot_r, wy + dot_r), fill=ACCENT_HI + (255,))
# 3) 定位针(水滴形 + 白色内圆)
pin_cx = box[0] + inner * 0.68
pin_cy = box[1] + inner * 0.38
pin_r = inner * 0.185
tip_y = pin_cy + pin_r * 2.16
# 针身:圆 + 收窄到底部的三角,拼成水滴
draw.ellipse((pin_cx - pin_r, pin_cy - pin_r, pin_cx + pin_r, pin_cy + pin_r), fill=PIN + (255,))
draw.polygon([
(pin_cx - pin_r * 0.60, pin_cy + pin_r * 0.78),
(pin_cx + pin_r * 0.60, pin_cy + pin_r * 0.78),
(pin_cx, tip_y),
], fill=PIN + (255,))
# 白色内圆:小尺寸下也能看出是"定位标记"
hole = pin_r * 0.40
draw.ellipse((pin_cx - hole, pin_cy - hole, pin_cx + hole, pin_cy + hole), fill=(255, 255, 255, 255))
# 4) 针下方落点阴影(让针"站"在地图上)
sh_w = pin_r * 0.95
sh_h = max(1, int(pin_r * 0.22))
shadow = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
ImageDraw.Draw(shadow).ellipse(
(pin_cx - sh_w, tip_y - sh_h * 0.4, pin_cx + sh_w, tip_y + sh_h * 1.6),
fill=(0, 0, 0, 90),
)
img = Image.alpha_composite(img, shadow)
draw = ImageDraw.Draw(img)
# 5) 当前位置:轨迹上的一个高亮圆点(在针的左下侧)
px, py = curve[int(len(curve) * 0.72)]
pr = line_w * 0.72
draw.ellipse((px - pr * 1.8, py - pr * 1.8, px + pr * 1.8, py + pr * 1.8),
fill=ACCENT + (60,))
draw.ellipse((px - pr, py - pr, px + pr, py + pr), fill=ACCENT_HI + (255,))
return img.resize((size, size), Image.LANCZOS)
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
shutil.rmtree(ICONSET, ignore_errors=True)
ICONSET.mkdir(parents=True, exist_ok=True)
# iconset 需要的尺寸组合
specs = [
(16, "icon_16x16.png"), (32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"), (64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"), (256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"), (512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"), (1024, "icon_512x512@2x.png"),
]
cache: dict[int, Image.Image] = {}
for size, name in specs:
if size not in cache:
cache[size] = draw_icon(size)
cache[size].save(ICONSET / name)
print(f"已生成 {len(specs)} 个尺寸 → {ICONSET}")
# 预览图(512)
preview = OUT_DIR / "AppIcon-preview.png"
cache[512].save(preview)
print(f"预览图 → {preview}")
# 合成 icns
try:
subprocess.run(["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS)], check=True)
print(f"已生成 {ICNS}({ICNS.stat().st_size // 1024} KB)")
except Exception as exc:
print(f"iconutil 失败:{exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""列出 iOS 设备并打印 iOS 版本 / DVT 虚拟定位可用性。
用法(项目根目录下执行):
./.venv/bin/python tools/probe_device.py
./.venv/bin/python tools/probe_device.py --rsd 10.7.0.1 51234
./.venv/bin/python tools/probe_device.py --json
说明:
* 没有连接设备时打印中文提示并以退出码 2 结束,不会抛栈。
* ``--rsd HOST PORT`` 时会对 iOS 17+ 设备真正建立一次 RSD+DVT 会话做连通性验证
(不设置任何位置,验证后立即断开)。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_provider import ( # noqa: E402
PMD3_MISSING_HINT,
RealLocationProvider,
pymobiledevice3_version,
pymobiledevice3_available,
)
#: iOS 17 起 DVT 服务只能通过 RSD 隧道访问
RSD_REQUIRED_IOS_MAJOR = 17
def _print(text: str = "") -> None:
print(text, flush=True)
async def _enumerate_devices(udid: str | None) -> list[dict]:
"""优先用 lead 的 core/device.py,不可用时退回直接查 usbmux。"""
devices: list[dict] = []
try: # lead 的 DeviceManager(可能尚未实现或签名不同,失败则退化)
from core.device import DeviceManager # type: ignore
for factory in (lambda: DeviceManager(), lambda: DeviceManager(mock=False)):
try:
manager = factory()
raw = manager.list_devices()
if asyncio.iscoroutine(raw):
raw = await raw
for item in raw or []:
devices.append(dict(item))
if devices:
return devices
except Exception:
continue
except Exception:
pass
# 退化路径:直接用 pymobiledevice3 的 usbmux
from pymobiledevice3 import usbmux
from pymobiledevice3.lockdown import create_using_usbmux
for mux_device in await usbmux.list_devices():
if udid and not mux_device.matches_udid(udid):
continue
entry: dict = {
"udid": mux_device.serial,
"connection": (mux_device.connection_type or "").lower(),
"name": None,
"product_type": None,
"ios_version": None,
"ios_major": 0,
"developer_mode": None,
"state": "connected",
"error": None,
}
try:
lockdown = await create_using_usbmux(serial=mux_device.serial, autopair=False)
except Exception as exc:
entry["state"] = "unpaired"
entry["error"] = f"{type(exc).__name__}: {exc}"
devices.append(entry)
continue
try:
entry["name"] = lockdown.all_values.get("DeviceName")
entry["product_type"] = lockdown.all_values.get("ProductType")
version = str(lockdown.product_version or "")
entry["ios_version"] = version
try:
entry["ios_major"] = int(version.split(".", 1)[0])
except (ValueError, IndexError):
entry["ios_major"] = 0
try:
entry["developer_mode"] = await lockdown.get_developer_mode_status()
except Exception:
entry["developer_mode"] = None
finally:
try:
await lockdown.close()
except Exception:
pass
devices.append(entry)
return devices
def _judge(device: dict, rsd: tuple | None) -> tuple[bool, str]:
"""返回 (DVT 是否可能可用, 中文说明)。"""
major = int(device.get("ios_major") or 0)
state = device.get("state")
if state == "unpaired":
return False, "未配对:请在 iPhone 上点击「信任此电脑」后重试"
if device.get("developer_mode") is False:
return False, "未开启开发者模式:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启)"
if major >= RSD_REQUIRED_IOS_MAJOR:
if rsd:
return True, f"iOS {major} ≥ 17:需要 RSD 隧道,已提供 {rsd[0]}:{rsd[1]}(待实测)"
return False, (
f"iOS {major} ≥ 17:必须提供 RSD 隧道(--rsd HOST PORT)。"
"免 root:pymobiledevice3 remote start-tunnel --native --script-mode;"
"需 root:sudo pymobiledevice3 remote start-tunnel --script-mode"
)
if major == 0:
return False, "无法识别 iOS 版本:设备可能未解锁或未配对"
return True, f"iOS {major} < 17:可经 usbmux/lockdown 直连 DVT(或老版 simulatelocation 服务)"
async def _probe_dvt(device: dict, rsd: tuple | None) -> dict:
"""真正建立一次会话验证 DVT 是否可用(不设置位置)。"""
provider = RealLocationProvider(
rsd_host=rsd[0] if rsd else None,
rsd_port=rsd[1] if rsd else None,
udid=device.get("udid"),
)
try:
info = await provider.connect(device)
except Exception as exc:
return {"ok": False, "detail": str(exc)}
try:
await provider.close()
except Exception:
pass
return {"ok": True, "detail": info.get("detail", "连接成功"), "strategy": info.get("strategy")}
async def _main_async(args: argparse.Namespace) -> int:
rsd = (args.rsd[0], int(args.rsd[1])) if args.rsd else None
if not pymobiledevice3_available():
_print(f"[错误] {PMD3_MISSING_HINT}")
return 1
_print(f"pymobiledevice3 版本:{pymobiledevice3_version()}")
_print(f"Python:{sys.version.split()[0]}")
if rsd:
_print(f"RSD 隧道:{rsd[0]}:{rsd[1]}")
_print("")
try:
devices = await _enumerate_devices(args.udid)
except Exception as exc:
_print(f"[错误] 枚举设备失败:{type(exc).__name__}: {exc}")
return 1
if not devices:
_print("未发现 iOS 设备。请:")
_print(" 1) 用 USB 数据线连接 iPhone(不是仅充电线);")
_print(" 2) 解锁手机并在弹窗中点击「信任此电脑」;")
_print(" 3) 若使用 Wi-Fi 连接,请确认与电脑在同一网络。")
return 2
results = []
for device in devices:
available, reason = _judge(device, rsd)
entry = {"device": device, "available": available, "reason": reason, "probe": None}
if args.probe and available:
entry["probe"] = await _probe_dvt(device, rsd)
entry["available"] = bool(entry["probe"].get("ok"))
results.append(entry)
if args.json:
_print(json.dumps(results, ensure_ascii=False, indent=2))
return 0
for index, entry in enumerate(results, 1):
device = entry["device"]
_print(f"=== 设备 {index} ===")
_print(f" UDID : {device.get('udid')}")
_print(f" 名称 : {device.get('name') or '(未知)'}")
_print(f" 型号 : {device.get('product_type') or '(未知)'}")
_print(f" iOS 版本 : {device.get('ios_version') or '(未知)'} (major={device.get('ios_major')})")
_print(f" 连接方式 : {device.get('connection') or '(未知)'}")
_print(f" 配对状态 : {device.get('state')}")
developer_mode = device.get("developer_mode")
_print(f" 开发者模式 : {'已开启' if developer_mode else ('未开启' if developer_mode is False else '未知')}")
if device.get("error"):
_print(f" 错误 : {device['error']}")
_print(f" DVT 可用性 : {'可能可用' if entry['available'] else '不可用'} —— {entry['reason']}")
if entry["probe"]:
probe = entry["probe"]
_print(f" 实测连接 : {'成功' if probe.get('ok') else '失败'} —— {probe.get('detail')}")
_print("")
_print("提示:iOS 17+ 必须先建立 RSD 隧道;不带 --rsd 时只做静态判断。")
_print(" 加 --probe --rsd HOST PORT 可实测建立一次 DVT 会话。")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="列出 iOS 设备并检查 DVT 虚拟定位可用性")
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="RSD 隧道地址(iOS 17+ 必需)")
parser.add_argument("--udid", help="只检查指定 UDID")
parser.add_argument("--probe", action="store_true", help="真正建立一次 DVT 会话做连通性验证")
parser.add_argument("--json", action="store_true", help="以 JSON 输出")
args = parser.parse_args()
try:
return asyncio.run(_main_async(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""命令行测试真机虚拟定位(单点设置 / 清除)。
用法(项目根目录下执行):
# iOS<17:USB 直连,设置单点位置并保持(Ctrl-C 结束,结束时自动恢复真实定位)
./.venv/bin/python tools/probe_location.py --lat 39.908 --lon 116.397
# iOS 17+:先建隧道再指定 RSD 地址
./.venv/bin/python tools/probe_location.py --rsd 10.7.0.1 51234 --lat 39.908 --lon 116.397
# 只恢复真实定位
./.venv/bin/python tools/probe_location.py --rsd 10.7.0.1 51234 --clear
# 自动化:只保持 10 秒后自动清除
./.venv/bin/python tools/probe_location.py --lat 39.908 --lon 116.397 --duration 10
注意:DVT 虚拟定位依赖**保持连接**(连接断开即失效),所以本工具在 --duration 期间
一直持有同一个 DVT 会话;退出前会调用 clear_location() 让手机恢复真实定位。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_provider import ( # noqa: E402
PMD3_MISSING_HINT,
RealLocationProvider,
pymobiledevice3_available,
pymobiledevice3_version,
)
def _print(text: str = "") -> None:
print(text, flush=True)
def _log(level: str, msg: str) -> None:
prefix = {"error": "[错误]", "warning": "[警告]", "success": "[成功]"}.get(level, "[信息]")
_print(f"{prefix} {msg}")
async def _main_async(args: argparse.Namespace) -> int:
if not pymobiledevice3_available():
_print(f"[错误] {PMD3_MISSING_HINT}")
return 1
if not args.clear and (args.lat is None or args.lon is None):
_print("[错误] 需要同时提供 --lat 与 --lon(或使用 --clear 仅恢复真实定位)")
return 1
provider = RealLocationProvider(
rsd_host=args.rsd[0] if args.rsd else None,
rsd_port=int(args.rsd[1]) if args.rsd else None,
udid=args.udid,
connect_timeout=args.timeout,
on_log=_log,
)
_print(f"pymobiledevice3 版本:{pymobiledevice3_version()}")
if args.rsd:
_print(f"RSD 隧道:{args.rsd[0]}:{args.rsd[1]}")
else:
_print("未指定 --rsd:将尝试 usbmux/lockdown 直连(仅 iOS<17 可用)")
_print("")
try:
info = await provider.connect(None)
except Exception as exc:
_print(f"[错误] 连接失败:{exc}")
return 1
_print(f"[信息] 会话已建立:{info.get('detail')}")
_print(f"[信息] 策略={info.get('strategy')} iOS={info.get('ios_version') or info.get('ios_major')}")
exit_code = 0
try:
if args.clear:
await provider.clear_location()
_print("[成功] 已恢复真实定位")
else:
await provider.set_location(float(args.lat), float(args.lon))
_print(f"[成功] 已设置虚拟定位 lat={float(args.lat):.6f}, lon={float(args.lon):.6f}")
if args.json:
_print(json.dumps({"ok": True, "lat": args.lat, "lon": args.lon}, ensure_ascii=False))
if args.duration and args.duration > 0:
_print(f"[信息] 保持 {args.duration:.0f} 秒(期间不要断开连接)…")
await asyncio.sleep(args.duration)
else:
_print("[信息] 定位保持中:按 Ctrl-C 结束并恢复真实定位…")
try:
while True:
await asyncio.sleep(1.0)
except (KeyboardInterrupt, asyncio.CancelledError):
_print("")
_print("[信息] 收到中断,准备恢复真实定位…")
except KeyboardInterrupt:
_print("")
_print("[信息] 收到中断,准备恢复真实定位…")
except Exception as exc:
_print(f"[错误] {exc}")
exit_code = 1
finally:
try:
await provider.clear_location()
_print("[信息] 已调用 clear_location(),设备恢复真实定位")
except Exception as exc:
_print(f"[警告] 恢复真实定位失败:{exc}")
exit_code = exit_code or 1
try:
await provider.close()
except Exception as exc:
_print(f"[警告] 关闭会话时出错:{exc}")
return exit_code
def main() -> int:
parser = argparse.ArgumentParser(description="命令行测试真机虚拟定位(DVT)")
parser.add_argument("--lat", type=float, help="纬度(-90~90,WGS84)")
parser.add_argument("--lon", type=float, help="经度(-180~180,WGS84)")
parser.add_argument("--clear", action="store_true", help="只清除虚拟定位,恢复真实定位")
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="RSD 隧道地址(iOS 17+ 必需)")
parser.add_argument("--udid", help="目标设备 UDID(默认第一台 USB 设备)")
parser.add_argument("--duration", type=float, default=0.0, help="保持秒数;0 表示等待 Ctrl-C(默认 0)")
parser.add_argument("--timeout", type=float, default=15.0, help="建立会话超时秒数(默认 15)")
parser.add_argument("--json", action="store_true", help="成功后额外输出一行 JSON")
args = parser.parse_args()
try:
return asyncio.run(_main_async(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""给界面截图(视觉验收用)。
做法:headless Chrome 的一次性 --screenshot 模式 + 独立 user-data-dir,
不依赖 CDP WebSocket(本机没有 websocket-client 库,Chrome 的 CDP 又需要它)。
--screenshot 在页面 load 事件后立即截图,用一次注入脚本做等待/交互,
脚本通过 URL fragment 传不进去,所以这里用临时 HTML 包装页来间接执行前置动作。
用法:
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8787/ out.png [--wait 9]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
def find_chrome() -> str:
for path in CHROME_CANDIDATES:
if os.path.exists(path):
return path
raise SystemExit("未找到 Chrome / Edge / Chromium")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("out", nargs="?", default="screenshot.png")
parser.add_argument("--wait", type=float, default=9.0, help="页面加载后额外等待秒数(等瓦片/WS 渲染)")
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--profile", default="/tmp/iosloc-shot-profile")
parser.add_argument("--json", action="store_true", help="同时打印页面控制台日志(--enable-logging)")
args = parser.parse_args()
chrome = find_chrome()
profile = Path(args.profile)
if profile.exists():
shutil.rmtree(profile, ignore_errors=True)
profile.mkdir(parents=True, exist_ok=True)
out = Path(args.out).resolve()
if out.exists():
out.unlink()
command = [
chrome,
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--disable-extensions",
"--force-device-scale-factor=2",
f"--window-size={args.width},{args.height}",
f"--user-data-dir={profile}",
# Chrome 没有"加载后等待 N 秒"的开关,用虚拟时间预算让定时器与网络请求跑完
f"--virtual-time-budget={int(args.wait * 1000)}",
f"--screenshot={out}",
args.url,
]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, start_new_session=True)
try:
output, _ = process.communicate(timeout=max(60.0, args.wait * 6))
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
process.kill()
output, _ = process.communicate()
print("Chrome 超时被终止")
if args.json and output:
for line in output.splitlines():
if any(key in line for key in ("ERROR", "SEVERE", "Uncaught", "console")):
print(" [chrome]", line[:220])
if not out.exists() or out.stat().st_size == 0:
print("截图失败")
if output:
print(output[-1500:])
return 1
print(f"截图已保存:{out}({out.stat().st_size // 1024} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""离线自测:不接任何 iOS 设备,验证 Mock/Real 两个 provider 的契约与中文降级逻辑。
用法:
./.venv/bin/python tools/selftest_location.py
覆盖:
1. `core.location_mock` / `core.location_provider` 可 import(即使未安装 pymobiledevice3)。
2. MockLocationProvider 五个接口 + 5~10ms 延时 + 事件循环内运行。
3. RealLocationProvider 的降级:未连接下发 / 未配对 / 未开开发者模式 / iOS17+ 无隧道 /
probe() 永不抛异常 / close() 幂等 —— 全部应为**中文可读信息**,不得是 AttributeError。
"""
from __future__ import annotations
import asyncio
import os
import sys
import threading
import time
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_mock import MockLocationProvider # noqa: E402
from core.location_provider import ( # noqa: E402
LocationProviderError,
RealLocationProvider,
pymobiledevice3_available,
pymobiledevice3_version,
)
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
PASSED.append(name)
print(f" [通过] {name}" + (f" —— {detail}" if detail else ""))
else:
FAILED.append(f"{name}: {detail}")
print(f" [失败] {name} —— {detail}")
def has_chinese(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
async def test_mock() -> None:
print("\n== 1. MockLocationProvider ==")
provider = MockLocationProvider()
check("初始 connected=False", provider.connected is False)
info = await provider.connect({"udid": "MOCK-0001", "ios_version": "26.0", "ios_major": 26})
check("connect 返回 dict 且 ok=True", isinstance(info, dict) and info.get("ok") is True, str(info.get("detail")))
check("connect 后 connected=True", provider.connected is True)
check("connect 返回中文说明", has_chinese(str(info.get("detail"))))
started = time.perf_counter()
await provider.set_location(39.908, 116.397)
elapsed_ms = (time.perf_counter() - started) * 1000
check("set_location 延时 5~10ms", 4.0 <= elapsed_ms <= 60.0, f"实测 {elapsed_ms:.2f}ms")
check("send_count 递增", provider.send_count == 1)
check("last_location 记录正确", provider.last_location == (39.908, 116.397))
await provider.clear_location()
check("clear_location 后 last_location=None", provider.last_location is None)
await provider.close()
check("close 后 connected=False", provider.connected is False)
await provider.close() # 幂等
check("close 幂等", provider.connected is False)
probe = await provider.probe()
check("mock probe ok=True", probe.get("ok") is True and probe.get("ios_major") == 26, str(probe))
async def test_real_degradation() -> None:
print("\n== 2. RealLocationProvider 降级逻辑 ==")
installed = pymobiledevice3_available()
print(f" pymobiledevice3 已安装:{installed}(版本 {pymobiledevice3_version()})")
provider = RealLocationProvider()
check("初始 connected=False", provider.connected is False)
# 2.1 未连接就下发
try:
await provider.set_location(39.9, 116.4)
check("未连接下发应抛异常", False, "没有抛异常")
except LocationProviderError as exc:
check("未连接下发抛 LocationProviderError", True, str(exc))
check("未连接下发错误为中文", has_chinese(str(exc)), str(exc))
except Exception as exc: # noqa: BLE001
check("未连接下发抛 LocationProviderError", False, f"{type(exc).__name__}: {exc}")
# 2.2 未连接 clear 应为 no-op(不抛)
try:
await provider.clear_location()
check("未连接 clear_location 为 no-op", True)
except Exception as exc: # noqa: BLE001
check("未连接 clear_location 为 no-op", False, f"{type(exc).__name__}: {exc}")
# 2.3 未配对
try:
await RealLocationProvider().connect({"udid": "X", "state": "unpaired", "ios_major": 16})
check("未配对设备应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
check("未配对设备报中文错误", has_chinese(str(exc)) and "信任" in str(exc), str(exc))
except Exception as exc: # noqa: BLE001
check("未配对设备报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.4 未开开发者模式
try:
await RealLocationProvider().connect({"udid": "X", "developer_mode": False, "ios_major": 16})
check("未开开发者模式应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
check("未开开发者模式报中文错误", has_chinese(str(exc)) and "开发者模式" in str(exc), str(exc))
except Exception as exc: # noqa: BLE001
check("未开开发者模式报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.5 iOS 17+ 没有隧道
try:
await RealLocationProvider().connect(
{"udid": "X", "ios_version": "26.0", "ios_major": 26, "developer_mode": True, "state": "connected"}
)
check("iOS17+ 无隧道应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
text = str(exc)
if installed:
check("iOS17+ 无隧道提示建隧道", has_chinese(text) and ("隧道" in text or "run.sh" in text), text)
else:
check("未装 pymobiledevice3 时提示安装", has_chinese(text) and "pymobiledevice3" in text, text)
except Exception as exc: # noqa: BLE001
check("iOS17+ 无隧道提示建隧道", False, f"{type(exc).__name__}: {exc}")
# 2.6 经纬度越界
provider_range = RealLocationProvider()
provider_range._connected = True # 仅用于测试参数校验分支
try:
await provider_range.set_location(123.0, 116.0)
check("越界纬度应拒绝", False, "没有抛异常")
except LocationProviderError as exc:
check("越界纬度报中文错误", has_chinese(str(exc)), str(exc))
except Exception as exc: # noqa: BLE001
check("越界纬度报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.7 probe 永不抛异常
probe = await RealLocationProvider(rsd_host=None, rsd_port=None).probe()
check(
"probe 返回 ok/detail/ios_major",
isinstance(probe, dict) and set(probe) >= {"ok", "detail", "ios_major"},
str(probe),
)
check("probe detail 为中文", has_chinese(str(probe.get("detail"))), str(probe.get("detail")))
probe_tunnel = await RealLocationProvider(rsd_host="127.0.0.1", rsd_port=1).probe()
check("probe(隧道不可达) 不抛异常且 ok=False", probe_tunnel.get("ok") is False, str(probe_tunnel.get("detail")))
check("probe(隧道不可达) 文案为中文", has_chinese(str(probe_tunnel.get("detail"))))
# 2.7b probe(device):带设备快照时做静态判断并回填 ios_major
probe_dev = await RealLocationProvider().probe({"ios_major": 26, "developer_mode": False, "udid": "X"})
check(
"probe(device) 未开开发者模式 → ok=False 且中文",
probe_dev.get("ok") is False and "开发者模式" in str(probe_dev.get("detail")),
str(probe_dev.get("detail")),
)
check("probe(device) 回填 ios_major=26", probe_dev.get("ios_major") == 26, str(probe_dev.get("ios_major")))
probe_dev17 = await RealLocationProvider().probe({"ios_major": 26, "developer_mode": True, "udid": "X"})
check(
"probe(device) iOS17+ 无隧道 → 提示建隧道",
probe_dev17.get("ok") is False and has_chinese(str(probe_dev17.get("detail"))),
str(probe_dev17.get("detail"))[:80],
)
# 2.8 close 幂等
fresh = RealLocationProvider()
await fresh.close()
await fresh.close()
check("close 幂等且 connected=False", fresh.connected is False)
# 2.9 真实 RSD 地址不可达时的中文错误
unreachable = RealLocationProvider(rsd_host="127.0.0.1", rsd_port=1, connect_timeout=3.0)
if installed:
try:
await unreachable.connect({"udid": "X", "ios_major": 26, "developer_mode": True})
check("不可达隧道应报错", False, "没有抛异常")
except LocationProviderError as exc:
check("不可达隧道报中文错误", has_chinese(str(exc)), str(exc)[:120])
except Exception as exc: # noqa: BLE001
check("不可达隧道报中文错误", False, f"{type(exc).__name__}: {exc}")
finally:
await unreachable.close()
class _MiniRunner:
"""``core/server.py`` 的 ``AsyncRunner`` 最小替身(后台线程 + 独立事件循环)。"""
def __init__(self) -> None:
self._loop = asyncio.new_event_loop()
self._ready = threading.Event()
self._thread = threading.Thread(target=self._run, name="selftest-runner", daemon=True)
self._thread.start()
self._ready.wait(5.0)
def _run(self) -> None:
asyncio.set_event_loop(self._loop)
self._ready.set()
self._loop.run_forever()
def run(self, coro, timeout: float = 30.0, wait: bool = True):
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(timeout=timeout) if wait else future
def shutdown(self) -> None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=3.0)
except Exception:
pass
def test_lead_integration() -> None:
"""照抄 core/server.py 的真实用法:关键字 log=、connect_sync(runner)、runner.run(coro)。"""
print("\n== 3. 与 lead 的 core/server.py 集成 ==")
runner = _MiniRunner()
logs: list[tuple[str, str]] = []
try:
mock = MockLocationProvider(log=lambda level, msg: logs.append((level, msg)))
info = mock.connect_sync(runner)
check("MockLocationProvider(log=...) 构造成功", isinstance(info, dict) and info.get("ok") is True)
check("connect_sync(runner) 建立会话", mock.connected is True)
check("log= 回调收到日志", len(logs) > 0, f"{len(logs)} 条")
runner.run(mock.set_location(39.908, 116.397))
check("set_location(lat, lon) 经 runner 调用成功", mock.send_count == 1)
runner.run(mock.clear_location())
runner.run(mock.close())
check("close 后 connected=False", mock.connected is False)
real = RealLocationProvider(rsd_host=None, rsd_port=None, udid="X", log=lambda level, msg: None)
check("RealLocationProvider(log=...) 构造成功", real.connected is False)
detail = runner.run(real.probe({"ios_major": 26, "developer_mode": False}))
check("probe(device) 经 runner 调用不抛异常", isinstance(detail, dict) and detail.get("ok") is False)
try:
real.connect_sync(runner, {"udid": "X", "ios_major": 26, "developer_mode": True})
check("connect_sync 无隧道时抛中文异常", False, "没有抛异常")
except LocationProviderError as exc:
check("connect_sync 无隧道时抛中文异常", has_chinese(str(exc)), str(exc)[:60])
runner.run(real.close())
finally:
runner.shutdown()
async def main_async() -> int:
print("=" * 68)
print("虚拟定位后端离线自测(无需 iOS 设备)")
print(f"Python {sys.version.split()[0]} | cwd={os.getcwd()}")
print("=" * 68)
await test_mock()
await test_real_degradation()
test_lead_integration()
print("\n" + "=" * 68)
print(f"通过 {len(PASSED)} 项,失败 {len(FAILED)} 项")
if FAILED:
for item in FAILED:
print(f" - {item}")
return 1
print("全部通过 ✅")
return 0
def main() -> int:
try:
return asyncio.run(main_async())
except KeyboardInterrupt:
return 130
except Exception: # noqa: BLE001
traceback.print_exc()
return 2
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""验证打包后的 .app 能否**独立运行**(不依赖项目目录、不依赖 venv、不依赖系统 Python)。
检查项:
1. .app 结构完整(可执行文件、Info.plist、图标、前端资源)
2. 用干净环境启动(清空 PYTHON* 环境变量、不加载 .pth),端口能起来
3. 关键 API 可用:/api/health、/api/devices、/api/settings/tiles、/ 返回前端
4. 前端资源确实打包进去了(index.html / js / css 都能取到)
5. 瓦片代理可用(说明网络与缓存路径都对)
6. tunnel-helper 子命令可执行(真机连接依赖它;打包后不能再依赖 python -m pymobiledevice3)
7. 数据目录写在 ~/Library/Application Support/iOSLocationTrack(只读包外可写)
用法:./.venv/bin/python tools/verify_bundle.py
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APP = ROOT / "dist" / "虚拟定位轨迹.app"
BIN = APP / "Contents" / "MacOS" / "iOSLocationTrack"
PORT = 8830
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
print("\033[1m打包产物独立运行验证\033[0m")
if not APP.exists():
print(f" \033[31m✗ 未找到 {APP},请先运行 PyInstaller\033[0m")
return 1
print("\n1) 包结构")
check("可执行文件存在", BIN.exists(), str(BIN))
check("Info.plist 存在", (APP / "Contents" / "Info.plist").exists())
check("图标已嵌入", (APP / "Contents" / "Resources" / "AppIcon.icns").exists())
web = APP / "Contents" / "Frameworks" / "web"
check("前端资源已打包", (web / "index.html").exists(), str(web))
check("前端 JS 已打包", (web / "js" / "app.js").exists())
check("前端样式已打包", (web / "style.css").exists())
size_mb = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
print(f" 包体积:{size_mb:.1f} MB")
print("\n2) Info.plist 关键字段")
try:
plist = subprocess.run(
["/usr/libexec/PlistBuddy", "-c", "Print", str(APP / "Contents" / "Info.plist")],
capture_output=True, text=True, timeout=10,
).stdout
check("包名正确", "虚拟定位轨迹" in plist)
check("Bundle ID 正确", "com.local.ios-location-track" in plist)
check("最低系统版本已声明", "LSMinimumSystemVersion" in plist)
except Exception as exc:
check("读取 Info.plist", False, str(exc)[:80])
print("\n3) 干净环境启动(不依赖 venv / 项目目录)")
env = {
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": os.path.expanduser("~"),
"LANG": "zh_CN.UTF-8",
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
# 明确清掉 Python 相关变量,证明不依赖外部环境
}
log = Path("/tmp/bundle-run.log")
proc = subprocess.Popen(
[str(BIN), "--mock", "--no-browser", "--window", "none", "--port", str(PORT)],
env=env, stdout=log.open("wb"), stderr=subprocess.STDOUT,
cwd="/tmp", # 故意用 /tmp 作为工作目录,验证不依赖项目目录
start_new_session=True,
)
base = f"http://127.0.0.1:{PORT}"
ready = False
deadline = time.time() + 60
while time.time() < deadline:
try:
with urllib.request.urlopen(base + "/api/health", timeout=2) as resp:
if resp.status == 200:
ready = True
break
except Exception:
time.sleep(0.5)
check("独立启动成功(不依赖 venv)", ready, f"见 {log}")
if not ready:
try:
print(" 启动日志:")
print(" ", log.read_text(encoding="utf-8", errors="replace")[-800:])
except Exception:
pass
proc.terminate()
print(f"\n通过 {PASS} 项,失败 {FAIL} 项")
return 1
try:
print("\n4) 关键接口")
with urllib.request.urlopen(base + "/api/health", timeout=5) as resp:
health = json.loads(resp.read())
check("GET /api/health", health.get("ok") is True, str(health)[:100])
check("运行在打包模式(无 venv)", health.get("pymobiledevice3") is True, str(health)[:120])
with urllib.request.urlopen(base + "/", timeout=5) as resp:
html = resp.read().decode("utf-8", "replace")
check("GET / 返回前端页面", "<html" in html.lower() and "虚拟定位" in html, "")
for path, label in [("/js/app.js", "app.js"), ("/js/map.js", "map.js"), ("/style.css", "style.css")]:
with urllib.request.urlopen(base + path, timeout=5) as resp:
body = resp.read()
check(f"静态资源 {label} 可取", resp.status == 200 and len(body) > 1000,
f"{resp.status} {len(body)}B")
with urllib.request.urlopen(base + "/api/devices", timeout=8) as resp:
devices = json.loads(resp.read())
check("GET /api/devices", "devices" in devices, str(devices)[:100])
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=8) as resp:
tiles = json.loads(resp.read())
check("GET /api/settings/tiles", bool(tiles.get("sources")), "")
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as resp:
tile = resp.read()
check("瓦片代理可用", tile[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile)}B")
print("\n5) 数据目录(包外可写)")
data_dir = Path.home() / "Library" / "Application Support" / "iOSLocationTrack"
check("数据目录已创建", data_dir.is_dir(), str(data_dir))
check("config.json 已生成", (data_dir / "config.json").exists(), str(data_dir))
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
time.sleep(2)
except Exception:
proc.terminate()
time.sleep(1)
if proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
print("\n6) tunnel-helper 子命令(真机连接依赖它)")
helper = subprocess.run(
[str(BIN), "tunnel-helper", "--udid", "NONEXISTENT-UDID"],
env=env, capture_output=True, text=True, timeout=90,
)
output = (helper.stdout or "") + (helper.stderr or "")
# 不存在的设备必然失败,但必须是"能跑起来、给出中文/英文错误",而不是崩溃或无此命令
check("tunnel-helper 可执行(非崩溃)",
helper.returncode in (0, 1, 2) and "Traceback" not in output,
f"rc={helper.returncode} {output.strip()[:120]}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+334
View File
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>iOS 虚拟定位 · 轨迹工具</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232f81f7'/%3E%3Cpath d='M16 6a6.5 6.5 0 0 0-6.5 6.5C9.5 17.4 16 26 16 26s6.5-8.6 6.5-13.5A6.5 6.5 0 0 0 16 6Zm0 8.8a2.4 2.4 0 1 1 0-4.8 2.4 2.4 0 0 1 0 4.8Z' fill='%23fff'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<!-- ===================== 顶部:品牌 + 设备卡片 + 操作 ===================== -->
<header class="topbar">
<div class="brand">
<div class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7Zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5Z"/></svg>
</div>
<div class="brand-text">
<h1>iOS 虚拟定位 · 轨迹工具</h1>
<p id="appSubtitle">本地服务未连接</p>
</div>
</div>
<div class="device-card" id="deviceCard">
<div class="device-empty">正在读取设备信息…</div>
</div>
<div class="top-actions">
<span class="pill pill-mute" id="serverInfo">服务端 --</span>
<span class="pill pill-err" id="connPill">未连接</span>
<button type="button" class="btn" id="btnDevicesRefresh" title="重新扫描 USB / 网络设备">刷新设备</button>
<button type="button" class="btn btn-primary" id="btnDeviceConnect" title="建立隧道并连接 DVT 定位服务">连接设备</button>
<button type="button" class="btn btn-danger" id="btnDeviceDisconnect" title="释放会话并恢复真实定位">断开</button>
</div>
</header>
<!-- ===================== 全局提示条 ===================== -->
<div class="banner hidden" id="alertBanner">
<span class="banner-icon" aria-hidden="true">!</span>
<span class="banner-text" id="bannerText"></span>
<button type="button" class="btn btn-small" id="bannerAction">重试</button>
</div>
<!-- ===================== 主体 ===================== -->
<main class="workspace" id="workspace">
<!-- ---------- 左侧:轨迹设置 ---------- -->
<aside class="panel panel-left" id="leftPanel">
<div class="panel-head">
<h2>轨迹设置</h2>
<span class="panel-sub" id="routeMeta">未命名轨迹</span>
</div>
<div class="panel-body">
<section class="block">
<label class="block-label">出行模式</label>
<div class="seg" id="modeGroup">
<button type="button" class="seg-btn is-active" data-mode="driving">驾车</button>
<button type="button" class="seg-btn" data-mode="cycling">骑行</button>
<button type="button" class="seg-btn" data-mode="walking">步行</button>
<button type="button" class="seg-btn" data-mode="flight">飞行</button>
</div>
<div class="row" style="margin-top:6px">
<button type="button" class="btn btn-small" id="btnCampusPreset" title="步行 1.4 m/s、下发 10Hz、漂移 4m、浮动 ±8%、不循环">校园跑预设</button>
<button type="button" class="btn btn-small" id="btnMarathonPreset" title="骑行 5.5 m/s、下发 10Hz、漂移 6m、浮动 ±10%">长跑预设</button>
</div>
</section>
<section class="block">
<div class="row row-between">
<label class="block-label" for="jumpCoord">快速定位到坐标 <span class="hint-mini">校园跑:先填学校坐标</span></label>
</div>
<div class="row">
<input type="text" id="jumpCoord" class="input" placeholder="例如 30.2741, 120.1551" title="纬度, 经度(WGS84,逗号或空格分隔)">
<button type="button" class="btn btn-small" id="btnJumpCoord">跳转</button>
</div>
<div class="row" style="margin-top:4px">
<button type="button" class="btn btn-small" id="btnSavePlace">记住此地</button>
<select id="placeSelect" class="input select" title="已记住的地点">
<option value="">(暂无记住的地点)</option>
</select>
<button type="button" class="btn btn-small" id="btnGoPlace">前往</button>
</div>
<p class="hint-mini" style="margin:4px 0 0">跳转只是把地图移到该点,方便你在学校范围内画轨迹;不会改变手机定位。</p>
</section>
<section class="block">
<div class="row row-between">
<label class="block-label" for="speedRange">播放倍速 <span class="hint-mini">0.1x – 50x</span></label>
<span class="value-tag" id="speedValue">1.0x</span>
</div>
<input type="range" id="speedRange" class="range" min="0" max="1000" step="1" value="500" title="0.1x - 50x(指数刻度)">
<label class="check">
<input type="checkbox" id="loopToggle">
<span>循环播放(到达终点后回到起点)</span>
</label>
</section>
<section class="block">
<div class="row row-between">
<label class="block-label">真实感模拟 <span class="hint-mini">校园跑等打卡类测试</span></label>
</div>
<label class="check">
<input type="checkbox" id="driftToggle" checked>
<span>定位漂移(模拟 GPS 误差缓慢游走)</span>
</label>
<div class="row row-between">
<label class="hint-mini" for="driftRange">漂移半径</label>
<span class="value-tag" id="driftValue">4 米</span>
</div>
<input type="range" id="driftRange" class="range" min="0" max="50" step="1" value="4" title="0 - 50 米">
<label class="check" style="margin-top:6px">
<input type="checkbox" id="jitterToggle" checked>
<span>速度浮动(瞬时速度在设定值上下波动)</span>
</label>
<div class="row row-between">
<label class="hint-mini" for="jitterRange">浮动幅度</label>
<span class="value-tag" id="jitterValue">±8%</span>
</div>
<input type="range" id="jitterRange" class="range" min="0" max="60" step="1" value="8" title="0 - 60%">
<label class="check" style="margin-top:6px">
<input type="checkbox" id="timingJitterToggle" checked>
<span>发送节奏抖动(真实 GPS 不是精确每秒一次)</span>
</label>
<label class="check">
<input type="checkbox" id="deriveSpeedToggle" checked>
<span>速度由位移推算(与 iOS 的算法一致)</span>
</label>
<label class="check" style="margin-top:6px">
<input type="checkbox" id="grossErrorToggle">
<span>注入粗差跳点(测自己 App 的过滤逻辑)</span>
</label>
<div class="row row-between">
<label class="hint-mini" for="grossErrorRange">粗差概率</label>
<span class="value-tag" id="grossErrorValue">2%</span>
</div>
<input type="range" id="grossErrorRange" class="range" min="0" max="20" step="1" value="2" title="0 - 20%" disabled>
<p class="hint-mini" style="margin:6px 0 0">
漂移用多尺度噪声(慢漂+中尺度+快抖),沿轨迹平滑游走;发送间隔带 ±15% 抖动与偶发丢采样;
上报速度由实际位移÷时间推算,速度/位移/时间三者自洽。
</p>
</section>
<section class="block">
<label class="block-label">路线概览</label>
<div class="stats">
<div class="stat"><span class="stat-k">总距离</span><span class="stat-v" id="statDistance">--</span></div>
<div class="stat"><span class="stat-k">按速度预计</span><span class="stat-v" id="statDuration">--</span></div>
<div class="stat"><span class="stat-k">按倍速播放</span><span class="stat-v" id="statPlayDuration">--</span></div>
<div class="stat"><span class="stat-k">路径点数</span><span class="stat-v" id="statPoints">0</span></div>
<div class="stat"><span class="stat-k">途经点</span><span class="stat-v" id="statWaypoints">0</span></div>
<div class="stat"><span class="stat-k">路线引擎</span><span class="stat-v" id="statEngine">--</span></div>
</div>
</section>
<section class="block">
<div class="row">
<label class="block-label inline" for="routeName">轨迹名称</label>
<input type="text" id="routeName" class="input" maxlength="60" placeholder="未命名轨迹">
</div>
</section>
<section class="block">
<div class="row row-between">
<label class="block-label">途经点列表</label>
<span class="hint-mini">拖拽排序 · 双击改坐标</span>
</div>
<div class="wp-list" id="wpList"></div>
<div class="wp-empty" id="wpEmpty">还没有途经点:点地图工具条上的「添加途经点」在地图上点选,或导入 GPX。</div>
</section>
<section class="block">
<label class="block-label" for="savedRoutes">本地保存的路线</label>
<div class="row">
<select class="input select" id="savedRoutes"></select>
<button type="button" class="btn" id="btnSaveRoute" title="保存到浏览器 localStorage">保存</button>
<button type="button" class="btn" id="btnLoadRoute" title="载入所选路线">载入</button>
<button type="button" class="btn btn-danger" id="btnDeleteSaved" title="删除所选路线">删除</button>
</div>
</section>
<section class="block">
<label class="block-label" for="tileSourceSelect">底图</label>
<select class="input select" id="tileSourceSelect"></select>
<div class="row row-wrap gap-top">
<button type="button" class="btn btn-small" id="btnTunnelEnsure" title="iOS 17+ 建立 RSD 隧道(必要时需要管理员权限)">建立隧道</button>
<button type="button" class="btn btn-small" id="btnTunnelManual" title="手动填写 RSD 主机与端口">手动 RSD</button>
<button type="button" class="btn btn-small" id="btnMountImage" title="给 iOS 设备挂载开发者镜像(DVT 虚拟定位的前提,内置镜像素材)">挂载开发者镜像</button>
<button type="button" class="btn btn-small" id="btnTilesClear" title="清理服务端瓦片磁盘缓存并重新拉取">清理瓦片缓存</button>
</div>
<p class="hint-mini" style="margin:6px 0 0">
「挂载开发者镜像」是虚拟定位的前提(iOS 17+ 一般由 Xcode 自动完成)。
应用内置了 iOS 17–27.x 全系共用的镜像素材,无需联网下载;签名由 Apple 在线签发。
点击后过程实时输出到右侧运行日志。<b>仅支持 iOS 17 及以上</b>。
</p>
</section>
<section class="block block-muted">
<label class="block-label">快捷键</label>
<div class="keys">
<span><kbd>空格</kbd> 播放 / 暂停</span>
<span><kbd>Esc</kbd> 结束折线绘制</span>
<span><kbd>Del</kbd> 删除选中点</span>
<span><kbd>双击</kbd> 放大 / 结束绘制</span>
</div>
</section>
<!-- 固定操作条:始终可见的主操作 -->
<section class="actions-sticky">
<button type="button" class="btn btn-primary btn-block" id="btnBuildRoute">加载 OSRM 路线</button>
<div class="btn-grid">
<button type="button" class="btn" id="btnClear">清空</button>
<button type="button" class="btn" id="btnExportGpx">导出 GPX</button>
<button type="button" class="btn" id="btnImportGpx">导入 GPX</button>
<button type="button" class="btn" id="btnClearLocation">恢复真实定位</button>
</div>
</section>
</div>
</aside>
<!-- ---------- 中间:地图 ---------- -->
<section class="map-area" id="mapArea">
<div class="map-toolbar" id="drawToolbar">
<button type="button" class="tool-btn is-active" id="toolSelect" data-tool="select" title="选择 / 拖拽途经点">选择</button>
<button type="button" class="tool-btn" id="toolAdd" data-tool="add" title="在地图上点击添加途经点">添加途经点</button>
<button type="button" class="tool-btn" id="toolPolyline" data-tool="polyline" title="连续点击加点,双击或 Esc 结束">折线绘制</button>
<span class="tool-sep"></span>
<button type="button" class="tool-btn" id="toolUndo" title="撤销上一步">撤销</button>
<button type="button" class="tool-btn" id="toolDelete" title="删除选中的途经点">删除选中点</button>
<button type="button" class="tool-btn" id="toolFit" title="缩放至完整轨迹">全览</button>
<span class="tool-hint" id="toolHint">拖拽平移 · 滚轮缩放</span>
</div>
<div class="map-chips" id="mapChips">
<span class="chip" id="chipPlayState">未播放</span>
<span class="chip" id="chipProgress">进度 0%</span>
<span class="chip" id="chipSpeed">速度 --</span>
<span class="chip" id="chipCoord">--</span>
<span class="chip chip-mute" id="chipTrack">轨迹点 0</span>
</div>
<div id="map" class="map-host"></div>
</section>
<!-- ---------- 右侧:日志 ---------- -->
<aside class="panel panel-right" id="logPanel">
<div class="panel-head log-head">
<h2 class="log-title">运行日志</h2>
<span class="panel-sub" id="logCount">0 / 300</span>
<div class="log-actions">
<button type="button" class="btn btn-small" id="btnFetchLogs" title="向服务端拉取最近 200 条日志">拉取</button>
<button type="button" class="btn btn-small" id="btnClearLog">清空</button>
<button type="button" class="btn btn-small" id="btnToggleLog" title="折叠 / 展开日志面板">折叠</button>
</div>
</div>
<div class="log-body" id="logBody"></div>
<div class="log-collapsed-label" id="logCollapsedLabel">运 行 日 志</div>
</aside>
</main>
<!-- ===================== 底部播放条 ===================== -->
<footer class="playbar">
<div class="transport">
<button type="button" class="tbtn tbtn-play" id="btnPlay" title="开始播放">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
<span>播放</span>
</button>
<button type="button" class="tbtn" id="btnPause" title="暂停" disabled>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M7 5h3.5v14H7V5Zm6.5 0H17v14h-3.5V5Z"/></svg>
<span>暂停</span>
</button>
<button type="button" class="tbtn" id="btnResume" title="继续" disabled>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 5.5v13l10-6.5-10-6.5Zm12 0h2v13h-2v-13Z"/></svg>
<span>继续</span>
</button>
<button type="button" class="tbtn tbtn-stop" id="btnStop" title="停止播放并恢复真实定位" disabled>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 6h12v12H6z"/></svg>
<span>停止</span>
</button>
</div>
<div class="progress-wrap">
<div class="progress-top">
<span class="play-state" id="playStateText">未播放</span>
<span class="progress-num" id="liveProgress">0.0%</span>
</div>
<input type="range" id="progressRange" class="range range-progress" min="0" max="1000" step="1" value="0">
<div class="progress-times">
<span>已用 <b id="timeElapsed">00:00</b></span>
<span>剩余 <b id="timeRemaining">00:00</b></span>
<span>序号 <b id="liveIndex">0 / 0</b></span>
</div>
</div>
<div class="live-stats">
<div class="live"><span class="live-k">实时速度</span><span class="live-v" id="liveSpeed">--</span></div>
<div class="live"><span class="live-k">当前坐标</span><span class="live-v live-v-mono" id="liveCoord">--</span></div>
<div class="live"><span class="live-k">倍速</span><span class="live-v" id="liveScale">1.0x</span></div>
</div>
</footer>
</div>
<!-- ===================== 弹窗 / 提示 ===================== -->
<div class="modal-mask hidden" id="modalMask">
<div class="modal" role="dialog" aria-modal="true">
<h3 id="modalTitle">提示</h3>
<div class="modal-body" id="modalBody"></div>
<div class="modal-actions">
<button type="button" class="btn" id="modalCancel">取消</button>
<button type="button" class="btn btn-primary" id="modalOk">确定</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toastWrap"></div>
<input type="file" id="gpxFile" accept=".gpx,.xml,application/gpx+xml,text/xml" hidden>
<script src="js/coord.js"></script>
<script src="js/ws.js"></script>
<script src="js/map.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+2385
View File
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
/*!
* coord.js — 坐标系统转换与地理计算(纯 vanilla ES2020,无外部依赖)
*
* 约定(见 docs/PROTOCOL.md):
* - 内部存储、发往后端的一切经纬度 **一律 WGS84**;
* - 高德 / 腾讯瓦片为 GCJ-02(火星坐标),ArcGIS / OSM 为 WGS84;
* - 显示用点 = WGS84 → 瓦片基准(GCJ-02 源时做一次 wgs84ToGcj02),
* 取点(鼠标 → 坐标)= 瓦片基准 → WGS84(gcj02ToWgs84)。
*
* 导出:window.Coord(转换)、window.Geo(距离/格式化工具)
*/
(function (global) {
'use strict';
var PI = Math.PI;
var A = 6378245.0; // 克拉索夫斯基椭球长半轴
var EE = 0.00669342162296594323; // 偏心率平方
/** 中国大陆粗略范围之外(含港澳台、境外)不做偏移 */
function outOfChina(lng, lat) {
if (!isFinite(lng) || !isFinite(lat)) return true;
if (lng < 72.004 || lng > 137.8347) return true;
if (lat < 0.8293 || lat > 55.8271) return true;
return false;
}
function transformLat(x, y) {
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
function transformLng(x, y) {
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
/**
* WGS84 → GCJ-02
* @returns {[number, number]} [lng, lat]
*/
function wgs84ToGcj02(lng, lat) {
lng = +lng; lat = +lat;
if (outOfChina(lng, lat)) return [lng, lat];
var dLat = transformLat(lng - 105.0, lat - 35.0);
var dLng = transformLng(lng - 105.0, lat - 35.0);
var radLat = lat / 180.0 * PI;
var magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
var sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
return [lng + dLng, lat + dLat];
}
/**
* GCJ-02 → WGS84(迭代逼近,精度 1e-9 度 ≈ 0.1mm,足够)
* @returns {[number, number]} [lng, lat]
*/
function gcj02ToWgs84(lng, lat) {
lng = +lng; lat = +lat;
if (outOfChina(lng, lat)) return [lng, lat];
var wLng = lng, wLat = lat;
for (var i = 0; i < 12; i++) {
var g = wgs84ToGcj02(wLng, wLat);
var dLng = lng - g[0];
var dLat = lat - g[1];
wLng += dLng;
wLat += dLat;
if (Math.abs(dLng) < 1e-10 && Math.abs(dLat) < 1e-10) break;
}
return [wLng, wLat];
}
/** 判断瓦片源使用的坐标基准 */
function datumOfSource(key, src) {
if (src) {
var explicit = src.datum || src.coordsys || src.crs || src.coordinate_system;
if (typeof explicit === 'string') {
var e = explicit.toLowerCase();
if (e.indexOf('gcj') >= 0 || e.indexOf('mars') >= 0 || e.indexOf('amap') >= 0) return 'gcj02';
if (e.indexOf('wgs') >= 0 || e.indexOf('4326') >= 0 || e.indexOf('epsg') >= 0) return 'wgs84';
}
}
var probe = ((key || '') + ' ' + ((src && (src.url || src.name)) || '')).toLowerCase();
if (/amap|autonavi|gaode|tencent|gtimg|map\.qq|qqmap/.test(probe)) return 'gcj02';
return 'wgs84';
}
/** WGS84 → 显示基准(GCJ-02 源需要偏移,其余原样返回) */
function toDisplay(lng, lat, datum) {
if (datum === 'gcj02') return wgs84ToGcj02(lng, lat);
return [+lng, +lat];
}
/** 显示基准 → WGS84 */
function fromDisplay(lng, lat, datum) {
if (datum === 'gcj02') return gcj02ToWgs84(lng, lat);
return [+lng, +lat];
}
/** 保留 6 位小数(契约要求下发坐标 6 位小数) */
function round6(v) { return Math.round((+v) * 1e6) / 1e6; }
global.Coord = {
outOfChina: outOfChina,
wgs84ToGcj02: wgs84ToGcj02,
gcj02ToWgs84: gcj02ToWgs84,
datumOfSource: datumOfSource,
toDisplay: toDisplay,
fromDisplay: fromDisplay,
round6: round6,
A: A,
EE: EE
};
/* ------------------------------------------------------------------ */
/* 地理计算 / 格式化小工具 */
/* ------------------------------------------------------------------ */
var EARTH_R = 6371008.8; // 平均地球半径(米)
function rad(d) { return d * PI / 180; }
/** 两点球面距离(米),入参 [lng,lat] 或 (lng,lat) */
function haversine(a, b) {
var lng1, lat1, lng2, lat2;
if (Array.isArray(a)) { lng1 = a[0]; lat1 = a[1]; } else { lng1 = a; lat1 = arguments[1]; b = arguments[2]; lng2 = b[0]; lat2 = b[1]; }
if (Array.isArray(b)) { lng2 = b[0]; lat2 = b[1]; }
var dLat = rad(lat2 - lat1);
var dLng = rad(lng2 - lng1);
var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return 2 * EARTH_R * Math.asin(Math.min(1, Math.sqrt(s)));
}
/** 折线总长度(米) */
function pathLength(path) {
var d = 0;
if (!path || path.length < 2) return 0;
for (var i = 1; i < path.length; i++) d += haversine(path[i - 1], path[i]);
return d;
}
/** 距离格式化:<1000 → 米,否则 公里 */
function fmtDistance(m) {
if (!isFinite(m)) return '--';
if (m < 1000) return Math.round(m) + ' 米';
if (m < 10000) return (m / 1000).toFixed(2) + ' 公里';
return (m / 1000).toFixed(1) + ' 公里';
}
/** 秒 → 中文时长 */
function fmtDuration(sec) {
if (!isFinite(sec) || sec < 0) return '--';
sec = Math.round(sec);
var h = Math.floor(sec / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = sec % 60;
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
if (h > 0) return h + ':' + pad(m) + ':' + pad(s);
return pad(m) + ':' + pad(s);
}
/** 秒 → 「1 小时 2 分」式估算文案 */
function fmtDurationCn(sec) {
if (!isFinite(sec) || sec < 0) return '--';
sec = Math.round(sec);
if (sec < 60) return sec + ' 秒';
var m = Math.floor(sec / 60);
if (m < 60) return m + ' 分 ' + (sec % 60) + ' 秒';
var h = Math.floor(m / 60);
return h + ' 小时 ' + (m % 60) + ' 分';
}
function fmtSpeed(ms) {
if (!isFinite(ms)) return '--';
return (ms * 3.6).toFixed(1) + ' km/h';
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function fmtLngLat(lng, lat, digits) {
var d = digits == null ? 6 : digits;
if (!isFinite(lng) || !isFinite(lat)) return '--';
return (+lng).toFixed(d) + ', ' + (+lat).toFixed(d);
}
/** 倍速滑块(0..1000)↔ 0.1x..50x 指数映射,低速段更细腻 */
var SPEED_MIN = 0.1, SPEED_MAX = 50;
function sliderToSpeed(t) {
var k = SPEED_MAX / SPEED_MIN;
return SPEED_MIN * Math.pow(k, clamp(+t, 0, 1));
}
function speedToSlider(v) {
var k = SPEED_MAX / SPEED_MIN;
return clamp(Math.log(clamp(+v, SPEED_MIN, SPEED_MAX) / SPEED_MIN) / Math.log(k), 0, 1);
}
function fmtSpeedScale(v) {
var x = +v;
if (x >= 10) return x.toFixed(0) + 'x';
if (x >= 1) return x.toFixed(1) + 'x';
return x.toFixed(2) + 'x';
}
/** 时间戳 → HH:MM:SS */
function fmtClock(ts) {
var d = ts == null ? new Date() : new Date(ts * 1000);
if (isNaN(d.getTime())) d = new Date();
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
global.Geo = {
haversine: haversine,
pathLength: pathLength,
fmtDistance: fmtDistance,
fmtDuration: fmtDuration,
fmtDurationCn: fmtDurationCn,
fmtSpeed: fmtSpeed,
fmtLngLat: fmtLngLat,
fmtClock: fmtClock,
clamp: clamp,
sliderToSpeed: sliderToSpeed,
speedToSlider: speedToSlider,
fmtSpeedScale: fmtSpeedScale,
SPEED_MIN: SPEED_MIN,
SPEED_MAX: SPEED_MAX
};
})(typeof window !== 'undefined' ? window : globalThis);
+1186
View File
File diff suppressed because it is too large. Load diff
+339
View File
@@ -0,0 +1,339 @@
/*!
* ws.js — WebSocket 客户端(协议见 docs/PROTOCOL.md §3)
*
* 能力:
* - 连接 ws://127.0.0.1:8787/ws,连接后立即发 hello
* - 自动重连:指数退避 400ms → 10s(带抖动),连接成功后重置
* - 心跳:每 10s 发 ping;连续 3 次没有 pong 主动断开重连
* - 命令请求-应答:request(cmd, fields, timeout) → Promise(ack ok:false 会 reject)
* - 事件订阅:on(type, fn) / on('*', fn) / onState(fn)
*
* 导出:window.WSClient
*/
(function (global) {
'use strict';
var CMD_TIMEOUT = 8000;
var TICK_TIMEOUT = 6000;
var HEARTBEAT_MS = 10000;
var MAX_BACKOFF = 10000;
function nowMs() {
return (global.performance && global.performance.now) ? global.performance.now() : Date.now();
}
function WSClient(opts) {
opts = opts || {};
this.url = opts.url || defaultUrl();
// 部分服务端实现会在 101 响应里回带未请求的 Sec-WebSocket-Protocol,
// 浏览器按 RFC 6455 会直接判定握手失败。这里在首次连接失败后自动带上 v1 再试一次。
this.subprotocols = opts.subprotocols || null;
this.everOpened = false;
this.protocolFallback = false;
this.handlers = Object.create(null); // type -> [fn]
this.stateHandlers = [];
this.state = 'closed'; // closed | connecting | open
this.sock = null;
this.seq = 0;
this.pending = Object.create(null); // id -> {resolve, reject, timer, cmd}
this.attempt = 0;
this.closedByUser = false;
this.reconnectTimer = null;
this.heartbeatTimer = null;
this.lastPongAt = 0;
// 最近一次收到任何入站消息的时间(不只是 pong):用于判断连接是否真的静默
this.lastInboundAt = 0;
this.missedPings = 0;
this.manualClose = false;
}
function defaultUrl() {
var loc = global.location;
var host = (loc && loc.hostname) ? loc.hostname : '127.0.0.1';
var port = (loc && loc.port) ? loc.port : '8787';
var proto = (loc && loc.protocol === 'https:') ? 'wss:' : 'ws:';
return proto + '//' + host + ':' + port + '/ws';
}
WSClient.prototype.on = function (type, fn) {
(this.handlers[type] || (this.handlers[type] = [])).push(fn);
return this;
};
WSClient.prototype.off = function (type, fn) {
var list = this.handlers[type];
if (!list) return this;
var i = list.indexOf(fn);
if (i >= 0) list.splice(i, 1);
return this;
};
WSClient.prototype.onState = function (fn) {
this.stateHandlers.push(fn);
return this;
};
WSClient.prototype._setState = function (s, detail) {
if (this.state === s && !detail) return;
this.state = s;
for (var i = 0; i < this.stateHandlers.length; i++) {
try { this.stateHandlers[i](s, detail); } catch (e) { /* 忽略订阅者异常 */ }
}
};
WSClient.prototype._emit = function (type, payload, raw) {
var list = this.handlers[type];
if (list) {
for (var i = 0; i < list.length; i++) {
try { list[i](payload, raw); } catch (e) {
if (global.console) global.console.error('[ws] 事件处理异常 ' + type, e);
}
}
}
var all = this.handlers['*'];
if (all) {
for (var j = 0; j < all.length; j++) {
try { all[j](payload, raw, type); } catch (e2) { /* 忽略 */ }
}
}
};
WSClient.prototype.connect = function () {
var self = this;
if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return this;
this.closedByUser = false;
this.manualClose = false;
this._setState('connecting');
var sock;
try {
sock = this.subprotocols ? new WebSocket(this.url, this.subprotocols) : new WebSocket(this.url);
} catch (e) {
this._scheduleReconnect('WebSocket 构造失败:' + e.message);
return this;
}
this.sock = sock;
sock.onopen = function () {
self.attempt = 0;
self.everOpened = true;
self.lastPongAt = nowMs();
self.lastInboundAt = nowMs();
self.missedPings = 0;
self._setState('open');
self._emit('open', { url: self.url });
// hello 用请求-应答发送:服务端把 welcome 数据并回在 hello 的 ack 里
// (契约里 hello → welcome;两种形态都兼容:ack 命中后归一化成 welcome 事件)
self.request('hello', { client: 'web' }, 8000).then(function (msg) {
self._emit('welcome', msg);
}).catch(function (err) {
self._emit('hello_failed', { error: err && err.message });
});
self._startHeartbeat();
};
sock.onmessage = function (ev) {
var msg;
try {
msg = JSON.parse(ev.data);
} catch (e) {
self._emit('error', { error: '服务端消息不是合法 JSON', raw: String(ev.data).slice(0, 200) });
return;
}
if (!msg || typeof msg !== 'object') return;
self._dispatch(msg);
};
sock.onerror = function () {
self._emit('sockerror', { url: self.url });
};
sock.onclose = function (ev) {
self._stopHeartbeat();
self.sock = null;
if (self.manualClose || self.closedByUser) {
self._setState('closed', ev && ev.reason);
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: true });
return;
}
self._setState('closed', ev && ev.reason);
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: false });
self._scheduleReconnect(ev && ev.reason ? ('连接关闭:' + ev.reason) : '连接已关闭');
};
return this;
};
WSClient.prototype._dispatch = function (msg) {
// 任何入站消息都证明服务端还活着。不能只认 pong:
// 服务端执行 device.connect(iOS 17+ 建 RSD 隧道,最长约 1 分钟)时会持续推 log 帧,
// 但由于该命令是同步阻塞的,ping 的 pong 可能迟迟回不来——只认 pong 会误判断线。
this.lastInboundAt = nowMs();
this.missedPings = 0;
if (msg.type === 'pong') {
this.lastPongAt = nowMs();
}
// 请求-应答:只要带 id 且命中 pending,就作为该请求的应答
if (msg.id && this.pending[msg.id]) {
var p = this.pending[msg.id];
delete this.pending[msg.id];
if (p.timer) clearTimeout(p.timer);
if (msg.type === 'ack' && msg.ok === false) {
var err = new Error(msg.error || '服务端拒绝了该命令');
err.code = msg.code || 'ack_failed';
err.cmd = msg.cmd || p.cmd;
p.reject(err);
} else {
p.resolve(msg);
}
// 应答同时也是事件(例如 ack 之外带状态),继续广播
}
this._emit(msg.type || 'unknown', msg, msg);
};
/** 发送原始对象(无应答等待) */
WSClient.prototype.send = function (obj) {
if (!this.sock || this.sock.readyState !== 1) return false;
try {
this.sock.send(JSON.stringify(obj));
return true;
} catch (e) {
this._emit('error', { error: '发送失败:' + e.message });
return false;
}
};
WSClient.prototype.nextId = function (prefix) {
this.seq += 1;
return (prefix || 'c') + this.seq + '-' + Math.floor(Math.random() * 1e6).toString(36);
};
/**
* 命令请求-应答。
* @param {string} cmd 命令 type,例如 'play.start'
* @param {object} [fields] 附加字段
* @param {number} [timeout] 毫秒,默认 8000
* @returns {Promise<object>} 应答消息;ok:false 或超时 → reject(Error)
*/
WSClient.prototype.request = function (cmd, fields, timeout) {
var self = this;
var id = this.nextId();
var payload = Object.assign({ type: cmd, id: id }, fields || {});
return new Promise(function (resolve, reject) {
if (!self.sock || self.sock.readyState !== 1) {
var e = new Error('尚未连接到本地服务,请确认服务已启动');
e.code = 'ws_closed';
reject(e);
return;
}
var timer = setTimeout(function () {
if (self.pending[id]) {
delete self.pending[id];
var te = new Error('命令超时(' + cmd + ')');
te.code = 'timeout';
te.cmd = cmd;
reject(te);
}
}, timeout || CMD_TIMEOUT);
self.pending[id] = { resolve: resolve, reject: reject, timer: timer, cmd: cmd };
var ok = self.send(payload);
if (!ok) {
clearTimeout(timer);
delete self.pending[id];
var se = new Error('发送失败:连接不可用');
se.code = 'ws_send_failed';
reject(se);
}
});
};
WSClient.prototype.isOpen = function () {
return !!this.sock && this.sock.readyState === 1;
};
WSClient.prototype._startHeartbeat = function () {
var self = this;
this._stopHeartbeat();
this.heartbeatTimer = setInterval(function () {
if (!self.isOpen()) return;
var lastSeen = Math.max(self.lastInboundAt || 0, self.lastPongAt || 0);
var idle = nowMs() - lastSeen;
// 只要还在收到任何消息(log/status/location/ack)就说明连接健康,不累计丢包
if (idle > HEARTBEAT_MS * 3) {
self.missedPings += 1;
} else {
self.missedPings = 0;
}
if (self.missedPings >= 3) {
self._emit('error', {
error: '连接静默超过 ' + Math.round(HEARTBEAT_MS * 9 / 1000) + ' 秒,正在重建连接'
});
try { self.sock.close(); } catch (e) { /* 忽略 */ }
return;
}
self.request('ping', {}, TICK_TIMEOUT).catch(function () { /* ping 超时由 missedPings 兜底 */ });
}, HEARTBEAT_MS);
};
WSClient.prototype._stopHeartbeat = function () {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
};
WSClient.prototype._scheduleReconnect = function (reason) {
var self = this;
if (this.closedByUser || this.reconnectTimer) return;
if (!this.everOpened && !this.protocolFallback && !this.subprotocols) {
this.protocolFallback = true;
this.subprotocols = ['v1'];
this.attempt = 0;
this._emit('protocol_fallback', { reason: reason || '' });
}
var delay = Math.min(MAX_BACKOFF, 400 * Math.pow(2, this.attempt)) + Math.floor(Math.random() * 250);
this.attempt += 1;
this._emit('reconnecting', { delay: delay, attempt: this.attempt, reason: reason });
this.reconnectTimer = setTimeout(function () {
self.reconnectTimer = null;
self.connect();
}, delay);
};
WSClient.prototype.close = function () {
this.closedByUser = true;
this.manualClose = true;
this._stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
var ids = Object.keys(this.pending);
for (var i = 0; i < ids.length; i++) {
var p = this.pending[ids[i]];
if (p && p.timer) clearTimeout(p.timer);
if (p) p.reject(new Error('连接已关闭'));
delete this.pending[ids[i]];
}
if (this.sock) {
try { this.sock.close(); } catch (e) { /* 忽略 */ }
}
this.sock = null;
this._setState('closed', 'manual');
};
/** 立即重连(用户点「重试」时用) */
WSClient.prototype.reconnectNow = function () {
this.attempt = 0;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.sock) {
try { this.sock.close(); } catch (e) { /* 忽略 */ }
this.sock = null;
}
this.connect();
};
global.WSClient = WSClient;
})(typeof window !== 'undefined' ? window : globalThis);
+803
View File
@@ -0,0 +1,803 @@
/* ==========================================================================
iOS 虚拟定位 · 轨迹工具 — 深色专业风样式
设计基准 1280x820,最小可用宽度 1024;系统字体栈,无任何外部资源。
========================================================================== */
:root {
--bg: #0a0d12;
--bg-2: #0f141c;
--panel: #131a24;
--panel-2: #182130;
--panel-3: #1d2734;
--line: #24303f;
--line-2: #2f3d4f;
--text: #e6edf3;
--text-dim: #9aa8ba;
--text-mute: #6c7c90;
--accent: #2f81f7;
--accent-hi: #4c94ff;
--accent-dim: #1b4f9c;
--ok: #3fb950;
--warn: #d29922;
--err: #f85149;
--radius: 10px;
--radius-sm: 7px;
--shadow: 0 6px 20px rgba(0, 0, 0, .45);
--font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "PingFang SC", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
min-width: 1024px;
overflow: hidden;
background: var(--bg);
color: var(--text);
font: 13px/1.5 var(--font);
-webkit-font-smoothing: antialiased;
user-select: none;
}
h1, h2, h3 { margin: 0; font-weight: 600; }
button { font-family: inherit; }
input, select { font-family: inherit; }
.hidden { display: none !important; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2b3a4d; border-radius: 6px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: #3a4c63; background-clip: content-box; }
.app {
display: grid;
/* 三个直属子元素:topbar / workspace / playbar。
行数必须与实际子元素数量一致,否则最后一行(播放条)会被多余的空行挤扁,
导致底部的「播放」按钮落到可视区域之外,用户完全看不到。 */
grid-template-rows: auto minmax(0, 1fr) auto;
height: 100vh;
min-height: 0;
}
/* ============================ 顶部栏 ============================ */
.topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
background: linear-gradient(180deg, #141c27 0%, #101720 100%);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
.brand-logo {
width: 34px; height: 34px;
display: grid; place-items: center;
border-radius: 9px;
color: #fff;
background: linear-gradient(145deg, var(--accent) 0%, #1a5fd0 100%);
box-shadow: 0 2px 8px rgba(47, 129, 247, .35);
}
.brand-text h1 { font-size: 14px; letter-spacing: .2px; }
.brand-text p { margin: 1px 0 0; font-size: 11px; color: var(--text-mute); }
/* 设备卡片 */
.device-card {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 6px 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
min-height: 46px;
overflow: hidden;
}
.device-empty {
color: var(--warn);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.device-empty::before { content: "⚠"; font-size: 14px; }
.dev-name { display: flex; flex-direction: column; gap: 1px; flex: 1 1 auto; min-width: 120px; max-width: 260px; }
.dev-name b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev-name span { font-size: 11px; color: var(--text-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev-grid {
display: grid;
grid-template-columns: repeat(4, minmax(58px, auto));
gap: 3px 16px;
flex: 0 1 auto;
}
.dev-row { display: flex; flex-direction: column; gap: 0; min-width: 0; }
.dev-k { font-size: 10px; color: var(--text-mute); letter-spacing: .3px; }
.dev-v { font-size: 12px; color: var(--text); }
.dev-v.ok { color: var(--ok); }
.dev-v.warn { color: var(--warn); }
.dev-v.err { color: var(--err); }
.dev-hint {
font-size: 11px;
color: var(--warn);
background: rgba(210, 153, 34, .1);
border: 1px solid rgba(210, 153, 34, .3);
padding: 2px 7px;
border-radius: 5px;
}
.top-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.pill {
font-size: 11px;
padding: 3px 9px;
border-radius: 100px;
border: 1px solid var(--line-2);
color: var(--text-dim);
white-space: nowrap;
}
.pill-ok { color: #0b1210; background: var(--ok); border-color: var(--ok); font-weight: 600; }
.pill-err { color: #fff; background: #7d2019; border-color: #b0342a; font-weight: 600; }
.pill-warn { color: #1a1405; background: var(--warn); border-color: var(--warn); font-weight: 600; }
.pill-mute { color: var(--text-mute); }
/* ============================ 通用按钮 ============================ */
.btn {
appearance: none;
border: 1px solid var(--line-2);
background: var(--panel-2);
color: var(--text);
padding: 5px 11px;
border-radius: var(--radius-sm);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: background .12s, border-color .12s, transform .06s;
}
.btn:hover { background: var(--panel-3); border-color: #3d4f66; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .42; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--accent-dim); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent); }
.btn-danger { background: #46201d; border-color: #7e3a33; color: #ffd9d5; }
.btn-danger:hover { background: #5c2823; }
.btn-small { padding: 3px 8px; font-size: 11px; }
.btn-block { width: 100%; display: block; padding: 7px 11px; font-size: 13px; }
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px; }
/* ============================ 提示条 ============================ */
.banner {
display: flex;
align-items: center;
gap: 9px;
margin: 8px 12px 0;
padding: 7px 12px;
background: rgba(210, 153, 34, .12);
border: 1px solid rgba(210, 153, 34, .38);
border-radius: var(--radius-sm);
font-size: 12px;
color: #f3d493;
}
.banner.is-error { background: rgba(248, 81, 73, .12); border-color: rgba(248, 81, 73, .42); color: #ffb4ae; }
.banner.is-info { background: rgba(47, 129, 247, .12); border-color: rgba(47, 129, 247, .4); color: #b9d6ff; }
.banner-icon {
width: 17px; height: 17px; flex: 0 0 auto;
display: grid; place-items: center;
border-radius: 50%; background: currentColor; color: #12161c;
font-size: 11px; font-weight: 700;
}
.banner-text { flex: 1 1 auto; }
/* ============================ 工作区 ============================ */
.workspace {
display: grid;
grid-template-columns: 312px minmax(0, 1fr) 300px;
gap: 10px;
padding: 8px 12px 10px;
min-height: 0;
}
.workspace.log-collapsed { grid-template-columns: 312px minmax(0, 1fr) 36px; }
@media (max-width: 1180px) {
.workspace { grid-template-columns: 288px minmax(0, 1fr) 262px; }
.workspace.log-collapsed { grid-template-columns: 288px minmax(0, 1fr) 36px; }
}
@media (max-width: 1080px) {
.workspace { grid-template-columns: 272px minmax(0, 1fr) 236px; }
.workspace.log-collapsed { grid-template-columns: 272px minmax(0, 1fr) 36px; }
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.panel-left { min-width: 0; }
.panel-right { min-width: 0; overflow: hidden; }
.panel-head {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #1a2331 0%, #151d29 100%);
border-radius: var(--radius) var(--radius) 0 0;
}
.panel-head h2 { font-size: 13px; }
.panel-sub { font-size: 11px; color: var(--text-mute); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
.panel-body { padding: 9px 12px 14px; overflow-y: auto; min-height: 0; scrollbar-gutter: stable; }
.block { margin-bottom: 11px; }
.block:last-child { margin-bottom: 0; }
.block-muted { opacity: .85; }
.block-label {
display: block;
font-size: 11px;
color: var(--text-dim);
letter-spacing: .4px;
margin-bottom: 6px;
}
.block-label.inline { margin-bottom: 0; white-space: nowrap; }
.row .input { flex: 1 1 auto; min-width: 0; }
.row { display: flex; align-items: center; gap: 6px; }
.row-wrap { flex-wrap: wrap; }
.gap-top { margin-top: 7px; }
.row-between { justify-content: space-between; }
.value-tag {
font-family: var(--mono);
font-size: 12px;
color: var(--accent-hi);
background: rgba(47, 129, 247, .12);
border: 1px solid rgba(47, 129, 247, .3);
border-radius: 5px;
padding: 1px 7px;
}
/* 分段控件 */
.seg {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 3px;
}
.seg-btn {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 12px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
}
.seg-btn:hover { background: var(--panel-3); color: var(--text); }
.seg-btn.is-active { background: var(--accent); color: #fff; font-weight: 600; }
/* 滑块 */
.range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 20px;
background: transparent;
cursor: pointer;
}
.range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 3px;
background: linear-gradient(90deg, var(--accent-dim), #2b3a4d);
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
margin-top: -5px;
border-radius: 50%;
background: #eaf2ff;
border: 2px solid var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, .5);
}
.range:disabled::-webkit-slider-thumb { border-color: #566273; background: #98a5b5; }
.range-scale {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-mute);
margin-top: -3px;
}
.check { display: flex; align-items: center; gap: 7px; margin-top: 9px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
.check input { accent-color: var(--accent); width: 14px; height: 14px; }
/* 统计 */
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.stat {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 4px 6px;
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.stat-k { font-size: 9px; color: var(--text-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat-v { font-size: 10.5px; font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 左侧面板底部固定操作条(保证「加载 OSRM 路线」始终可见) */
.actions-sticky {
position: sticky;
bottom: -1px;
z-index: 3;
margin: 4px -12px -14px;
padding: 8px 12px 9px;
background: linear-gradient(180deg, rgba(19, 26, 36, .82) 0%, var(--panel) 42%);
border-top: 1px solid var(--line);
backdrop-filter: blur(4px);
}
/* 输入 */
.input {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--line-2);
color: var(--text);
border-radius: var(--radius-sm);
padding: 5px 8px;
font-size: 12px;
outline: none;
user-select: text;
}
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(47, 129, 247, .18); }
.select { appearance: none; cursor: pointer; padding-right: 20px;
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%), linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
background-position: calc(100% - 12px) 12px, calc(100% - 8px) 12px;
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
}
.row .input.select { flex: 1 1 auto; }
/* 途经点列表 */
.wp-list { display: flex; flex-direction: column; gap: 4px; max-height: 208px; overflow-y: auto; }
.wp-item {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 5px 6px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 11px;
}
.wp-item:hover { border-color: var(--line-2); background: var(--panel-2); }
.wp-item.is-selected { border-color: var(--warn); box-shadow: 0 0 0 1px rgba(210, 153, 34, .35) inset; }
.wp-item.is-dragging { opacity: .45; }
.wp-item.drag-over { border-color: var(--accent); background: rgba(47, 129, 247, .12); }
.wp-drag { color: var(--text-mute); cursor: grab; font-size: 12px; letter-spacing: -1px; margin-top: 1px; }
.wp-idx {
flex: 0 0 auto;
width: 19px; height: 19px;
display: grid; place-items: center;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: #fff;
background: var(--accent);
}
.wp-item.is-start .wp-idx { background: var(--ok); }
.wp-item.is-end .wp-idx { background: var(--err); }
.wp-main { display: flex; flex-direction: column; gap: 3px; flex: 1 1 auto; min-width: 0; }
.wp-coord { font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); white-space: nowrap; cursor: text; }
.wp-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.wp-btn {
appearance: none;
border: 1px solid var(--line-2);
background: var(--panel-2);
color: var(--text-dim);
border-radius: 4px;
font-size: 10px;
padding: 2px 5px;
cursor: pointer;
}
.wp-btn:hover { color: var(--text); border-color: var(--accent); }
.wp-btn.danger:hover { color: #ffd9d5; border-color: var(--err); background: #46201d; }
.wp-empty {
font-size: 11px;
color: var(--text-mute);
line-height: 1.7;
padding: 10px;
border: 1px dashed var(--line-2);
border-radius: var(--radius-sm);
text-align: center;
}
.hint-mini { font-size: 10px; color: var(--text-mute); margin-bottom: 6px; }
.keys { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 8px; font-size: 10.5px; color: var(--text-mute); }
kbd {
display: inline-block;
min-width: 16px;
text-align: center;
padding: 0 4px;
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--bg-2);
color: var(--text-dim);
font-family: var(--mono);
font-size: 10px;
}
/* ============================ 地图区 ============================ */
.map-area { position: relative; min-width: 0; min-height: 0; display: flex; }
.map-host {
position: relative;
flex: 1 1 auto;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
background: #0b0f14;
}
.map-canvas { display: block; touch-action: none; cursor: grab; }
.slippy-map.is-dragging .map-canvas { cursor: grabbing; }
.slippy-map.is-crosshair .map-canvas { cursor: crosshair; }
/* 地图内控件 */
.map-zoom { position: absolute; right: 10px; top: 10px; display: flex; flex-direction: column; gap: 6px; z-index: 6; }
.map-btn {
width: 30px; height: 30px;
border-radius: 8px;
border: 1px solid var(--line-2);
background: rgba(19, 26, 36, .92);
color: var(--text);
font-size: 15px;
line-height: 1;
cursor: pointer;
backdrop-filter: blur(4px);
}
.map-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.map-warn {
position: absolute;
right: 10px; top: 86px;
z-index: 6;
display: flex; align-items: center; gap: 7px;
padding: 4px 8px;
font-size: 11px;
color: #ffb4ae;
background: rgba(70, 32, 29, .92);
border: 1px solid rgba(248, 81, 73, .45);
border-radius: 7px;
max-width: 260px;
}
.map-warn-btn {
appearance: none; border: 1px solid rgba(248, 81, 73, .6);
background: transparent; color: #ffd9d5; border-radius: 5px;
font-size: 10px; padding: 1px 6px; cursor: pointer;
}
.map-warn-btn:hover { background: rgba(248, 81, 73, .25); }
.map-readout {
position: absolute;
left: 10px; bottom: 36px;
z-index: 6;
display: flex; flex-direction: column; gap: 1px;
padding: 4px 8px;
border-radius: 7px;
background: rgba(11, 15, 20, .78);
border: 1px solid var(--line);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-dim);
pointer-events: none;
backdrop-filter: blur(3px);
}
.map-readout .mr-z { color: var(--text-mute); }
.map-scalebar {
position: absolute;
left: 10px; bottom: 14px;
z-index: 6;
display: flex; align-items: center; gap: 6px;
font-size: 10px;
color: var(--text-dim);
pointer-events: none;
}
.ms-bar {
height: 7px;
border: 1px solid var(--text-dim);
border-top: 0;
min-width: 24px;
background: linear-gradient(90deg, rgba(230, 237, 243, .18), rgba(230, 237, 243, .02));
}
.map-attrib {
position: absolute;
right: 8px; bottom: 8px;
z-index: 6;
font-size: 10px;
color: var(--text-mute);
background: rgba(11, 15, 20, .7);
padding: 1px 6px;
border-radius: 5px;
pointer-events: none;
}
/* 绘制工具条 */
.map-toolbar {
position: absolute;
left: 10px; top: 10px;
z-index: 7;
display: flex; align-items: center; gap: 5px;
padding: 5px 7px;
border-radius: 9px;
background: rgba(19, 26, 36, .94);
border: 1px solid var(--line-2);
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
max-width: calc(100% - 24px);
flex-wrap: wrap;
}
.tool-btn {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--text-dim);
font-size: 12px;
padding: 4px 9px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.tool-btn:hover { background: var(--panel-3); color: var(--text); }
.tool-btn.is-active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.tool-sep { width: 1px; height: 16px; background: var(--line-2); margin: 0 3px; }
.tool-hint { font-size: 10.5px; color: var(--text-mute); padding-left: 4px; white-space: nowrap; }
/* 地图状态胶囊 */
.map-chips {
position: absolute;
left: 50%; bottom: 12px;
transform: translateX(-50%);
z-index: 6;
display: flex; gap: 6px;
flex-wrap: wrap;
justify-content: center;
max-width: 58%;
pointer-events: none;
}
.chip {
font-size: 10.5px;
padding: 3px 9px;
border-radius: 100px;
background: rgba(11, 15, 20, .8);
border: 1px solid var(--line);
color: var(--text-dim);
font-family: var(--mono);
white-space: nowrap;
backdrop-filter: blur(3px);
}
.chip.is-playing { color: #0b1210; background: var(--ok); border-color: var(--ok); font-weight: 600; }
.chip.is-paused { color: #1a1405; background: var(--warn); border-color: var(--warn); font-weight: 600; }
.chip-mute { color: var(--text-mute); }
/* ============================ 日志面板 ============================ */
.log-head { align-items: center; }
.log-title { font-size: 13px; }
.log-actions { margin-left: auto; display: flex; gap: 5px; }
.log-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 6px 8px;
font-family: var(--mono);
font-size: 10.5px;
line-height: 1.65;
user-select: text;
min-height: 0;
}
.log-line { display: flex; gap: 6px; padding: 1px 0; border-bottom: 1px solid rgba(36, 48, 63, .45); }
.log-msg, .log-line { overflow-wrap: anywhere; }
.log-ts { color: var(--text-mute); flex: 0 0 auto; }
.log-lvl { flex: 0 0 auto; width: 34px; text-align: center; border-radius: 4px; font-size: 9.5px; height: 15px; line-height: 15px; }
.log-msg { flex: 1 1 auto; color: var(--text-dim); }
.level-info .log-lvl { background: rgba(47, 129, 247, .18); color: #8ab6ff; }
.level-warn .log-lvl { background: rgba(210, 153, 34, .18); color: #e3bb63; }
.level-error .log-lvl { background: rgba(248, 81, 73, .2); color: #ff9c95; }
.level-success .log-lvl { background: rgba(63, 185, 80, .18); color: #7ee294; }
.level-error .log-msg { color: #ffb4ae; }
.level-warn .log-msg { color: #f0d69a; }
.level-success .log-msg { color: #9fe8b0; }
.log-collapsed-label { display: none; }
.workspace.log-collapsed .log-body,
.workspace.log-collapsed .log-head h2,
.workspace.log-collapsed .panel-sub,
.workspace.log-collapsed #btnFetchLogs,
.workspace.log-collapsed #btnClearLog { display: none; }
.workspace.log-collapsed .log-head {
border-bottom: 0;
background: transparent;
padding: 6px 0;
justify-content: center;
}
.workspace.log-collapsed .log-actions { margin: 0 auto; }
.workspace.log-collapsed #btnToggleLog {
padding: 2px 6px;
font-size: 13px;
line-height: 1.1;
background: var(--panel-2);
color: var(--text-dim);
}
.workspace.log-collapsed .log-collapsed-label {
display: block;
writing-mode: vertical-rl;
letter-spacing: 2px;
font-size: 10.5px;
color: var(--text-mute);
margin: 10px auto;
user-select: none;
}
.workspace.log-collapsed #logPanel { cursor: pointer; }
.workspace.log-collapsed #logPanel:hover .log-collapsed-label { color: var(--text); }
/* ============================ 播放条 ============================ */
.playbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 8px 14px;
background: linear-gradient(180deg, #131b26 0%, #0f151e 100%);
border-top: 1px solid var(--line);
}
.transport { display: flex; gap: 6px; }
.tbtn {
appearance: none;
display: flex; align-items: center; gap: 5px;
border: 1px solid var(--line-2);
background: var(--panel-2);
color: var(--text);
border-radius: 8px;
padding: 6px 11px;
font-size: 12px;
cursor: pointer;
transition: background .12s, border-color .12s;
}
.tbtn:hover:not(:disabled) { background: var(--panel-3); border-color: #3d4f66; }
.tbtn:disabled { opacity: .4; cursor: not-allowed; }
.tbtn-play:not(:disabled) { background: var(--accent-dim); border-color: var(--accent); color: #fff; }
.tbtn-play:not(:disabled):hover { background: var(--accent); }
.tbtn-stop:not(:disabled):hover { background: #46201d; border-color: var(--err); color: #ffd9d5; }
.progress-wrap { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.progress-top { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
.play-state { font-weight: 600; color: var(--text); }
.play-state.is-playing { color: var(--ok); }
.play-state.is-paused { color: var(--warn); }
.progress-num { font-family: var(--mono); color: var(--accent-hi); }
.range-progress { height: 18px; }
.range-progress::-webkit-slider-runnable-track { height: 6px; background: #23303f; }
.range-progress::-webkit-slider-thumb { width: 15px; height: 15px; margin-top: -5px; }
.progress-times { display: flex; gap: 16px; font-size: 10.5px; color: var(--text-mute); }
.progress-times b { color: var(--text-dim); font-family: var(--mono); font-weight: 500; }
.live-stats { display: flex; gap: 14px; }
.live { display: flex; flex-direction: column; gap: 0; min-width: 84px; }
.live-k { font-size: 10px; color: var(--text-mute); }
.live-v { font-size: 12px; color: var(--text); font-family: var(--mono); }
.live-v-mono { font-size: 11.5px; }
@media (max-width: 1080px) {
.live { min-width: 70px; }
.live-v { font-size: 11px; }
.tbtn span { display: none; }
.tbtn { padding: 7px 9px; }
}
/* ============================ 弹窗 ============================ */
.modal-mask {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(4, 7, 11, .62);
display: grid;
place-items: center;
backdrop-filter: blur(2px);
}
.modal {
width: 380px;
max-width: calc(100vw - 40px);
background: var(--panel);
border: 1px solid var(--line-2);
border-radius: 12px;
box-shadow: var(--shadow);
padding: 14px 16px 12px;
}
.modal h3 { font-size: 14px; margin-bottom: 10px; }
.modal-body { font-size: 12.5px; color: var(--text-dim); line-height: 1.7; }
.modal-body .field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.modal-body .field label { width: 46px; color: var(--text-dim); font-size: 12px; }
.modal-body .field .input { flex: 1 1 auto; font-family: var(--mono); }
.modal-body .hint { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
/* ============================ Toast ============================ */
.toast-wrap {
position: fixed;
right: 16px; bottom: 96px;
z-index: 200;
display: flex; flex-direction: column; gap: 7px;
align-items: flex-end;
pointer-events: none;
}
.toast {
max-width: 380px;
padding: 7px 12px;
border-radius: 9px;
font-size: 12px;
color: var(--text);
background: rgba(24, 33, 48, .96);
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
box-shadow: var(--shadow);
animation: toast-in .18s ease-out;
}
.toast.is-ok { border-left-color: var(--ok); }
.toast.is-warn { border-left-color: var(--warn); }
.toast.is-err { border-left-color: var(--err); }
.toast.is-out { opacity: 0; transform: translateY(6px); transition: opacity .25s, transform .25s; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* 紧凑高度下的自适应(例如 820px 高的窗口) */
@media (max-height: 780px) {
.wp-list { max-height: 180px; }
.block { margin-bottom: 11px; }
.map-readout { bottom: 28px; }
}
+30
View File
@@ -0,0 +1,30 @@
{
"_comment": "瓦片源由服务端 /api/settings/tiles 提供;此文件仅作为前端离线兜底。coord 字段表示瓦片坐标系:gcj02 需要把 WGS84 转成 GCJ-02 后再请求。",
"current": "amap",
"sources": {
"amap": {
"name": "高德矢量",
"max_zoom": 18,
"attribution": "© 高德地图",
"datum": "gcj02"
},
"amap_sat": {
"name": "高德卫星",
"max_zoom": 18,
"attribution": "© 高德地图",
"datum": "gcj02"
},
"tencent": {
"name": "腾讯地图",
"max_zoom": 18,
"attribution": "© 腾讯地图",
"datum": "gcj02"
},
"arcgis_sat": {
"name": "全球卫星(Esri)",
"max_zoom": 19,
"attribution": "Esri, Maxar, Earthstar Geographics",
"datum": "wgs84"
}
}
}