commit 85e9a537505a48c75cb25ee23e84b9b1bbbb5df2 Author: jbjf6666 Date: Fri Oct 16 01:05:09 2026 +0800 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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d4532d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Location.spec b/Location.spec new file mode 100644 index 0000000..453129a --- /dev/null +++ b/Location.spec @@ -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, + }, +) diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..aa6c152 --- /dev/null +++ b/README.en.md @@ -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 +``` + +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** (` 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** (` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6547226 --- /dev/null +++ b/README.md @@ -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`。 diff --git a/core/config_store.py b/core/config_store.py new file mode 100644 index 0000000..f27dfb7 --- /dev/null +++ b/core/config_store.py @@ -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() diff --git a/core/device.py b/core/device.py new file mode 100644 index 0000000..8612488 --- /dev/null +++ b/core/device.py @@ -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() diff --git a/core/geoutils.py b/core/geoutils.py new file mode 100644 index 0000000..17825a1 --- /dev/null +++ b/core/geoutils.py @@ -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 diff --git a/core/location_mock.py b/core/location_mock.py new file mode 100644 index 0000000..91dc181 --- /dev/null +++ b/core/location_mock.py @@ -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"" + ) + + 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(), + } diff --git a/core/location_provider.py b/core/location_provider.py new file mode 100644 index 0000000..55c2574 --- /dev/null +++ b/core/location_provider.py @@ -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"" + ) diff --git a/core/noise.py b/core/noise.py new file mode 100644 index 0000000..bfe520c --- /dev/null +++ b/core/noise.py @@ -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 diff --git a/core/playback.py b/core/playback.py new file mode 100644 index 0000000..07d820f --- /dev/null +++ b/core/playback.py @@ -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"] diff --git a/core/protocol.py b/core/protocol.py new file mode 100644 index 0000000..2ad58d4 --- /dev/null +++ b/core/protocol.py @@ -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 diff --git a/core/route_engine.py b/core/route_engine.py new file mode 100644 index 0000000..f261b2c --- /dev/null +++ b/core/route_engine.py @@ -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 '\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() diff --git a/core/server.py b/core/server.py new file mode 100644 index 0000000..972cc09 --- /dev/null +++ b/core/server.py @@ -0,0 +1,1352 @@ +"""HTTP + WebSocket 服务端:实现 docs/PROTOCOL.md 的全部接口。 + +架构: +- 一个后台线程跑 asyncio 事件循环(AsyncRunner),设备/DVT 的所有协程都在那里执行; +- ThreadingHTTPServer 处理 HTTP 与 WebSocket 升级,每条连接一个线程; +- 播放引擎线程通过 AsyncRunner 串行下发定位。 +""" +from __future__ import annotations + +import asyncio +import concurrent.futures +import mimetypes +import os +import posixpath +import re +import shutil +import subprocess +import threading +import time +import urllib.parse +from collections import deque +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Callable + +from . import config_store, device as device_mod, protocol, route_engine, tilecache, tunnel as tunnel_mod, ws + +def _web_root() -> Path: + """前端资源目录。 + + - 源码运行:项目根目录下的 web/ + - 打包成 .app 独立运行:PyInstaller 解包目录(sys._MEIPASS)/web + """ + import sys + + base = getattr(sys, "_MEIPASS", None) + if base: + candidate = Path(base) / "web" + if candidate.is_dir(): + return candidate + return Path(__file__).resolve().parent.parent / "web" + + +WEB_ROOT = _web_root() + + +class AsyncRunner: + """把一个 asyncio 事件循环放到后台线程,供同步代码提交协程。""" + + def __init__(self) -> None: + self._loop = asyncio.new_event_loop() + self._ready = threading.Event() + self._thread = threading.Thread(target=self._run, name="async-runner", daemon=True) + self._thread.start() + self._ready.wait(timeout=5.0) + + def _run(self) -> None: + asyncio.set_event_loop(self._loop) + self._ready.set() + self._loop.run_forever() + + @property + def loop(self) -> asyncio.AbstractEventLoop: + return self._loop + + def run(self, coro, timeout: float | None = 30.0, wait: bool = True): + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + if not wait: + return future + try: + return future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + future.cancel() + raise protocol.AppError("设备响应超时,请检查连接后重试", "device_timeout", status=504) + + def shutdown(self) -> None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=3.0) + except Exception: + pass + + +class App: + """应用装配:配置、设备、隧道、定位会话、路由、播放、瓦片、日志。""" + + def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT) -> None: + self.root = Path(root) + self.mock = mock + self.port = port + self.host = protocol.DEFAULT_HOST + self.config = config_store.ConfigStore(self.root / "config.json") + # 首次运行就把配置落盘:否则界面上改的设置(漂移/下发频率等)重启后会丢, + # 打包成 .app 时数据目录是新建的,这一点尤其明显。 + if not self.config.path.exists(): + try: + self.config.save() + except Exception: + pass + self.runner = AsyncRunner() + self.hub = ws.Hub() + self.tunnel = tunnel_mod.TunnelManager(log=self.log, on_change=self._on_tunnel_change) + self.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change) + self.routes = route_engine.RouteEngine(self.config) + self.tiles = tilecache.TileCache( + self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024) + ) + self.logs: deque[dict[str, Any]] = deque(maxlen=500) + self._lock = threading.RLock() + self.provider: Any = None + self.provider_udid: str | None = None + self.playback = None # 延后创建(依赖 provider 注入) + from .playback import PlaybackEngine + + self.playback = PlaybackEngine( + runner=self.runner, + config=self.config, + on_location=self._broadcast_location, + on_status=self._broadcast_status, + on_log=self.log, + ) + self.started_at = time.time() + self.shutdown_event = threading.Event() + # 开发者镜像挂载是否正在进行(避免重复触发) + self._mount_running = False + + # ---------------- 日志 ---------------- + def log(self, level: str, message: str) -> None: + entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)} + self.logs.append(entry) + self.hub.broadcast({"type": "log", **entry}) + + def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]: + try: + count = int(limit) + except (TypeError, ValueError) as exc: + raise protocol.AppError("limit 必须是整数", "bad_request") from exc + items = list(self.logs)[-max(1, min(count, 500)):] + return items + + # ---------------- 广播 ---------------- + def _broadcast_location(self, payload: dict[str, Any]) -> None: + self.hub.broadcast({"type": "location", **payload}) + + def _broadcast_status(self, payload: dict[str, Any]) -> None: + self.hub.broadcast({"type": "status", **payload}) + + def _on_devices_change(self) -> None: + self.hub.broadcast({"type": "devices", **self.devices.devices_json()}) + + def _on_tunnel_change(self) -> None: + self.hub.broadcast({"type": "tunnel", **self.tunnel.snapshot()}) + + def welcome_payload(self) -> dict[str, Any]: + return { + "type": "welcome", + "version": protocol.APP_VERSION, + "name": protocol.APP_NAME, + "mock": self.mock, + "config": self.config.snapshot(), + "devices": self.devices.list_devices() and [d.to_json() for d in self.devices.list_devices()], + "selected": self.devices.selected_udid(), + # 与 tunnel.ensure 用同一套语义:mock 模式/IOS<17 不该显示"空闲/建立中" + "tunnel": self._tunnel_semantics(), + "backend": self.devices.backend, + "pymobiledevice3": device_mod.pymobiledevice3_importable(), + } + + def _tunnel_semantics(self) -> dict[str, Any]: + """统一的隧道语义(welcome / REST / WS 三处共用,避免行为不一致)。""" + device = self.devices.get_selected() + if self.mock: + return { + "state": "connected", + "message": "模拟模式:无需建立 RSD 隧道", + "rsd": "mock", + "mode": "mock", + "udid": device.udid if device else None, + "ios_major": device.ios_major if device else None, + "need_tunnel": False, + } + if device is not None and (device.ios_major or 0) < 17: + return { + "state": "unsupported", + "message": f"iOS {device.ios_version} 使用 usbmux + 开发者镜像的经典通道,不需要 RSD 隧道", + "rsd": None, + "mode": None, + "udid": device.udid, + "ios_major": device.ios_major, + "need_tunnel": False, + } + snapshot = self.tunnel.snapshot() + snapshot["ios_major"] = device.ios_major if device else None + snapshot["need_tunnel"] = True + return snapshot + + # ---------------- 会话管理 ---------------- + def provider_for(self, device: device_mod.Device, connect: bool = True) -> Any: + """按需创建并连接定位 provider(mock 或真机)。""" + with self._lock: + if self.provider is not None and self.provider_udid == device.udid: + if not connect or getattr(self.provider, "connected", False): + return self.provider + if self.mock: + from .location_mock import MockLocationProvider + + provider = MockLocationProvider(log=self.log) + provider.connect_sync(self.runner) + self.provider = provider + self.provider_udid = device.udid + self.playback.set_provider(provider) + return provider + + from .location_provider import RealLocationProvider + + # 连之前先体检:iOS<17 必须有开发者镜像,否则连了也是白连, + # 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。 + self._preflight(device) + + rsd = None + if (device.ios_major or 0) >= 17: + if not self.tunnel.ready: + snapshot = self.tunnel.ensure(device.udid, allow_sudo=False) + if snapshot["state"] != "connected": + raise protocol.AppError( + snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED, + "tunnel_required", + status=409, + ) + rsd = self.tunnel.address() + provider = RealLocationProvider( + rsd_host=rsd[0] if rsd else None, + rsd_port=rsd[1] if rsd else None, + udid=device.udid, + log=self.log, + ) + self.runner.run(provider.connect(device), timeout=40.0) + self.provider = provider + self.provider_udid = device.udid + self.playback.set_provider(provider) + device_mod.DeviceManager.mark(self.devices, device.udid, simulating=True) + return provider + + def _preflight(self, device: device_mod.Device) -> None: + """连接前的可用性体检,尽量在动手之前给出可操作的中文提示。 + + 典型场景:同时插着两台设备,用户选中的那台(例如 Wi-Fi 连接的旧设备)没挂开发者镜像, + 而另一台 USB 设备是可用的——这时必须明确告诉用户该换哪台,而不是抛底层英文错误。 + """ + if (device.ios_major or 0) >= 17: + return # iOS 17+ 走 RSD 隧道,不需要开发者镜像 + + mounted = self._developer_image_mounted(device.udid) + if mounted is None or mounted: + return + others = [ + d for d in self.devices.list_devices() + if d.udid != device.udid and (d.ios_major or 0) >= 17 and d.state == "connected" + ] + hint = "" + if others: + best = others[0] + hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})" + raise protocol.AppError( + f"{device.name}(iOS {device.ios_version})尚未挂载开发者镜像,无法做虚拟定位。" + f"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid {device.udid}{hint}", + "developer_image_missing", + status=409, + ) + + def _developer_session_ready(self, udid: str, major: int) -> bool: + """判断"现在能不能真的做虚拟定位"。 + + - **iOS 17 及以上**:DVT 走 RSD 隧道,lockdown 里根本没有那个服务名, + 因此不能拿服务名去试。这里以"镜像是否已挂载"为准 —— 后续连接设备时 + 会自动建隧道并打开 DVT,真连不上会在那一步给出明确报错。 + - **iOS 16 及更早**:镜像挂载 ≠ DVT 可用(实测 16.5 上个性化镜像挂上了 + 但服务不存在),必须真的开一次服务才算就绪。 + """ + import asyncio + + if not major or major >= 17: + return self._developer_image_mounted(udid) is True + + async def probe() -> bool: + from pymobiledevice3.lockdown import create_using_usbmux + + try: + client = await create_using_usbmux( + serial=udid, autopair=False, connection_type="USB", pair_timeout=3.0 + ) + except Exception: + return False + try: + conn = await client.start_lockdown_developer_service( + "com.apple.instruments.remoteserver.DVTSecureSocketProxy" + ) + try: + conn.close() + except Exception: + pass + return True + except Exception: + return False + finally: + try: + await client.close() + except Exception: + pass + + try: + return bool(self.runner.run(probe(), timeout=25.0)) + except Exception: + return False + + def _developer_image_mounted(self, udid: str) -> bool | None: + """查询设备是否已挂载开发者镜像(查询失败返回 None)。 + + 用 Python API 查询(不再依赖 pymobiledevice3 的 CLI), + 因此打包成 .app 后同样可用。 + """ + async def probe() -> bool: + from pymobiledevice3.lockdown import create_using_usbmux + from pymobiledevice3.services.mobile_image_mounter import ( + MobileImageMounterService, + ) + + client = await create_using_usbmux( + serial=udid, autopair=False, connection_type="USB", pair_timeout=3.0 + ) + try: + async with MobileImageMounterService(lockdown=client) as mounter: + for image_type in ("Personalized", "Developer"): + try: + if await mounter.is_image_mounted(image_type): + return True + except Exception: + continue + return False + finally: + try: + await client.close() + except Exception: + pass + + try: + return bool(self.runner.run(probe(), timeout=25.0)) + except Exception: + return None + + def close_session(self, stop_playback: bool = True) -> None: + with self._lock: + provider = self.provider + self.provider = None + self.provider_udid = None + if stop_playback and self.playback is not None: + self.playback.stop_locked(notify=False) + if provider is not None: + try: + self.runner.run(provider.close(), timeout=12.0, wait=True) + except Exception as exc: + self.log("warn", f"释放设备会话时出错:{exc}") + self.playback.set_provider(None) + for device in self.devices.list_devices(): + device_mod.DeviceManager.mark(self.devices, device.udid, simulating=False) + + # ---------------- 命令分发 ---------------- + def dispatch(self, cmd: str, message: dict[str, Any]) -> dict[str, Any]: + """同步执行一条命令,异常统一转成 AppError(供 REST 复用)。""" + try: + handler = self._COMMANDS.get(cmd) + if handler is None: + raise protocol.AppError(f"未知命令:{cmd}", "unknown_command", status=404) + return handler(self, message) or {} + except protocol.AppError: + raise + except Exception as exc: + detail = f"{exc.__class__.__name__}: {exc}" + self.log("error", f"{cmd} 内部错误:{detail}") + raise protocol.AppError(f"内部错误:{detail[:160]}", "internal", status=500) from exc + + def handle_command(self, cmd: str, message: dict[str, Any]) -> dict[str, Any]: + return self.dispatch(cmd, message) + + def handle_ws(self, conn: ws.WSConnection, message: dict[str, Any]) -> None: + cmd = str(message.get("type") or "").strip() + request_id = message.get("id") + try: + handler = self._COMMANDS.get(cmd) + if handler is None: + raise protocol.AppError(f"未知命令:{cmd or '(空)'}", "unknown_command") + result = handler(self, message) + if request_id is not None: + # 注意:payload 里可能自带 type(如 welcome),必须先剔除,否则会把 ack 的类型覆盖掉 + payload = {k: v for k, v in (result or {}).items() if k not in ("type", "id")} + conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, **payload}) + except protocol.AppError as exc: + self.log("error", f"{cmd}: {exc.message}") + if request_id is not None: + conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, "ok": False, + "error": exc.message, "code": exc.code}) + else: + conn.send_json({"type": "error", "error": exc.message, "code": exc.code}) + except Exception as exc: # 兜底,绝不让 WS 线程崩掉 + detail = f"{exc.__class__.__name__}: {exc}" + self.log("error", f"{cmd} 内部错误:{detail}") + if request_id is not None: + conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, "ok": False, + "error": f"内部错误:{detail[:160]}", "code": "internal"}) + else: + conn.send_json({"type": "error", "error": f"内部错误:{detail[:160]}", "code": "internal"}) + + def _cmd_hello(self, message: dict[str, Any]) -> dict[str, Any]: + self.devices.refresh() + return self.welcome_payload() + + def _cmd_ping(self, message: dict[str, Any]) -> dict[str, Any]: + return {"ts": protocol.now_ts()} + + def _cmd_devices_refresh(self, message: dict[str, Any]) -> dict[str, Any]: + self.devices.refresh() + return self.devices.devices_json() + + def _cmd_device_select(self, message: dict[str, Any]) -> dict[str, Any]: + udid = str(message.get("udid") or "").strip() + if not udid: + raise protocol.AppError("缺少 udid", "bad_request") + self.close_session() + device = self.devices.select(udid) + self.config.update({"last_device": udid}) + return {"device": device.to_json()} + + def _cmd_device_connect(self, message: dict[str, Any]) -> dict[str, Any]: + device = self.devices.get_selected() + if device is None: + self.devices.refresh() + device = self.devices.get_selected() + if device is None: + raise protocol.AppError( + "未检测到 iOS 设备:请用数据线连接、解锁设备并在弹窗中点「信任」", "no_device", status=404 + ) + self.log("info", f"正在连接 {device.name}(iOS {device.ios_version or '未知'})…") + provider = self.provider_for(device) + detail = {} + probe = getattr(provider, "probe", None) + if callable(probe): + try: + detail = self.runner.run(probe(), timeout=25.0) or {} + except Exception as exc: + self.log("warn", f"连接自检未通过:{exc}") + self.log("success", f"设备已连接:{device.name}") + return { + "device": (self.devices.get(device.udid) or device).to_json(), + "tunnel": self.tunnel.snapshot(), + "probe": detail, + } + + def _cmd_device_disconnect(self, message: dict[str, Any]) -> dict[str, Any]: + self.close_session() + self.tunnel.stop() + self.log("info", "已断开设备并释放隧道") + return {"device": (self.devices.get_selected().to_json() if self.devices.get_selected() else None), + "tunnel": self.tunnel.snapshot()} + + def _cmd_location_set(self, message: dict[str, Any]) -> dict[str, Any]: + lat, lng = protocol.validate_latlng(message.get("lat"), message.get("lng")) + device = self._require_device() + provider = self.provider_for(device) + if self.playback.active: + self.playback.stop(notify=False, clear_provider=False) + self.playback.set_provider(provider) + self.playback.set_point(lat, lng, provider=provider) + self.log("success", f"已把设备定位到 {lat:.6f}, {lng:.6f}") + return {} + + def _cmd_location_clear(self, message: dict[str, Any]) -> dict[str, Any]: + device = self.devices.get_selected() + if device is not None and self.provider is not None: + self.provider_for(device) + self.playback.clear_point(provider=self.provider) + return {} + + def _cmd_play_start(self, message: dict[str, Any]) -> dict[str, Any]: + device = self._require_device() + route = route_engine.sanitize_route_payload(message.get("route")) + speed_scale = message.get("speed_scale") + loop = message.get("loop") + try: + start_index = int(message.get("start_index") or 0) + except (TypeError, ValueError): + start_index = 0 + # 前端可以把"真实感"参数一并带上(漂移 / 速度浮动) + tuning = message.get("tuning") + if isinstance(tuning, dict): + patch = {k: tuning[k] for k in self._TUNING_KEYS if k in tuning} + if patch: + self.playback.update_tuning(**patch) + provider = self.provider_for(device) + self.playback.start(route=route, speed_scale=speed_scale, loop=loop, start_index=start_index, provider=provider) + self.log( + "success", + f"开始沿轨迹播放:{route.name},{route.distance / 1000:.2f} 公里 / " + f"{_fmt_duration(route.duration)},倍速 {self.playback.speed_scale}x,下发 {self.config.get('send_hz')}Hz", + ) + self.playback.log_tuning() + return {"status": self.playback.status(force=True), "route": route.to_json(), + "tuning": self.playback.tuning()} + + def _cmd_play_pause(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.pause() + return {"status": self.playback.status(force=True)} + + def _cmd_play_resume(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.resume() + return {"status": self.playback.status(force=True)} + + def _cmd_play_stop(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.stop(notify=True, clear_provider=True) + return {"status": self.playback.status(force=True)} + + def _cmd_play_seek(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.seek(message.get("progress")) + return {"status": self.playback.status(force=True)} + + def _cmd_play_speed(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.set_speed(message.get("speed_scale")) + return {"status": self.playback.status(force=True)} + + _TUNING_KEYS = ( + "drift_enabled", "drift_m", "speed_jitter_enabled", "speed_jitter", + "timing_jitter_enabled", "derive_speed", + "gross_error_enabled", "gross_error_probability", "gross_error_m", + ) + + def _cmd_play_tuning(self, message: dict[str, Any]) -> dict[str, Any]: + """实时调整"真实感"参数(只改参数,不打断播放)。 + + 只传消息里**实际出现**的字段:缺失的字段保持原值, + 显式传 false/0 也能生效(服务端 update_tuning 用哨兵区分"未提供")。 + """ + patch = {k: message[k] for k in self._TUNING_KEYS if k in message} + tuning = self.playback.update_tuning(**patch) + if "log" in message and message.get("log"): + self.playback.log_tuning() + return {"tuning": tuning, "status": self.playback.status(force=True)} + + def _cmd_play_loop(self, message: dict[str, Any]) -> dict[str, Any]: + self.playback.set_loop(message.get("loop")) + return {"status": self.playback.status(force=True)} + + # ---------------- 开发者镜像挂载(iOS 17 以下必需) ---------------- + def _cmd_mount_developer_image(self, message: dict[str, Any]) -> dict[str, Any]: + """挂载开发者镜像。iOS<17 的 DVT 定位必须先挂镜像,iOS 17+ 一般由 Xcode 自动挂。 + + 在后台线程执行(下载 + 挂载可能几十秒),过程日志实时推给界面。 + """ + device = self.devices.get_selected() + if device is None: + self.devices.refresh() + device = self.devices.get_selected() + if device is None: + raise protocol.AppError("未检测到 iOS 设备", "no_device", status=404) + if self.mock: + self.log("info", "模拟模式:跳过镜像挂载") + return {"mounted": True, "message": "模拟模式,无需挂载"} + if self._mount_running: + raise protocol.AppError("正在挂载中,请稍候…", "busy", status=409) + + self._mount_running = True + threading.Thread( + target=self._mount_worker, args=(device.udid, device.name, device.ios_version, device.ios_major), + name="mounter", daemon=True, + ).start() + return {"started": True} + + def _mount_worker(self, udid: str, name: str, version: str, major: int) -> None: + try: + self.log("info", f"开始为 {name}(iOS {version})挂载开发者镜像…") + # 关键:不能只看"镜像是否挂载"就判定可用 —— 实测 iOS 16.5 上个性化镜像 + # 显示 IsMounted=true,但 DVT 服务并不会启动(挂载成功 ≠ 能虚拟定位)。 + # 因此这里以"开发者服务是否真的能打开"为准。 + if self._developer_session_ready(udid, major): + self.log("success", "开发者服务已就绪,无需重复挂载") + self.hub.broadcast({"type": "mounted", "mounted": True, "message": "开发者服务已就绪"}) + return + + ok, detail = self._mount_with_fallback(udid, version, major) + + # 用"服务是否可用"做最终判定,而不是仅看挂载标志 + ready = self._developer_session_ready(udid, major) + if ok and ready: + self.log("success", f"开发者镜像挂载完成且服务已就绪({detail})") + self.hub.broadcast({"type": "mounted", "mounted": True, "message": detail}) + elif ok and not ready: + note = ( + "镜像已挂到设备上,但开发者服务仍未启动,虚拟定位还不可用。" + "请用 Xcode(Window → Devices and Simulators)连接一次设备完成 " + "Preparing device for development,再回来重试。" + ) + self.log("error", note) + self.hub.broadcast({"type": "mounted", "mounted": False, "message": note}) + else: + self.log("error", f"挂载失败:{detail}") + self.hub.broadcast({"type": "mounted", "mounted": False, "message": detail}) + except Exception as exc: + self.log("error", f"挂载异常:{exc.__class__.__name__}: {exc}") + self.hub.broadcast({"type": "mounted", "mounted": False, "message": str(exc)[:150]}) + finally: + self._mount_running = False + + def _mount_with_fallback(self, udid: str, version: str, major: int) -> tuple[bool, str]: + """挂载开发者镜像(面向 iOS 17 及以上)。 + + 本工具支持的虚拟定位路径是 **iOS 17+ 的 RSD + DVT**: + 应用内置一套个性化镜像(全系共用),挂载时由 Apple TSS 按设备在线签发, + 因此不需要逐版本素材,iOS 17 到 27.x 都走同一条路。 + + iOS 16 及更早不在支持范围:那套经典镜像由 Apple 按系统构建号签发, + 社区镜像在多数新构建上会被设备拒签(实测 16.5/20F66 失败,libimobiledevice 同样失败)。 + 这里直接给出明确提示,不做徒劳重试。 + """ + if major and major < 17: + return False, ( + "本工具只支持 iOS 17 及以上。你的设备是 iOS " + version + "," + "它的开发者镜像必须由 Apple 按系统构建号签发,第三方镜像会被设备拒签," + "所以无法通过本工具挂载。若确实要调试该设备,请先用 Xcode 连接一次" + "(Window → Devices and Simulators)让它自动完成镜像准备。" + ) + + if not self._stage_personalized_ddi(): + return False, ( + "应用内没有个性化镜像素材,且自动下载失败。" + "请在网络可用时重试,或用 Xcode 连接设备自动挂载一次。" + ) + + self.log("info", f"用内置个性化镜像挂载(iOS {version})…") + ok, detail = self._mount_personalized(udid, version) + if ok: + return True, detail + return False, self._clean_mounter_error(detail) + + @staticmethod + def _clean_mounter_error(raw: str) -> str: + """把底层异常整理成一句人话(原始字符串里塞满了嵌套的 Error Domain)。""" + text = str(raw) + low = text.lower() + if "alreadymounted" in low.replace(" ", "") or "already mounted" in low: + return "镜像已经在设备上(无需重复挂载)" + if "failed to verify the disk image signature" in low: + return "设备拒绝了这个镜像的签名(该系统的镜像需由 Apple 按构建号签发)" + if "reset by peer" in low or "connection aborted" in low: + return "与 Apple 服务器通信被中断(TSS 签名需要联网,请检查网络后重试)" + if "access denied" in low or "requires root" in low: + return "需要管理员权限" + if "no device" in low: + return "未找到设备" + # 去掉嵌套的 UserInfo 噪声,只留第一句 + first = text.replace("\n", " ").split("UserInfo=")[0].strip() + return first[:120] if first else "未知错误" + + def _mount_personalized(self, udid: str, version: str) -> tuple[bool, str]: + """用内置个性化镜像挂载(需联网向 Apple 申请 TSS 签名)。 + + 优先走 Python API(打包版可用),失败再退回 CLI(源码版)。 + """ + ok, detail = self._run_auto_mount(udid, version) + if ok: + return True, detail + if self._mounter_cli() is not None: + self.log("info", "Python API 挂载失败,改用命令行重试…") + return self._run_mounter(["auto-mount", "--udid", udid]) + return False, detail + def _run_auto_mount(self, udid: str, version: str) -> tuple[bool, str]: + """用 Python API 直接挂载(不依赖 pymobiledevice3 的 CLI)。 + + `auto_mount` 会按 iOS 版本自动分流: + - iOS < 17 → auto_mount_developer(经典镜像,需要能写入 Xcode 的 DeviceSupport 目录) + - iOS >= 17 → auto_mount_personalized(用内置个性化镜像 + Apple TSS 在线签名) + 打包成 .app 后没有 CLI 可用,因此这条路径是打包版唯一可行的挂载方式。 + """ + async def worker() -> None: + 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 + ) + try: + # 先查设备上是否已有镜像:已有就直接返回,避免 AlreadyMountedError。 + # 两种类型都查(经典 Developer / 个性化 Personalized)。 + try: + async with MobileImageMounterService(lockdown=client) as mounter: + for image_type in ("Personalized", "Developer"): + try: + if await mounter.is_image_mounted(image_type): + return + except Exception: + continue + except Exception: + pass + await auto_mount(client, version=version) + finally: + try: + await client.close() + except Exception: + pass + + try: + self.runner.run(worker(), timeout=420.0) + return True, "挂载完成" + except Exception as exc: + name = exc.__class__.__name__ + text = str(exc) + # 设备上已经有镜像时 pymobiledevice3 会抛 AlreadyMountedError —— + # 这不是失败(我们要的就是"已挂载"状态),直接当成功处理。 + if "AlreadyMounted" in name or "already mounted" in text.lower(): + return True, "镜像已在设备上(无需重复挂载)" + return False, f"{name}: {text}" + + def _run_mounter(self, args: list[str]) -> tuple[bool, str]: + """执行 pymobiledevice3 mounter 子命令(源码版兜底路径),输出实时写入日志。""" + import subprocess as _sp + + cli = self._mounter_cli() + if cli is None: + return False, "当前环境无法调用 pymobiledevice3 命令行" + command = [*cli, "mounter", *args] + self.log("info", "执行:" + " ".join(str(part) for part in command[1:])) + try: + process = _sp.Popen( + command, stdout=_sp.PIPE, stderr=_sp.STDOUT, text=True, bufsize=1, + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + ) + except Exception as exc: + return False, f"无法启动挂载进程:{exc}" + tail: list[str] = [] + assert process.stdout is not None + for line in process.stdout: + text = line.strip() + if not text: + continue + tail.append(text) + if len(tail) > 40: + tail.pop(0) + self.log("info", "[挂载] " + text[:200]) + code = process.wait(timeout=300) + if code == 0: + return True, "挂载命令执行成功" + summary = tail[-1] if tail else f"退出码 {code}" + if "AccessDenied" in summary or "root" in summary.lower(): + summary += "(可能需要管理员权限)" + return False, summary + + def _mounter_cli(self) -> list[str] | None: + """返回可用的 pymobiledevice3 CLI 前缀(打包版不支持,返回 None)。""" + import sys + + if getattr(sys, "frozen", False): + return None + return self.tunnel._cli_command() + + def _stage_personalized_ddi(self) -> bool: + """把应用内置的个性化镜像装到 pymobiledevice3 期望的缓存目录。 + + 期望位置:~/.pymobiledevice3/Xcode_iOS_DDI_Personalized/ + 文件名必须是 Image.dmg / BuildManifest.plist / **Image.trustcache** + (注意源文件名是 Image.dmg.trustcache)。 + + 这样 iOS 17+ 全系(17 ~ 27.x)都能离线挂载;只有 TSS 签名需要联网, + 而那是 Apple 服务器按设备实时签发的,无法预置。 + """ + import sys + + sources: list[Path] = [] + base = getattr(sys, "_MEIPASS", None) + if base: + sources.append(Path(base) / "resources" / "ddi" / "personalized") + sources.append(self.root / "resources" / "ddi" / "personalized") + sources.append(Path.home() / ".ios-location-ddi" / "personalized") + + src = next((d for d in sources if (d / "Image.dmg").exists()), None) + if src is None: + return False + + dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized" + try: + dest.mkdir(parents=True, exist_ok=True) + mapping = { + "Image.dmg": "Image.dmg", + "BuildManifest.plist": "BuildManifest.plist", + "Image.dmg.trustcache": "Image.trustcache", + } + copied = [] + for source_name, dest_name in mapping.items(): + source_file = src / source_name + if not source_file.exists(): + continue + target = dest / dest_name + # 体积一致就跳过,避免每次挂载都拷 15MB + if target.exists() and target.stat().st_size == source_file.stat().st_size: + continue + shutil.copy2(source_file, target) + copied.append(dest_name) + if copied: + self.log("info", "已从应用内置素材安装个性化镜像:" + "、".join(copied)) + return True + except Exception as exc: + self.log("warn", f"安装内置个性化镜像失败:{exc}") + return False + device = self.devices.get_selected() + if device is None: + return {"mounted": None, "running": self._mount_running} + return { + "mounted": self._developer_image_mounted(device.udid), + "running": self._mount_running, + "ios_major": device.ios_major, + } + + def _cmd_mount_status(self, message: dict[str, Any]) -> dict[str, Any]: + """查询当前设备的开发者镜像/会话状态。""" + device = self.devices.get_selected() + if device is None: + return {"mounted": None, "ready": False, "running": self._mount_running} + major = device.ios_major or 0 + return { + "mounted": self._developer_image_mounted(device.udid), + "ready": self._developer_session_ready(device.udid, major), + "running": self._mount_running, + "ios_major": major, + "supported": major >= 17, + } + + def _cmd_log_list(self, message: dict[str, Any]) -> dict[str, Any]: + return {"lines": self.recent_logs(_as_int(message.get("limit"), 200, "limit"))} + + def _cmd_route_build(self, message: dict[str, Any]) -> dict[str, Any]: + route = self.routes.build( + message.get("waypoints") or [], + mode=message.get("mode") or "driving", + name=message.get("name"), + use_osrm=bool(message.get("use_osrm", True)), + loop=bool(message.get("loop")), + speed_scale=message.get("speed_scale") or 1.0, + ) + engine = route.engine + self.log( + "info", + f"路线规划完成({'OSRM 真实路网' if engine == 'osrm' else '直线'}):" + f"{route.distance / 1000:.2f} 公里,预计 {_fmt_duration(route.duration)}", + ) + return {"route": route.to_json(), "engine": engine} + + def _cmd_tunnel_manual(self, message: dict[str, Any]) -> dict[str, Any]: + snapshot = self.tunnel.use_manual( + str(message.get("host") or ""), message.get("port"), self.devices.selected_udid() + ) + self.log("info", f"已切换为手动 RSD:{snapshot.get('rsd')}") + return {"tunnel": snapshot} + + def _cmd_tunnel_ensure(self, message: dict[str, Any]) -> dict[str, Any]: + if self.mock or (self.devices.get_selected() is not None + and (self.devices.get_selected().ios_major or 0) < 17): + semantics = self._tunnel_semantics() + return {"tunnel": semantics, "need_tunnel": False} + device = self.devices.get_selected() + snapshot = self.tunnel.ensure(device.udid if device else None, allow_sudo=bool(message.get("sudo"))) + snapshot["ios_major"] = device.ios_major if device else None + snapshot["need_tunnel"] = True + return {"tunnel": snapshot, "need_tunnel": True} + + def _cmd_tiles_clear(self, message: dict[str, Any]) -> dict[str, Any]: + removed = self.tiles.clear() + self.log("info", f"已清理瓦片缓存:{removed} 个文件") + return {"removed": removed} + + def _cmd_config_update(self, message: dict[str, Any]) -> dict[str, Any]: + patch = message.get("config") or {} + updated = self.config.update(patch) + self.tiles.max_bytes = max(64, int(updated.get("tile_cache_max_mb") or 1024)) * 1024 * 1024 + return {"config": updated} + + def _require_device(self) -> device_mod.Device: + device = self.devices.get_selected() + if device is None: + self.devices.refresh() + device = self.devices.get_selected() + if device is None: + raise protocol.AppError( + "未检测到 iOS 设备:请用数据线连接、解锁设备并在弹窗中点「信任」", "no_device", status=404 + ) + return device + + _COMMANDS: dict[str, Callable[["App", dict[str, Any]], dict[str, Any]]] = { + "hello": _cmd_hello, + "ping": _cmd_ping, + "devices.refresh": _cmd_devices_refresh, + "device.select": _cmd_device_select, + "device.connect": _cmd_device_connect, + "device.disconnect": _cmd_device_disconnect, + "location.set": _cmd_location_set, + "location.clear": _cmd_location_clear, + "play.start": _cmd_play_start, + "play.pause": _cmd_play_pause, + "play.resume": _cmd_play_resume, + "play.stop": _cmd_play_stop, + "play.seek": _cmd_play_seek, + "play.speed": _cmd_play_speed, + "play.tuning": _cmd_play_tuning, + "play.loop": _cmd_play_loop, + "log.list": _cmd_log_list, + "route.build": _cmd_route_build, + "tunnel.manual": _cmd_tunnel_manual, + "tunnel.ensure": _cmd_tunnel_ensure, + "tiles.clear": _cmd_tiles_clear, + "device.mount_image": _cmd_mount_developer_image, + "device.mount_status": _cmd_mount_status, + "config.update": _cmd_config_update, + } + + # ---------------- 关停 ---------------- + def shutdown(self) -> None: + try: + self.playback.stop(notify=False, clear_provider=True) + except Exception: + pass + try: + self.close_session(stop_playback=False) + except Exception: + pass + try: + self.tunnel.stop() + except Exception: + pass + try: + self.devices.stop_monitor() + except Exception: + pass + try: + self.hub.close_all() + except Exception: + pass + try: + self.runner.shutdown() + except Exception: + pass + + +# ---------------- HTTP ---------------- +class Handler(BaseHTTPRequestHandler): + server_version = f"iOSLocationTool/{protocol.APP_VERSION}" + protocol_version = "HTTP/1.1" + app: App # 由 serve() 注入 + + # 静音默认访问日志,改用 app.log + def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 + return + + def log_error(self, fmt: str, *args: Any) -> None: # noqa: A003 + return + + def handle_one_request(self) -> None: + """浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。""" + try: + super().handle_one_request() + except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): + self.close_connection = True + + # ---------- 安全校验 ---------- + # 本工具监听在本机端口上,默认只允许「本机页面 / 无 Origin 的本机客户端」访问。 + # 否则任意一个网页都能通过跨站简单请求(text/plain 不需要预检)改配置、 + # 下发虚拟定位、甚至调用 /api/shutdown 关掉程序。 + _LOCAL_HOST_RE = re.compile(r"^(?:localhost|127\.0\.0\.1|\[::1\]|::1)(?::\d+)?$", re.I) + + def _check_origin(self) -> None: + origin = self.headers.get("Origin") + if not origin: + return # 非浏览器客户端(curl / 本工具自身)不带 Origin + if origin == "null": + raise protocol.AppError("已拒绝来自沙箱页面(Origin: null)的请求", "forbidden", status=403) + netloc = urllib.parse.urlparse(origin).netloc.strip() + request_host = (self.headers.get("Host") or "").strip() + # 同源请求永远放行(这样 --host 0.0.0.0 时用局域网地址访问也正常) + if netloc and request_host and netloc.lower() == request_host.lower(): + return + if self._LOCAL_HOST_RE.match(netloc): + return + raise protocol.AppError( + f"已拒绝来自 {origin} 的跨站请求(本工具仅允许本机/同源页面访问)", "forbidden", status=403 + ) + + def _check_host(self) -> None: + """防 DNS rebinding:攻击者把域名解析到 127.0.0.1 时 Host 会是其域名。""" + host = (self.headers.get("Host") or "").strip() + if not host: + return + if self._LOCAL_HOST_RE.match(host): + return + # 允许用户显式用 --host 0.0.0.0 指定其它地址访问的场景 + if self.app.host not in ("127.0.0.1", "localhost"): + return + raise protocol.AppError( + f"已拒绝 Host: {host} 的请求(防 DNS rebinding;请用 127.0.0.1 或 localhost 访问)", + "forbidden", + status=403, + ) + + # ---------- 工具 ---------- + def _send_json(self, payload: dict[str, Any], status: int = 200) -> None: + body = protocol.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def _send_bytes(self, body: bytes, content_type: str, status: int = 200, cache: str = "no-store") -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", cache) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def _read_body(self) -> bytes: + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = 0 + if length <= 0: + return b"" + if length > 64 * 1024 * 1024: + raise protocol.AppError("请求体过大", "too_large", status=413) + return self.rfile.read(length) + + def _json_body(self) -> dict[str, Any]: + raw = self._read_body() + if not raw: + return {} + try: + data = protocol.loads(raw) + except Exception as exc: + raise protocol.AppError("请求体不是合法 JSON", "bad_json") from exc + if not isinstance(data, dict): + raise protocol.AppError("请求体必须是 JSON 对象", "bad_json") + return data + + def _query(self) -> dict[str, list[str]]: + parsed = urllib.parse.urlparse(self.path) + return urllib.parse.parse_qs(parsed.query) + + # ---------- 路由 ---------- + def do_GET(self) -> None: # noqa: N802 + path = urllib.parse.urlparse(self.path).path + try: + self._check_origin() + self._check_host() + if path == "/ws": + self._handle_ws() + return + if path.startswith("/api/"): + self._handle_api_get(path) + return + self._serve_static(path) + except protocol.AppError as exc: + self._send_json(exc.to_dict(), exc.status) + except (BrokenPipeError, ConnectionResetError): + pass + except Exception as exc: + self.app.log("error", f"GET {path} 失败:{exc.__class__.__name__}: {exc}") + self._send_json({"ok": False, "error": f"服务端错误:{exc}"[:200], "code": "internal"}, 500) + + def do_POST(self) -> None: # noqa: N802 + path = urllib.parse.urlparse(self.path).path + try: + self._check_origin() + self._check_host() + if path.startswith("/api/"): + self._handle_api_post(path) + return + self._send_json({"ok": False, "error": "接口不存在", "code": "not_found"}, 404) + except protocol.AppError as exc: + self._send_json(exc.to_dict(), exc.status) + except (BrokenPipeError, ConnectionResetError): + pass + except Exception as exc: + self.app.log("error", f"POST {path} 失败:{exc.__class__.__name__}: {exc}") + self._send_json({"ok": False, "error": f"服务端错误:{exc}"[:200], "code": "internal"}, 500) + + # ---------- WebSocket ---------- + def _handle_ws(self) -> None: + if not ws.is_websocket_upgrade(self.headers): + self._send_json({"ok": False, "error": "该端点仅支持 WebSocket", "code": "bad_upgrade"}, 400) + return + connection = ws.handshake(self.connection, self.headers) + if connection is None: + self.close_connection = True + return + self.close_connection = True # 连接已被劫持,HTTP 循环不再读取 + self.app.hub.add(connection) + self.app.log("info", f"界面已连接({connection.addr[0]})") + + def on_message(conn: ws.WSConnection, message: dict[str, Any]) -> None: + self.app.handle_ws(conn, message) + + def on_close(conn: ws.WSConnection) -> None: + self.app.hub.remove(conn) + + connection.run(on_message, on_close) + + # ---------- API ---------- + def _handle_api_get(self, path: str) -> None: + app = self.app + if path == "/api/health": + self._send_json({ + "ok": True, + "version": protocol.APP_VERSION, + "name": protocol.APP_NAME, + "mock": app.mock, + "device_count": len(app.devices.list_devices()), + "backend": app.devices.backend, + "pymobiledevice3": device_mod.pymobiledevice3_importable(), + "uptime": round(time.time() - app.started_at, 2), + "ws_clients": app.hub.count, + }) + return + if path == "/api/devices": + self._send_json({"ok": True, **app.devices.devices_json()}) + return + if path == "/api/config": + self._send_json({"ok": True, "config": app.config.snapshot()}) + return + if path == "/api/settings/tiles": + sources = app.config.tile_sources() + current = app.config.get("tile_source") or "amap" + items = [ + { + "key": key, + "id": key, + "name": value.get("name") or key, + "url": value.get("url") or "", + "max_zoom": int(value.get("max_zoom") or 18), + "attribution": value.get("attribution") or "", + "datum": str(value.get("coord") or ("gcj02" if key.startswith(("amap", "tencent")) else "wgs84")), + "subdomains": value.get("subdomains") or [], + } + for key, value in sources.items() + ] + self._send_json({ + "ok": True, + "sources": items, + "tile_sources": sources, + "current": current, + "tile_source": current, + "selected": current, + "cache": { + "dir": str(app.config.cache_dir()), + "size_mb": round(app.tiles.size_bytes() / 1048576, 2), + "max_mb": int(app.config.get("tile_cache_max_mb") or 1024), + "stats": dict(app.tiles.stats), + }, + }) + return + if path == "/api/tunnel": + self._send_json({"ok": True, "tunnel": app._tunnel_semantics()}) + return + if path == "/api/logs": + raw_limit = (self._query().get("limit") or ["200"])[0] + self._send_json({"ok": True, "lines": app.recent_logs(_as_int(raw_limit, 200, "limit"))}) + return + if path.startswith("/api/tiles/"): + self._serve_tile(path) + return + raise protocol.AppError("接口不存在", "not_found", status=404) + + def _handle_api_post(self, path: str) -> None: + app = self.app + if path == "/api/devices/select": + body = self._json_body() + udid = str(body.get("udid") or "").strip() + if not udid: + raise protocol.AppError("缺少 udid", "bad_request") + app.close_session() + device = app.devices.select(udid) + app.config.update({"last_device": udid}) + self._send_json({"ok": True, "device": device.to_json()}) + return + if path == "/api/devices/connect": + result = app.handle_command("device.connect", {}) + tunnel = app.handle_command("tunnel.ensure", {}).get("tunnel") or app.tunnel.snapshot() + self._send_json({"ok": True, **result, "tunnel": tunnel}) + return + if path == "/api/devices/disconnect": + app.close_session() + app.tunnel.stop() + device = app.devices.get_selected() + self._send_json({ + "ok": True, + "device": device.to_json() if device else None, + "tunnel": app.tunnel.snapshot(), + }) + return + if path == "/api/config": + patch = self._json_body() + updated = app.config.update(patch.get("config") if "config" in patch else patch) + self._send_json({"ok": True, "config": updated}) + return + if path == "/api/route/build": + body = self._json_body() + result = app.handle_command("route.build", body) + self._send_json({"ok": True, **result}) + return + if path == "/api/gpx/export": + body = self._json_body() + route = route_engine.sanitize_route_payload(body.get("route") or body) + if body.get("name"): + route.name = str(body["name"])[:80] + text = route_engine.to_gpx(route) + body_bytes = text.encode("utf-8") + filename = urllib.parse.quote(f"{route.name or 'track'}.gpx") + self.send_response(200) + self.send_header("Content-Type", "application/gpx+xml; charset=utf-8") + self.send_header("Content-Length", str(len(body_bytes))) + self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{filename}") + self.end_headers() + try: + self.wfile.write(body_bytes) + except (BrokenPipeError, ConnectionResetError): + pass + return + if path == "/api/gpx/import": + raw = self._read_body() + text = raw.decode("utf-8", "replace") + coords, name, _kind = route_engine.from_gpx(text) + payload = [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in coords] + self._send_json({ + "ok": True, + # waypoints 与 path 都给全量点:前端可用 path 保形重放,也可只取端点做途经点 + "waypoints": payload, + "path": payload, + "points": payload, + "name": name, + "count": len(payload), + }) + return + if path == "/api/location": + body = self._json_body() + result = app.handle_command("location.set", body) + self._send_json({"ok": True, **result}) + return + if path == "/api/tunnel": + body = self._json_body() + # 统一走命令分发:mock 模式 / iOS<17 的"不需要隧道"语义只在一处实现, + # 避免 REST 与 WS 两条路径行为不一致(曾导致 mock 下显示"隧道空闲/建立中")。 + cmd = "tunnel.manual" if body.get("host") else "tunnel.ensure" + result = app.handle_command(cmd, body) + self._send_json({"ok": True, **result}) + return + if path == "/api/tunnel/manual": + body = self._json_body() + result = app.handle_command("tunnel.manual", body) + self._send_json({"ok": True, **result}) + return + if path == "/api/shutdown": + self._send_json({"ok": True}) + app.log("info", "收到退出请求,正在关闭…") + app.shutdown_event.set() + return + raise protocol.AppError("接口不存在", "not_found", status=404) + + def _serve_tile(self, path: str) -> None: + parts = path[len("/api/tiles/"):].split("/") + if len(parts) < 4: + raise protocol.AppError("瓦片地址格式应为 /api/tiles/{source}/{z}/{x}/{y}.png", "bad_tile", status=404) + source_key = urllib.parse.unquote(parts[0]) + z, x = parts[1], parts[2] + y = re.sub(r"\.(png|jpg|jpeg|webp)$", "", parts[3], flags=re.I) + iz, ix, iy = tilecache.validate_tile_request(z, x, y) + key, source = self.app.config.tile_source(source_key) + result = self.app.tiles.get_tile(key, source, iz, ix, iy) + if result.status != 200: + # 契约要求失败回 404(前端才会走重试/告警逻辑),而不是用 200 + 占位图掩盖 + self._send_bytes(result.body, "image/png", 404, cache="no-store") + return + self._send_bytes(result.body, result.content_type, 200, cache="public, max-age=604800") + + # ---------- 静态文件 ---------- + def _serve_static(self, path: str) -> None: + if path in ("/", ""): + path = "/index.html" + rel = posixpath.normpath(urllib.parse.unquote(path)).lstrip("/") + if rel.startswith(".."): + raise protocol.AppError("非法路径", "bad_path", status=400) + target = (WEB_ROOT / rel).resolve() + if not str(target).startswith(str(WEB_ROOT.resolve())): + raise protocol.AppError("非法路径", "bad_path", status=400) + if target.is_dir(): + target = target / "index.html" + if not target.exists() or not target.is_file(): + # 前端是单页应用:未知路径回落到 index.html + fallback = WEB_ROOT / "index.html" + if fallback.exists() and "." not in rel.rsplit("/", 1)[-1]: + target = fallback + else: + self._send_json({"ok": False, "error": f"文件不存在:{rel}", "code": "not_found"}, 404) + return + content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" + if content_type.startswith("text/") or content_type in ("application/javascript", "application/json"): + content_type += "; charset=utf-8" + body = target.read_bytes() + self._send_bytes(body, content_type, 200, cache="no-cache") + + +def _as_int(value: Any, default: int, field: str) -> int: + """把外部输入安全地转成整数;非法值回中文 400,而不是 500 内部错误。""" + if value is None: + return default + if isinstance(value, bool): + raise protocol.AppError(f"{field} 必须是整数", "bad_request") + number = protocol.finite(value, field) + if number != int(number): + raise protocol.AppError(f"{field} 必须是整数", "bad_request") + return int(number) + + +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 serve(app: App, host: str = protocol.DEFAULT_HOST, port: int = protocol.DEFAULT_PORT) -> ThreadingHTTPServer: + app.host = host + handler = type("BoundHandler", (Handler,), {"app": app}) + httpd = ThreadingHTTPServer((host, port), handler) + httpd.daemon_threads = True + return httpd diff --git a/core/tilecache.py b/core/tilecache.py new file mode 100644 index 0000000..b7a776a --- /dev/null +++ b/core/tilecache.py @@ -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 diff --git a/core/tunnel.py b/core/tunnel.py new file mode 100644 index 0000000..1dce30f --- /dev/null +++ b/core/tunnel.py @@ -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[0-9a-fA-F:.]{3,})\s+(?P\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" diff --git a/core/ws.py b/core/ws.py new file mode 100644 index 0000000..1a34346 --- /dev/null +++ b/core/ws.py @@ -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() diff --git a/docs/DEVICE_NOTES.md b/docs/DEVICE_NOTES.md new file mode 100644 index 0000000..1517a63 --- /dev/null +++ b/docs/DEVICE_NOTES.md @@ -0,0 +1,1031 @@ +# 设备侧取证笔记(pymobiledevice3 / DVT 虚拟定位 / iOS 17+ 隧道) + +> 本文由 **device-dev**(task-2)维护。所有 API 事实均取自**本机真实源码**,不是凭记忆。 +> 冻结契约见 [PROTOCOL.md](PROTOCOL.md),本文件不修改契约,只记录实现依据。 +> +> 取证来源:先从阿里云镜像下载官方 wheel 并解包到 `/tmp/pmd3src/extracted/`, +> 再与安装进 `.venv` 的 `site-packages` 版本逐条比对(见第 1 节)。 +> 引用格式:`文件路径:行号`,相对 `pymobiledevice3/` 包根目录。 + +--- + +## 1. 版本与 Python 3.12 依赖 + +| 项目 | 结果 | +| --- | --- | +| 包版本 | **11.29.1**(wheel:`pymobiledevice3-11.29.1-py3-none-any.whl`) | +| `Requires-Python` | `>=3.9`(wheel METADATA 第 24 行) | +| 官方 classifier | 明确列出 `Programming Language :: Python :: 3.12`(METADATA 第 21 行) | +| 本机解释器 | `.venv` → Python **3.12.14**(Homebrew `/opt/homebrew/opt/python@3.12`) | +| 安装源 | 阿里云镜像 `https://mirrors.aliyun.com/pypi/simple/`(pypi.org 在本机 12s 才响应,官方源不可用) | +| 安装结果 | ✅ 成功(`Successfully installed … pymobiledevice3-11.29.1 …`,退出码 0,日志 `/tmp/pmd3_install.log`) | +| 安装位置 | `/Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages` | +| 纯 Python | `py3-none-any`,无本地编译(个别依赖带 arm64 wheel,如 `qh3`、`cffi`) | + +依赖里与隧道/DTX 相关的关键项(METADATA 第 25-68 行): + +- `cryptography>=43.0.0`、`construct>=2.9.29`、`bpylist2>=4.0.1` —— lockdown / DTX 编解码 +- `qh3<2,>=1.0.0` —— QUIC(tunneld 与 RemotePairing 隧道) +- `pytun-pmd3>=3.0.3` —— 内核 utun 隧道(需要 root) +- `pmd-pytcp>=0.3.7; python_version >= "3.9"` —— **免 root 的纯 Python 用户态网络栈**(userspace 隧道) +- `sslpsk-pmd3>=1.0.3; python_version < "3.13"` —— 本机是 3.12,所以会装它 + +> 注意:Python 3.12 下 `TunnelProtocol.DEFAULT` = **QUIC**(`cli/remote.py:108-116`: +> "default: TCP on Python >=3.13, otherwise QUIC"),这是后面隧道方案取舍的依据之一。 + +--- + +## 2. iOS<17 路径:usbmux / lockdown 直连 DVT + +### 2.1 拿 lockdown 客户端 + +`lockdown.py:1370-1382`: + +```python +async def create_using_usbmux( + serial: Optional[str] = None, + identifier: Optional[str] = None, + label: str = DEFAULT_LABEL, + autopair: bool = True, + connection_type: Optional[str] = None, + pair_timeout: Optional[float] = None, + local_hostname: Optional[str] = None, + pair_record: Optional[dict[str, Any]] = None, + pairing_records_cache_folder: Optional[Path] = None, + port: int = SERVICE_PORT, + usbmux_address: Optional[str] = None, +) -> UsbmuxLockdownClient: +``` + +- 返回值是 `UsbmuxLockdownClient`(`lockdown.py:1149`),继承 `LockdownClient`(`lockdown.py:102`)。 +- 它是 `LockdownServiceProvider`(`lockdown_service_provider.py:15`)的子类,因此可以直接丢给 `DvtProvider`。 +- `close()` 是 async:`lockdown.py:899-904` → `await self.service.close()`。 +- 开发者模式查询:`lockdown.py:534-541` `async def get_developer_mode_status(self) -> bool` + (读 `com.apple.security.mac.amfi` 域的 `DeveloperModeStatus`)。 + +### 2.2 usbmux 地址怎么拿 + +`usbmux.py`: + +| 行号 | API | 说明 | +| --- | --- | --- | +| 129-132 | `@dataclass class MuxDevice(devid, serial, connection_type)` | usbmux 设备条目 | +| 452 | `async def create_mux(usbmux_address=None)` | 打开 usbmuxd 连接(async context manager) | +| 456 | `async def list_devices(usbmux_address=None) -> list[MuxDevice]` | 枚举设备 | +| 485 | `async def select_device(udid=..., connection_type=...)` | 选设备 | +| 500-507 | `async def select_devices_by_connection_type(connection_type, usbmux_address=None)` | 按 USB/Network 过滤 | + +`usbmux_address` 参数适用于远程/自定义 usbmuxd(`USBMUXD_SOCKET_ADDRESS` 环境变量);本机默认走 +`/var/run/usbmuxd`。**我们不需要自己连 usbmux 拿地址**:`create_using_usbmux(serial=...)` 内部已完成 +`ServiceConnection.create_using_usbmux(...)`(`lockdown.py:1404-1406`)。 + +### 2.3 DVT provider 怎么建 + +`services/dvt/instruments/dvt_provider.py:8-53`: + +```python +class DvtProvider(DtxServiceProvider): + SERVICE_NAME = "com.apple.instruments.remoteserver.DVTSecureSocketProxy" # iOS 14-16 + RSD_SERVICE_NAME = "com.apple.instruments.dtservicehub" # iOS 17+ + OLD_SERVICE_NAME = "com.apple.instruments.remoteserver" # iOS < 14 + + def __init__(self, lockdown: LockdownServiceProvider, + strip_ssl: Optional[bool] = None, + dtx: Optional[DTXConnection] = None) -> None: ... +``` + +**关键结论:iOS<17 与 iOS≥17 用的是同一个 `DvtProvider` 类**,服务名由 +`DtxServiceProvider.service_name_for()` 自动选择(`dtx_service_provider.py:80-97`): + +```python +@classmethod +def service_name_for(cls, service_provider: LockdownServiceProvider) -> str: + if isinstance(service_provider, RemoteServiceDiscoveryService): + return cls.RSD_SERVICE_NAME if cls.RSD_SERVICE_NAME is not None else cls.SERVICE_NAME + if cls.OLD_SERVICE_NAME is not None and Version(service_provider.product_version).major < 14: + return cls.OLD_SERVICE_NAME + return cls.SERVICE_NAME +``` + +- 传 `RemoteServiceDiscoveryService` → `com.apple.instruments.dtservicehub`(隧道,iOS17+) +- 传 `LockdownClient` → `com.apple.instruments.remoteserver.DVTSecureSocketProxy`(iOS14-16) +- 传 `LockdownClient` 且 `product_version < 14` → `com.apple.instruments.remoteserver` + +SSL 剥离(老服务需要)也自动判断:`dtx_service_provider.py:99-111 _default_strip_ssl()`, +RSD 永远返回 `False`,lockdown 且 iOS<14 返回 `True`。 + +### 2.4 iOS<17 的另一条路(我们用作兜底) + +`services/simulate_location.py:8-40` `DtSimulateLocation`,走老开发者服务 +`com.apple.dt.simulatelocation`,自己手写 4 字节大端协议头: + +```python +SERVICE_NAME = "com.apple.dt.simulatelocation" # :17 +async def clear(self): # :23-26 + service = await self.lockdown.start_lockdown_developer_service(self.SERVICE_NAME) + await service.sendall(struct.pack(">I", 1)) +async def set(self, latitude, longitude): # :28-40 + service = await self.lockdown.start_lockdown_developer_service(self.SERVICE_NAME) + await service.sendall(struct.pack(">I", 0)) + await service.sendall(struct.pack(">I", len(lat)) + lat.encode()) + await service.sendall(struct.pack(">I", len(lon)) + lon.encode()) +``` + +pymobiledevice3 的 CLI 把这条路标为 **iOS<17 专用**:`cli/developer/simulate_location.py:16` +(`help="Simulate GPS location on iOS versions below 17 ..."`),对应命令 +`pymobiledevice3 developer simulate-location set -- LAT LON`。 +注意它**每次 set 都新开一个服务连接**(源码注释 `services/simulate_location.py:13-14`: +"A fresh lockdown developer service connection is opened for each command."),所以它天然不需要长连接; +但需要设备已挂载开发者镜像(`start_lockdown_developer_service`,`lockdown_service_provider.py:162-170` +失败时会提示 `pymobiledevice3 mounter mount`)。 + +--- + +## 3. iOS≥17 路径:RSD 隧道 + DVT + +### 3.1 RSD 客户端构造参数 + +`remote/remote_service_discovery.py:81-152`: + +```python +class RemoteServiceDiscoveryService(LockdownServiceProvider): + def __init__( + self, + address: tuple[str, int], # (host, port) + name: Optional[str] = None, + open_connection: Optional[Callable[..., Any]] = None, # 自定义拨号器(userspace 隧道注入) + auxiliary_metadata: Optional[dict[str, dict[str, Any]]] = None, + handshake_uuid: Optional[uuid.UUID] = None, + ) -> None: +``` + +- **注意是位置参数 `address` 一个元组**,不是 `host=` / `port=` 两个关键字(`self.service = RemoteXPCConnection(address, ...)`,:141)。 +- host/port 从**隧道**来:内核 utun 隧道(有 root)、macOS 原生隧道、或 tunneld 发布的地址。 +- `RSD_PORT = 58783`(`remote_service_discovery.py:38`),Bonjour 广播的 RSD 端口。 +- 是 async context manager:`__aenter__` → `connect()`(:558-563),`__aexit__` → `close()`(:552-556)。 +- `connect()`(:303-342):握手后填充 `peer_info`、`udid`、`product_type`,并尽力建立远程 lockdown + (`com.apple.mobile.lockdown.remote.trusted` → `.untrusted`)。 +- `product_version`(:178-185)从 `peer_info["Properties"]["OSVersion"]` 取,**connect 之后才可用**。 + +### 3.2 服务发现:没有 browse,直接查 peer_info + +RSD 不用 lockdownd 的 StartService RPC。真正的 API: + +| 行号 | API | 返回 | +| --- | --- | --- | +| 545-550 | `_get_service_entry(name)` | `peer_info["Services"][name]`,缺失抛 `InvalidServiceError` | +| 503-511 | `get_service_port(name) -> int` | 该服务的设备侧 TCP 端口 | +| 513-525 | `get_service_features(name)` | 服务声明的 features | +| 472-483 | `start_remote_service(name)` | 未连接的 `RemoteXPCConnection` | +| 409-451 | `start_lockdown_service(name)` | 完成 RSD check-in 的 `ServiceConnection` | +| 485-501 | `start_service(name)` | 按 `UsesRemoteXPC` 自动选上面两种 | + +`peer_info["Services"]` 的每一项形如 `{"Port": , "Properties": {...}}`。 +**没有 `browse()` 方法**;Bonjour 浏览是 `remote/utils.py get_rsds()` 与 `cli/remote.py browse` 的事。 + +### 3.3 DVT provider 怎么建(iOS≥17) + +**不需要** `DvtProvider` 之外的类,也没有 `RemoteServiceDiscoveryService.get_dvt_provider()` 这个方法 +(我在 11.29.1 源码里 grep 过 `get_dvt_provider`,不存在)。正确做法是把 RSD 对象直接传给 `DvtProvider`: + +```python +async with RemoteServiceDiscoveryService((host, port)) as rsd: + async with DvtProvider(rsd) as dvt: # 自动用 com.apple.instruments.dtservicehub + ... +``` + +依据:`dtx_service_provider.py:93-94` 的 `isinstance(service_provider, RemoteServiceDiscoveryService)` 分支 ++ `dvt_provider.py:34 RSD_SERVICE_NAME`。 + +--- + +## 4. DVT 虚拟定位的精确调用方式(最重要) + +### 4.1 类与方法签名 + +`services/dvt/instruments/location_simulation.py:7-47`: + +```python +class LocationSimulationService(DTXService): # :7 + IDENTIFIER = "com.apple.instruments.server.services.LocationSimulation" # :8 + + @dtx_method("simulateLocationWithLatitude:longitude:") # :10 + async def simulate_location_with_latitude_longitude_(self, latitude: float, longitude: float) -> None: ... + + @dtx_method("stopLocationSimulation", expects_reply=False) # :13 + async def stop_location_simulation(self) -> None: ... + + +class LocationSimulation(DtxService[LocationSimulationService], LocationSimulationBase): # :17 + def __init__(self, dvt: DtxServiceProvider): # :26 + super().__init__(dvt) + LocationSimulationBase.__init__(self) + + async def set(self, latitude: float, longitude: float) -> None: # :30 + await self.service.simulate_location_with_latitude_longitude_(latitude, longitude) + + async def clear(self) -> None: # :41 + await self.service.stop_location_simulation() +``` + +- 包路径:`pymobiledevice3.services.dvt.instruments.location_simulation.LocationSimulation` +- 类名**不是** `DvtLocationSimulator`(11.29.1 里没有这个名字),是 `LocationSimulation`。 +- 方法名是 `set(latitude, longitude)` / `clear()`,**不是** `simulate_location(lat, lon)` / `clear_location()`。 +- 构造参数是 **`DvtProvider`(`DtxServiceProvider`)**,不是 lockdown。 + +### 4.2 上下文管理器语义(决定我们怎么保持长连接) + +`dtx_service.py:21-170`: + +```python +class DtxService(Generic[_SVC_T]): + def __init__(self, provider: DtxServiceProvider, service: Optional[_SVC_T] = None) -> None: # :52 + self._provider = provider + self._service = service + async def connect(self) -> None: # :140-151 打开 DTX channel(幂等) + async def close(self) -> None: # :153-158 **no-op**(通道生命周期归 provider 管) + async def __aenter__(self) -> Self: # :160-162 await self.connect() + async def __aexit__(...): # :164-170 await self.close() +``` + +`dtx_service_provider.py:117-178`: + +```python +def __init__(self, lockdown, strip_ssl=None, dtx=None) # :117 (dtx 传入时 provider 不负责关它) +async def connect(self) -> None: # :149 打开传输 + DTX 握手(幂等) +async def close(self) -> None: # :167 await self._dtx.aclose() +async def __aenter__/__aexit__ # :173-178 +``` + +**结论(三条,全部有源码依据)**: + +1. `LocationSimulation` 与 `DvtProvider` **都是 async context manager**,但也可以手动 + `await provider.connect()` → `await location.connect()`,然后一直复用(`connect()` 幂等,:154/:149)。 +2. `LocationSimulation.close()` 是 **no-op**(`dtx_service.py:153-158`),真正的释放靠 + `DvtProvider.close()` 关闭 DTX 连接(`dtx_service_provider.py:167-171`)。 +3. **必须显式调用 `clear()`**,否则设备会一直停留在模拟位置;`__aexit__` 不会帮你 clear。 + +### 4.3 「连接断掉定位就失效」——CLI 取证 + +`cli/developer/dvt/simulate_location.py:36-38`: + +```python +async with DvtProvider(service_provider) as dvt, LocationSimulation(dvt) as location_simulation: + await location_simulation.set(latitude, longitude) + OSUTILS.wait_return() +``` + +`OSUTILS.wait_return()` 在 POSIX 上是阻塞等待信号(`osu/posix_util.py:129-131`): + +```python +def wait_return(self): + print("Press Ctrl+C to send a SIGINT or use 'kill' command to send a SIGTERM") + signal.sigwait([signal.SIGINT, signal.SIGTERM]) +``` + +即官方 CLI **set 之后一直挂在连接上直到 Ctrl-C**。这直接证明:**DVT 虚拟定位依赖长连接, +连接断开后模拟位置即失效**。→ 我们的 `RealLocationProvider` 必须长连接(见第 6 节实现)。 + +### 4.4 可直接复制的 10 行示例(iOS≥17) + +```python +import asyncio +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 + +async def main(host: str, port: int, lat: float, lon: float) -> None: + async with RemoteServiceDiscoveryService((host, port)) as rsd: # 隧道给的 RSD 地址 + async with DvtProvider(rsd) as dvt: # -> dtservicehub + async with LocationSimulation(dvt) as sim: # 打开 DTX channel + await sim.set(lat, lon) # 单点定位 + await asyncio.sleep(30) # 期间保持连接! + await sim.clear() # 恢复真实定位 + +asyncio.run(main("10.7.0.1", 51234, 39.908, 116.397)) +``` + +iOS<17(无隧道)只需把第一行换成 lockdown: + +```python +from pymobiledevice3.lockdown import create_using_usbmux +lockdown = await create_using_usbmux(serial=None) # 第一台 USB 设备 +# 之后 DvtProvider(lockdown) -> LocationSimulation 完全一样 +``` + +### 4.5 CLI 等价命令 + +| 命令 | 适用 | 源码 | +| --- | --- | --- | +| `pymobiledevice3 developer dvt simulate-location set -- LAT LON` | **iOS 17+**(跑在隧道上) | `cli/developer/dvt/simulate_location.py:26-38` | +| `pymobiledevice3 developer dvt simulate-location clear` | iOS 17+ | 同上 :18-23 | +| `pymobiledevice3 developer simulate-location set -- LAT LON` | **iOS<17**(老服务) | `cli/developer/simulate_location.py:28-35` | +| `pymobiledevice3 developer simulate-location clear` | iOS<17 | 同上 :21-25 | + +两者都在 `developer` 子命令树里(`cli/developer/__init__.py:64-71`:`cli.add_typer(dvt.cli)`、 +`cli.add_typer(simulate_location.cli)`)。注意 **`dvt` 是父命令名**, +`developer simulate-location` 与 `developer dvt simulate-location` 是两条不同的命令。 + +--- + +## 5. iOS 17+ 隧道方案(逐条 + 是否需要 root) + +### 5.1 结论速查表 + +| 方案 | 命令 | 需要 root? | 跨进程可用? | 源码依据 | +| --- | --- | --- | --- | --- | +| 经典内核隧道 | `sudo pymobiledevice3 remote start-tunnel --script-mode` | **是** | 是(打印 `HOST PORT`) | `cli/remote.py:326-429`,`is_admin` 检查在 :417-418 | +| lockdown 别名 | `sudo pymobiledevice3 lockdown start-tunnel --script-mode` | **是** | 是 | `cli/lockdown.py:211-222`(`@sudo_required`) | +| macOS 原生隧道 | `pymobiledevice3 remote start-tunnel --native --script-mode` | **否** | 是 | `cli/remote.py:297-323`、:367-376、:405-416 | +| 进程内 userspace 隧道 | `--userspace` / `UserspaceRsdTunnel` | **否** | **否**(仅同进程) | `userspace_tunnel.py:793-834`、`cli/cli_common.py:564-573` | +| tunneld 服务 | `sudo pymobiledevice3 remote tunneld` | **是**(`@sudo_required`) | 是(但客户端要按 tunneld 协议/dialer 接) | `cli/remote.py:93-125`、`tunneld/api.py:16` | + +### 5.2 逐条取证 + +**(a) `lockdown start-tunnel` —— 需要 root** + +`cli/lockdown.py:211-222`: + +```python +@cli.command("start-tunnel") +@sudo_required +@async_command +async def cli_start_tunnel(service_provider: ServiceProviderDep, script_mode: ... = False) -> None: + """start tunnel""" + await async_cli_start_tunnel(service_provider, script_mode) +``` + +`@sudo_required` 的实现(`cli/cli_common.py:165-173`): + +```python +def sudo_required(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not OSUTILS.is_admin: + raise AccessDeniedError() + else: + func(*args, **kwargs) + return wrapper +``` + +即非 root 直接抛 `AccessDeniedError`(`exceptions.py:546-549` "Need extra permissions to execute this command")。 +它内部用 `TunnelProtocol.TCP`(`cli/lockdown.py:207`)。 + +**(b) `remote start-tunnel` —— 经典路径需要管理员,`--native` 免 root** + +`cli/remote.py:326-429`,关键几段: + +```python +@cli.command("start-tunnel") +@async_command +async def cli_start_tunnel(...): + """start tunnel (classic root tunnel by default; --native piggybacks Apple's tunnel on macOS, no root)""" + ... + if native: + ... + await native_tunnel_task(udid, script_mode=script_mode) # :415 免 root 分支 + return + if not OSUTILS.is_admin: # :417-418 + raise AccessDeniedError() +``` + +`native_tunnel_task`(`cli/remote.py:297-323`)会打印: + +``` +Identifier: +RSD Address:
+RSD Port: +Use the follow connection option: +--rsd
+``` + +`--native` 的帮助文本(:367-376)明确写了 "no root, macOS only",并提示它和 remoted 会互相抢占 RSD 连接 +("Its RSD connection and remoted's evict each other, so it is opt-in")。实现见 +`remote/native_tunnel.py:950 async def establish_native_rsd(serial=None)`。 + +**(c) `remote tunneld` —— 需要 root,且默认 QUIC** + +`cli/remote.py:93-125`: + +```python +@cli.command("tunneld") +@sudo_required # :94 ← 需要 root +def cli_tunneld( + host: ... = TUNNELD_DEFAULT_ADDRESS[0], # :96 + port: ... = TUNNELD_DEFAULT_ADDRESS[1], # :97 + protocol: ... = TunnelProtocol.DEFAULT, # :116 Python 3.12 → QUIC + ... +``` + +- 默认监听地址:`tunneld/api.py:16` `TUNNELD_DEFAULT_ADDRESS = ("127.0.0.1", 49151)`。 +- `remote.py:108-116` 的 help:**"default: TCP on Python >=3.13, otherwise QUIC"** → 本机 3.12 默认 QUIC。 +- tunneld 的价值是「一次 root 起服务,之后多个非特权客户端复用」;但客户端要按 tunneld 的 + 传输协议接入(QUIC + 自定义 API),对我们的单进程应用来说复杂度明显高于 (a)/(b)。 + +**(d) 进程内 userspace 隧道 —— 完全免 root,但只在同进程可用** + +`remote/userspace_tunnel.py:793-834` 的类文档: + +``` +class UserspaceRsdTunnel: + """A no-root, in-process iOS 17+ RSD tunnel and its connected RSD, as one closeable handle. + Replaces the kernel utun (which needs root/admin) with a pure-Python PyTCP stack ... + Constraints: + * One tunnel per process. ... + * The device address is in-process only, reachable only from this process's userspace + stack — never by an external tool. The RSD reports this via + RemoteServiceDiscoveryService.is_in_process_tunnel ... +``` + +`remote_service_discovery.py:146-152`: + +```python +@property +def is_in_process_tunnel(self) -> bool: + """True when this RSD reaches the device through an in-process dialer (the userspace + tunnel) rather than a kernel-routable interface. The device address (self.service.address) + is then only reachable from THIS process ...""" + return self.open_connection is not None +``` + +便利函数:`userspace_tunnel.py:998-1000` +`async def establish_userspace_rsd(serial=None, autopair=True, remotepairing_fallback=True) -> RemoteServiceDiscoveryService`。 + +**(e) 上游的自动升级逻辑(可作为我们的语义参照)** + +`__main__.py:408-452`:当 iOS 17+ 设备上的 developer 命令因 `InvalidServiceError` / +`RSDRequiredError` 失败时,CLI 会**自动重试一次并建立默认隧道**,日志为: + +``` +Trying again over {the native tunnel|a no-root userspace tunnel|tunneld} since +it is a developer command on an iOS 17+ device +(override with PYMOBILEDEVICE3_DEFAULT_FALLBACK, or --tunnel/--userspace/--native) +``` + +默认优先级由 `cli/cli_common.py:433-454 default_transport_preference()` 决定:内置默认 +`"userspace"`(免 root),可用环境变量 `PYMOBILEDEVICE3_DEFAULT_FALLBACK` 覆盖为 `native`/`tunneld`。 + +### 5.3 无 root 情况下最可行的方案(明确推荐) + +**推荐 ①(跨进程,macOS,免 root)**: + +```bash +./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode +# 输出: → 交给 RealLocationProvider(rsd_host=..., rsd_port=...) +``` + +理由:无需 sudo;打印的地址是**内核可路由**的,任何进程都能用;正好匹配 +「core/tunnel.py 提供 rsd_host/rsd_port」的架构。 +代价:与系统的 `remoted` 抢占同一台设备的 RSD 连接,可能被顶掉(重新建隧道即可)。 + +**推荐 ②(有管理员权限时最稳)**: + +```bash +sudo ./.venv/bin/python -m pymobiledevice3 remote start-tunnel --script-mode +# 输出: +``` + +这是官方经典路径,不依赖 macOS 的 remoted,长时间运行最稳定;`run.sh` 提示用户输入密码即可。 + +**推荐 ③(单进程应用,完全免 root)**:进程内 userspace 隧道 +(`UserspaceRsdTunnel` / `establish_userspace_rsd`),但 RSD 地址**不能**跨进程传递; +如果要走这条路,必须把**已连接的 RSD 对象**直接注入 `RealLocationProvider(rsd=...)` +(我为这个场景预留了可选关键字参数,见第 6.3 节)。 + +**不推荐**:`remote tunneld`(要 root + Python 3.12 下默认 QUIC,客户端集成成本高); +`lockdown start-tunnel`(要 root,且只是经典隧道的别名)。 + +--- + +## 6. 我们的实现(core/location_provider.py / core/location_mock.py) + +### 6.1 与 lead 的接口约定 + +- 只使用 lead 提供的 `DeviceManager.list_devices()`(Device dict 字段见 PROTOCOL.md 2.1)与 + `DeviceManager.get_selected()`;`connect(device)` 用容错读取(dict 的 `.get()`),字段缺失不会崩。 +- 我**不改** `core/device.py`、`core/tunnel.py`;需要的额外能力通过构造参数注入。 +- RSD 地址由 lead 的 `core/tunnel.py` 提供。 + +### 6.2 冻结签名与返回结构 + +```python +class RealLocationProvider: + def __init__(self, rsd_host=None, rsd_port=None, udid=None, *, rsd=None, lockdown=None, + connect_timeout=15.0, on_log=None, log=None) # log= 是 on_log 的别名 + async def connect(self, device) -> dict # 建立长连接会话 + def connect_sync(self, runner, device=None) -> dict # 同步版(提交给调用方 runner) + async def set_location(self, lat, lon) -> None + async def clear_location(self) -> None + async def close(self) -> None + @property connected -> bool + async def probe(self, device=None) -> dict # {"ok","detail","ios_major",...};device 可选 +``` + +`MockLocationProvider` 同构:`MockLocationProvider(device=None, on_log=None, latency=None, log=None)` ++ `connect_sync(runner, device=None)`。 + +> **`log=` 与 `connect_sync()` 是按 lead 的 `core/server.py` 真实调用点加的兼容别名** +> (`core/server.py:147` `MockLocationProvider(log=self.log)`、`:148` `provider.connect_sync(self.runner)`、 +> `:167-172` `RealLocationProvider(..., log=self.log)`)。冻结的五个方法签名一个都没改,只是**新增**。 +> `connect_sync` 只调用传入 runner 的 `run(coro)`,**不新建事件循环/线程**(协程仍在 runner 自己的循环里跑)。 + +`connect()` 返回(mock 与真机同构,方便 lead 组装 `/api/devices/connect`): + +```json +{"ok": true, "mock": false, "udid": "...", "ios_version": "26.0", "ios_major": 26, + "connection": "usb", "tunnel": "connected", "strategy": "dvt-rsd", + "rsd": "10.7.0.1:51234", "pymobiledevice3": "11.29.1", "detail": "中文说明"} +``` + +### 6.3 三条策略与降级顺序 + +| strategy | 触发条件 | 实现 | +| --- | --- | --- | +| `dvt-rsd` | 注入了 `rsd=` 对象,或给了 `rsd_host`+`rsd_port` | `RemoteServiceDiscoveryService((host,port))` → `DvtProvider(rsd)` → `LocationSimulation` | +| `dvt-lockdown` | 无 RSD 且 iOS<17 | `create_using_usbmux(serial=udid)` → `DvtProvider(lockdown)` → `LocationSimulation` | +| `legacy` | `dvt-lockdown` 失败 | `DtSimulateLocation`(`com.apple.dt.simulatelocation`,**会真开一次服务校验可用性**) | + +- **长连接**:`connect()` 只建一次;`set_location()` 复用同一 DTX channel(`await self._location.set(...)`), + 不重连。依据见 4.3(连接断开定位即失效)。 +- **同一事件循环**:全部原生 async/await,无线程、无 `asyncio.run`。 +- **中文异常**:所有失败包成 `LocationProviderError`,文案含修复建议;iOS≥17 无隧道时直接给出 + 「请以管理员权限运行 run.sh」+「--native 免 root 隧道」两条路。 +- **不崩的降级**:未安装 pymobiledevice3 时 `import core.location_provider` 仍然成功, + `probe()` 返回 `ok=False` + 中文安装指引,而不是 `ModuleNotFoundError`。 +- **不假成功**:`legacy` 策略在 `connect()` 阶段就真开一次 + `start_lockdown_developer_service()` 校验(源码依据:`services/simulate_location.py:19-22` + 的构造不做设备 I/O),真机实测踩到过这个坑,详见 8.1。 + +### 6.4 `probe()` 返回 + +```json +{"ok": false, "detail": "iOS 17 及以上必须先用 RSD 隧道…", "ios_major": 26, "strategy": null} +``` + +- `probe()` **永不抛异常**,供前端「测试连接」按钮直接展示 `detail`。 +- 可选参数 `device`(与 `connect()` 同一个 Device dict):传入后先做静态判断 + (未配对 / 未开开发者模式 / iOS 版本),能省掉一次设备往返并回填 `ios_major`。 +- 无隧道 + iOS<17 时会**真的去连一次**(点「测试连接」就应该真的测); + 无隧道 + iOS17+ 时只报隧道状态,不做无意义重连。 + +### 6.5 与 lead 的 `core/server.py` 真实联调(`--mock` 模式端到端) + +我用 lead 的入口在 **8791** 端口起了一次 mock 服务(不干扰他 8799 上的实例),实测: + +```console +$ ./.venv/bin/python main.py --mock --port 8791 --no-browser --window none + +$ curl -s http://127.0.0.1:8791/api/health +{"ok":true,"version":"1.0.0","name":"iOS 虚拟定位轨迹工具","mock":true,"device_count":1, + "backend":"mock","pymobiledevice3":true,"uptime":2.31,"ws_clients":0} + +$ curl -s -X POST http://127.0.0.1:8791/api/devices/connect -d '{}' +{"ok":true,"device":{...},"tunnel":{...}, + "probe":{"ok":true,"detail":"模拟模式:DVT 虚拟定位可用(iOS 26,无需隧道)","ios_major":26}} + +$ curl -s -X POST http://127.0.0.1:8791/api/location -d '{"lat":39.908,"lng":116.397}' +{"ok":true} + +$ curl -s "http://127.0.0.1:8791/api/logs?limit=20" +… {"level":"success","msg":"[模拟模式] 已连接模拟设备 (iPhone 17 Pro)(udid=MOCK-0001,iOS 26.0)"} + {"level":"info", "msg":"[模拟模式] 模拟模式:定位下发仅记录日志,不会真正修改设备位置"} + {"level":"info", "msg":"[模拟模式] 下发定位 lat=39.908000, lon=116.397000(第 1 次,模拟)"} + {"level":"success","msg":"已把设备定位到 39.908000, 116.397000"} + {"level":"info", "msg":"[模拟模式] 下发定位 lat=31.230400, lon=121.473700(第 2 次,模拟)"} +``` + +验证到: + +1. `MockLocationProvider(log=self.log)` + `connect_sync(self.runner)` 在 lead 的真实装配下工作正常; +2. `probe` 的中文 detail 直接出现在 `/api/devices/connect` 响应里(前端「测试连接」可直接用); +3. `set_location(lat, lon)` 的**参数顺序正确**(日志里 lat=39.908 / lon=116.397 没有互换); +4. provider 的日志经 `log=` 回调进入 lead 的日志面板(`/api/logs` 可见)。 + +> 测试用的 8791 服务已关闭(`lsof` 确认端口释放),lead 的 8799 实例未受影响。 + +--- + +## 7. 自测命令与真实输出 + +以下命令均为 device-dev 在本机实际执行过、并复制真实输出的结果。 + +### 7.1 安装结果 + +```console +$ ./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ \ + --timeout 30 --retries 2 pymobiledevice3 +... +Successfully installed ASGIMiddlewareStaticFile-0.7.0 IPython-9.17.1 Pillow-12.3.0 aiofiles-25.1.0 +... pymobiledevice3-11.29.1 pmd-pytcp-0.3.7 qh3-1.9.4 pytun-pmd3-3.0.3 cryptography-50.0.2 +... sslpsk-pmd3-1.0.3 typer-0.27.3 typer-injector-0.3.0 ... +[exit 0] + +$ ./.venv/bin/python -m pip show pymobiledevice3 +Name: pymobiledevice3 +Version: 11.29.1 +Location: /Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages +``` + +`site-packages` 里的关键源码与用于取证的 wheel **逐字节一致**(`diff -q` 全部 SAME): + +```console +SAME services/dvt/instruments/location_simulation.py +SAME services/dvt/instruments/dvt_provider.py +SAME dtx_service_provider.py +SAME dtx_service.py +SAME remote/remote_service_discovery.py +SAME services/simulate_location.py +``` + +### 7.2 语法 / import 自检 + +```console +$ ./.venv/bin/python -m compileall core/location_provider.py core/location_mock.py tools/*.py +COMPILEALL OK + +$ ./.venv/bin/python -c "import core.location_provider, core.location_mock; print('IMPORT OK')" +IMPORT OK +``` + +> 这两个模块在 **pymobiledevice3 尚未安装时**也照样 import 成功(懒加载 + 中文错误), +> 已在安装前实测过一次。 + +### 7.3 离线自测(无设备也全绿) + +```console +$ ./.venv/bin/python tools/selftest_location.py +==================================================================== +虚拟定位后端离线自测(无需 iOS 设备) +Python 3.12.14 | cwd=/Users/jbjf/Desktop/location +==================================================================== + +== 1. MockLocationProvider == + [通过] 初始 connected=False + [通过] connect 返回 dict 且 ok=True —— 模拟设备已连接(iOS 26.0,无需隧道) + [通过] connect 后 connected=True + [通过] connect 返回中文说明 + [通过] set_location 延时 5~10ms —— 实测 9.90ms + [通过] send_count 递增 + [通过] last_location 记录正确 + [通过] clear_location 后 last_location=None + [通过] close 后 connected=False + [通过] close 幂等 + [通过] mock probe ok=True —— {'ok': True, 'detail': '模拟模式:DVT 虚拟定位可用(iOS 26,无需隧道)', 'ios_major': 26} + +== 2. RealLocationProvider 降级逻辑 == + pymobiledevice3 已安装:True(版本 11.29.1) + [通过] 初始 connected=False + [通过] 未连接下发抛 LocationProviderError —— 尚未连接设备,无法下发定位:请先调用 connect()(或点击界面上的「连接设备」) + [通过] 未连接 clear_location 为 no-op + [通过] 未配对设备报中文错误 —— 设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试… + [通过] 未开开发者模式报中文错误 —— 设备未开启「开发者模式」:请在 iPhone 上打开 设置 → 隐私与安全性 → 开发者模式… + [通过] iOS17+ 无隧道提示建隧道 —— 设备为 iOS 26.0,需要先建立 RSD 隧道才能虚拟定位。iOS 17 及以上必须先用 RSD 隧道… + [通过] 越界纬度报中文错误 —— 经纬度超出合法范围:lat=123.0, lon=116.0 + [通过] probe 返回 ok/detail/ios_major + [通过] probe detail 为中文 + [通过] probe(隧道不可达) 不抛异常且 ok=False —— 建立 RSD/DVT 会话失败:无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立 + [通过] 文案为中文 + [通过] probe(device) 未开开发者模式 → ok=False 且中文 + [通过] probe(device) 回填 ios_major=26 + [通过] probe(device) iOS17+ 无隧道 → 提示建隧道 + [通过] close 幂等且 connected=False + [通过] 不可达隧道报中文错误 —— 建立 RSD/DVT 会话失败:无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立 + +== 3. 与 lead 的 core/server.py 集成 == + [通过] MockLocationProvider(log=...) 构造成功 + [通过] connect_sync(runner) 建立会话 + [通过] log= 回调收到日志 —— 2 条 + [通过] set_location(lat, lon) 经 runner 调用成功 + [通过] close 后 connected=False + [通过] RealLocationProvider(log=...) 构造成功 + [通过] probe(device) 经 runner 调用不抛异常 + [通过] connect_sync 无隧道时抛中文异常 —— 设备为 iOS 26,需要先建立 RSD 隧道才能虚拟定位。iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位… + +==================================================================== +通过 36 项,失败 0 项 +全部通过 ✅ +``` + +> 说明:上表是**最终一轮**的输出(36 项,含第 3 节照抄 `core/server.py` 用法的 8 条集成断言); +> 早期未加这些断言时是 25 项,同样全绿。 +> 第 3 节用的是 `core/server.py` 里 `AsyncRunner` 的同构替身(后台线程 + 独立事件循环), +> 因此也验证了「跨线程提交协程」这一真实运行模型下我的 provider 不会自己新建事件循环。 + +(`tools/selftest_location.py` 里断言了:错误信息必须含中文、必须是 `LocationProviderError` 而不是 +`AttributeError`/`ModuleNotFoundError`。) + +--- + +## 8. 真机事实(**本机实际接了两台 iPhone**) + +> 任务书写的是「本机没有连接 iOS 设备」,但实测**有**。这条对验收影响很大,特此记录。 + +```console +$ ./.venv/bin/python -m pymobiledevice3 usbmux list +[ + {"BuildVersion": "20F66", "ConnectionType": "Network", "DeviceName": "iPhone 17 Pro Max", + "Identifier": "00008120-0011713A1444C01E", "ProductType": "iPhone15,3", "ProductVersion": "16.5"}, + {"BuildVersion": "23G83", "ConnectionType": "USB", "DeviceName": "iPhone", + "Identifier": "00008030-001551840A02402E", "ProductType": "iPhone12,1", "ProductVersion": "26.6.1"} +] +``` + +| UDID | 型号 | iOS | 连接 | 开发者模式 | 已挂载开发者镜像 | +| --- | --- | --- | --- | --- | --- | +| `00008120-0011713A1444C01E` | iPhone 17 Pro Max (iPhone15,3) | 16.5 | Network | ✅ 已开启 | ❌ `mounter list` → `[]` | +| `00008030-001551840A02402E` | iPhone (iPhone12,1) | **26.6.1** | USB | ❌ 未开启 | ❌ `mounter list` → `[]` | + +`pymobiledevice3 doctor` 的真实结论(本机能力): + +```console +Host — this works: + Device plugged in iPhone (00008030-001551840A02402E), and usbmux lists it + usbmux daemon reachable, 2 device(s): …C01E over Network, …402E over USB + Service access opened a lockdown connection to 00008030-001551840A02402E + Tunnel (native) remotepairingd answered, 1 device(s) ← 免 root 原生隧道在本机可用 + Tunnel (userspace) pmd-pytcp 0.3.7 ← 免 root 进程内隧道也可用 + +Host — not available here: + Tunnel (tunneld) not running on 127.0.0.1:49151 + +Device — worth knowing: + Developer mode disabled + Developer image no Personalized image is mounted + so: `developer` commands will fail even with a working tunnel + fix: pymobiledevice3 mounter auto-mount +``` + +**结论:真机端到端现在无法跑通,原因不在代码**——两台设备都没有挂载开发者镜像,且 iOS 26.6.1 那台 +没开开发者模式。要让真机路径生效,需要用户手动做两步(都会改动设备,device-dev 未擅自执行): + +1. iOS 26 那台开启「开发者模式」(`pymobiledevice3 amfi enable-developer-mode`,设备会重启并需解锁); +2. `pymobiledevice3 mounter auto-mount` 挂载开发者镜像。 + +### 8.1 真机实测:iOS 16.5 设备(开发者模式开、无镜像、Network) + +```console +$ ./.venv/bin/python tools/probe_device.py --udid 00008120-0011713A1444C01E --probe +pymobiledevice3 版本:11.29.1 +Python:3.12.14 + +[真机] 建立 DVT 会话(iOS<17 直连)失败:设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像, + 请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount;iOS 17+ 则需要 RSD 隧道… + 改用老版 simulatelocation 服务重试 +=== 设备 1 === + UDID : 00008120-0011713A1444C01E + 名称 : iPhone 17 Pro Max + 型号 : iPhone15,3 + iOS 版本 : 16.5 (major=16) + 连接方式 : network + 配对状态 : connected + 开发者模式 : 已开启 + DVT 可用性 : 不可用 —— iOS 16 < 17:可经 usbmux/lockdown 直连 DVT(或老版 simulatelocation 服务) + 实测连接 : 失败 —— 建立老版虚拟定位会话(com.apple.dt.simulatelocation)失败:设备未提供所需的开发者服务… +``` + +真机验证到的事实: + +1. `create_using_usbmux(serial=...)` 对 **Network(Wi-Fi)** 连接的设备同样有效; +2. 无开发者镜像时,`DvtProvider(lockdown).connect()` 真的抛 `InvalidServiceError`(`str(e)` 就是 + `"InvalidService"`,不带 "Error" 后缀); +3. **实测发现并修掉一个真 bug**:`DtSimulateLocation.__init__` 不做任何设备 I/O + (`services/simulate_location.py:19-22`),所以「构造成功」不等于「服务可用」。 + 最初版本因此出现「connect 报成功 → 第一次 set 才失败」的假成功;现在 + `_connect_lockdown_path()` 会真的 `await lockdown.start_lockdown_developer_service(...)` 校验一次 + (见 `core/location_provider.py` 的 `legacy` 分支),上面的输出就是修复后的**诚实失败**。 + +### 8.2 iOS 26.6.1 设备(USB、开发者模式未开) + +> **⚠️ 本节是 20:47 的中间状态,已被后续进展取代(保留作为取证过程)。** +> 21:00 之后实测的最终事实: +> - 用户在手机上**开启了开发者模式**并重启,`amfi developer-mode-status` 返回 `true`; +> - 开发者镜像已挂载:`mounter list` 显示 `Personalized DeveloperDiskImage 642.16`、 +> `MountPath=/System/Developer`、`ProductBuildVersion=27A266a` +> (由 Xcode 的 `CoreDeviceService` 用本机素材自动挂载); +> - **虚拟定位链路已端到端跑通**:免 root 的 `remote start-tunnel --native --script-mode` +> 建立 RSD 隧道 → 打开 DVT `dtservicehub` → `LocationSimulation` 长连接 → +> 单点定位与轨迹播放均成功(`tests/real_gui_acceptance.py` 11/11、 +> `tests/real_device_e2e.py` 通过),停止后恢复真实定位; +> - 素材下载会被 **SNI 级阻断**(raw.githubusercontent.com 的 4 个 IP 只有 +> `185.199.111.133` 可用),离线抓取方案见 `tools/ddi_offline_fetch.sh`。 + +```console +$ ./.venv/bin/python tools/probe_device.py --udid 00008030-001551840A02402E --probe +=== 设备 1 === + UDID : 00008030-001551840A02402E + 名称 : iPhone + 型号 : iPhone12,1 + iOS 版本 : 26.6.1 (major=26) + 连接方式 : usb + 配对状态 : connected + 开发者模式 : 未开启 + DVT 可用性 : 不可用 —— 未开启开发者模式:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启) +``` + +`--probe` 直接在**静态判断**阶段就拦下(开发者模式未开),不会再浪费一次设备往返。 + +--- + +## 9. CLI `--help` 实测输出(真实终端输出,逐字粘贴) + +### 9.1 `pymobiledevice3 --help`(命令树) + +``` + Usage: python -m pymobiledevice3 [OPTIONS] COMMAND [ARGS]... + + Swiss-army CLI for pairing, inspecting, backing up, and automating iOS devices. + +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ developer Developer tooling for iOS devices (requires Developer Mode │ +│ + mounted DeveloperDiskImage). │ +│ lockdown Pair/Unpair device or access other lockdown services │ +│ remote Create and browse RemoteXPC tunnels (RSD/tunneld) for │ +│ developer services. │ +│ usbmux Inspect usbmuxd-connected devices and forward TCP ports to │ +│ them. │ +│ mounter Mount/Umount DeveloperDiskImage or query related info │ +│ version Query pymobiledevice3 version │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 9.2 `developer dvt simulate-location --help`(iOS 17+,DVT 路径) + +```console +$ ./.venv/bin/python -m pymobiledevice3 developer dvt simulate-location --help + Usage: python -m pymobiledevice3 developer dvt simulate-location [OPTIONS] COMMAND [ARGS]... + + Simulate device location through DVT on iOS 17 and newer. + +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ clear Clear the currently simulated location on iOS 17 and newer. │ +│ set Set a simulated location through DVT on iOS 17 and newer. │ +│ play Replay a GPX route through DVT on iOS 17 and newer. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 9.3 `developer simulate-location --help`(iOS<17,老服务路径) + +```console +$ ./.venv/bin/python -m pymobiledevice3 developer simulate-location --help + Usage: python -m pymobiledevice3 developer simulate-location [OPTIONS] COMMAND [ARGS]... + + Simulate GPS location on iOS versions below 17 (set, clear, or replay GPX routes). + +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ clear Stop location simulation and resume real GPS on iOS versions below 17.│ +│ set Set a fixed simulated location on iOS versions below 17 (latitude, │ +│ longitude). │ +│ Example: `set 40.690008 -74.045843` (Liberty Island). │ +│ play Replay a GPX route on iOS versions below 17; optionally disable │ +│ sleeps and add timing jitter. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +> 两条命令的 help 文本("iOS 17 and newer" / "iOS versions below 17")与源码里的 +> `cli/developer/dvt/simulate_location.py:13` 与 `cli/developer/simulate_location.py:16` 完全对应, +> 这也从 CLI 侧证明了「iOS<17 与 iOS≥17 是两条不同的服务」。 + +### 9.4 `remote start-tunnel --help`(注意 `--native` = 免 root) + +```console +$ ./.venv/bin/python -m pymobiledevice3 remote start-tunnel --help + Usage: python -m pymobiledevice3 remote start-tunnel [OPTIONS] + + start tunnel (classic root tunnel by default; --native piggybacks Apple's + tunnel on macOS, no root) + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --script-mode --no-script-mode Print only HOST and port for scripts │ +│ instead of formatted output. │ +│ [default: no-script-mode] │ +│ --protocol -p Transport protocol for the tunnel. │ +│ [default: (TCP on Python >=3.13, │ +│ otherwise QUIC)] │ +│ --native --no-native Piggyback Apple's remoted tunnel via │ +│ remotepairingd and publish its RSD │ +│ address instead of creating a new tunnel; │ +│ no root, macOS only. Its RSD connection │ +│ and remoted's evict each other, so it is │ +│ opt-in … │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 9.5 `remote tunneld --help`(默认 QUIC,且需要 root) + +```console +$ ./.venv/bin/python -m pymobiledevice3 remote tunneld --help + Usage: python -m pymobiledevice3 remote tunneld [OPTIONS] + + Start Tunneld service for remote tunneling + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --host Address to bind the tunneld server to.│ +│ [default: 127.0.0.1] │ +│ --port Port to bind the tunneld server to. │ +│ [default: 49151] │ +│ --protocol -p Transport protocol for tunneld │ +│ (default: TCP on Python >=3.13, │ +│ otherwise QUIC). │ +│ [default: quic] ← 3.12 下默认 │ +│ --usb/--no-usb, --wifi/--no-wifi, --usbmux/--no-usbmux, --mobdev2/… │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 9.6 `lockdown start-tunnel --help` + +```console +$ ./.venv/bin/python -m pymobiledevice3 lockdown start-tunnel --help + Usage: python -m pymobiledevice3 lockdown start-tunnel [OPTIONS] + + start tunnel + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --script-mode --no-script-mode Show only HOST and port number to allow │ +│ easy parsing from external shell scripts │ +│ --help -h Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +(它只有 `--script-mode`——因为 RSD 主机/端口由底层内核隧道直接决定;实现见 +`cli/lockdown.py:193-222`,内部以 `TunnelProtocol.TCP` 调 `start_tunnel_task`。) + +### 9.7 `remote browse --help` + +```console +$ ./.venv/bin/python -m pymobiledevice3 remote browse --help + Usage: python -m pymobiledevice3 remote browse [OPTIONS] + + browse RemoteXPC devices (remotepairingd on macOS by default, bonjour elsewhere) + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --timeout Browse timeout (in seconds) [default: 3] │ +│ --native --no-native Browse via Apple's remotepairingd (the remoted │ +│ tunnel daemon) instead of bonjour; no root, │ +│ macOS only … │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 9.8 无 root 实测:三条命令一律拒绝 + +```console +$ ./.venv/bin/python -m pymobiledevice3 remote tunneld +2026-10-14 20:33:29 … __main__[83672] ERROR This command requires root privileges. Consider retrying with "sudo". +[exit 1] + +$ ./.venv/bin/python -m pymobiledevice3 remote start-tunnel +2026-10-14 20:33:29 … __main__[83689] ERROR This command requires root privileges. Consider retrying with "sudo". +[exit 1] + +$ ./.venv/bin/python -m pymobiledevice3 lockdown start-tunnel +2026-10-14 20:33:33 … __main__[83717] ERROR This command requires root privileges. Consider retrying with "sudo". +[exit 1] +``` + +→ 与 5.3 的结论互相印证:**免 root 的跨进程隧道只有 `remote start-tunnel --native --script-mode`** +(本机 `doctor` 也确认 `Tunnel (native) remotepairingd answered, 1 device(s)`)。 + + +--- + +## 10. 交付物清单与给 lead 的接线说明 + +### 10.1 我交付的文件(写作用域内,未碰任何别人的文件) + +| 文件 | 内容 | +| --- | --- | +| `core/location_provider.py` | `RealLocationProvider`(`dvt-rsd` / `dvt-lockdown` / `legacy` 三策略,长连接,中文异常,`probe()`) | +| `core/location_mock.py` | `MockLocationProvider`(`--mock` 模式,接口与真机同构,5~10ms 模拟往返) | +| `tools/probe_device.py` | 列出设备 + iOS 版本 + 开发者模式 + DVT 可用性;`--probe --rsd HOST PORT` 实测建会话 | +| `tools/probe_location.py` | 命令行单点定位:`--lat --lon [--clear] [--rsd HOST PORT] [--duration N]` | +| `tools/selftest_location.py` | 离线自测(无设备,36 项断言,含与 `core/server.py` 的集成断言) | +| `docs/DEVICE_NOTES.md` | 本文件(全部取证) | + +### 10.2 lead 侧的接线方式 + +```python +from core.location_provider import RealLocationProvider, LocationProviderError +from core.location_mock import MockLocationProvider + +# 真机(RSD 地址来自 core/tunnel.py) +provider = RealLocationProvider(rsd_host=host, rsd_port=port, udid=device["udid"], on_log=push_log) +info = await provider.connect(device) # {"ok":..., "strategy":"dvt-rsd", "detail": 中文} +await provider.set_location(lat, lon) # 注意签名顺序是 (lat, lon) +await provider.clear_location() # play.stop / location.clear 必须调用 +await provider.close() + +# mock 模式 +provider = MockLocationProvider(on_log=push_log) +``` + +- `connect()` 返回的 dict 里 `tunnel` 字段可直接映射到 `Device.tunnel`; + `info["detail"]` 适合直接作为 `/api/devices/connect` 的提示文案。 +- 失败一律是 `LocationProviderError`,`str(e)` 就是给用户看的中文(可直接转成 + `{"ok":false,"error":str(e)}`)。`probe()` 则永不抛异常。 +- iOS 17+ 且没有隧道时抛:「设备为 iOS X,需要先建立 RSD 隧道才能虚拟定位。…sudo ./run.sh…」, + 可直接用于 PROTOCOL.md 里的 `tunnel_hint`。 +- **隧道首选**:`./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode` + (macOS 免 root,输出 `HOST PORT`;本机 `doctor` 已验证 remotepairingd 可用)。 + 有管理员权限时用 `sudo … remote start-tunnel --script-mode` 更稳(不与系统 remoted 抢连接)。 +- 可选扩展(不破坏冻结签名):`RealLocationProvider(…, rsd=<已连接的 RemoteServiceDiscoveryService>)` + 用于把**同进程**的免 root userspace 隧道(`UserspaceRsdTunnel`)直接注入; + 注意它的地址跨进程不可达,不能靠子进程打印 HOST/PORT 传递。 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..4d0e79f --- /dev/null +++ b/docs/PROTOCOL.md @@ -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": ""}`,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":}` | +| POST | `/api/devices/connect` | 建立隧道/DVT 会话 → `{"ok":true,"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":,"engine":"osrm"\|"straight"}` | +| POST | `/api/gpx/export` | body `{"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": "", "id": "<可选请求id>", ...}` +- 服务端 → 客户端:`{"type": "", "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":,"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":[...],"selected":""}` | 设备列表变化 | +| `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 | + +任何人不得直接改别人拥有的文件;需要改动就发消息商量。 diff --git a/docs/VERIFY_REPORT.md b/docs/VERIFY_REPORT.md new file mode 100644 index 0000000..0901c9e --- /dev/null +++ b/docs/VERIFY_REPORT.md @@ -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 告警 | diff --git a/docs/WINDOWS_PORT.md b/docs/WINDOWS_PORT.md new file mode 100644 index 0000000..6538039 --- /dev/null +++ b/docs/WINDOWS_PORT.md @@ -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/
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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..29e08db --- /dev/null +++ b/main.py @@ -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()) diff --git a/pip.conf b/pip.conf new file mode 100644 index 0000000..ccdc334 --- /dev/null +++ b/pip.conf @@ -0,0 +1,3 @@ +[global] +index-url = https://mirrors.aliyun.com/pypi/simple/ +timeout = 30 diff --git a/resources/AppIcon-preview.png b/resources/AppIcon-preview.png new file mode 100644 index 0000000..f76a235 Binary files /dev/null and b/resources/AppIcon-preview.png differ diff --git a/resources/AppIcon.icns b/resources/AppIcon.icns new file mode 100644 index 0000000..7e08a0b Binary files /dev/null and b/resources/AppIcon.icns differ diff --git a/resources/AppIcon.iconset/icon_128x128.png b/resources/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..a23bce8 Binary files /dev/null and b/resources/AppIcon.iconset/icon_128x128.png differ diff --git a/resources/AppIcon.iconset/icon_128x128@2x.png b/resources/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..a60991a Binary files /dev/null and b/resources/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/resources/AppIcon.iconset/icon_16x16.png b/resources/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..bf8c414 Binary files /dev/null and b/resources/AppIcon.iconset/icon_16x16.png differ diff --git a/resources/AppIcon.iconset/icon_16x16@2x.png b/resources/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..b2ffd4e Binary files /dev/null and b/resources/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/resources/AppIcon.iconset/icon_256x256.png b/resources/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..a60991a Binary files /dev/null and b/resources/AppIcon.iconset/icon_256x256.png differ diff --git a/resources/AppIcon.iconset/icon_256x256@2x.png b/resources/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..f76a235 Binary files /dev/null and b/resources/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/resources/AppIcon.iconset/icon_32x32.png b/resources/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..b2ffd4e Binary files /dev/null and b/resources/AppIcon.iconset/icon_32x32.png differ diff --git a/resources/AppIcon.iconset/icon_32x32@2x.png b/resources/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..2156515 Binary files /dev/null and b/resources/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/resources/AppIcon.iconset/icon_512x512.png b/resources/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..f76a235 Binary files /dev/null and b/resources/AppIcon.iconset/icon_512x512.png differ diff --git a/resources/AppIcon.iconset/icon_512x512@2x.png b/resources/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..79c12a8 Binary files /dev/null and b/resources/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/resources/ddi/personalized/BuildManifest.plist b/resources/ddi/personalized/BuildManifest.plist new file mode 100644 index 0000000..e68b327 Binary files /dev/null and b/resources/ddi/personalized/BuildManifest.plist differ diff --git a/resources/ddi/personalized/Image.dmg.trustcache b/resources/ddi/personalized/Image.dmg.trustcache new file mode 100644 index 0000000..dcec241 Binary files /dev/null and b/resources/ddi/personalized/Image.dmg.trustcache differ diff --git a/resources/ddi/personalized/Image.trustcache b/resources/ddi/personalized/Image.trustcache new file mode 100644 index 0000000..dcec241 Binary files /dev/null and b/resources/ddi/personalized/Image.trustcache differ diff --git a/scripts/build_app.sh b/scripts/build_app.sh new file mode 100644 index 0000000..4a29137 --- /dev/null +++ b/scripts/build_app.sh @@ -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 "================================================" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..f171b81 --- /dev/null +++ b/scripts/run.sh @@ -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" "$@" diff --git a/scripts/test-location.sh b/scripts/test-location.sh new file mode 100644 index 0000000..0215cac --- /dev/null +++ b/scripts/test-location.sh @@ -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" diff --git a/tests/drift_sim.py b/tests/drift_sim.py new file mode 100644 index 0000000..4577d9f --- /dev/null +++ b/tests/drift_sim.py @@ -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()) diff --git a/tests/offline_browser.py b/tests/offline_browser.py new file mode 100644 index 0000000..5b41790 --- /dev/null +++ b/tests/offline_browser.py @@ -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>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()) diff --git a/tests/offline_tiles.py b/tests/offline_tiles.py new file mode 100644 index 0000000..6cb3cf0 --- /dev/null +++ b/tests/offline_tiles.py @@ -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()) diff --git a/tests/real_device_e2e.py b/tests/real_device_e2e.py new file mode 100644 index 0000000..0300322 --- /dev/null +++ b/tests/real_device_e2e.py @@ -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()) diff --git a/tests/real_gui_acceptance.py b/tests/real_gui_acceptance.py new file mode 100644 index 0000000..75e2935 --- /dev/null +++ b/tests/real_gui_acceptance.py @@ -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()) diff --git a/tests/selftest.py b/tests/selftest.py new file mode 100644 index 0000000..d0a5eb3 --- /dev/null +++ b/tests/selftest.py @@ -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", " 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 / 返回前端页面", " 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", "= 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()) diff --git a/tests/ui_acceptance.py b/tests/ui_acceptance.py new file mode 100644 index 0000000..a7818bc --- /dev/null +++ b/tests/ui_acceptance.py @@ -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()) diff --git a/tools/ddi_offline_fetch.sh b/tools/ddi_offline_fetch.sh new file mode 100644 index 0000000..328c304 --- /dev/null +++ b/tools/ddi_offline_fetch.sh @@ -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 " +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 " diff --git a/tools/diagnose_run.py b/tools/diagnose_run.py new file mode 100644 index 0000000..4a58f58 --- /dev/null +++ b/tools/diagnose_run.py @@ -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()) diff --git a/tools/fetch_ddi.py b/tools/fetch_ddi.py new file mode 100644 index 0000000..84f9e1c --- /dev/null +++ b/tools/fetch_ddi.py @@ -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()) diff --git a/tools/make_dmg.py b/tools/make_dmg.py new file mode 100644 index 0000000..cef9ed7 --- /dev/null +++ b/tools/make_dmg.py @@ -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()) diff --git a/tools/make_icon.py b/tools/make_icon.py new file mode 100644 index 0000000..67d8eb7 --- /dev/null +++ b/tools/make_icon.py @@ -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()) diff --git a/tools/probe_device.py b/tools/probe_device.py new file mode 100644 index 0000000..529daff --- /dev/null +++ b/tools/probe_device.py @@ -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()) diff --git a/tools/probe_location.py b/tools/probe_location.py new file mode 100644 index 0000000..80fb099 --- /dev/null +++ b/tools/probe_location.py @@ -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()) diff --git a/tools/screenshot.py b/tools/screenshot.py new file mode 100644 index 0000000..eb82faa --- /dev/null +++ b/tools/screenshot.py @@ -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()) diff --git a/tools/selftest_location.py b/tools/selftest_location.py new file mode 100644 index 0000000..4b22e7d --- /dev/null +++ b/tools/selftest_location.py @@ -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()) diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py new file mode 100644 index 0000000..d5b2838 --- /dev/null +++ b/tools/verify_bundle.py @@ -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 / 返回前端页面", " 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()) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8398fcc --- /dev/null +++ b/web/index.html @@ -0,0 +1,334 @@ + + + + + + +iOS 虚拟定位 · 轨迹工具 + + + + +
+ + +
+
+ +
+

iOS 虚拟定位 · 轨迹工具

+

本地服务未连接

+
+
+ +
+
正在读取设备信息…
+
+ +
+ 服务端 -- + 未连接 + + + +
+
+ + + + + +
+ + + + + +
+
+ + + + + + + + 拖拽平移 · 滚轮缩放 +
+ +
+ 未播放 + 进度 0% + 速度 -- + -- + 轨迹点 0 +
+ +
+
+ + + + +
+ + +
+
+ + + + +
+ +
+
+ 未播放 + 0.0% +
+ +
+ 已用 00:00 + 剩余 00:00 + 序号 0 / 0 +
+
+ +
+
实时速度--
+
当前坐标--
+
倍速1.0x
+
+
+
+ + + + +
+ + + + + + + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..b5f0e6d --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,2385 @@ +/*! + * app.js — 状态机与全部交互(严格按 docs/PROTOCOL.md) + * + * 内容: + * - 设备卡片 / 连接 / 断开 / 隧道状态 / 开发者模式提示 + * - 模式、倍速、循环、路线概览、途经点列表(拖拽排序 / 双击改坐标 / 定位 / 删除) + * - 绘制工具条(选择 / 添加途经点 / 折线绘制 / 撤销 / 删除 / 全览) + * - 播放控制(播放 / 暂停 / 继续 / 停止 / seek / 倍速)与实时状态 + * - GPX 导入导出、路线保存到 localStorage + * - 日志面板(最多 300 行) + * + * 所有坐标对外(后端 / localStorage / 日志)一律 WGS84。 + */ +(function () { + 'use strict'; + + var WS_URL = (function () { + var loc = window.location; + var proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + var host = loc.hostname || '127.0.0.1'; + var port = loc.port || '8787'; + return proto + '//' + host + ':' + port + '/ws'; + })(); + + var LOG_MAX = 300; + var UNDO_MAX = 50; + var PREF_KEY = 'iosloc.prefs.v1'; + var ROUTES_KEY = 'iosloc.routes.v1'; + + var LEVEL_CN = { info: '信息', warn: '警告', error: '错误', success: '成功' }; + var MODE_CN = { driving: '驾车', cycling: '骑行', walking: '步行', flight: '飞行' }; + var CONN_CN = { usb: 'USB', network: '网络', wifi: 'Wi-Fi', mock: '模拟', lan: '局域网' }; + var STATE_CN = { connected: '已连接', unpaired: '未配对', unavailable: '不可用' }; + var TUNNEL_CN = { + idle: '空闲', starting: '建立中', connected: '已连接', + error: '失败', unsupported: '不需要' + }; + + var $ = function (sel) { return document.querySelector(sel); }; + var $$ = function (sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }; + + /* ================================================================== */ + /* 全局状态 */ + /* ================================================================== */ + + var S = { + ws: null, + map: null, + health: null, + mock: false, + version: '--', + config: null, + modeDefaults: { driving: 13.9, cycling: 5.5, walking: 1.4, flight: 60.0 }, + + devices: [], + selected: null, + device: null, + tunnel: null, + + mode: 'driving', + speedScale: 1, + loop: false, + routeName: '', + + waypoints: [], // WGS84 [lng,lat][] + drawnPath: [], // 手绘折线(WGS84) + route: null, // 服务端返回的 Route 对象 + polyline: [], // 正在绘制的折线 + drawing: false, + selectedWp: -1, + drawMode: 'select', + + playing: false, + paused: false, + status: null, + scrubbing: false, + progress: 0, + + logs: [], + undo: [], + tileSources: [], + tileSource: '', + logCollapsed: true, + + lastLocation: null, + + // 真实感模拟(校园跑等打卡测试):GPS 漂移 + 速度浮动 + driftEnabled: true, + driftM: 4, + jitterEnabled: true, + jitterPercent: 8, + timingJitter: true, + deriveSpeed: true, + grossError: false, + grossErrorPercent: 2, + config: null + }; + + /* ================================================================== */ + /* 小工具 */ + /* ================================================================== */ + + function toast(msg, kind, ms) { + var wrap = $('#toastWrap'); + var t = document.createElement('div'); + t.className = 'toast' + (kind ? ' is-' + kind : ''); + t.textContent = msg; + wrap.appendChild(t); + setTimeout(function () { + t.classList.add('is-out'); + setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 320); + }, ms || 3400); + } + + var modalOnOk = null; + + function openModal(opt) { + $('#modalTitle').textContent = opt.title || '提示'; + $('#modalBody').innerHTML = opt.html || ''; + $('#modalOk').textContent = opt.okText || '确定'; + $('#modalCancel').textContent = opt.cancelText || '取消'; + $('#modalOk').className = 'btn ' + (opt.danger ? 'btn-danger' : 'btn-primary'); + modalOnOk = opt.onOk || null; + $('#modalMask').classList.remove('hidden'); + var first = $('#modalBody').querySelector('input, select, textarea'); + if (first) setTimeout(function () { first.focus(); first.select && first.select(); }, 30); + } + + function closeModal() { + $('#modalMask').classList.add('hidden'); + modalOnOk = null; + } + + $('#modalOk').addEventListener('click', function () { + var fn = modalOnOk; + if (fn) { + var r = fn(); + if (r === false) return; + } + closeModal(); + }); + $('#modalCancel').addEventListener('click', closeModal); + $('#modalMask').addEventListener('click', function (e) { + if (e.target === $('#modalMask')) closeModal(); + }); + + function confirmModal(title, text, onOk, okText) { + openModal({ + title: title, + html: '
' + text + '
', + okText: okText || '确定', + danger: true, + onOk: onOk + }); + } + + function setBusy(btn, busy, busyText) { + if (!btn) return; + if (busy) { + if (!btn.dataset.origText) btn.dataset.origText = btn.textContent; + btn.disabled = true; + if (busyText) btn.textContent = busyText; + } else { + btn.disabled = false; + if (btn.dataset.origText) { btn.textContent = btn.dataset.origText; delete btn.dataset.origText; } + } + } + + /** JSON 接口;服务端 ok:false 一律抛出中文 Error */ + function apiJson(path, opts) { + return fetch(path, opts).then(function (res) { + var ct = res.headers.get('content-type') || ''; + if (ct.indexOf('application/json') >= 0) { + return res.json().then(function (data) { + if (data && data.ok === false) { + var e = new Error(data.error || ('请求失败:' + path)); + e.code = data.code || 'api_error'; + throw e; + } + return data; + }); + } + if (!res.ok) { + var err = new Error('请求失败 HTTP ' + res.status + '(' + path + ')'); + err.code = 'http_' + res.status; + throw err; + } + return res.text(); + }); + } + + function postJson(path, body) { + return apiJson(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body || {}) + }); + } + + /** 下载文本文件 */ + function downloadText(text, filename, mime) { + var blob = new Blob([text], { type: mime || 'text/plain;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(function () { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 1200); + } + + function debounce(fn, ms) { + var t = null; + return function () { + var args = arguments, self = this; + clearTimeout(t); + t = setTimeout(function () { fn.apply(self, args); }, ms); + }; + } + + /* ================================================================== */ + /* 日志 */ + /* ================================================================== */ + + var lastLog = null; + + /** 把数字(坐标、序号、时间)换成 # 作为“同一类消息”的指纹 */ + function logSignature(msg) { + return String(msg).replace(/-?\d+(?:\.\d+)?/g, '#'); + } + + function pushLog(level, msg, ts) { + var l = { ts: ts == null ? Date.now() / 1000 : ts, level: LEVEL_CN[level] ? level : 'info', msg: String(msg) }; + var sig = logSignature(l.msg); + // 短时间内同类型消息(例如 5Hz 的定位下发日志)合并成一行,避免刷屏 + if (lastLog && lastLog.sig === sig && lastLog.level === l.level && Math.abs(l.ts - lastLog.ts) < 3) { + lastLog.count += 1; + lastLog.ts = l.ts; + var merged = lastLog.msg + ' ×' + lastLog.count; + var mEl = lastLog.row.querySelector('.log-msg'); + if (mEl) mEl.textContent = merged; + if (S.logs.length) S.logs[S.logs.length - 1] = { ts: l.ts, level: l.level, msg: merged }; + return; + } + S.logs.push(l); + var body = $('#logBody'); + var atBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 24; + while (S.logs.length > LOG_MAX) { + S.logs.shift(); + if (body.firstChild) body.removeChild(body.firstChild); + } + var row = document.createElement('div'); + row.className = 'log-line level-' + l.level; + row.innerHTML = ''; + row.querySelector('.log-ts').textContent = Geo.fmtClock(l.ts); + row.querySelector('.log-lvl').textContent = LEVEL_CN[l.level]; + row.querySelector('.log-msg').textContent = l.msg; + body.appendChild(row); + if (atBottom) body.scrollTop = body.scrollHeight; + lastLog = { sig: sig, count: 1, row: row, msg: l.msg, level: l.level, ts: l.ts }; + $('#logCount').textContent = S.logs.length + ' / ' + LOG_MAX; + var label = $('#logCollapsedLabel'); + if (label) label.textContent = '运行日志 (' + S.logs.length + ')'; + // 出错时自动展开日志面板,避免错误被折叠面板藏住 + if (l.level === 'error' && S.logCollapsed) setLogCollapsed(false, true); + } + + function setLogs(lines) { + S.logs = []; + lastLog = null; + $('#logBody').innerHTML = ''; + (lines || []).forEach(function (l) { pushLog(l.level, l.msg, l.ts); }); + if (!lines || !lines.length) pushLog('info', '服务端暂无历史日志'); + } + + /* ================================================================== */ + /* 首选项 / 本地路线 */ + /* ================================================================== */ + + function loadPrefs() { + try { + var p = JSON.parse(localStorage.getItem(PREF_KEY) || '{}'); + if (p.mode && MODE_CN[p.mode]) S.mode = p.mode; + if (isFinite(p.speedScale)) S.speedScale = Geo.clamp(+p.speedScale, Geo.SPEED_MIN, Geo.SPEED_MAX); + if (typeof p.loop === 'boolean') S.loop = p.loop; + if (p.tileSource) S.tileSource = p.tileSource; + if (p.routeName) S.routeName = p.routeName; + if (typeof p.logCollapsed === 'boolean') S.logCollapsed = p.logCollapsed; + if (p.view && isFinite(p.view.lng)) S.savedView = p.view; + return p; + } catch (e) { + return {}; + } + } + + var savePrefs = debounce(function () { + try { + localStorage.setItem(PREF_KEY, JSON.stringify({ + mode: S.mode, + speedScale: S.speedScale, + loop: S.loop, + tileSource: S.tileSource, + routeName: S.routeName, + logCollapsed: S.logCollapsed, + view: S.map ? { lng: S.map.getCenter().lng, lat: S.map.getCenter().lat, zoom: S.map.getZoom() } : null + })); + } catch (e) { /* localStorage 不可用时忽略 */ } + }, 350); + + function readSavedRoutes() { + try { + var arr = JSON.parse(localStorage.getItem(ROUTES_KEY) || '[]'); + return Array.isArray(arr) ? arr : []; + } catch (e) { return []; } + } + + function writeSavedRoutes(arr) { + try { + localStorage.setItem(ROUTES_KEY, JSON.stringify(arr)); + return true; + } catch (e) { + toast('保存失败:浏览器本地存储不可用', 'err'); + return false; + } + } + + /* ================================================================== */ + /* 初始化 */ + /* ================================================================== */ + + function init() { + loadPrefs(); + + S.map = new SlippyMap('#map', { zoom: 12, minZoom: 3 }); + S.map.setView(116.397428, 39.90923, 12, true); + bindMapEvents(); + bindUI(); + + var v = S.savedView; + if (v && isFinite(v.lng)) S.map.setView(v.lng, v.lat, v.zoom || 12, true); + S.map.setSource({ key: 'amap', name: '高德矢量', max_zoom: 18, attribution: '© 高德地图', datum: 'gcj02' }); + + applyPrefsToUI(); + renderDevice(); + renderWaypoints(); + updateStats(); + renderSavedRoutes(); + updateTransport(); + + setLogCollapsed(S.logCollapsed, true); + pushLog('info', '前端已启动,正在连接本地服务 ' + WS_URL); + connectWs(); + loadHealth(); + loadConfig(); + loadTileSources(); + + window.addEventListener('beforeunload', function () { savePrefs(); }); + } + + function applyPrefsToUI() { + $$('#modeGroup .seg-btn').forEach(function (b) { + b.classList.toggle('is-active', b.dataset.mode === S.mode); + }); + $('#speedRange').value = Math.round(Geo.speedToSlider(S.speedScale) * 1000); + $('#speedValue').textContent = Geo.fmtSpeedScale(S.speedScale); + $('#liveScale').textContent = Geo.fmtSpeedScale(S.speedScale); + $('#loopToggle').checked = S.loop; + $('#routeName').value = S.routeName || ''; + } + + /* ================================================================== */ + /* WebSocket */ + /* ================================================================== */ + + function connectWs() { + S.ws = new WSClient({ url: WS_URL }); + S.ws.onState(function (st) { + var pill = $('#connPill'); + if (st === 'open') { pill.textContent = '服务已连接'; pill.className = 'pill pill-ok'; } + else if (st === 'connecting') { pill.textContent = '连接中…'; pill.className = 'pill pill-warn'; } + else { pill.textContent = '未连接'; pill.className = 'pill pill-err'; } + $('#appSubtitle').textContent = st === 'open' + ? '本地服务 ' + WS_URL.replace('ws://', '').replace('/ws', '') + ' · 已连接' + : '本地服务未连接,正在自动重连…'; + updateBanner(); + updateButtons(); + }); + + S.ws.on('open', function () { + pushLog('success', '已连接到本地服务'); + }); + + S.ws.on('close', function (info) { + if (!info.manual) pushLog('warn', '与本地服务的连接已断开,正在自动重连…'); + S.playing = false; S.paused = false; + updateTransport(); + }); + + S.ws.on('protocol_fallback', function (info) { + pushLog('warn', '服务端握手返回了未请求的 WebSocket 子协议,已切换兼容模式(v1)重连' + + (info && info.reason ? ':' + info.reason : '')); + }); + + S.ws.on('reconnecting', function (info) { + $('#appSubtitle').textContent = '连接断开,' + Math.round(info.delay / 100) / 10 + ' 秒后重试…'; + }); + + S.ws.on('welcome', applySessionInfo); + + S.ws.on('pong', function () { /* 心跳正常,无需处理 */ }); + + S.ws.on('ack', function (msg) { + if (msg.ok === false) { + pushLog('error', '命令失败:' + (msg.error || msg.cmd || '未知错误')); + return; + } + // 服务端把 welcome 数据并在 hello 的 ack 里返回(welcome 字段不在独立消息里) + if (msg.cmd === 'hello') applySessionInfo(msg); + }); + + S.ws.on('error', function (msg) { + var text = msg && msg.error ? msg.error : '服务端返回未知错误'; + pushLog('error', text); + toast(text, 'err'); + }); + + S.ws.on('devices', function (msg) { + applyDevices(msg.devices, msg.selected); + }); + + S.ws.on('device', function (msg) { + S.device = msg.device || null; + if (S.device) S.selected = S.device.udid; + renderDevice(); + updateBanner(); + }); + + S.ws.on('tunnel', function (msg) { + S.tunnel = msg; + if (S.device) S.device.tunnel = msg.state || S.device.tunnel; + pushLog(msg.state === 'error' ? 'error' : 'info', + '隧道状态:' + (TUNNEL_CN[msg.state] || msg.state) + (msg.message ? ' — ' + msg.message : '')); + renderDevice(); + updateBanner(); + // tunnel 事件可能是瞬时中间态(starting):设备对象才是权威状态,直接回读对齐 + // (tunnel 事件很稀少,不需要节流;节流会导致卡片停在「建立中」) + lastDeviceSync = 0; + syncDeviceFromServer(true); + scheduleTunnelWatchdog(msg.state); + }); + + S.ws.on('status', onStatus); + + // 开发者镜像挂载结果 + S.ws.on('mounted', function (m) { + var btn = $('#btnMountImage'); + if (btn) btn.disabled = false; + if (m && m.mounted) { + pushLog('success', '开发者镜像挂载完成:' + (m.message || '')); + toast('开发者镜像已挂载,现在可以进行虚拟定位', 'ok', 4200); + } else { + pushLog('error', '开发者镜像挂载失败:' + ((m && m.message) || '未知原因')); + toast('挂载失败,详情见运行日志', 'err', 5000); + } + }); + S.ws.on('location', onLocation); + + S.ws.on('log', function (msg) { pushLog(msg.level, msg.msg, msg.ts); }); + S.ws.on('logs', function (msg) { + if (msg.lines) setLogs(msg.lines); + }); + + S.ws.connect(); + } + + /** 应用会话握手信息(兼容 hello→ack 与 hello→welcome 两种服务端实现) */ + function applySessionInfo(msg) { + if (!msg) return; + if (msg.version) S.version = msg.version; + if (msg.mock != null) S.mock = !!msg.mock; + if (msg.config) applyConfig(msg.config); + if (msg.tunnel) { + S.tunnel = msg.tunnel; + if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel; + } + if (Array.isArray(msg.devices)) applyDevices(msg.devices, msg.selected); + $('#serverInfo').textContent = '服务端 v' + S.version + (S.mock ? '·模拟' : ''); + if (!S.sessionLogged) { + S.sessionLogged = true; + pushLog('success', '服务端就绪:版本 ' + S.version + (S.mock ? '(模拟模式)' : '') + + (msg.backend ? ',后端 ' + msg.backend : '')); + } + renderDevice(); + updateBanner(); + updateButtons(); + loadTileSources(); + req('devices.refresh', {}, 6000).catch(function () { /* 忽略,等待推送 */ }); + req('log.list', { limit: 200 }, 6000).catch(function () { /* 忽略 */ }); + } + + var lastDeviceSync = 0; + var tunnelWatch = null; + + /** 以服务端设备对象为准回读设备状态(tunnel/state 字段) */ + function syncDeviceFromServer(force) { + var t = Date.now(); + if (!force && t - lastDeviceSync < 1200) return; + lastDeviceSync = t; + if (S.ws && S.ws.isOpen()) { + req('devices.refresh', {}, 8000).then(function (msg) { + if (msg.devices) applyDevices(msg.devices, msg.selected); + }).catch(function () { /* 忽略 */ }); + } else { + apiJson('/api/devices').then(function (d) { + applyDevices(d.devices, d.selected); + }).catch(function () { /* 忽略 */ }); + } + } + + /** + * 隧道停在「建立中」时的兜底:服务端不一定会为中间态推后续事件, + * 这里每 2 秒回读一次权威设备状态,最长 20 秒;仍无结果就给出可操作提示。 + */ + function scheduleTunnelWatchdog(state) { + if (tunnelWatch) { clearInterval(tunnelWatch); tunnelWatch = null; } + if (state !== 'starting') { S.tunnelStuck = false; return; } + var tries = 0; + tunnelWatch = setInterval(function () { + tries += 1; + syncDeviceFromServer(true); + if (S.device && S.device.tunnel !== 'starting') { + clearInterval(tunnelWatch); + tunnelWatch = null; + S.tunnelStuck = false; + renderDevice(); + return; + } + if (tries >= 10) { + clearInterval(tunnelWatch); + tunnelWatch = null; + S.tunnelStuck = true; + pushLog('warn', '隧道建立超过 20 秒仍未完成。iOS 17+ 通常需要管理员权限(sudo 运行服务);' + + '也可用「手动 RSD」直接填写地址端口。'); + renderDevice(); + updateBanner(); + } + }, 2000); + } + + function req(cmd, fields, timeout) { + if (!S.ws || !S.ws.isOpen()) { + var e = new Error('尚未连接到本地服务,请稍后重试'); + e.code = 'ws_closed'; + return Promise.reject(e); + } + return S.ws.request(cmd, fields, timeout); + } + + /* ================================================================== */ + /* 配置 / 健康检查 / 瓦片源 */ + /* ================================================================== */ + + function loadHealth() { + apiJson('/api/health').then(function (h) { + S.health = h; + S.version = h.version || S.version; + S.mock = !!h.mock; + $('#serverInfo').textContent = '服务端 v' + S.version + (S.mock ? '·模拟' : ''); + updateBanner(); + }).catch(function () { + $('#serverInfo').textContent = '服务端不可达'; + }); + } + + function applyConfig(cfg) { + if (!cfg) return; + S.config = cfg; + if (cfg.mode_defaults) { + Object.keys(cfg.mode_defaults).forEach(function (k) { + if (isFinite(cfg.mode_defaults[k])) S.modeDefaults[k] = +cfg.mode_defaults[k]; + }); + } + if (isFinite(cfg.default_speed_scale) && S.speedScale === 1) { + S.speedScale = Geo.clamp(+cfg.default_speed_scale, Geo.SPEED_MIN, Geo.SPEED_MAX); + applyPrefsToUI(); + } + if (!S.tileSource && cfg.tile_source) S.tileSource = cfg.tile_source; + // 真实感模拟参数(服务端为准,例如上次退出前调好的值) + if (typeof cfg.drift_enabled === 'boolean') S.driftEnabled = cfg.drift_enabled; + if (isFinite(cfg.drift_m)) S.driftM = Math.round(+cfg.drift_m); + if (typeof cfg.speed_jitter_enabled === 'boolean') S.jitterEnabled = cfg.speed_jitter_enabled; + if (isFinite(cfg.speed_jitter)) S.jitterPercent = Math.round(+cfg.speed_jitter * 100); + if (typeof cfg.timing_jitter_enabled === 'boolean') S.timingJitter = cfg.timing_jitter_enabled; + if (typeof cfg.derive_speed === 'boolean') S.deriveSpeed = cfg.derive_speed; + if (typeof cfg.gross_error_enabled === 'boolean') S.grossError = cfg.gross_error_enabled; + if (isFinite(cfg.gross_error_probability)) S.grossErrorPercent = Math.round(+cfg.gross_error_probability * 100); + if (typeof applyTuningUi === 'function') applyTuningUi(); + } + + function loadConfig() { + apiJson('/api/config').then(function (data) { + applyConfig(data.config); + if (!S.tileSources.length) buildTileSourcesFromConfig(); + }).catch(function (e) { + pushLog('warn', '读取配置失败:' + e.message); + }); + } + + /** 兼容多种 /api/settings/tiles 返回结构 */ + function normalizeTileSettings(data) { + var payload = (data && (data.settings || data)) || {}; + var raw = payload.sources || payload.tile_sources || payload.tiles || payload.list || null; + var current = payload.current || payload.tile_source || payload.selected || null; + var list = []; + if (Array.isArray(raw)) { + raw.forEach(function (it) { + if (!it) return; + var key = it.key || it.id || it.name; + if (key) list.push(toTileSource(key, it)); + }); + } else if (raw && typeof raw === 'object') { + Object.keys(raw).forEach(function (k) { list.push(toTileSource(k, raw[k] || {})); }); + } + return { list: list, current: current }; + } + + function toTileSource(key, o) { + o = o || {}; + return { + key: String(key), + name: o.name || String(key), + max_zoom: +(o.max_zoom || o.maxZoom || 18), + attribution: o.attribution || '', + datum: Coord.datumOfSource(key, o) + }; + } + + function buildTileSourcesFromConfig() { + var cfg = S.config || {}; + var list = []; + if (cfg.tile_sources) { + Object.keys(cfg.tile_sources).forEach(function (k) { list.push(toTileSource(k, cfg.tile_sources[k])); }); + } + if (!list.length) { + list = [{ key: 'amap', name: '高德矢量', max_zoom: 18, attribution: '© 高德地图', datum: 'gcj02' }]; + } + S.tileSources = list; + if (!S.tileSource) S.tileSource = cfg.tile_source || list[0].key; + renderTileSelect(); + applyTileSource(S.tileSource, true); + } + + function loadTileSources() { + apiJson('/api/settings/tiles').then(function (data) { + var n = normalizeTileSettings(data); + if (!n.list.length) { buildTileSourcesFromConfig(); return; } + S.tileSources = n.list; + S.tileSource = S.tileSource || n.current || n.list[0].key; + if (!S.tileSources.some(function (s) { return s.key === S.tileSource; })) { + S.tileSource = n.current && n.list.some(function (s) { return s.key === n.current; }) + ? n.current : n.list[0].key; + } + renderTileSelect(); + applyTileSource(S.tileSource, true); + }).catch(function (e) { + pushLog('warn', '读取瓦片源列表失败:' + e.message + ',使用配置中的瓦片源'); + buildTileSourcesFromConfig(); + }); + } + + function renderTileSelect() { + var sel = $('#tileSourceSelect'); + sel.innerHTML = ''; + S.tileSources.forEach(function (s) { + var o = document.createElement('option'); + o.value = s.key; + o.textContent = s.name + (s.datum === 'gcj02' ? '(GCJ-02)' : '(WGS84)'); + sel.appendChild(o); + }); + sel.value = S.tileSource; + } + + /** 切换底图;persist=true 时同步到服务端配置 */ + function applyTileSource(key, initial) { + var src = S.tileSources.filter(function (s) { return s.key === key; })[0]; + if (!src) return; + var changed = S.map.getSource().key !== key; + S.tileSource = key; + S.map.setSource(src); + S.map.invalidate(); + $('#tileSourceSelect').value = key; + if (!initial) { + pushLog('info', '底图已切换为「' + src.name + '」,坐标基准 ' + (src.datum === 'gcj02' ? 'GCJ-02' : 'WGS84')); + postJson('/api/config', { tile_source: key }).catch(function (e) { + pushLog('warn', '底图设置未能保存到服务端:' + e.message); + }); + } + if (changed) savePrefs(); + } + + /* ================================================================== */ + /* 设备 */ + /* ================================================================== */ + + function applyDevices(list, selected) { + S.devices = Array.isArray(list) ? list : []; + if (selected) S.selected = selected; + if (!S.selected || !S.devices.some(function (d) { return d.udid === S.selected; })) { + S.selected = S.devices.length ? S.devices[0].udid : null; + } + S.device = S.devices.filter(function (d) { return d.udid === S.selected; })[0] || null; + renderDevice(); + updateBanner(); + } + + function renderDevice() { + var card = $('#deviceCard'); + var d = S.device; + + if (!d) { + card.innerHTML = '
未检测到 iOS 设备 — 请用数据线连接 iPhone,' + + '解锁屏幕后点击右上角「刷新设备」
'; + } else { + var stateCls = d.state === 'connected' ? 'ok' : (d.state === 'unpaired' ? 'warn' : 'err'); + var tunCls = d.tunnel === 'connected' ? 'ok' : (d.tunnel === 'error' ? 'err' : + (d.tunnel === 'starting' ? 'warn' : '')); + var html = ''; + html += '
' + esc(d.name || d.udid) + '' + esc(d.udid || '') + '
'; + html += '
'; + html += devRow('型号', d.product_type || '--'); + html += devRow('系统', 'iOS ' + (d.ios_version || '--')); + html += devRow('连接', CONN_CN[d.connection] || d.connection || '--'); + html += devRow('设备状态', STATE_CN[d.state] || d.state || '--', stateCls); + html += devRow('隧道', TUNNEL_CN[d.tunnel] || d.tunnel || '--', tunCls); + html += devRow('虚拟定位', d.simulating ? '模拟中' : '未模拟', d.simulating ? 'ok' : ''); + html += devRow('开发者模式', d.developer_mode ? '已开启' : '未开启', d.developer_mode ? 'ok' : 'warn'); + html += '
'; + if (d.state === 'unpaired') { + html += '
设备未配对:请在 iPhone 上点「信任此电脑」后重新连接
'; + } + if (d.developer_mode === false) { + html += '
未开启开发者模式:设置 → 隐私与安全性 → 开发者模式
'; + } + if (d.tunnel === 'starting' && S.tunnelStuck) { + html += '
隧道建立已超过 20 秒,请检查是否以管理员权限运行,或改用「手动 RSD」
'; + } + if (d.tunnel === 'error' && d.tunnel_hint) { + html += '
' + esc(d.tunnel_hint) + '
'; + } + if (d.last_error) { + html += '
' + esc(d.last_error) + '
'; + } + card.innerHTML = html; + } + + if (S.devices.length > 1) { + var sel = document.createElement('select'); + sel.className = 'input select'; + sel.style.maxWidth = '190px'; + S.devices.forEach(function (dd) { + var o = document.createElement('option'); + o.value = dd.udid; + o.textContent = (dd.name || dd.udid) + ' · iOS ' + (dd.ios_version || '?'); + if (dd.udid === S.selected) o.selected = true; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { selectDevice(sel.value); }); + card.appendChild(sel); + } + + updateButtons(); + updateBanner(); + } + + function devRow(k, v, cls) { + return '
' + esc(k) + '' + + '' + esc(v) + '
'; + } + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function updateButtons() { + var open = S.ws && S.ws.isOpen(); + var d = S.device; + $('#btnDevicesRefresh').disabled = !open; + $('#btnDeviceConnect').disabled = !open || !d || d.state !== 'connected'; + $('#btnDeviceDisconnect').disabled = !open || !d; + $('#btnFetchLogs').disabled = !open; + updateTransport(); + } + + function updateBanner() { + var banner = $('#alertBanner'); + var text = '', kind = '', action = null; + + if (!S.ws || !S.ws.isOpen()) { + text = '本地服务未连接(' + WS_URL.replace('ws://', '') + ')。请确认已启动服务后点击「重试」。'; + kind = 'is-error'; + action = { label: '重试', fn: function () { S.ws.reconnectNow(); } }; + } else if (S.device && S.device.state === 'unpaired') { + text = '「' + (S.device.name || S.device.udid) + '」尚未与此电脑配对,请在 iPhone 上点击「信任」后重新连接。'; + } else if (S.device && S.device.developer_mode === false) { + text = '请在 iPhone 上开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式,开启后需重启手机,否则无法模拟定位。'; + } else if (S.device && S.device.tunnel === 'error') { + text = S.device.tunnel_hint || '隧道建立失败。iOS 17 及以上需要管理员权限建立隧道,请以 sudo 运行服务。'; + kind = 'is-error'; + action = { label: '建立隧道', fn: function () { $('#btnTunnelEnsure').click(); } }; + } else if (!S.device && S.devices.length === 0) { + text = '未检测到 iOS 设备。请用数据线连接 iPhone 并解锁屏幕;首次连接需要在手机上信任此电脑。'; + kind = 'is-info'; + } else if (S.mock) { + text = '当前为模拟模式(--mock):不会真正下发给手机,可用于完整验证轨迹与播放流程。'; + kind = 'is-info'; + } + + if (!text) { banner.classList.add('hidden'); return; } + banner.className = 'banner ' + kind; + $('#bannerText').textContent = text; + var btn = $('#bannerAction'); + if (action) { + btn.classList.remove('hidden'); + btn.textContent = action.label; + btn.onclick = action.fn; + } else { + btn.classList.add('hidden'); + btn.onclick = null; + } + } + + function refreshDevices() { + var btn = $('#btnDevicesRefresh'); + setBusy(btn, true, '扫描中…'); + var done = function () { setBusy(btn, false); }; + if (S.ws && S.ws.isOpen()) { + req('devices.refresh', {}, 12000).then(function (msg) { + if (msg.devices) applyDevices(msg.devices, msg.selected); + pushLog('info', '设备列表已刷新,共 ' + S.devices.length + ' 台'); + }).catch(function (e) { + pushLog('error', '刷新设备失败:' + e.message); + toast(e.message, 'err'); + }).then(done); + } else { + apiJson('/api/devices').then(function (data) { + applyDevices(data.devices, data.selected); + }).catch(function (e) { + toast(e.message, 'err'); + }).then(done); + } + } + + function selectDevice(udid) { + if (!udid) return; + if (S.ws && S.ws.isOpen()) { + req('device.select', { udid: udid }, 10000).then(function (msg) { + if (msg.device) { S.device = msg.device; renderDevice(); } + pushLog('info', '已选择设备:' + ((msg.device && msg.device.name) || udid)); + }).catch(function (e) { toast(e.message, 'err'); }); + } else { + postJson('/api/devices/select', { udid: udid }).then(function (data) { + S.device = data.device; + renderDevice(); + }).catch(function (e) { toast(e.message, 'err'); }); + } + } + + function deviceConnect() { + var btn = $('#btnDeviceConnect'); + setBusy(btn, true, '连接中…'); + pushLog('info', '正在建立隧道并连接 DVT 定位服务…'); + var finish = function () { setBusy(btn, false); }; + if (S.ws && S.ws.isOpen()) { + req('device.connect', {}, 90000).then(function (msg) { + if (msg.device) { S.device = msg.device; renderDevice(); } + if (msg.tunnel) { S.tunnel = msg.tunnel; } + toast('设备已连接', 'ok'); + syncDeviceFromServer(true); + pushLog('success', '设备连接成功' + (msg.tunnel && msg.tunnel.rsd ? '(RSD ' + msg.tunnel.rsd + ')' : '')); + }).catch(function (e) { + pushLog('error', '连接设备失败:' + e.message); + toast(e.message, 'err', 5200); + }).then(finish); + } else { + postJson('/api/devices/connect', {}).then(function (data) { + S.device = data.device; + renderDevice(); + toast('设备已连接', 'ok'); + }).catch(function (e) { + toast(e.message, 'err', 5200); + }).then(finish); + } + } + + function deviceDisconnect() { + confirmModal('断开设备', '断开后将释放 DVT 会话并让手机恢复真实定位,确定继续?', function () { + var btn = $('#btnDeviceDisconnect'); + setBusy(btn, true, '断开中…'); + var finish = function () { setBusy(btn, false); }; + var after = function () { + stopLocalPlaybackState(); + toast('已断开连接,手机已恢复真实定位', 'ok'); + }; + if (S.ws && S.ws.isOpen()) { + req('device.disconnect', {}, 20000).then(function (msg) { + if (msg.device) { S.device = msg.device; renderDevice(); } + after(); + }).catch(function (e) { toast(e.message, 'err'); }).then(finish); + } else { + postJson('/api/devices/disconnect', {}).then(after) + .catch(function (e) { toast(e.message, 'err'); }).then(finish); + } + }, '断开'); + } + + /* ================================================================== */ + /* 地图事件 */ + /* ================================================================== */ + + function bindMapEvents() { + var m = S.map; + + m.on('click', function (e) { + if (S.drawMode === 'add') { + addWaypoint(e.lng, e.lat); + } else if (S.drawMode === 'polyline') { + if (!S.drawing) startPolyline(); + S.polyline.push([Coord.round6(e.lng), Coord.round6(e.lat)]); + m.setPath(S.polyline, '#d29922'); + $('#toolHint').textContent = '折线绘制中:已加 ' + S.polyline.length + ' 点,双击或 Esc 结束'; + pushLog('info', '折线加点 ' + S.polyline.length + ':' + Geo.fmtLngLat(e.lng, e.lat, 6)); + } else { + selectWaypoint(-1); + } + }); + + m.on('dblclick', function () { + if (S.drawMode === 'polyline' && S.drawing) finishPolyline(); + }); + + m.on('waypointclick', function (e) { selectWaypoint(e.index); }); + + m.on('waypointdrag', function (e) { + if (S.waypoints[e.index]) { + S.waypoints[e.index][0] = Coord.round6(e.lng); + S.waypoints[e.index][1] = Coord.round6(e.lat); + } + var row = $('#wpList').children[e.index]; + if (row) row.querySelector('.wp-coord').textContent = Geo.fmtLngLat(e.lng, e.lat, 6); + updateStats(); + }); + + m.on('waypointdragend', function (e) { + if (!e.moved) return; + pushUndo(); + invalidateRoute('途经点已移动'); + renderWaypoints(); + updateStats(); + }); + + m.on('move', function () { savePrefs(); }); + m.on('zoomend', function () { savePrefs(); }); + } + + function setDrawMode(mode) { + if (mode !== 'polyline' && S.drawing) finishPolyline(); + S.drawMode = mode; + $$('#drawToolbar .tool-btn[data-tool]').forEach(function (b) { + b.classList.toggle('is-active', b.dataset.tool === mode); + }); + S.map.setDrawMode(mode); + S.map.setDoubleClickZoom(mode !== 'polyline'); + S.map.setWaypoints(S.waypoints, { draggable: mode === 'select' }); + var hints = { + select: '拖拽平移 · 滚轮缩放 · 可拖动途经点', + add: '点击地图添加途经点', + polyline: '连续点击加点,双击或 Esc 结束' + }; + $('#toolHint').textContent = hints[mode] || ''; + } + + /* ================================================================== */ + /* 途经点 / 折线 */ + /* ================================================================== */ + + function snapshot() { + return JSON.stringify({ waypoints: S.waypoints, drawnPath: S.drawnPath, route: S.route }); + } + + function pushUndo() { + S.undo.push(snapshot()); + if (S.undo.length > UNDO_MAX) S.undo.shift(); + } + + function undo() { + if (!S.undo.length) { toast('没有可撤销的操作', 'warn'); return; } + var st = JSON.parse(S.undo.pop()); + S.waypoints = st.waypoints || []; + S.drawnPath = st.drawnPath || []; + S.route = st.route || null; + syncMapLayers(); + renderWaypoints(); + updateStats(); + toast('已撤销上一步', 'info', 1600); + } + + function invalidateRoute(reason) { + if (S.route) { + S.route = null; + pushLog('warn', (reason || '路线已变更') + ',需要重新点击「加载 OSRM 路线」'); + } + syncMapLayers(); + } + + function addWaypoint(lng, lat) { + pushUndo(); + S.waypoints.push([Coord.round6(lng), Coord.round6(lat)]); + S.route = null; + S.drawnPath = []; + syncMapLayers(); + renderWaypoints(); + updateStats(); + var i = S.waypoints.length - 1; + selectWaypoint(i); + pushLog('info', '已添加途经点 ' + (i + 1) + ':' + Geo.fmtLngLat(lng, lat, 6)); + } + + function removeWaypoint(index) { + if (index < 0 || index >= S.waypoints.length) return; + pushUndo(); + S.waypoints.splice(index, 1); + S.route = null; + if (S.selectedWp === index) S.selectedWp = -1; + else if (S.selectedWp > index) S.selectedWp -= 1; + syncMapLayers(); + renderWaypoints(); + updateStats(); + } + + function setWaypointCoords(index, lng, lat) { + if (index < 0 || index >= S.waypoints.length) return; + pushUndo(); + S.waypoints[index] = [Coord.round6(lng), Coord.round6(lat)]; + S.route = null; + syncMapLayers(); + renderWaypoints(); + updateStats(); + } + + function reorderWaypoints(from, to) { + if (from === to || from < 0 || to < 0 || from >= S.waypoints.length || to >= S.waypoints.length) return; + pushUndo(); + var item = S.waypoints.splice(from, 1)[0]; + S.waypoints.splice(to, 0, item); + S.route = null; + syncMapLayers(); + renderWaypoints(); + updateStats(); + } + + function selectWaypoint(index, center) { + S.selectedWp = index; + S.map.setSelected(index); + $$('#wpList .wp-item').forEach(function (row, i) { + row.classList.toggle('is-selected', i === index); + }); + if (index >= 0 && center && S.waypoints[index]) { + S.map.setView(S.waypoints[index][0], S.waypoints[index][1], Math.max(S.map.getZoom(), 15)); + } + } + + function syncMapLayers() { + S.map.setWaypoints(S.waypoints, { draggable: S.drawMode === 'select' }); + if (S.drawing) { + S.map.setPath(S.polyline, '#d29922'); + } else if (S.route && S.route.path) { + S.map.setPath(S.route.path); + } else if (S.drawnPath.length >= 2) { + S.map.setPath(S.drawnPath, '#d29922'); + } else if (S.waypoints.length >= 2) { + S.map.setPath(S.waypoints.slice(), '#8b98a8'); + } else { + S.map.setPath([]); + } + S.map.setSelected(S.selectedWp); + } + + function startPolyline() { + S.drawing = true; + S.polyline = []; + pushLog('info', '开始折线绘制:连续点击地图加点,双击或 Esc 结束'); + $('#toolHint').textContent = '折线绘制中:点击加点,双击或 Esc 结束'; + } + + function finishPolyline() { + S.drawing = false; + var pts = S.polyline.slice(); + S.polyline = []; + if (pts.length < 2) { + S.map.setPath([]); + syncMapLayers(); + toast('折线至少需要 2 个点', 'warn'); + return; + } + pushUndo(); + S.drawnPath = pts; + S.route = null; + syncMapLayers(); + updateStats(); + S.map.fitBounds(pts, { padding: 60 }); + pushLog('success', '折线绘制完成,共 ' + pts.length + ' 点,长度 ' + Geo.fmtDistance(Geo.pathLength(pts))); + toast('折线绘制完成,可直接播放或导出 GPX', 'ok'); + setDrawMode('select'); + } + + function renderWaypoints() { + var list = $('#wpList'); + list.innerHTML = ''; + $('#wpEmpty').classList.toggle('hidden', S.waypoints.length > 0); + var n = S.waypoints.length; + + S.waypoints.forEach(function (wp, i) { + var row = document.createElement('div'); + row.className = 'wp-item' + (i === 0 ? ' is-start' : (i === n - 1 ? ' is-end' : '')) + + (i === S.selectedWp ? ' is-selected' : ''); + row.draggable = true; + row.dataset.index = String(i); + row.innerHTML = + '⋮⋮' + + '' + (i === 0 ? '起' : (i === n - 1 ? '终' : (i + 1))) + '' + + '' + + '' + Geo.fmtLngLat(wp[0], wp[1], 6) + '' + + '' + + '' + + '' + + '' + + ''; + + row.addEventListener('click', function (ev) { + var btn = ev.target.closest('button'); + if (btn) { + ev.stopPropagation(); + if (btn.dataset.act === 'del') removeWaypoint(i); + else if (btn.dataset.act === 'center') { + selectWaypoint(i); + S.map.setView(wp[0], wp[1], Math.max(S.map.getZoom(), 15)); + } else if (btn.dataset.act === 'single') setSingleLocation(wp[0], wp[1], i); + return; + } + selectWaypoint(i); + S.map.setView(wp[0], wp[1], Math.max(S.map.getZoom(), 14)); + }); + + row.addEventListener('dblclick', function () { editWaypointCoords(i); }); + + row.addEventListener('dragstart', function (ev) { + ev.dataTransfer.effectAllowed = 'move'; + try { ev.dataTransfer.setData('text/plain', String(i)); } catch (err) { /* 忽略 */ } + row.classList.add('is-dragging'); + }); + row.addEventListener('dragend', function () { row.classList.remove('is-dragging'); }); + row.addEventListener('dragover', function (ev) { + ev.preventDefault(); + row.classList.add('drag-over'); + }); + row.addEventListener('dragleave', function () { row.classList.remove('drag-over'); }); + row.addEventListener('drop', function (ev) { + ev.preventDefault(); + row.classList.remove('drag-over'); + var from = parseInt(ev.dataTransfer.getData('text/plain'), 10); + if (isFinite(from)) reorderWaypoints(from, i); + }); + + list.appendChild(row); + }); + + $('#statWaypoints').textContent = String(n); + $('#routeMeta').textContent = (S.routeName || '未命名轨迹'); + updateButtons(); + } + + function editWaypointCoords(index) { + var wp = S.waypoints[index]; + if (!wp) return; + openModal({ + title: '修改途经点坐标(第 ' + (index + 1) + ' 点)', + okText: '保存', + html: + '
' + + '
' + + '
坐标为 WGS84,单位度(例如 116.397428 / 39.909230)。高德底图显示时会自动转换为 GCJ-02。
', + onOk: function () { + var lng = parseFloat($('#m_lng').value); + var lat = parseFloat($('#m_lat').value); + if (!isFinite(lng) || !isFinite(lat) || Math.abs(lng) > 180 || Math.abs(lat) > 90) { + toast('坐标格式不正确', 'err'); + return false; + } + setWaypointCoords(index, lng, lat); + return true; + } + }); + } + + /* ================================================================== */ + /* 路线构建 / 统计 */ + /* ================================================================== */ + + function buildLocalRoute(points, engine) { + var speed = S.modeDefaults[S.mode] || 13.9; + var path = points.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }); + var distance = Geo.pathLength(path); + var duration = speed > 0 ? distance / speed : 0; + return { + id: 'r-' + Date.now(), + name: S.routeName || '未命名轨迹', + mode: S.mode, + waypoints: (S.waypoints.length >= 2 ? S.waypoints : path).map(function (p) { return [p[0], p[1]]; }), + path: path, + speeds: path.map(function () { return speed; }), + distance: distance, + duration: duration, + engine: engine || 'straight', + loop: S.loop, + speed_scale: S.speedScale + }; + } + + /** 归一化服务端 Route,补齐可能缺失的字段 */ + function normalizeRoute(r, engine) { + if (!r || !Array.isArray(r.path) || r.path.length < 2) { + throw new Error('服务端返回的路线数据不完整'); + } + var path = r.path.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }); + var speed = S.modeDefaults[S.mode] || 13.9; + var speeds = Array.isArray(r.speeds) && r.speeds.length === path.length + ? r.speeds.map(Number) : path.map(function () { return speed; }); + var distance = isFinite(r.distance) ? +r.distance : Geo.pathLength(path); + var duration = isFinite(r.duration) ? +r.duration : (speed > 0 ? distance / speed : 0); + return { + id: r.id || ('r-' + Date.now()), + name: r.name || S.routeName || '未命名轨迹', + mode: r.mode || S.mode, + waypoints: Array.isArray(r.waypoints) && r.waypoints.length + ? r.waypoints.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }) + : S.waypoints.map(function (p) { return [p[0], p[1]]; }), + path: path, + speeds: speeds, + distance: distance, + duration: duration, + engine: r.engine || engine || 'straight', + loop: !!S.loop, + speed_scale: S.speedScale + }; + } + + function buildRoute() { + if (S.waypoints.length < 2) { + toast('至少需要 2 个途经点才能规划路线', 'warn'); + return; + } + var btn = $('#btnBuildRoute'); + setBusy(btn, true, '规划中…'); + pushLog('info', '正在规划路线(' + (MODE_CN[S.mode] || S.mode) + ',' + S.waypoints.length + ' 个途经点)…'); + + postJson('/api/route/build', { waypoints: S.waypoints, mode: S.mode }).then(function (data) { + S.route = normalizeRoute(data.route, data.engine); + S.drawnPath = []; + syncMapLayers(); + S.map.fitBounds(S.route.path, { padding: 60 }); + updateStats(); + if (S.route.engine === 'osrm') { + pushLog('success', 'OSRM 路线已生成:' + Geo.fmtDistance(S.route.distance) + ',' + Geo.fmtDuration(S.route.duration)); + toast('路线已生成(OSRM 路网)', 'ok'); + } else { + pushLog('warn', '路线规划服务不可用,已按直线生成;播放与导出仍然可用'); + toast('OSRM 不可用,已退化为直线路线', 'warn', 4600); + } + }).catch(function (e) { + pushLog('error', '路线规划失败:' + e.message + ',改用本地直线路线'); + toast('路线服务不可用,已本地生成直线路线', 'warn', 4600); + if (S.waypoints.length >= 2) { + S.route = buildLocalRoute(S.waypoints, 'straight'); + S.drawnPath = []; + syncMapLayers(); + S.map.fitBounds(S.route.path, { padding: 60 }); + updateStats(); + } + }).then(function () { setBusy(btn, false); }); + } + + /** 播放/导出用的路线:优先服务端路线 → 手绘折线 → 途经点直线 */ + function ensureRoute() { + if (S.route && S.route.path && S.route.path.length >= 2) return S.route; + if (S.drawnPath.length >= 2) return buildLocalRoute(S.drawnPath, 'drawn'); + if (S.waypoints.length >= 2) return buildLocalRoute(S.waypoints, 'straight'); + return null; + } + + function currentPath() { + if (S.route && S.route.path) return S.route.path; + if (S.drawnPath.length >= 2) return S.drawnPath; + return S.waypoints; + } + + function updateStats() { + var path = currentPath(); + var route = S.route; + var distance = route && isFinite(route.distance) ? route.distance : Geo.pathLength(path); + var speed = S.modeDefaults[S.mode] || 13.9; + var duration = route && isFinite(route.duration) ? route.duration : (speed > 0 ? distance / speed : 0); + + $('#statDistance').textContent = distance > 0 ? Geo.fmtDistance(distance) : '--'; + $('#statDuration').textContent = duration > 0 ? Geo.fmtDurationCn(duration) : '--'; + $('#statPlayDuration').textContent = duration > 0 + ? Geo.fmtDurationCn(duration / Math.max(0.01, S.speedScale)) : '--'; + $('#statPoints').textContent = String(path ? path.length : 0); + $('#statWaypoints').textContent = String(S.waypoints.length); + + var engine = '--'; + if (S.drawing) engine = '绘制中'; + else if (route) engine = route.engine === 'osrm' ? 'OSRM 路网' : (route.engine === 'drawn' ? '手绘折线' : '直线'); + else if (S.drawnPath.length >= 2) engine = '手绘折线'; + else if (S.waypoints.length >= 2) engine = '待规划'; + $('#statEngine').textContent = engine; + + $('#chipTrack').textContent = '轨迹点 ' + (S.map ? S.map.layers.track.length : 0); + $('#routeMeta').textContent = (S.routeName || '未命名轨迹') + (route ? '' : '(未规划)'); + updateTransport(); + } + + /* ================================================================== */ + /* 播放控制 */ + /* ================================================================== */ + + function routePayload(route) { + return { + id: route.id, + name: route.name || S.routeName || '未命名轨迹', + mode: route.mode || S.mode, + waypoints: (route.waypoints || S.waypoints).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }), + path: route.path.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }), + speeds: route.speeds, + distance: route.distance, + duration: route.duration, + engine: route.engine, + loop: !!S.loop, + speed_scale: S.speedScale + }; + } + + function startPlay() { + var route = ensureRoute(); + if (!route) { + toast('请先添加至少 2 个途经点,或绘制折线、加载路线', 'warn', 4200); + return; + } + route.loop = !!S.loop; + route.speed_scale = S.speedScale; + S.map.clearTrack(); + S.map.setPlayedIndex(-1); + updateStats(); + pushLog('info', '开始播放:' + Geo.fmtDistance(route.distance) + ',倍速 ' + Geo.fmtSpeedScale(S.speedScale) + (S.loop ? ',循环' : '')); + req('play.start', { + route: routePayload(route), + speed_scale: S.speedScale, + loop: !!S.loop, + start_index: 0, + tuning: { + drift_enabled: !!S.driftEnabled, + drift_m: Number(S.driftM), + speed_jitter_enabled: !!S.jitterEnabled, + speed_jitter: Number(S.jitterPercent), + timing_jitter_enabled: !!S.timingJitter, + derive_speed: !!S.deriveSpeed, + gross_error_enabled: !!S.grossError, + gross_error_probability: Number(S.grossErrorPercent) / 100 + } + }, 15000).then(function () { + S.playing = true; + S.paused = false; + S.stoppedAt = 0; + S.statusSeq = null; + updateTransport(); + pushLog('success', '播放已开始'); + }).catch(function (e) { + pushLog('error', '开始播放失败:' + e.message); + toast(e.message, 'err', 4600); + }); + } + + function pausePlay() { + req('play.pause', {}, 8000).then(function () { + S.paused = true; + updateTransport(); + pushLog('info', '播放已暂停'); + }).catch(function (e) { toast(e.message, 'err'); }); + } + + function resumePlay() { + req('play.resume', {}, 8000).then(function () { + S.paused = false; + S.playing = true; + updateTransport(); + pushLog('info', '继续播放'); + }).catch(function (e) { toast(e.message, 'err'); }); + } + + function stopPlay(silent) { + req('play.stop', {}, 10000).then(function () { + stopLocalPlaybackState(); + pushLog('info', '播放已停止,设备已恢复真实定位'); + if (!silent) toast('已停止播放,设备恢复真实定位', 'ok'); + }).catch(function (e) { + stopLocalPlaybackState(); + if (!silent) toast(e.message, 'err'); + }); + } + + function stopLocalPlaybackState() { + S.playing = false; + S.paused = false; + S.status = null; + S.statusSeq = null; + S.stoppedAt = Date.now(); + S.progress = 0; + S.distanceDone = 0; + $('#progressRange').value = 0; + $('#liveProgress').textContent = '0.0%'; + $('#timeElapsed').textContent = '00:00'; + $('#timeRemaining').textContent = '00:00'; + $('#liveIndex').textContent = '0 / 0'; + $('#liveSpeed').textContent = '--'; + $('#chipSpeed').textContent = '速度 --'; + updateProgressChip(); + S.map.setPlayedIndex(-1); + updateTransport(); + } + + function seek(progress) { + progress = Geo.clamp(progress, 0, 1); + req('play.seek', { progress: progress }, 8000).then(function () { + S.progress = progress; + }).catch(function (e) { + toast(e.message, 'err', 3800); + }); + } + + function setSpeedScale(v, opts) { + S.speedScale = Geo.clamp(+v, Geo.SPEED_MIN, Geo.SPEED_MAX); + $('#speedValue').textContent = Geo.fmtSpeedScale(S.speedScale); + $('#liveScale').textContent = Geo.fmtSpeedScale(S.speedScale); + $('#speedRange').value = Math.round(Geo.speedToSlider(S.speedScale) * 1000); + if (S.route) S.route.speed_scale = S.speedScale; + updateStats(); + savePrefs(); + if (S.playing && !(opts && opts.silent)) { + req('play.speed', { speed_scale: S.speedScale }, 8000).catch(function (e) { + pushLog('warn', '设置播放倍速失败:' + e.message); + }); + } + } + + function toggleLoop() { + S.loop = $('#loopToggle').checked; + savePrefs(); + if (S.playing || S.paused) { + // 契约没有单独的 loop 命令:带上当前进度重新开始播放以应用循环设置 + var route = ensureRoute(); + if (!route) return; + var idx = S.status && isFinite(S.status.index) ? S.status.index : 0; + route.loop = S.loop; + pushLog('info', '循环设置已变更,正在按当前位置重新开始播放…'); + req('play.start', { + route: routePayload(route), + speed_scale: S.speedScale, + loop: S.loop, + start_index: idx + }, 15000).then(function () { + S.playing = true; + S.paused = false; + updateTransport(); + }).catch(function (e) { + toast('应用循环设置失败:' + e.message, 'err'); + }); + } else { + pushLog('info', S.loop ? '已开启循环播放' : '已关闭循环播放'); + } + } + + function updateProgressChip() { + var pct = (Geo.clamp(S.progress, 0, 1) * 100).toFixed(1) + '%'; + var d = (S.status && isFinite(S.status.distance_done)) ? S.status.distance_done : S.distanceDone; + $('#chipProgress').textContent = '进度 ' + pct + (isFinite(d) && d > 0 ? ' · 已走 ' + Geo.fmtDistance(d) : ''); + } + + function onStatus(st) { + // play.stop 之后服务端可能还会推一条最终状态(progress=1)。用户已点「停止」, + // 这里在 4 秒窗口内忽略非播放态的状态推送,避免进度条/定位点被“弹回终点”。 + if (S.stoppedAt) { + if (st.playing) { + S.stoppedAt = 0; + } else if (Date.now() - S.stoppedAt < 4000) { + updateTransport(); + return; + } else { + S.stoppedAt = 0; + } + } + // seq 自增:丢弃乱序/重复的旧状态 + if (isFinite(st.seq)) { + if (S.statusSeq != null && st.seq <= S.statusSeq) return; + S.statusSeq = st.seq; + } + S.status = st; + var wasPlaying = S.playing; + S.playing = !!st.playing; + S.paused = !!st.paused; + // 服务端回报的真实感参数(可能有别的客户端改过),状态变化时同步到开关 + if (st.tuning) { + var t = st.tuning; + var changed = (typeof t.drift_enabled === 'boolean' && t.drift_enabled !== S.driftEnabled) || + (isFinite(t.drift_m) && Math.round(t.drift_m) !== S.driftM) || + (typeof t.speed_jitter_enabled === 'boolean' && t.speed_jitter_enabled !== S.jitterEnabled) || + (isFinite(t.speed_jitter_percent) && Math.round(t.speed_jitter_percent) !== S.jitterPercent) || + (typeof t.timing_jitter_enabled === 'boolean' && t.timing_jitter_enabled !== S.timingJitter) || + (typeof t.derive_speed === 'boolean' && t.derive_speed !== S.deriveSpeed) || + (typeof t.gross_error_enabled === 'boolean' && t.gross_error_enabled !== S.grossError); + if (changed) { + if (typeof t.drift_enabled === 'boolean') S.driftEnabled = t.drift_enabled; + if (isFinite(t.drift_m)) S.driftM = Math.round(t.drift_m); + if (typeof t.speed_jitter_enabled === 'boolean') S.jitterEnabled = t.speed_jitter_enabled; + if (isFinite(t.speed_jitter_percent)) S.jitterPercent = Math.round(t.speed_jitter_percent); + if (typeof t.timing_jitter_enabled === 'boolean') S.timingJitter = t.timing_jitter_enabled; + if (typeof t.derive_speed === 'boolean') S.deriveSpeed = t.derive_speed; + if (typeof t.gross_error_enabled === 'boolean') S.grossError = t.gross_error_enabled; + if (typeof applyTuningUi === 'function') applyTuningUi(); + } + } + if (isFinite(st.speed_scale) && Math.abs(st.speed_scale - S.speedScale) > 1e-6 && !S.speedDragging) { + // 服务端为准(例如其他客户端改过倍速) + S.speedScale = Geo.clamp(st.speed_scale, Geo.SPEED_MIN, Geo.SPEED_MAX); + applyPrefsToUI(); + updateStats(); + } + if (isFinite(st.progress) && !S.scrubbing) { + S.progress = st.progress; + $('#progressRange').value = Math.round(st.progress * 1000); + $('#liveProgress').textContent = (st.progress * 100).toFixed(1) + '%'; + updateProgressChip(); + } + if (isFinite(st.elapsed)) $('#timeElapsed').textContent = Geo.fmtDuration(st.elapsed); + if (isFinite(st.remaining)) $('#timeRemaining').textContent = Geo.fmtDuration(st.remaining); + if (isFinite(st.index)) { + var total = isFinite(st.total) ? st.total : (currentPath() || []).length; + $('#liveIndex').textContent = st.index + ' / ' + total; + S.map.setPlayedIndex(st.index); + } + if (isFinite(st.lng) && isFinite(st.lat)) { + S.map.setCurrent(st.lng, st.lat); + $('#liveCoord').textContent = Geo.fmtLngLat(st.lng, st.lat, 6); + $('#chipCoord').textContent = Geo.fmtLngLat(st.lng, st.lat, 6); + } + if (isFinite(st.distance_done)) S.distanceDone = st.distance_done; + updateProgressChip(); + if (st.loop != null) { + S.loop = !!st.loop; + $('#loopToggle').checked = S.loop; + } + if (!wasPlaying && S.playing) pushLog('info', '轨迹播放中…'); + if (wasPlaying && !S.playing && st.progress >= 1) { + pushLog('success', '轨迹播放完成' + (S.loop ? '(循环继续)' : '')); + } + updateTransport(); + } + + function onLocation(loc) { + S.lastLocation = loc; + // play.stop / location.clear 后服务端会推 source:"cleared" 且 lng/lat 为 null + if (loc.source === 'cleared' || loc.lng == null || loc.lat == null) { + if (loc.source === 'cleared') { + S.map.setCurrent(null); + $('#liveCoord').textContent = '--'; + $('#chipCoord').textContent = '--'; + $('#liveSpeed').textContent = '--'; + $('#chipSpeed').textContent = '速度 --'; + pushLog('info', '设备已恢复真实定位'); + } + return; + } + if (isFinite(loc.lng) && isFinite(loc.lat)) { + S.map.setCurrent(loc.lng, loc.lat); + $('#liveCoord').textContent = Geo.fmtLngLat(loc.lng, loc.lat, 6); + $('#chipCoord').textContent = Geo.fmtLngLat(loc.lng, loc.lat, 6); + if (loc.source === 'play') { + S.map.pushTrack(loc.lng, loc.lat); + $('#chipTrack').textContent = '轨迹点 ' + S.map.layers.track.length; + } + } + if (isFinite(loc.speed)) { + $('#liveSpeed').textContent = Geo.fmtSpeed(loc.speed); + $('#chipSpeed').textContent = '速度 ' + Geo.fmtSpeed(loc.speed); + } + if (isFinite(loc.progress) && !S.scrubbing && !S.stoppedAt) { + S.progress = loc.progress; + $('#progressRange').value = Math.round(loc.progress * 1000); + $('#liveProgress').textContent = (loc.progress * 100).toFixed(1) + '%'; + updateProgressChip(); + } + if (loc.source === 'manual') { + pushLog('success', '单点定位已下发:' + Geo.fmtLngLat(loc.lng, loc.lat, 6)); + } + } + + function updateTransport() { + var hasRoute = !!(S.route || S.drawnPath.length >= 2 || S.waypoints.length >= 2); + var playing = S.playing && !S.paused; + $('#btnPlay').disabled = playing || !hasRoute; + $('#btnPause').disabled = !playing; + $('#btnResume').disabled = !(S.playing && S.paused); + $('#btnStop').disabled = !(S.playing || S.paused); + $('#progressRange').disabled = !(S.playing || S.paused); + + var stateText = '未播放'; + var cls = ''; + if (S.playing && !S.paused) { stateText = '播放中'; cls = 'is-playing'; } + else if (S.playing && S.paused) { stateText = '已暂停'; cls = 'is-paused'; } + else if (S.progress >= 1 && hasRoute) { stateText = '已结束'; } + $('#playStateText').textContent = stateText; + $('#playStateText').className = 'play-state ' + cls; + $('#chipPlayState').textContent = stateText; + $('#chipPlayState').className = 'chip ' + cls; + } + + function setSingleLocation(lng, lat, index) { + req('location.set', { lng: Coord.round6(lng), lat: Coord.round6(lat) }, 10000).then(function () { + S.map.setCurrent(lng, lat); + pushLog('success', '已让设备定位到第 ' + (index + 1) + ' 个途经点:' + Geo.fmtLngLat(lng, lat, 6)); + toast('已下发单点定位', 'ok'); + }).catch(function (e) { + pushLog('error', '单点定位失败:' + e.message); + toast(e.message, 'err', 4600); + }); + } + + function clearLocation() { + confirmModal('恢复真实定位', '将停止模拟并让手机恢复 GPS 真实定位,确定继续?', function () { + if (S.playing || S.paused) stopPlay(true); + req('location.clear', {}, 10000).then(function () { + pushLog('success', '已发送「恢复真实定位」'); + toast('设备已恢复真实定位', 'ok'); + }).catch(function (e) { + pushLog('warn', '恢复真实定位失败:' + e.message); + toast(e.message, 'err', 4600); + }); + }, '恢复'); + } + + /* ================================================================== */ + /* GPX 导入导出 */ + /* ================================================================== */ + + function exportGpx() { + var route = ensureRoute(); + if (!route) { + toast('没有可导出的路线,请先添加途经点或绘制折线', 'warn', 4200); + return; + } + var name = S.routeName || route.name || '未命名轨迹'; + var btn = $('#btnExportGpx'); + setBusy(btn, true, '导出中…'); + pushLog('info', '正在导出 GPX:' + name + '(' + route.path.length + ' 点)'); + fetch('/api/gpx/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ route: routePayload(route), name: name }) + }).then(function (res) { + var ct = res.headers.get('content-type') || ''; + if (ct.indexOf('json') >= 0) { + return res.json().then(function (j) { + throw new Error(j.error || '导出失败'); + }); + } + if (!res.ok) throw new Error('导出失败 HTTP ' + res.status); + return res.text().then(function (text) { + var filename = (name.replace(/[\\/:*?"<>|]/g, '_') || 'route') + '.gpx'; + downloadText(text, filename, 'application/gpx+xml;charset=utf-8'); + pushLog('success', 'GPX 已开始下载:' + filename + '(' + text.length + ' 字节)'); + toast('GPX 已开始下载', 'ok'); + }); + }).catch(function (e) { + pushLog('error', '导出 GPX 失败:' + e.message); + toast(e.message, 'err', 4600); + }).then(function () { setBusy(btn, false); }); + } + + function importGpx() { + $('#gpxFile').value = ''; + $('#gpxFile').click(); + } + + function onGpxFile(ev) { + var file = ev.target.files && ev.target.files[0]; + if (!file) return; + pushLog('info', '正在导入 GPX:' + file.name + '(' + file.size + ' 字节)'); + var reader = new FileReader(); + reader.onload = function () { + var text = String(reader.result || ''); + fetch('/api/gpx/import', { + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + body: text + }).then(function (res) { + return res.json(); + }).then(function (data) { + if (!data || data.ok === false) throw new Error((data && data.error) || '导入失败'); + var wps = (data.waypoints || []).map(function (p) { + return [Coord.round6(p[0]), Coord.round6(p[1])]; + }); + var full = (data.path || data.track || data.points || []).map(function (p) { + return [Coord.round6(p[0]), Coord.round6(p[1])]; + }); + if (!wps.length && !full.length) throw new Error('GPX 中没有可用的坐标点'); + pushUndo(); + S.route = null; + var geom = full.length >= wps.length ? full : wps; + var MAX_WP = 200; + if (geom.length > MAX_WP) { + // 大轨迹(例如把导出的整条路线再导回来):完整几何用于显示/播放/导出, + // 途经点只保留起终点,否则列表会渲染上万行 + S.waypoints = [geom[0], geom[geom.length - 1]]; + S.drawnPath = geom; + } else { + S.waypoints = wps.length >= 2 ? wps : geom; + // 服务端只回起点/终点时,用完整轨迹点保证导入后的形状可播放、可导出 + S.drawnPath = geom.length > S.waypoints.length ? geom : []; + } + S.selectedWp = -1; + if (data.name) { + S.routeName = data.name; + $('#routeName').value = data.name; + savePrefs(); + } + syncMapLayers(); + renderWaypoints(); + updateStats(); + S.map.fitBounds(wps, { padding: 60 }); + var note = S.drawnPath.length ? '(完整几何 ' + S.drawnPath.length + ' 点,途经点保留 ' + S.waypoints.length + ' 个)' : ''; + pushLog('success', 'GPX 导入成功:' + (data.count || wps.length) + ' 个点' + note + + (data.name ? ',名称「' + data.name + '」' : '')); + toast('已导入 ' + (data.count || wps.length) + ' 个点' + (note ? ',轨迹已按完整几何载入' : ''), 'ok', 4200); + if (wps.length >= 2) buildRoute(); + }).catch(function (e) { + pushLog('error', 'GPX 导入失败:' + e.message); + toast(e.message, 'err', 4600); + }); + }; + reader.onerror = function () { + toast('读取文件失败', 'err'); + }; + reader.readAsText(file); + } + + /* ================================================================== */ + /* 本地保存的路线 */ + /* ================================================================== */ + + function renderSavedRoutes() { + var list = readSavedRoutes(); + var sel = $('#savedRoutes'); + sel.innerHTML = ''; + if (!list.length) { + var o = document.createElement('option'); + o.value = ''; + o.textContent = '(暂无保存的路线)'; + sel.appendChild(o); + sel.disabled = true; + } else { + sel.disabled = false; + list.forEach(function (it, i) { + var op = document.createElement('option'); + op.value = String(i); + op.textContent = (it.name || ('路线 ' + (i + 1))) + ' · ' + + new Date(it.ts || Date.now()).toLocaleString('zh-CN', { hour12: false }); + sel.appendChild(op); + }); + } + $('#btnLoadRoute').disabled = !list.length; + $('#btnDeleteSaved').disabled = !list.length; + } + + function saveRouteLocal() { + var route = ensureRoute(); + if (!route && !S.waypoints.length) { + toast('没有可保存的内容', 'warn'); + return; + } + var name = ($('#routeName').value || '').trim() || ('轨迹 ' + new Date().toLocaleString('zh-CN', { hour12: false })); + S.routeName = name; + var list = readSavedRoutes(); + var payload = { + name: name, + ts: Date.now(), + mode: S.mode, + speed_scale: S.speedScale, + loop: S.loop, + waypoints: S.waypoints, + drawnPath: S.drawnPath, + route: route + }; + var idx = -1; + list.forEach(function (it, i) { if (it.name === name) idx = i; }); + if (idx >= 0) list[idx] = payload; else list.push(payload); + if (writeSavedRoutes(list)) { + renderSavedRoutes(); + savePrefs(); + pushLog('success', '路线已保存到浏览器本地:' + name); + toast('已保存到本地:' + name, 'ok'); + } + } + + function loadRouteLocal() { + var list = readSavedRoutes(); + var i = parseInt($('#savedRoutes').value, 10); + if (!isFinite(i) || !list[i]) { toast('请先选择要载入的路线', 'warn'); return; } + var it = list[i]; + pushUndo(); + S.waypoints = (it.waypoints || []).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }); + S.drawnPath = (it.drawnPath || []).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }); + S.route = it.route || null; + S.routeName = it.name || ''; + $('#routeName').value = S.routeName; + if (it.mode && MODE_CN[it.mode]) { + S.mode = it.mode; + $$('#modeGroup .seg-btn').forEach(function (b) { b.classList.toggle('is-active', b.dataset.mode === S.mode); }); + } + if (isFinite(it.speed_scale)) setSpeedScale(it.speed_scale, { silent: true }); + if (typeof it.loop === 'boolean') { S.loop = it.loop; $('#loopToggle').checked = S.loop; } + syncMapLayers(); + renderWaypoints(); + updateStats(); + var pts = currentPath(); + if (pts && pts.length >= 2) S.map.fitBounds(pts, { padding: 60 }); + pushLog('success', '已载入本地路线:' + S.routeName); + toast('已载入:' + S.routeName, 'ok'); + } + + function deleteSavedRoute() { + var list = readSavedRoutes(); + var i = parseInt($('#savedRoutes').value, 10); + if (!isFinite(i) || !list[i]) { toast('请先选择要删除的路线', 'warn'); return; } + var name = list[i].name; + confirmModal('删除本地路线', '确定删除保存的路线「' + esc(name) + '」?此操作不可恢复。', function () { + list.splice(i, 1); + writeSavedRoutes(list); + renderSavedRoutes(); + pushLog('warn', '已删除本地路线:' + name); + }, '删除'); + } + + /* ================================================================== */ + /* UI 绑定 */ + /* ================================================================== */ + + function bindUI() { + // 顶部 + $('#btnDevicesRefresh').addEventListener('click', refreshDevices); + $('#btnDeviceConnect').addEventListener('click', deviceConnect); + $('#btnDeviceDisconnect').addEventListener('click', deviceDisconnect); + + // 模式 + $$('#modeGroup .seg-btn').forEach(function (b) { + b.addEventListener('click', function () { + S.mode = b.dataset.mode; + $$('#modeGroup .seg-btn').forEach(function (x) { x.classList.toggle('is-active', x === b); }); + savePrefs(); + updateStats(); + pushLog('info', '出行模式:' + (MODE_CN[S.mode] || S.mode)); + if (S.route && S.route.engine === 'osrm') { + toast('已切换为' + MODE_CN[S.mode] + ',如需按新模式的路线请重新加载', 'info', 3600); + } + }); + }); + + // 倍速 + var speedRange = $('#speedRange'); + speedRange.addEventListener('input', function () { + S.speedDragging = true; + var v = Geo.sliderToSpeed(speedRange.value / 1000); + S.speedScale = v; + $('#speedValue').textContent = Geo.fmtSpeedScale(v); + $('#liveScale').textContent = Geo.fmtSpeedScale(v); + updateStats(); + }); + speedRange.addEventListener('change', function () { + S.speedDragging = false; + setSpeedScale(Geo.sliderToSpeed(speedRange.value / 1000)); + }); + + $('#loopToggle').addEventListener('change', toggleLoop); + + // ---- 真实感模拟:定位漂移 / 速度浮动 ---- + var driftToggle = $('#driftToggle'); + var driftRange = $('#driftRange'); + var jitterToggle = $('#jitterToggle'); + var jitterRange = $('#jitterRange'); + + function applyTuningUi() { + driftToggle.checked = !!S.driftEnabled; + driftRange.value = S.driftM; + driftRange.disabled = !S.driftEnabled; + $('#driftValue').textContent = S.driftM + ' 米'; + jitterToggle.checked = !!S.jitterEnabled; + jitterRange.value = S.jitterPercent; + jitterRange.disabled = !S.jitterEnabled; + $('#jitterValue').textContent = '±' + S.jitterPercent + '%'; + var tj = $('#timingJitterToggle'); + if (tj) tj.checked = !!S.timingJitter; + var ds = $('#deriveSpeedToggle'); + if (ds) ds.checked = !!S.deriveSpeed; + var ge = $('#grossErrorToggle'); + if (ge) ge.checked = !!S.grossError; + var gr = $('#grossErrorRange'); + if (gr) { gr.value = S.grossErrorPercent; gr.disabled = !S.grossError; } + var gv = $('#grossErrorValue'); + if (gv) gv.textContent = S.grossErrorPercent + '%'; + } + + function pushTuning(withLog) { + applyTuningUi(); + savePrefs(); + if (!S.ws || !S.ws.isOpen()) return; + req('play.tuning', { + drift_enabled: !!S.driftEnabled, + drift_m: Number(S.driftM), + speed_jitter_enabled: !!S.jitterEnabled, + speed_jitter: Number(S.jitterPercent), + timing_jitter_enabled: !!S.timingJitter, + derive_speed: !!S.deriveSpeed, + gross_error_enabled: !!S.grossError, + gross_error_probability: Number(S.grossErrorPercent) / 100, + log: !!withLog + }, 8000).catch(function (e) { + pushLog('error', '真实感设置失败:' + e.message); + }); + } + + driftToggle.addEventListener('change', function () { + S.driftEnabled = driftToggle.checked; + pushTuning(true); + toast(S.driftEnabled ? ('已开启定位漂移(' + S.driftM + ' 米)') : '已关闭定位漂移', 'info', 2600); + }); + driftRange.addEventListener('input', function () { + S.driftM = Number(driftRange.value); + $('#driftValue').textContent = S.driftM + ' 米'; + }); + driftRange.addEventListener('change', function () { pushTuning(true); }); + + jitterToggle.addEventListener('change', function () { + S.jitterEnabled = jitterToggle.checked; + pushTuning(true); + toast(S.jitterEnabled ? ('已开启速度浮动(±' + S.jitterPercent + '%)') : '已关闭速度浮动', 'info', 2600); + }); + jitterRange.addEventListener('input', function () { + S.jitterPercent = Number(jitterRange.value); + $('#jitterValue').textContent = '±' + S.jitterPercent + '%'; + }); + jitterRange.addEventListener('change', function () { pushTuning(true); }); + + var timingToggle = $('#timingJitterToggle'); + if (timingToggle) { + timingToggle.addEventListener('change', function () { + S.timingJitter = timingToggle.checked; + pushTuning(true); + toast(S.timingJitter ? '已开启发送节奏抖动(贴近真实 GPS)' : '已关闭发送节奏抖动(精确等间隔)', 'info', 2600); + }); + } + var deriveToggle = $('#deriveSpeedToggle'); + if (deriveToggle) { + deriveToggle.addEventListener('change', function () { + S.deriveSpeed = deriveToggle.checked; + pushTuning(true); + toast(S.deriveSpeed ? '上报速度由位移推算(与 iOS 一致)' : '上报速度用基准速度×浮动系数', 'info', 3000); + }); + } + var grossToggle = $('#grossErrorToggle'); + var grossRange = $('#grossErrorRange'); + if (grossToggle) { + grossToggle.addEventListener('change', function () { + S.grossError = grossToggle.checked; + pushTuning(true); + toast(S.grossError ? ('已开启粗差注入(' + S.grossErrorPercent + '% 概率)') : '已关闭粗差注入', 'info', 3000); + }); + } + if (grossRange) { + grossRange.addEventListener('input', function () { + S.grossErrorPercent = Number(grossRange.value); + $('#grossErrorValue').textContent = S.grossErrorPercent + '%'; + }); + grossRange.addEventListener('change', function () { pushTuning(true); }); + } + + // ---- 校园跑 / 长跑预设:一键把参数调到适合运动类 App 的组合 ---- + function applyPreset(name) { + if (name === 'campus') { + S.mode = 'walking'; + S.driftEnabled = true; S.driftM = 4; + S.jitterEnabled = true; S.jitterPercent = 8; + } else { + S.mode = 'cycling'; + S.driftEnabled = true; S.driftM = 6; + S.jitterEnabled = true; S.jitterPercent = 10; + } + S.loop = false; + $$('#modeGroup .seg-btn').forEach(function (x) { + x.classList.toggle('is-active', x.dataset.mode === S.mode); + }); + $('#loopToggle').checked = false; + applyTuningUi(); + // 关键:用 1Hz 下发,与真实手机 GPS 给 App 的回调频率一致。 + // 发太快(5~10Hz)时每帧位移只有 0.14~0.28 米,运动类 App 会当成 GPS 抖动丢掉, + // 结果就是"地图上点在动,但公里数一直是 0"。1Hz 下步行每帧位移 1.4 米, + // 落在 App 里程过滤器的正常范围内。 + req('config.update', { + config: { + send_hz: 1, + drift_enabled: true, drift_m: S.driftM, + speed_jitter_enabled: true, speed_jitter: S.jitterPercent / 100 + } + }, 8000).then(function (res) { + if (res && res.config) applyConfig(res.config); + var label = name === 'campus' ? '校园跑预设' : '长跑预设'; + toast(label + '已启用:' + MODE_CN[S.mode] + ',下发 1Hz,漂移 ' + S.driftM + + ' 米,速度浮动 ±' + S.jitterPercent + '%', 'ok', 4400); + pushLog('info', '已应用' + label + '(' + MODE_CN[S.mode] + ',1Hz,漂移 ' + + S.driftM + 'm,浮动 ±' + S.jitterPercent + '%)'); + }).catch(function (e) { + pushLog('error', '预设设置失败:' + e.message); + }); + // 正在播放时让"真实感"参数立即生效 + if (S.playing || S.paused) pushTuning(false); + savePrefs(); + updateStats(); + } + + $('#btnCampusPreset').addEventListener('click', function () { applyPreset('campus'); }); + $('#btnMarathonPreset').addEventListener('click', function () { applyPreset('run'); }); + + // ---- 快速定位到坐标(校园跑:先把地图移到学校范围)---- + function parseCoord(text) { + if (!text) return null; + var parts = String(text).replace(/[,、]/g, ',').split(/[\s,]+/).filter(function (x) { return x !== ''; }); + if (parts.length < 2) return null; + var lat = parseFloat(parts[0]), lng = parseFloat(parts[1]); + if (!isFinite(lat) || !isFinite(lng)) return null; + if (lat < -85 || lat > 85 || lng < -180 || lng > 180) return null; + return { lat: lat, lng: lng }; + } + + var PLACES_KEY = 'iosloc.places.v1'; + + function loadPlaces() { + try { return JSON.parse(localStorage.getItem(PLACES_KEY) || '[]') || []; } catch (e) { return []; } + } + + function renderPlaces() { + var sel = $('#placeSelect'); + if (!sel) return; + var list = loadPlaces(); + sel.innerHTML = ''; + if (!list.length) { + var opt = document.createElement('option'); + opt.value = ''; opt.textContent = '(暂无记住的地点)'; + sel.appendChild(opt); + return; + } + list.forEach(function (p, i) { + var item = document.createElement('option'); + item.value = String(i); + item.textContent = p.name + '(' + p.lat.toFixed(5) + ', ' + p.lng.toFixed(5) + ')'; + sel.appendChild(item); + }); + } + + $('#btnJumpCoord').addEventListener('click', function () { + var c = parseCoord($('#jumpCoord').value); + if (!c) { toast('坐标格式不对,请填「纬度, 经度」,例如 30.2741, 120.1551', 'err', 4200); return; } + S.map.setView(c.lng, c.lat, Math.max(S.map.getZoom(), 16), true); + toast('已跳转到 ' + c.lat.toFixed(5) + ', ' + c.lng.toFixed(5), 'ok', 2600); + pushLog('info', '地图已跳转到 ' + c.lat.toFixed(6) + ', ' + c.lng.toFixed(6)); + }); + + $('#btnSavePlace').addEventListener('click', function () { + var c = parseCoord($('#jumpCoord').value); + if (!c) { toast('请先填一个有效坐标', 'err', 3200); return; } + var name = prompt('给这个地点起个名字', '学校'); + if (name === null) return; + var list = loadPlaces().filter(function (p) { return p.name !== name; }); + list.push({ name: name || '未命名', lat: c.lat, lng: c.lng }); + try { localStorage.setItem(PLACES_KEY, JSON.stringify(list)); } catch (e) { /* 忽略 */ } + renderPlaces(); + toast('已记住「' + (name || '未命名') + '」', 'ok', 2400); + }); + + $('#btnGoPlace').addEventListener('click', function () { + var idx = $('#placeSelect').value; + var list = loadPlaces(); + if (idx === '' || !list[Number(idx)]) { toast('还没有记住的地点', 'warn', 3000); return; } + var p = list[Number(idx)]; + S.map.setView(p.lng, p.lat, Math.max(S.map.getZoom(), 16), true); + toast('已跳转到「' + p.name + '」', 'ok', 2600); + }); + + renderPlaces(); + + // 轨迹名称 + $('#routeName').addEventListener('input', function () { + S.routeName = $('#routeName').value; + $('#routeMeta').textContent = S.routeName || '未命名轨迹'; + savePrefs(); + }); + + // 路线按钮 + $('#btnBuildRoute').addEventListener('click', buildRoute); + $('#btnClear').addEventListener('click', function () { + confirmModal('清空轨迹', '将清空途经点、路线、绘制内容与轨迹点,确定继续?', function () { + pushUndo(); + S.waypoints = []; + S.route = null; + S.drawnPath = []; + S.polyline = []; + S.drawing = false; + S.selectedWp = -1; + S.map.clearTrack(); + S.map.setCurrent(null); + syncMapLayers(); + renderWaypoints(); + updateStats(); + setDrawMode('select'); + pushLog('warn', '已清空当前轨迹'); + }, '清空'); + }); + $('#btnExportGpx').addEventListener('click', exportGpx); + $('#btnImportGpx').addEventListener('click', importGpx); + $('#gpxFile').addEventListener('change', onGpxFile); + $('#btnClearLocation').addEventListener('click', clearLocation); + + // 本地路线 + $('#btnSaveRoute').addEventListener('click', saveRouteLocal); + $('#btnLoadRoute').addEventListener('click', loadRouteLocal); + $('#btnDeleteSaved').addEventListener('click', deleteSavedRoute); + $('#savedRoutes').addEventListener('dblclick', loadRouteLocal); + + // 底图 + $('#tileSourceSelect').addEventListener('change', function () { + applyTileSource($('#tileSourceSelect').value, false); + }); + + // 绘制工具条 + $$('#drawToolbar .tool-btn[data-tool]').forEach(function (b) { + b.addEventListener('click', function () { + setDrawMode(b.dataset.tool); + if (b.dataset.tool === 'polyline') startPolyline(); + }); + }); + $('#toolUndo').addEventListener('click', undo); + $('#toolDelete').addEventListener('click', function () { + if (S.selectedWp < 0) { toast('请先在地图或列表中选择一个途经点', 'warn'); return; } + removeWaypoint(S.selectedWp); + }); + $('#toolFit').addEventListener('click', function () { + var pts = currentPath(); + if (pts && pts.length >= 2) S.map.fitBounds(pts, { padding: 60 }); + else toast('当前没有可全览的轨迹', 'warn'); + }); + + // 播放条 + $('#btnPlay').addEventListener('click', startPlay); + $('#btnPause').addEventListener('click', pausePlay); + $('#btnResume').addEventListener('click', resumePlay); + $('#btnStop').addEventListener('click', function () { stopPlay(false); }); + + var pr = $('#progressRange'); + pr.addEventListener('input', function () { + S.scrubbing = true; + var p = pr.value / 1000; + $('#liveProgress').textContent = (p * 100).toFixed(1) + '%'; + if (S.status && isFinite(S.status.elapsed) && isFinite(S.status.remaining)) { + var total = S.status.elapsed + S.status.remaining; + $('#timeElapsed').textContent = Geo.fmtDuration(total * p); + $('#timeRemaining').textContent = Geo.fmtDuration(total * (1 - p)); + } + }); + var commitSeek = function () { + if (!S.scrubbing) return; + S.scrubbing = false; + seek(pr.value / 1000); + }; + pr.addEventListener('change', commitSeek); + pr.addEventListener('pointerup', commitSeek); + + // 设备与缓存(契约外的补充命令) + $('#btnTunnelEnsure').addEventListener('click', tunnelEnsure); + $('#btnTunnelManual').addEventListener('click', tunnelManual); + // iOS 17 以下:挂载开发者镜像(后台执行,过程日志实时推送) + $('#btnMountImage').addEventListener('click', function () { + var btn = this; + var dev = S.device; + if (!dev) { toast('请先连接设备', 'err', 3000); return; } + if ((dev.ios_major || 0) < 17) { + toast('本工具只支持 iOS 17 及以上;你的设备是 iOS ' + dev.ios_version + + ',其镜像需由 Apple 按构建号签发,请用 Xcode 连接一次自动准备', 'err', 6000); + return; + } + btn.disabled = true; + pushLog('info', '开始挂载开发者镜像:' + (dev.name || '') + '(iOS ' + (dev.ios_version || '?') + ')'); + toast('正在挂载开发者镜像,过程见右侧日志…', 'info', 3200); + req('device.mount_image', {}, 20000).then(function () { + // 结果通过 mounted 事件 / 日志推送,这里只做超时兜底恢复 + setTimeout(function () { btn.disabled = false; }, 3000); + }).catch(function (e) { + btn.disabled = false; + pushLog('error', '挂载请求失败:' + e.message); + toast(e.message, 'err', 4200); + }); + }); + + $('#btnTilesClear').addEventListener('click', tilesClear); + + // 日志面板 + $('#btnClearLog').addEventListener('click', function () { + S.logs = []; + lastLog = null; + $('#logBody').innerHTML = ''; + $('#logCount').textContent = '0 / ' + LOG_MAX; + }); + $('#btnFetchLogs').addEventListener('click', function () { + req('log.list', { limit: 200 }, 8000).then(function (msg) { + if (msg.lines) setLogs(msg.lines); + }).catch(function (e) { + toast('拉取日志失败:' + e.message, 'err'); + }); + }); + $('#btnToggleLog').addEventListener('click', toggleLogPanel); + $('#logPanel').addEventListener('click', function (ev) { + if (S.logCollapsed && !ev.target.closest('button')) toggleLogPanel(); + }); + + // 键盘 + document.addEventListener('keydown', onKeyDown); + } + + function tunnelEnsure() { + var btn = $('#btnTunnelEnsure'); + setBusy(btn, true, '建立中…'); + pushLog('info', '正在请求建立隧道(iOS 17+ 需要管理员权限)…'); + req('tunnel.ensure', {}, 120000).then(function (msg) { + if (msg.device) { S.device = msg.device; } + if (msg.tunnel) { + S.tunnel = msg.tunnel; + if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel; + } + // need_tunnel === false:mock 模式或 iOS<17 经典通道,无需等待隧道 + if (msg.need_tunnel === false) { + if (tunnelWatch) { clearInterval(tunnelWatch); tunnelWatch = null; } + S.tunnelStuck = false; + pushLog('info', '服务端无需建立 RSD 隧道:' + ((msg.tunnel && msg.tunnel.message) || '当前不需要隧道')); + } + renderDevice(); + updateBanner(); + syncDeviceFromServer(true); + pushLog('success', '隧道指令已完成:' + ((msg.tunnel && (msg.tunnel.message || msg.tunnel.state)) || '已连接')); + toast((msg.tunnel && msg.tunnel.message) || '隧道已建立', 'ok', 4200); + }).catch(function (e) { + pushLog('error', '建立隧道失败:' + e.message); + toast(e.message, 'err', 5200); + }).then(function () { setBusy(btn, false); }); + } + + function tunnelManual() { + openModal({ + title: '手动填写 RSD 地址', + okText: '连接', + html: + '
' + + '
' + + '
iOS 17 及以上需要 RSD 隧道。可先用管理员权限执行 ' + + 'pymobiledevice3 lockdown start-tunnel,把输出的地址与端口填到这里。
', + onOk: function () { + var host = ($('#m_rsd_host').value || '').trim(); + var port = parseInt($('#m_rsd_port').value, 10); + if (!host || !isFinite(port) || port <= 0 || port > 65535) { + toast('请填写正确的主机与端口', 'err'); + return false; + } + var btn = $('#btnTunnelManual'); + setBusy(btn, true, '连接中…'); + req('tunnel.manual', { host: host, port: port }, 60000).then(function (msg) { + if (msg.device) S.device = msg.device; + if (msg.tunnel) { + S.tunnel = msg.tunnel; + if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel; + } + renderDevice(); + updateBanner(); + pushLog('success', '已按手动 RSD 地址连接:' + host + ':' + port); + toast('已连接手动 RSD 地址', 'ok'); + }).catch(function (e) { + pushLog('error', '手动 RSD 连接失败:' + e.message); + toast(e.message, 'err', 5200); + }).then(function () { setBusy(btn, false); }); + return true; + } + }); + } + + function tilesClear() { + var btn = $('#btnTilesClear'); + setBusy(btn, true, '清理中…'); + req('tiles.clear', {}, 30000).then(function () { + S.map.retryFailedTiles(); + pushLog('success', '服务端瓦片缓存已清理,正在重新拉取底图'); + toast('瓦片缓存已清理', 'ok'); + }).catch(function (e) { + pushLog('warn', '清理瓦片缓存失败:' + e.message); + toast(e.message, 'err', 4200); + }).then(function () { setBusy(btn, false); }); + } + + function toggleLogPanel() { + setLogCollapsed(!S.logCollapsed); + } + + function setLogCollapsed(collapsed, silent) { + S.logCollapsed = !!collapsed; + $('#workspace').classList.toggle('log-collapsed', S.logCollapsed); + var btn = $('#btnToggleLog'); + btn.textContent = S.logCollapsed ? '‹' : '折叠'; + btn.title = S.logCollapsed ? '展开日志面板' : '折叠日志面板'; + if (!S.logCollapsed) setTimeout(function () { $('#logBody').scrollTop = $('#logBody').scrollHeight; }, 30); + if (S.map) setTimeout(function () { S.map.resize(); }, 60); + if (!silent) savePrefs(); + } + + function onKeyDown(e) { + var tag = (e.target && e.target.tagName) || ''; + var typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + if (e.key === 'Escape') { + if (!$('#modalMask').classList.contains('hidden')) { closeModal(); return; } + if (S.drawing) { finishPolyline(); return; } + if (typing) { e.target.blur(); return; } + setDrawMode('select'); + return; + } + if (typing) return; + if (e.key === ' ' || e.code === 'Space') { + e.preventDefault(); + if (S.playing && !S.paused) pausePlay(); + else if (S.playing && S.paused) resumePlay(); + else startPlay(); + } else if (e.key === 'Delete' || e.key === 'Backspace') { + if (S.selectedWp >= 0) { e.preventDefault(); removeWaypoint(S.selectedWp); } + } else if (e.key === '+' || e.key === '=') { + S.map.setZoom(S.map.getZoom() + 1); + } else if (e.key === '-' || e.key === '_') { + S.map.setZoom(S.map.getZoom() - 1); + } + } + + /* ================================================================== */ + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // 便于在控制台排查 + window.__IOSLOC = { S: S, toast: toast, pushLog: pushLog }; +})(); diff --git a/web/js/coord.js b/web/js/coord.js new file mode 100644 index 0000000..ce12f95 --- /dev/null +++ b/web/js/coord.js @@ -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); diff --git a/web/js/map.js b/web/js/map.js new file mode 100644 index 0000000..c37a99f --- /dev/null +++ b/web/js/map.js @@ -0,0 +1,1186 @@ +/*! + * map.js — 自绘滑动地图引擎(Canvas 2D,无任何外部库) + * + * 特性: + * - Web Mercator 投影,256px 瓦片,z3..z19(受瓦片源 max_zoom 限制),devicePixelRatio 适配 + * - 拖拽平移(带惯性)、滚轮以鼠标为锚点缩放、双击放大、缩放控件、比例尺、经纬度/层级读数 + * - 瓦片走本地服务端代理 GET /api/tiles/{source}/{z}/{x}/{y}.png + * 失败占位 + 最多 3 次退避重试 + 连续失败自动暂停加载(防止重试风暴)+ 一键重试 + * - 瓦片 LRU 内存缓存,淘汰时释放 Image + * - 图层:路径折线(path)、轨迹折线、已播放段高亮、途经点(可拖拽/选中)、起终点特殊图标、当前定位点(脉冲) + * + * 坐标:对外全部 WGS84(与 docs/PROTOCOL.md 一致)。瓦片源为 GCJ-02 时, + * 仅在「投影/绘制」这一步用 Coord.toDisplay 偏移,鼠标取点用 Coord.fromDisplay 还原。 + * + * 导出:window.SlippyMap + */ +(function (global) { + 'use strict'; + + var TILE = 256; + var MIN_ZOOM = 3; + var HARD_MAX_ZOOM = 19; + var MAX_PARALLEL_LOADS = 6; + var MAX_TILE_TRIES = 3; + var CACHE_LIMIT = 360; // 内存中最多保留的瓦片张数 + var FAIL_STREAK_LIMIT = 6; // 连续失败达到该值 → 暂停 6s + var LOAD_PAUSE_MS = 6000; + + var SCALE_STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, + 20000, 50000, 100000, 200000, 500000, 1000000, 2000000, 5000000]; + + var COLORS = { + bg: '#0b0f14', + tileEmpty: '#141a22', + tileBorder: '#1e2632', + tileFail: '#2a1a20', + tileFailMark: '#f85149', + pathOutline: '#08111f', + path: '#2f81f7', + playedOutline: '#08110a', + played: '#3fb950', + track: '#d29922', + wpStart: '#2ea043', + wpEnd: '#f85149', + wpMid: '#1f6feb', + wpSelected: '#ffd33d', + current: '#58a6ff' + }; + + function now() { + return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); + } + + /* ------------------------------------------------------------------ */ + /* 构造 */ + /* ------------------------------------------------------------------ */ + + function SlippyMap(container, opts) { + opts = opts || {}; + var self = this; + this.el = typeof container === 'string' ? document.querySelector(container) : container; + if (!this.el) throw new Error('地图容器不存在'); + + this.el.classList.add('slippy-map'); + this.canvas = document.createElement('canvas'); + this.canvas.className = 'map-canvas'; + this.el.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + // 视图状态 + this.center = { lng: 116.397428, lat: 39.90923 }; + this.zoom = opts.zoom || 12; + this.minZoom = opts.minZoom || MIN_ZOOM; + this.maxZoom = HARD_MAX_ZOOM; + + // 瓦片源 + this.source = { + key: '', name: '未选择瓦片源', url: '', max_zoom: 18, + attribution: '', datum: 'wgs84' + }; + + // 图层数据(全部 WGS84) + this.layers = { + path: [], + pathColor: COLORS.path, + track: [], + waypoints: [], + selected: -1, + current: null, + playedIndex: -1, + showTrack: true + }; + + this.waypointsDraggable = true; + this.doubleClickZoom = true; + this.drawMode = 'select'; + + // 交互状态 + this.pointers = new Map(); + this.dragging = false; + this.dragStart = null; + this.dragWp = null; + this.pinch = null; + this.velocity = { x: 0, y: 0 }; + this.inertiaOn = false; + this.mouse = null; + + // 瓦片状态 + this.tiles = new Map(); // key -> entry + this.queue = []; + this.activeLoads = 0; + this.failStreak = 0; + this.failTotal = 0; + this.loadPausedUntil = 0; + + // 渲染状态 + this.w = 1; this.h = 1; this.dpr = 1; + this._dirty = true; + this._rafId = null; + this._animOn = false; + this.handlers = Object.create(null); + + this._buildControls(); + this._bindEvents(); + this.resize(); + + if (global.ResizeObserver) { + this._ro = new ResizeObserver(function () { self.resize(); }); + this._ro.observe(this.el); + } else { + this._onWinResize = function () { self.resize(); }; + global.addEventListener('resize', this._onWinResize); + } + } + + /* ------------------------------------------------------------------ */ + /* 事件订阅 */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype.on = function (type, fn) { + (this.handlers[type] || (this.handlers[type] = [])).push(fn); + return this; + }; + + SlippyMap.prototype._emit = function (type, data) { + var list = this.handlers[type]; + if (!list) return; + for (var i = 0; i < list.length; i++) { + try { list[i](data); } catch (e) { + if (global.console) global.console.error('[map] 事件异常 ' + type, e); + } + } + }; + + /* ------------------------------------------------------------------ */ + /* 投影(Web Mercator) */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype._projectUnit = function (lng, lat) { + var la = Math.max(-85.05112878, Math.min(85.05112878, +lat)); + var x = (lng + 180) / 360; + var s = Math.sin(la * Math.PI / 180); + var y = 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI); + return { x: x, y: y }; + }; + + SlippyMap.prototype._unprojectUnit = function (x, y) { + var lng = x * 360 - 180; + var n = Math.PI - 2 * Math.PI * y; + var lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + return { lng: lng, lat: lat }; + }; + + /** WGS84 → 显示基准 unit(GCJ-02 源会做一次偏移;按坐标缓存,避免每帧重复转换上万次) */ + SlippyMap.prototype._displayUnit = function (lng, lat) { + var datum = this.source.datum; + if (this._duDatum !== datum) { + this._duDatum = datum; + this._duCache = new Map(); + } + var key = lng + ',' + lat; + var hit = this._duCache.get(key); + if (hit) return hit; + var d = Coord.toDisplay(lng, lat, datum); + var u = this._projectUnit(d[0], d[1]); + if (this._duCache.size > 40000) this._duCache.clear(); + this._duCache.set(key, u); + return u; + }; + + /** 显示基准 unit → WGS84 */ + SlippyMap.prototype._unitToWgs = function (u) { + var ll = this._unprojectUnit(u.x, u.y); + var w = Coord.fromDisplay(ll.lng, ll.lat, this.source.datum); + return { lng: w[0], lat: w[1] }; + }; + + SlippyMap.prototype._worldPx = function (zoom) { + if (zoom == null || zoom === this.zoom) { + if (this._wpZoom !== this.zoom) { + this._wpZoom = this.zoom; + this._wp = TILE * Math.pow(2, this.zoom); + } + return this._wp; + } + return TILE * Math.pow(2, zoom); + }; + + SlippyMap.prototype._centerUnit = function () { + var key = this.center.lng + ',' + this.center.lat + ',' + this.source.datum; + if (this._cuKey === key && this._cu) return this._cu; + this._cu = this._displayUnit(this.center.lng, this.center.lat); + this._cuKey = key; + return this._cu; + }; + + /** WGS84 → 屏幕 CSS 像素坐标 */ + SlippyMap.prototype.lngLatToPoint = function (lng, lat) { + var c = this._centerUnit(); + var u = this._displayUnit(lng, lat); + var wp = this._worldPx(); + return { x: (u.x - c.x) * wp + this.w / 2, y: (u.y - c.y) * wp + this.h / 2 }; + }; + + /** 屏幕 CSS 像素坐标 → WGS84 */ + SlippyMap.prototype.pointToLngLat = function (x, y) { + var c = this._centerUnit(); + var wp = this._worldPx(); + var u = { x: c.x + (x - this.w / 2) / wp, y: c.y + (y - this.h / 2) / wp }; + return this._unitToWgs(u); + }; + + SlippyMap.prototype.getCenter = function () { + return { lng: this.center.lng, lat: this.center.lat }; + }; + + SlippyMap.prototype.getZoom = function () { return this.zoom; }; + + SlippyMap.prototype.setView = function (lng, lat, zoom, silent) { + if (isFinite(lng)) this.center.lng = +lng; + if (isFinite(lat)) this.center.lat = Math.max(-85, Math.min(85, +lat)); + if (isFinite(zoom)) this.zoom = this._clampZoom(+zoom); + this.invalidate(); + if (!silent) this._emit('viewchange', { center: this.getCenter(), zoom: this.zoom }); + return this; + }; + + SlippyMap.prototype.setZoom = function (z, anchor) { + var target = Math.max(this.minZoom, Math.min(this.maxZoom, +z)); + if (target === this.zoom) return this; + var ax = anchor ? anchor.x : this.w / 2; + var ay = anchor ? anchor.y : this.h / 2; + var before = this._unitAtPoint(ax, ay); + this.zoom = target; + this._applyAnchor(before, ax, ay); + this.invalidate(); + this._emit('zoomend', { zoom: this.zoom, center: this.getCenter() }); + return this; + }; + + SlippyMap.prototype._clampZoom = function (z) { + return Math.max(this.minZoom, Math.min(this.maxZoom, z)); + }; + + SlippyMap.prototype._unitAtPoint = function (x, y) { + var c = this._centerUnit(); + var wp = this._worldPx(); + return { x: c.x + (x - this.w / 2) / wp, y: c.y + (y - this.h / 2) / wp }; + }; + + /** 缩放后让点 (x,y) 处的地理位置保持不变 */ + SlippyMap.prototype._applyAnchor = function (unit, x, y) { + var wp = this._worldPx(); + var cx = unit.x - (x - this.w / 2) / wp; + var cy = unit.y - (y - this.h / 2) / wp; + var ll = this._unitToWgs({ x: cx, y: cy }); + this.center.lng = ll.lng; + this.center.lat = ll.lat; + }; + + /** 让一组 WGS84 点全部可见 */ + SlippyMap.prototype.fitBounds = function (points, opts) { + opts = opts || {}; + if (!points || !points.length) return this; + var pad = opts.padding == null ? 48 : opts.padding; + var minU = { x: Infinity, y: Infinity }, maxU = { x: -Infinity, y: -Infinity }; + var i, u; + for (i = 0; i < points.length; i++) { + var p = points[i]; + if (!p || !isFinite(p[0]) || !isFinite(p[1])) continue; + u = this._displayUnit(p[0], p[1]); + if (u.x < minU.x) minU.x = u.x; + if (u.y < minU.y) minU.y = u.y; + if (u.x > maxU.x) maxU.x = u.x; + if (u.y > maxU.y) maxU.y = u.y; + } + if (!isFinite(minU.x) || !isFinite(maxU.x)) return this; + var dw = Math.max(maxU.x - minU.x, 1e-9); + var dh = Math.max(maxU.y - minU.y, 1e-9); + var availW = Math.max(40, this.w - pad * 2); + var availH = Math.max(40, this.h - pad * 2); + var z = this.maxZoom; + for (; z > this.minZoom; z--) { + var wp = this._worldPx(z); + if (dw * wp <= availW && dh * wp <= availH) break; + } + if (opts.maxZoom != null) z = Math.min(z, opts.maxZoom); + z = this._clampZoom(z); + var mid = { x: (minU.x + maxU.x) / 2, y: (minU.y + maxU.y) / 2 }; + var ll = this._unitToWgs(mid); + this.zoom = z; + this.center.lng = ll.lng; + this.center.lat = ll.lat; + this.invalidate(); + this._emit('zoomend', { zoom: this.zoom, center: this.getCenter() }); + return this; + }; + + /* ------------------------------------------------------------------ */ + /* 瓦片源 */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype.setSource = function (src) { + if (!src || !src.key) return this; + var same = this.source.key === src.key; + this.source = { + key: src.key, + name: src.name || src.key, + url: src.url || '', + max_zoom: Math.min(HARD_MAX_ZOOM, src.max_zoom ? +src.max_zoom : 18), + attribution: src.attribution || '', + datum: src.datum || Coord.datumOfSource(src.key, src) + }; + this.maxZoom = Math.max(this.minZoom + 1, this.source.max_zoom); + if (this.zoom > this.maxZoom) this.zoom = this.maxZoom; + this._updateAttrib(); + this._duCache = new Map(); + this._duDatum = this.source.datum; + this._cuKey = null; + if (!same) { + this._clearTiles(); + this.failTotal = 0; + this.failStreak = 0; + this.loadPausedUntil = 0; + } + this._updateWarn(); + this.invalidate(); + return this; + }; + + SlippyMap.prototype.getSource = function () { return this.source; }; + + SlippyMap.prototype._tileUrl = function (z, x, y) { + return '/api/tiles/' + encodeURIComponent(this.source.key) + '/' + z + '/' + x + '/' + y + '.png'; + }; + + SlippyMap.prototype._clearTiles = function () { + this.tiles.forEach(function (e) { SlippyMap._release(e); }); + this.tiles.clear(); + this.queue.length = 0; + this.activeLoads = 0; + }; + + SlippyMap._release = function (entry) { + if (!entry) return; + if (entry.retryTimer) { clearTimeout(entry.retryTimer); entry.retryTimer = null; } + if (entry.img) { + entry.img.onload = null; + entry.img.onerror = null; + try { entry.img.src = ''; } catch (e) { /* 忽略 */ } + entry.img = null; + } + entry.state = 'released'; + }; + + SlippyMap.prototype._getTile = function (z, x, y) { + var key = this.source.key + '/' + z + '/' + x + '/' + y; + var e = this.tiles.get(key); + if (e) { + e.lastUsed = now(); + // Map 保持插入序:重新插入实现 LRU 排序 + this.tiles.delete(key); + this.tiles.set(key, e); + return e; + } + e = { key: key, z: z, x: x, y: y, img: null, state: 'new', tries: 0, lastUsed: now() }; + this.tiles.set(key, e); + this._evictIfNeeded(); + return e; + }; + + SlippyMap.prototype._evictIfNeeded = function () { + while (this.tiles.size > CACHE_LIMIT) { + var oldestKey = this.tiles.keys().next().value; + var e = this.tiles.get(oldestKey); + this.tiles.delete(oldestKey); + SlippyMap._release(e); + } + }; + + SlippyMap.prototype._queueLoad = function (entry) { + if (!entry || entry.state === 'loading' || entry.state === 'ok' || entry.state === 'queued') return; + entry.state = 'queued'; + this.queue.push(entry); + }; + + SlippyMap.prototype._pump = function () { + var self = this; + var t = now(); + if (t < this.loadPausedUntil) { + // 连续失败暂停加载:定时唤醒,不占用渲染循环 + if (this._wakeTimer == null) { + this._wakeTimer = setTimeout(function () { + self._wakeTimer = null; + self._pump(); + self.invalidate(); + }, Math.max(200, this.loadPausedUntil - t + 50)); + } + return; + } + while (this.queue.length && this.activeLoads < MAX_PARALLEL_LOADS) { + var entry = this.queue.shift(); + if (!entry || !this.tiles.has(entry.key)) continue; + this._startLoad(entry); + } + }; + + SlippyMap.prototype._startLoad = function (entry) { + var self = this; + var img = new Image(); + entry.img = img; + entry.state = 'loading'; + this.activeLoads += 1; + img.decoding = 'async'; + img.onload = function () { + self.activeLoads = Math.max(0, self.activeLoads - 1); + if (!self.tiles.has(entry.key)) { SlippyMap._release(entry); self._pump(); return; } + entry.state = 'ok'; + entry.tries = 0; + entry.lastUsed = now(); + self.failStreak = 0; + self.invalidate(); + self._emit('tileload', { key: entry.key }); + self._pump(); + }; + img.onerror = function () { + self.activeLoads = Math.max(0, self.activeLoads - 1); + entry.state = 'error'; + entry.tries += 1; + self.failStreak += 1; + self.failTotal += 1; + if (self.failStreak >= FAIL_STREAK_LIMIT) { + self.loadPausedUntil = now() + LOAD_PAUSE_MS; + self._emit('tileerror', { paused: true, failTotal: self.failTotal }); + } else { + self._emit('tileerror', { paused: false, failTotal: self.failTotal }); + } + self._updateWarn(); + if (entry.tries < MAX_TILE_TRIES && self.tiles.has(entry.key)) { + entry.retryTimer = setTimeout(function () { + entry.retryTimer = null; + if (!self.tiles.has(entry.key)) return; + entry.state = 'new'; + self.queue.push(entry); + self._pump(); + self.invalidate(); + }, 500 * entry.tries); + } + self._pump(); + self.invalidate(); + }; + img.src = this._tileUrl(entry.z, entry.x, entry.y); + this.invalidate(); + }; + + /** 重试所有失败瓦片(界面上「重试」按钮 / 网络恢复时调用) */ + SlippyMap.prototype.retryFailedTiles = function () { + var self = this; + this.loadPausedUntil = 0; + this.failStreak = 0; + this.failTotal = 0; + this.tiles.forEach(function (e) { + if (e.state === 'error') { + e.state = 'new'; + e.tries = 0; + self.queue.push(e); + } + }); + this._updateWarn(); + this._pump(); + this.invalidate(); + return this; + }; + + /* ------------------------------------------------------------------ */ + /* 图层数据 */ + /* ------------------------------------------------------------------ */ + + /** 路径折线(Route.path,WGS84 [lng,lat][]) */ + SlippyMap.prototype.setPath = function (path, color) { + this.layers.path = Array.isArray(path) ? path : []; + if (color) this.layers.pathColor = color; + this.layers.playedIndex = -1; + this.invalidate(); + return this; + }; + + /** 轨迹折线(实际走过的点) */ + SlippyMap.prototype.setTrack = function (points) { + this.layers.track = Array.isArray(points) ? points : []; + this.invalidate(); + return this; + }; + + SlippyMap.prototype.pushTrack = function (lng, lat) { + var t = this.layers.track; + var last = t[t.length - 1]; + if (last && Math.abs(last[0] - lng) < 1e-7 && Math.abs(last[1] - lat) < 1e-7) return this; + t.push([+lng, +lat]); + if (t.length > 4000) t.splice(0, t.length - 4000); + this.invalidate(); + return this; + }; + + SlippyMap.prototype.clearTrack = function () { + this.layers.track = []; + this.invalidate(); + return this; + }; + + /** + * 途经点:[lng,lat][](WGS84) + * @param {Array} list + * @param {object} [opts] {draggable:boolean} + */ + SlippyMap.prototype.setWaypoints = function (list, opts) { + this.layers.waypoints = (list || []).map(function (p) { + return [ +p[0], +p[1] ]; + }); + if (opts && opts.draggable != null) this.waypointsDraggable = !!opts.draggable; + if (this.layers.selected >= this.layers.waypoints.length) this.layers.selected = -1; + this.invalidate(); + return this; + }; + + SlippyMap.prototype.setSelected = function (index) { + this.layers.selected = (index == null) ? -1 : index; + this.invalidate(); + return this; + }; + + /** 已播放段高亮:path 中 0..index 用高亮色 */ + SlippyMap.prototype.setPlayedIndex = function (index) { + this.layers.playedIndex = (index == null) ? -1 : index; + this.invalidate(); + return this; + }; + + /** 当前定位点(脉冲动画);null 表示隐藏 */ + SlippyMap.prototype.setCurrent = function (lng, lat) { + if (lng == null || lat == null || !isFinite(lng) || !isFinite(lat)) { + this.layers.current = null; + } else { + this.layers.current = { lng: +lng, lat: +lat }; + } + this.invalidate(); + return this; + }; + + SlippyMap.prototype.getCurrent = function () { + return this.layers.current ? { lng: this.layers.current.lng, lat: this.layers.current.lat } : null; + }; + + SlippyMap.prototype.setDrawMode = function (mode) { + this.drawMode = mode || 'select'; + this.el.classList.toggle('is-crosshair', this.drawMode === 'add' || this.drawMode === 'polyline'); + this.waypointsDraggable = (this.drawMode === 'select'); + this.invalidate(); + return this; + }; + + SlippyMap.prototype.setDoubleClickZoom = function (on) { + this.doubleClickZoom = !!on; + return this; + }; + + /* ------------------------------------------------------------------ */ + /* 交互 */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype._buildControls = function () { + var self = this; + + this.ctrlZoom = document.createElement('div'); + this.ctrlZoom.className = 'map-zoom'; + this.ctrlZoom.innerHTML = + '' + + ''; + this.ctrlZoom.addEventListener('click', function (ev) { + var b = ev.target.closest('button'); + if (!b) return; + self.setZoom(self.zoom + (b.dataset.act === 'in' ? 1 : -1)); + }); + this.el.appendChild(this.ctrlZoom); + + this.ctrlWarn = document.createElement('div'); + this.ctrlWarn.className = 'map-warn hidden'; + this.ctrlWarn.innerHTML = '' + + ''; + this.ctrlWarn.querySelector('.map-warn-btn').addEventListener('click', function () { + self.retryFailedTiles(); + }); + this.el.appendChild(this.ctrlWarn); + + this.ctrlReadout = document.createElement('div'); + this.ctrlReadout.className = 'map-readout'; + this.ctrlReadout.innerHTML = + '--z--'; + this.el.appendChild(this.ctrlReadout); + + this.ctrlScale = document.createElement('div'); + this.ctrlScale.className = 'map-scalebar'; + this.ctrlScale.innerHTML = '
'; + this.el.appendChild(this.ctrlScale); + + this.ctrlAttrib = document.createElement('div'); + this.ctrlAttrib.className = 'map-attrib'; + this.el.appendChild(this.ctrlAttrib); + }; + + SlippyMap.prototype._updateAttrib = function () { + var a = this.source.attribution || ''; + this.ctrlAttrib.textContent = (a ? a + ' · ' : '') + this.source.name; + }; + + SlippyMap.prototype._updateWarn = function () { + if (this.failTotal > 0) { + this.ctrlWarn.classList.remove('hidden'); + var extra = now() < this.loadPausedUntil ? '(已暂停加载,稍后自动重试)' : ''; + this.ctrlWarn.querySelector('.map-warn-text').textContent = + '瓦片加载失败 ' + this.failTotal + ' 张' + extra; + } else { + this.ctrlWarn.classList.add('hidden'); + } + }; + + SlippyMap.prototype._bindEvents = function () { + var self = this; + var cv = this.canvas; + + cv.addEventListener('pointerdown', function (e) { self._onDown(e); }); + cv.addEventListener('pointermove', function (e) { self._onMove(e); }); + cv.addEventListener('pointerup', function (e) { self._onUp(e); }); + cv.addEventListener('pointercancel', function (e) { self._onUp(e); }); + cv.addEventListener('pointerleave', function () { + self.mouse = null; + self._updateReadout(); + }); + cv.addEventListener('wheel', function (e) { self._onWheel(e); }, { passive: false }); + cv.addEventListener('dblclick', function (e) { + var p = self._localPoint(e); + var ll = self.pointToLngLat(p.x, p.y); + self._emit('dblclick', { x: p.x, y: p.y, lng: ll.lng, lat: ll.lat, original: e }); + if (self.doubleClickZoom) { + e.preventDefault(); + self.setZoom(Math.round(self.zoom) + 1, p); + } + }); + cv.addEventListener('contextmenu', function (e) { e.preventDefault(); }); + cv.addEventListener('click', function (e) { + if (self._suppressClick) { self._suppressClick = false; return; } + var p = self._localPoint(e); + var ll = self.pointToLngLat(p.x, p.y); + self._emit('click', { x: p.x, y: p.y, lng: ll.lng, lat: ll.lat, original: e }); + }); + }; + + SlippyMap.prototype._localPoint = function (e) { + var r = this.canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + + SlippyMap.prototype._hitWaypoint = function (x, y) { + var wps = this.layers.waypoints; + for (var i = wps.length - 1; i >= 0; i--) { + var p = this.lngLatToPoint(wps[i][0], wps[i][1]); + var dx = p.x - x, dy = p.y - y; + if (dx * dx + dy * dy <= 14 * 14) return i; + } + return -1; + }; + + SlippyMap.prototype._onDown = function (e) { + if (e.button !== undefined && e.button !== 0 && e.pointerType === 'mouse') return; + var p = this._localPoint(e); + this.mouse = p; + try { this.canvas.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ } + this.pointers.set(e.pointerId, p); + this._stopInertia(); + + if (this.pointers.size === 2) { + var pts = Array.from(this.pointers.values()); + this.dragging = false; + this.dragWp = null; + this.pinch = { + dist: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y), + zoom: this.zoom, + mid: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 } + }; + return; + } + + // 优先命中途经点(选择模式下可拖拽) + if (this.waypointsDraggable && this.drawMode === 'select') { + var hit = this._hitWaypoint(p.x, p.y); + if (hit >= 0) { + this.dragWp = { index: hit, moved: false, start: p }; + this.layers.selected = hit; + this._emit('waypointselect', { index: hit }); + this.invalidate(); + return; + } + } + + this.dragging = true; + this.dragStart = { p: p, centerUnit: this._centerUnit(), last: p, lastT: now() }; + this.velocity = { x: 0, y: 0 }; + this.el.classList.add('is-dragging'); + }; + + SlippyMap.prototype._onMove = function (e) { + var p = this._localPoint(e); + this.mouse = p; + if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p); + + if (this.pinch && this.pointers.size >= 2) { + var pts = Array.from(this.pointers.values()); + var dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); + var mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }; + if (this.pinch.dist > 0) { + var z = this.pinch.zoom + Math.log2(Math.max(0.01, dist / this.pinch.dist)); + var before = this._unitAtPoint(mid.x, mid.y); + this.zoom = this._clampZoom(z); + this._applyAnchor(before, mid.x, mid.y); + this.pinch.mid = mid; + this.invalidate(); + } + this._updateReadout(); + return; + } + + if (this.dragWp) { + var moved = Math.abs(p.x - this.dragWp.start.x) > 2 || Math.abs(p.y - this.dragWp.start.y) > 2; + if (moved) this.dragWp.moved = true; + var ll = this.pointToLngLat(p.x, p.y); + var i = this.dragWp.index; + if (this.layers.waypoints[i]) { + this.layers.waypoints[i][0] = ll.lng; + this.layers.waypoints[i][1] = ll.lat; + } + this._emit('waypointdrag', { index: i, lng: ll.lng, lat: ll.lat }); + this.invalidate(); + this._updateReadout(); + return; + } + + if (this.dragging && this.dragStart) { + var ds = this.dragStart; + var wp = this._worldPx(); + var dx = p.x - ds.p.x; + var dy = p.y - ds.p.y; + var u = { x: ds.centerUnit.x - dx / wp, y: ds.centerUnit.y - dy / wp }; + var c = this._unitToWgs(u); + this.center.lng = c.lng; + this.center.lat = c.lat; + // 甩动速度:只在间隔 ≥8ms 时采样(避免 pointermove 亚毫秒间隔造成速度尖峰),并限幅 + var t = now(); + var dt = t - ds.lastT; + if (dt >= 8) { + this.velocity = { + x: Math.max(-80, Math.min(80, (p.x - ds.last.x) / dt * 16)), + y: Math.max(-80, Math.min(80, (p.y - ds.last.y) / dt * 16)) + }; + ds.last = p; + ds.lastT = t; + } + this._suppressClick = true; + this.invalidate(); + this._emit('move', { center: this.getCenter(), zoom: this.zoom }); + this._updateReadout(); + return; + } + + this._updateReadout(); + }; + + SlippyMap.prototype._onUp = function (e) { + var had = this.pointers.has(e.pointerId); + var p = this.mouse || this._localPoint(e); + try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) { /* 忽略 */ } + this.pointers.delete(e.pointerId); + + if (this.pointers.size < 2) this.pinch = null; + + if (this.dragWp) { + var moved = this.dragWp.moved; + var idx = this.dragWp.index; + this.dragWp = null; + if (moved) { + this._suppressClick = true; + this._emit('waypointdragend', { + index: idx, + lng: this.layers.waypoints[idx] ? this.layers.waypoints[idx][0] : null, + lat: this.layers.waypoints[idx] ? this.layers.waypoints[idx][1] : null, + moved: true + }); + } else { + this._emit('waypointclick', { index: idx }); + } + this.invalidate(); + return; + } + + if (this.dragging) { + this.dragging = false; + this.el.classList.remove('is-dragging'); + var v = Math.hypot(this.velocity.x, this.velocity.y); + if (had && v > 1.2) { + this.inertiaOn = true; + this._scheduleRender(); + } else { + this.velocity = { x: 0, y: 0 }; + } + this._emit('dragend', { center: this.getCenter(), zoom: this.zoom }); + if (p) this._updateReadout(); + } + }; + + SlippyMap.prototype._stopInertia = function () { + this.inertiaOn = false; + this.velocity = { x: 0, y: 0 }; + }; + + SlippyMap.prototype._stepInertia = function () { + if (!this.inertiaOn) return false; + var wp = this._worldPx(); + var c = this._centerUnit(); + var u = { x: c.x - this.velocity.x / wp, y: c.y - this.velocity.y / wp }; + var ll = this._unitToWgs(u); + // 到达世界边界或速度衰减到阈值就停下 + if (ll.lng < -180 || ll.lng > 180) { this._stopInertia(); return false; } + this.center.lng = ll.lng; + this.center.lat = Math.max(-85, Math.min(85, ll.lat)); + this.velocity.x *= 0.93; + this.velocity.y *= 0.93; + if (Math.hypot(this.velocity.x, this.velocity.y) < 0.08) this._stopInertia(); + this._emit('move', { center: this.getCenter(), zoom: this.zoom }); + return true; + }; + + SlippyMap.prototype._onWheel = function (e) { + e.preventDefault(); + var p = this._localPoint(e); + var dy = e.deltaY; + if (e.deltaMode === 1) dy *= 18; + else if (e.deltaMode === 2) dy *= 100; + var step = -dy * 0.0022; + if (Math.abs(step) > 1.5) step = step > 0 ? 1.5 : -1.5; + var before = this._unitAtPoint(p.x, p.y); + this.zoom = this._clampZoom(this.zoom + step); + this._applyAnchor(before, p.x, p.y); + this._stopInertia(); + this.mouse = p; + this.invalidate(); + this._emit('zoomend', { zoom: this.zoom, center: this.getCenter() }); + this._updateReadout(); + }; + + /* ------------------------------------------------------------------ */ + /* 尺寸 / 读数 */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype.resize = function () { + var rect = this.el.getBoundingClientRect(); + var w = Math.max(1, Math.floor(rect.width)); + var h = Math.max(1, Math.floor(rect.height)); + var dpr = Math.min(3, Math.max(1, global.devicePixelRatio || 1)); + if (w === this.w && h === this.h && dpr === this.dpr) return; + this.w = w; this.h = h; this.dpr = dpr; + this.canvas.width = Math.floor(w * dpr); + this.canvas.height = Math.floor(h * dpr); + this.canvas.style.width = w + 'px'; + this.canvas.style.height = h + 'px'; + this.invalidate(); + }; + + SlippyMap.prototype._updateReadout = function () { + var ll; + if (this.mouse) { + ll = this.pointToLngLat(this.mouse.x, this.mouse.y); + } else { + ll = this.center; + } + var el = this.ctrlReadout.querySelector('.mr-ll'); + if (el) el.textContent = Geo.fmtLngLat(ll.lng, ll.lat, 6); + var zl = this.ctrlReadout.querySelector('.mr-z'); + if (zl) zl.textContent = 'z' + (Math.round(this.zoom * 10) / 10) + ' · ' + this.source.name; + }; + + SlippyMap.prototype._updateScale = function () { + var mpp = 156543.03392 * Math.cos(this.center.lat * Math.PI / 180) / Math.pow(2, this.zoom); + var target = mpp * 110; + var meters = SCALE_STEPS[0]; + for (var i = SCALE_STEPS.length - 1; i >= 0; i--) { + if (SCALE_STEPS[i] <= target) { meters = SCALE_STEPS[i]; break; } + } + var px = Math.max(24, Math.round(meters / mpp)); + var bar = this.ctrlScale.querySelector('.ms-bar'); + var lab = this.ctrlScale.querySelector('.ms-label'); + if (bar) bar.style.width = px + 'px'; + if (lab) lab.textContent = meters >= 1000 ? (meters / 1000) + ' 公里' : meters + ' 米'; + }; + + /* ------------------------------------------------------------------ */ + /* 渲染 */ + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype.invalidate = function () { + this._dirty = true; + this._scheduleRender(); + }; + + SlippyMap.prototype._scheduleRender = function () { + var self = this; + if (this._rafId != null) return; + this._rafId = global.requestAnimationFrame(function () { + self._rafId = null; + self._frame(); + }); + }; + + SlippyMap.prototype._frame = function () { + var anim = false; + if (this._stepInertia()) anim = true; + this._render(); + var paused = now() < this.loadPausedUntil; + var loading = !!(this.activeLoads || (this.queue.length && !paused)); + if (loading) this._pump(); + if (this.layers.current) anim = true; + if (loading || anim) this._scheduleRender(); + }; + + /** + * 当前视口需要绘制的瓦片列表(每帧只画可视范围)。 + * @returns {Array<{z:number,x:number,y:number,sx:number,sy:number,size:number}>} + */ + SlippyMap.prototype._visibleTiles = function () { + var c = this._centerUnit(); + var zoom = this.zoom; + var wp = this._worldPx(); + var tz = Math.max(this.minZoom, Math.min(this.source.max_zoom, Math.round(zoom))); + var size = TILE * Math.pow(2, zoom - tz); + var n = Math.pow(2, tz); + var unitsPerPx = 1 / wp; + var x0u = c.x - (this.w / 2) * unitsPerPx; + var x1u = c.x + (this.w / 2) * unitsPerPx; + var y0u = c.y - (this.h / 2) * unitsPerPx; + var y1u = c.y + (this.h / 2) * unitsPerPx; + var tx0 = Math.floor(x0u * n) - 1; + var tx1 = Math.floor(x1u * n) + 1; + var ty0 = Math.max(0, Math.floor(y0u * n) - 1); + var ty1 = Math.min(n - 1, Math.floor(y1u * n) + 1); + var out = []; + for (var ty = ty0; ty <= ty1; ty++) { + for (var tx = tx0; tx <= tx1; tx++) { + out.push({ + z: tz, + x: ((tx % n) + n) % n, + y: ty, + sx: (tx / n - c.x) * wp + this.w / 2, + sy: (ty / n - c.y) * wp + this.h / 2, + size: size + }); + } + } + return out; + }; + + SlippyMap.prototype._render = function () { + var ctx = this.ctx, w = this.w, h = this.h; + this._dirty = false; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = COLORS.bg; + ctx.fillRect(0, 0, w, h); + + var t = now(); + var tiles = this._visibleTiles(); + for (var i = 0; i < tiles.length; i++) { + var tl = tiles[i]; + var entry = this._getTile(tl.z, tl.x, tl.y); + this._drawTile(ctx, entry, tl.sx, tl.sy, tl.size, t); + } + + this._drawPaths(ctx); + this._drawWaypoints(ctx); + this._drawCurrent(ctx, t); + + this._updateScale(); + this._updateReadout(); + this._updateWarn(); + }; + + SlippyMap.prototype._drawTile = function (ctx, entry, sx, sy, size, t) { + var img = entry.img; + if (entry.state === 'ok' && img && img.complete && img.naturalWidth > 0) { + ctx.drawImage(img, sx, sy, size + 0.7, size + 0.7); + return; + } + if (entry.state === 'new' || entry.state === 'queued') { + this._queueLoad(entry); + this._pump(); + } + // 占位 + ctx.fillStyle = (entry.state === 'error' && entry.tries >= MAX_TILE_TRIES) ? COLORS.tileFail : COLORS.tileEmpty; + ctx.fillRect(sx, sy, size, size); + ctx.strokeStyle = COLORS.tileBorder; + ctx.lineWidth = 1; + ctx.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1); + if (entry.state === 'error' && entry.tries >= MAX_TILE_TRIES) { + ctx.strokeStyle = COLORS.tileFailMark; + ctx.lineWidth = 1.4; + var cx = sx + size / 2, cy = sy + size / 2, r = Math.min(10, size / 6); + ctx.beginPath(); + ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); + ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); + ctx.stroke(); + } else if (entry.state === 'loading' || entry.state === 'queued') { + // 加载中的呼吸圆点 + var phase = (t / 600) % 1; + var alpha = 0.15 + 0.5 * Math.abs(Math.sin(phase * Math.PI)); + ctx.fillStyle = 'rgba(88,166,255,' + alpha.toFixed(3) + ')'; + var cx2 = sx + size / 2, cy2 = sy + size / 2; + var r2 = Math.min(12, Math.max(3, size / 10)); + ctx.beginPath(); + ctx.arc(cx2, cy2, r2, 0, Math.PI * 2); + ctx.fill(); + } + }; + + /** + * 画折线(可选区间 [from, to])。 + * 屏幕距离小于 1.4px 的中间点会被抽稀:OSRM 路线动辄上万点, + * 全量描边会拖垮渲染(实测 8000 点在 GCJ-02 底图上每帧数百毫秒)。 + */ + SlippyMap.prototype._strokeLine = function (ctx, points, color, width, outline, from, to) { + if (!points || points.length < 2) return; + var start = Math.max(0, from || 0); + var end = Math.min(points.length - 1, to == null ? points.length - 1 : to); + if (end <= start) return; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + for (var pass = 0; pass < (outline ? 2 : 1); pass++) { + ctx.beginPath(); + var begun = false, lastX = 0, lastY = 0, i, p, sp; + for (i = start; i <= end; i++) { + p = points[i]; + if (!p || !isFinite(p[0]) || !isFinite(p[1])) continue; + sp = this.lngLatToPoint(p[0], p[1]); + if (!begun) { + ctx.moveTo(sp.x, sp.y); + begun = true; + lastX = sp.x; lastY = sp.y; + continue; + } + if (i !== end && Math.abs(sp.x - lastX) < 1.4 && Math.abs(sp.y - lastY) < 1.4) continue; + ctx.lineTo(sp.x, sp.y); + lastX = sp.x; lastY = sp.y; + } + if (!begun) return; + ctx.strokeStyle = pass === 0 ? (outline || '#08111f') : color; + ctx.lineWidth = pass === 0 ? width + 3 : width; + ctx.stroke(); + } + }; + + SlippyMap.prototype._drawPaths = function (ctx) { + var path = this.layers.path; + var i = this.layers.playedIndex; + if (path && path.length >= 2) { + // 未播放部分 + if (i > 0 && i < path.length - 1) { + this._strokeLine(ctx, path, 'rgba(47,129,247,0.55)', 3, 'rgba(8,17,31,0.7)', i, path.length - 1); + } else { + this._strokeLine(ctx, path, this.layers.pathColor, 3.5, COLORS.pathOutline); + } + // 已播放部分高亮 + if (i > 0) { + this._strokeLine(ctx, path, COLORS.played, 4, COLORS.playedOutline, 0, Math.min(path.length - 1, i)); + } + } + if (this.layers.showTrack && this.layers.track && this.layers.track.length >= 2) { + this._strokeLine(ctx, this.layers.track, COLORS.track, 2.5, 'rgba(10,12,16,0.8)'); + } + }; + + SlippyMap.prototype._drawWaypoints = function (ctx) { + var wps = this.layers.waypoints; + if (!wps || !wps.length) return; + var n = wps.length; + for (var i = 0; i < n; i++) { + var p = this.lngLatToPoint(wps[i][0], wps[i][1]); + if (p.x < -40 || p.y < -40 || p.x > this.w + 40 || p.y > this.h + 40) continue; + var isStart = i === 0; + var isEnd = i === n - 1 && n > 1; + var color = isStart ? COLORS.wpStart : (isEnd ? COLORS.wpEnd : COLORS.wpMid); + var r = isStart || isEnd ? 12 : 10; + var selected = (this.layers.selected === i); + + ctx.beginPath(); + ctx.arc(p.x, p.y + 1.5, r, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = selected ? 3 : 2; + ctx.strokeStyle = selected ? COLORS.wpSelected : 'rgba(255,255,255,0.85)'; + ctx.stroke(); + + var label = (isStart || isEnd) ? (isStart ? '起' : '终') : String(i + 1); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold ' + (isStart || isEnd ? 11 : 10) + 'px -apple-system, "PingFang SC", sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, p.x, p.y + 0.5); + ctx.textBaseline = 'alphabetic'; + ctx.textAlign = 'left'; + } + }; + + SlippyMap.prototype._drawCurrent = function (ctx, t) { + var cur = this.layers.current; + if (!cur) return; + var p = this.lngLatToPoint(cur.lng, cur.lat); + if (p.x < -60 || p.y < -60 || p.x > this.w + 60 || p.y > this.h + 60) return; + var phase = (t / 1600) % 1; + for (var k = 0; k < 2; k++) { + var ph = (phase + k * 0.5) % 1; + var r = 8 + 22 * ph; + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(88,166,255,' + (0.55 * (1 - ph)).toFixed(3) + ')'; + ctx.lineWidth = 2; + ctx.stroke(); + } + ctx.beginPath(); + ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(88,166,255,0.28)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); + ctx.fillStyle = COLORS.current; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = '#ffffff'; + ctx.stroke(); + }; + + /* ------------------------------------------------------------------ */ + + SlippyMap.prototype.destroy = function () { + if (this._ro) this._ro.disconnect(); + if (this._onWinResize) global.removeEventListener('resize', this._onWinResize); + if (this._rafId != null) global.cancelAnimationFrame(this._rafId); + if (this._wakeTimer) { clearTimeout(this._wakeTimer); this._wakeTimer = null; } + this._clearTiles(); + this.handlers = Object.create(null); + }; + + SlippyMap.COLORS = COLORS; + SlippyMap.TILE_SIZE = TILE; + global.SlippyMap = SlippyMap; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/web/js/ws.js b/web/js/ws.js new file mode 100644 index 0000000..19db7b2 --- /dev/null +++ b/web/js/ws.js @@ -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} 应答消息;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); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..fd92ac8 --- /dev/null +++ b/web/style.css @@ -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; } +} diff --git a/web/tile_sources.json b/web/tile_sources.json new file mode 100644 index 0000000..1fbc857 --- /dev/null +++ b/web/tile_sources.json @@ -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" + } + } +}