From 85e9a537505a48c75cb25ee23e84b9b1bbbb5df2 Mon Sep 17 00:00:00 2001 From: jbjf6666 Date: Fri, 16 Oct 2026 01:05:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20Windows=20?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D=E4=BA=A4=E6=8E=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序 --- .gitignore | 41 + Location.spec | 139 + README.en.md | 287 ++ README.md | 289 ++ core/config_store.py | 312 +++ core/device.py | 427 +++ core/geoutils.py | 234 ++ core/location_mock.py | 229 ++ core/location_provider.py | 701 +++++ core/noise.py | 208 ++ core/playback.py | 687 +++++ core/protocol.py | 160 ++ core/route_engine.py | 576 ++++ core/server.py | 1352 ++++++++++ core/tilecache.py | 323 +++ core/tunnel.py | 434 +++ core/ws.py | 326 +++ docs/DEVICE_NOTES.md | 1031 +++++++ docs/PROTOCOL.md | 190 ++ docs/VERIFY_REPORT.md | 721 +++++ docs/WINDOWS_PORT.md | 419 +++ main.py | 284 ++ pip.conf | 3 + resources/AppIcon-preview.png | Bin 0 -> 32610 bytes resources/AppIcon.icns | Bin 0 -> 233049 bytes resources/AppIcon.iconset/icon_128x128.png | Bin 0 -> 7685 bytes resources/AppIcon.iconset/icon_128x128@2x.png | Bin 0 -> 16050 bytes resources/AppIcon.iconset/icon_16x16.png | Bin 0 -> 725 bytes resources/AppIcon.iconset/icon_16x16@2x.png | Bin 0 -> 1728 bytes resources/AppIcon.iconset/icon_256x256.png | Bin 0 -> 16050 bytes resources/AppIcon.iconset/icon_256x256@2x.png | Bin 0 -> 32610 bytes resources/AppIcon.iconset/icon_32x32.png | Bin 0 -> 1728 bytes resources/AppIcon.iconset/icon_32x32@2x.png | Bin 0 -> 3678 bytes resources/AppIcon.iconset/icon_512x512.png | Bin 0 -> 32610 bytes resources/AppIcon.iconset/icon_512x512@2x.png | Bin 0 -> 67611 bytes .../ddi/personalized/BuildManifest.plist | Bin 0 -> 801505 bytes .../ddi/personalized/Image.dmg.trustcache | Bin 0 -> 1895 bytes resources/ddi/personalized/Image.trustcache | Bin 0 -> 1895 bytes scripts/build_app.sh | 61 + scripts/run.sh | 81 + scripts/test-location.sh | 18 + tests/drift_sim.py | 314 +++ tests/offline_browser.py | 157 ++ tests/offline_tiles.py | 110 + tests/real_device_e2e.py | 175 ++ tests/real_gui_acceptance.py | 190 ++ tests/selftest.py | 496 ++++ tests/ui_acceptance.py | 419 +++ tools/ddi_offline_fetch.sh | 73 + tools/diagnose_run.py | 231 ++ tools/fetch_ddi.py | 245 ++ tools/make_dmg.py | 221 ++ tools/make_icon.py | 198 ++ tools/probe_device.py | 224 ++ tools/probe_location.py | 142 + tools/screenshot.py | 100 + tools/selftest_location.py | 284 ++ tools/verify_bundle.py | 179 ++ web/index.html | 334 +++ web/js/app.js | 2385 +++++++++++++++++ web/js/coord.js | 235 ++ web/js/map.js | 1186 ++++++++ web/js/ws.js | 339 +++ web/style.css | 803 ++++++ web/tile_sources.json | 30 + 65 files changed, 18603 insertions(+) create mode 100644 .gitignore create mode 100644 Location.spec create mode 100644 README.en.md create mode 100644 README.md create mode 100644 core/config_store.py create mode 100644 core/device.py create mode 100644 core/geoutils.py create mode 100644 core/location_mock.py create mode 100644 core/location_provider.py create mode 100644 core/noise.py create mode 100644 core/playback.py create mode 100644 core/protocol.py create mode 100644 core/route_engine.py create mode 100644 core/server.py create mode 100644 core/tilecache.py create mode 100644 core/tunnel.py create mode 100644 core/ws.py create mode 100644 docs/DEVICE_NOTES.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/VERIFY_REPORT.md create mode 100644 docs/WINDOWS_PORT.md create mode 100644 main.py create mode 100644 pip.conf create mode 100644 resources/AppIcon-preview.png create mode 100644 resources/AppIcon.icns create mode 100644 resources/AppIcon.iconset/icon_128x128.png create mode 100644 resources/AppIcon.iconset/icon_128x128@2x.png create mode 100644 resources/AppIcon.iconset/icon_16x16.png create mode 100644 resources/AppIcon.iconset/icon_16x16@2x.png create mode 100644 resources/AppIcon.iconset/icon_256x256.png create mode 100644 resources/AppIcon.iconset/icon_256x256@2x.png create mode 100644 resources/AppIcon.iconset/icon_32x32.png create mode 100644 resources/AppIcon.iconset/icon_32x32@2x.png create mode 100644 resources/AppIcon.iconset/icon_512x512.png create mode 100644 resources/AppIcon.iconset/icon_512x512@2x.png create mode 100644 resources/ddi/personalized/BuildManifest.plist create mode 100644 resources/ddi/personalized/Image.dmg.trustcache create mode 100644 resources/ddi/personalized/Image.trustcache create mode 100644 scripts/build_app.sh create mode 100644 scripts/run.sh create mode 100644 scripts/test-location.sh create mode 100644 tests/drift_sim.py create mode 100644 tests/offline_browser.py create mode 100644 tests/offline_tiles.py create mode 100644 tests/real_device_e2e.py create mode 100644 tests/real_gui_acceptance.py create mode 100644 tests/selftest.py create mode 100644 tests/ui_acceptance.py create mode 100644 tools/ddi_offline_fetch.sh create mode 100644 tools/diagnose_run.py create mode 100644 tools/fetch_ddi.py create mode 100644 tools/make_dmg.py create mode 100644 tools/make_icon.py create mode 100644 tools/probe_device.py create mode 100644 tools/probe_location.py create mode 100644 tools/screenshot.py create mode 100644 tools/selftest_location.py create mode 100644 tools/verify_bundle.py create mode 100644 web/index.html create mode 100644 web/js/app.js create mode 100644 web/js/coord.js create mode 100644 web/js/map.js create mode 100644 web/js/ws.js create mode 100644 web/style.css create mode 100644 web/tile_sources.json 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 0000000000000000000000000000000000000000..f76a235cce197349218bc9e52c0fb8c70038b227 GIT binary patch literal 32610 zcmdpeKQidL|7bMuILP#V$Yu+r_ELd-1>s9~#&~xwgb^ttgTa5Snl%b{G{449N>#$2ZYisL~ z*T*8;yQ9*mi0%JB`N=CIzzzWNjIX+4&eG+*uHHKX>TeJ1rsY9RDk^usi~8U22W9$S zmeta>XeeV`nY3!3hQ#4X08=qpO@hkIm~wBr+VgvYkkxm4#ECDG>Y*0;?RN|@Qqj9@ z9s&kZ(|7cLH20qnuKUx4P8(Xp0r(m{qulTr31SSN_&SEr&D-&s-1_WvJCN@j^O$4z z1u+UUTFMirqm-bcY!S;->AM2F^BPN$#FjqO7j>o(PZu5n4uas37v=U5KN(eiHipa5 zebCq?^WV@<=S2dB#SMwzTn~cq61nJid8L^}J9OoEm~^&R*Qi2T%x}p^-+|k_TxDcNI8!wwku<}cIes_;=d1zPbKbUz;k|* zQOCJNd_(iVP`tIZbYNIuOOA+~w1;&MbxR&H2_{}Q zrN2v{(U;hF^eeGmul#saw?@LOmIox0y_-IK**G8hJIzT;vE9T5YL|Fc#x3P+eCM!- z&X|fGga@R9Zsdv-;$PBPK6`~5sfwsJgrrVVS=?A7rTJRj2lvN;j6&(OjX7JrUbS3udp(@wVS`iGRr~_Nn2IDqq z6)V&1VfRS%cU5&o-;wnn0I;294ZV3*?uwq`gN53D;i$4EYCH}1XGx@VGA}r;^&I1^ zc>d)I#yftt%^y>E0FXqC#7V7gNQS!0_2k>hY0mUlUjou}qdZ_3Q=WeYlKIh?wf?3q zc^Z;6^VQ%EyW8a6ELHB#Is7~gYl#XEpG+GiycU<_lI*nh=Y~%Q~^xrE_g^J4~fA&xgYDr>dDU0tk9W(+T@)^haa)n zZwD(C=o-Iwu}xR^?4bkTISg!tp@tM)6shmmUR2%()1^6j-0rKX;*PM`%4|+7xEChf z5uwaN%JFUf>gN;0Ck(*-1PiPuC~xZ0i=ZK?PesIgr*!g9xMHZE@S;(JC+LS2OzK=M zOtSXix%g)$k@9DRjc2^iVTfe3qM;TnibNSdA{y+xb=fDn-$9h5LM`-ek04}jtw;*T z-aBjo7F&?KqE7^tDCvXE3N`?o2TgZWIT+dVD;UB9uY>vn-^w`nzrjpL!ay1hr4pee zg$+&Tf*^_0AyVN=k({8(OBA3BQH{$}MtcGi;-|R`r=3C0%nvF;fSY4R7Yg^rhfeGj z6)AsJ{JD&;*P$u_2+f8?ihmj^4b^Dl`&Zw0Ssb&$kOzV$0IaTy3Ib5+CI#1n2;mvX z&e85ceF0%-%Oo;$1I1FKY^k+?!$UQ^u!wmY>9DT1;O`#{Qzc4MFBdvrvTuXPzpvN; zzs?Xwdj)>Vr*zxF1-WD#^#nEhUi!r3p9oGFuA(9odfEsY>N_;6DLQxB^rOb?hA<&C z8hlzV_HH3ocaPw!x;L6A5)(RBS7|8x4k5?By~N5r5V(WzA7H6cEd@>i@D#fSzZ$!M@#3Vu`06@Elsq1$0Y?F+O6DG%bl8}PdgfSdTC2%;u$z{|C1sOIN z4PMC)(*OXgH0DWB9oO{f>U~l%I@UM0Jm8ON=Evi!)N(QSQcn#Gmj{H1P3};an`O=- zPpZ$}(hTO-Q(ms+ZcmxB_8R?vUkwd398D~$NBTWT@}dG@ESdXpH%Ah0(DKsVY-i|; zFfTuCh;}tPSVU%tl-;D=F}qnn5SrdU)QX;FgSN)_6bL|Ygx{sf)b-G(m zee#p#PrK(ZX<<_3zcSI(Ckso0aL*!a#!9IoRaGrmz;}4yxCHg6A9-4$i3jutI$_I< z!^#*AqlA=*dl2XPhWtj+mhM9#@x>BEsSy&M!DUDxit0uzq~o9V*ePR`y^3M-IOnTe==hy@7Fh0?r7*PDii_}U_%A*z%{Ob= zV&!Uq?a9wfIrNG%M1kNX(Wpja4K}oZ&!GwgrQba65u)7s!WhUb2ctFbo?*fFp3|{^ z=Qwiq+l6y*+_$}7?2H^iY}`d0YKuuLqlj_06-#AIsxNj6=+Fam<+Vf3rkzkjmcwf{ zQdsys3IODB({$AwU<;!|j4_%`_a%SaAq=D*I)qGu zSz5%!c_@6bvoWPku8&3yP#-G=C5=8L)g3%FL_at#4L(PpRBjcSS!zk4AQFGiueP|_ zxA}m<(FM6l@=PRY3%$X{;Ht;)tbTh-;w49G!o9fk4Z=X_)WQ#OxZ=9RFcP1&4`Gb3(oK7= zd3uG1Ff4yrqg(RF-H<6r4PM^aH&PqfA8+n73l`mu=@=)ee1*1?(&M*q4YGyuMCqG3dnbqSh+opm*3CDD9< ze;-Q^F$Tts6<{;=(`6Cv>CKvLKuw|{OrWyErk&O`NmE?Vvm)Ldv3`{cMrnwo!T54w zea5o+APU5POe=c)GWr48U@@ov^B_XlQ4}iyX zfse~1)SW8F@#76|_Z9jr5IQD-mcs_~xf-gs^ev{ll9U{e zE(W%6-XcoN&teZcSEqEGU*|{#@-ie~mzRZ+KHHe$g>{sHtP8XD?l=6YSp z)f9+8;Rphk3Zp@Kb-~pIfhtq=Gn{{KyJJ<^tqMwS_6KoMnAySylFo~AKBOU!u8=D(PP@y1BiAMs1Nw4BFFH6H6 zX~g8JZizvsf|2plvd;6nQlr?m#?SB^^i=`Pt@lYyW%^Oi+uae%;Cp@$@-;29ADp+3 zk`AJ0i2Fdi0gsw+6EpLu=#5@|jSwk8P1e;^oFQBv9H84P!7mMAXc_xp(DEUhBh(}6 zOBzC=ODa!=*>D0)qwV2U^ZE|Z;OP$2|Z{Fq5_ z6S=eap6JuBDHDXACB&iTBHH+lCC6-&#ODD0i?jl5v@SB8iNPVl;8*+gr1D>RAH9T4 zm&WH;AUjCn_LSf$0W_QTPX8sUR;Vc8ukn_vrf-^Xz%naQZ!fq zZN{&HJUpVvaOCoVnT$5cgwY=+d|z@)Zz$yllUknNe#v1;z;@E3pV*GsC}vWQsZu7gF}iSGQ2bx+v`8o&jo zfcfG%Y0E~A8X6?JtK8FJeqLSLXzzwiHq&0Kjxi-Kr4)5)j+7y5{ZL)iZ75%T-TvDVz|+k zHgNq6>LZ$B`6u!_U)D6p*}b=%4W64rx)x9JaAZn})iLSpsYcgix?-#B4;{6SWCl^p znBvF0@#TAuu^L9-%}A>j7Y(s&9X;t(N0PAi0;xP^trNzU z`eZ^tsnWV*w5K}7_#SN~HynEY9Z{tg)TOsMzZK3@ny3cR{J}2VxR_nVfIhfzi3xK{ z$=CR7w3k$K5WSLImh^~!#jGA09*&GJpc_sS{Cl3%vzN5%1QFPfX>sfsO^0>{>cagr zj{L?7BULgEr@Sp0c8?8oq)lYmTYnqgWEmu#hinP276Kkc?Uns|U#@TmqiW5&A%C3o9~#H);BY|6ukiJ1r5fg?_x@)VJe z$-V&d+e=LhBl`;kZpm1s0I-jw#^$$WTG-O~##QXoCOn5SY;;&{bAFO~vY>XQwq-E| zNtiJDQ7TqRb;YWPFZWhRR~k-9RT|DoQSIU^jV-B32+hjk;o(qN78gIGUyVm24yuvL z^p&K`V3v}RLBgo^h@Tk~{s45bV+4{0{^*eZeVR^i=WNm#%xSVH*;Au{CT`jOr`)*) z7WDX8HM6Ov^(nBj)% z{6eP&T-dLOyW#oe`@36Dzn3ti&~Dx{MyMs{`++!9kZ|pH3iMVxofnjkXR9JR0`o#T zcw`Bf`FW7BC!VS&Ee9&EWV~V`$C4zVV}3=IioiP|VN|Vk_vdJaze(ZKmvVoqSMfx1 z$8URNtlRCRRnM?cZ;?4%sqXe2Gh^Og#Gp`J)I+Hzb4D~n_LYt0s{jlFmgfwGsW#1j z`VkGwL~+w~k@9oW^>V!}KXmJEC^F|kDsk$LE}hGfqGm%5^u%-O6JeR+xYD^1_gIeV7v`(h$f zTXO1jDk*14^9n9A>yBmrrQ5qSIf9TmM&BBiU<8FstC-Aq`IJP|ceHrwqSl^^w}&~K zLtC3D*R9aQ@mBc!a%+ENCU+)(lAl0Z@bRqfa-|@N)@=A?qWf-bTDBSw3-gbO`q1t)nBDlE zdv5QK?sx5X@AvG7oG4u)qaoVK{>e{a#7uj-?t6E6nU-mKwJS9{fcFvJv-R#o_{8_5 z<>c-}_|!LO59K>9kgh{XS0a*GIFv!Yvr^fTE-=Z+@>HqT!8|H=CS)dT=JQO%Ow`O% zZL&w*Yo8dUL(xW+5AWATqGB?i_jLNF0H!piOr~6>f}vL5u$d9zVnDdR8)XjUf5ZKp*;{(8I^b$-YlWdHM>-Fce z9OqEX@qq%gO0r6dN}9?Sl}tTIDR{&PaA1oKN3>S9O!D2-v_o1*T`7OR3PZ+8%jw;z z@R{#f%h?@0Q=VJ5H3`6NA!Ot~d2MUqm>B3i)jPQ}OUxXW4nhk>@uu<-^D*%W@X6Mo zQvdRp8pAim33`#F&A0)Fk=QmG!}$*Qk~ezsgkAds`*zbap2NP<`r6d`T94CA@LABS z4Sy~NQ<+`l#h)Z|OnIxBmz$njmjVpPc?DnubVyGyfA))P$9Z%?rqZY}tFQJo&SmK; zom5*#+eq7T+tDxd686U+Dn?s*Xm)M3u?b-TWkwb#VFn^79&pIbj5{G`=X!?SOc_43#L$o}X(_!=c#{4p55g2UPw&Co8K z)3TI?G%+WnNCo5ez%yTFGH0@9a<7ASe_08mgPDm+Rl#(#-T=EyZ7yC;eQbqEbMkmN znjY2Kvv;Fq@cX8vb8N>t)WhI71R#VEzllHxvTmNxp~j#iWzVQ9(JVEDVWCZi>eV7l z^l$fL>}49-mPci?h>vitBdE63+xO!uNeVi3u%diTLCffEG~Q-l8x%w?xU84K{exiM z=j1I`vb+$n;i$@w%FJEo!-1;--S;RuNMmS-9A0gjHdSSrS5GR!%WaJi#j60hXk)HI zyY7Rp(T|Bxkqa;%GBlIsRm>*Nv<}ul_AO@PuzdXa7jo%Qc z1o=+;o>P6I5_&$%HTL9jZP{t~h-DS5`=T9aGh0Oqr+#$S6Mc8BRZcI_!xL>Yo#m%^ z_lzwvE~JPVgp<9QV6-re%jkVsvoU|4`>nsyHYKV|2TkBi1|KN*&SMBGcXUO`42MZY zS7ZIpSX?uDSKqiVle^Q8xA&80lTxqc&Q$f+8n!=3IX6C(e0=xCmTalu{ zO=r|OeU69&QQUM)b4UUAPQw>YHmxyi4+3y`t^gN zR;ag_%=7m$VaT?+sKUYuR6>&9%406peiZfhzZJsOU?&xiO9{aWyT(4ISe9suyNZRd z&@t#7J=c4JFi?qGQ;4-Z-f zCl9_AO2M7v6mOjtNMUmcFNj59LZ$uGFQ(?+29v~kKkV4+(&kJjM>bp0K;xLM)mg0) zzWs48Cq~#xy|+r}BDt7x1|nEtDmPfUN>Irj>i+r#c=PpkeA~_Y7dJ6O9Wn!oTwzZb zx6!hq2O@)$<-Syz-6|IBSK^Tb$y3u^9@MVcSJSHpEd$GfZ6I%&fUR4B+R7ryxI8yT zLo5JfbyJH0Ap{#Z-0zQ3=+^%!{$w~v+BT>K=%27zE?&4zvDP>@1^o>pqlSuMZzSd+ z_Ocq(md!81pA|4DvoZFsK7Df2d4pCOi%S^s^QWs0mjCA28}qBNDUPUJ36WTJX?_Nj zy`6z#n@4b95GJde$G22fF!xZzS}u^zdp@_<=vZTREnHLCDehnPGEWKOU;Fz*SYC;F z)%aQtNc^@F#*fzdV43dmYZ<(u{Y)lBJ)6Kv0(zn@Sd5zbnm`LsZ2THtY5*jk{#xNFYg)9LRMyKr0{;Ga{L8tixlC~FluON z@#FxHF7}oHu>`z)n;AA!;OX4x`! z&KP30{4NgCh|CR&+(5j~#iXbbocyYPpns^}1XB?> ze(|`wH%KShy0nM6Rt*g>7hhF|vkSs)%f2E8G!kumXeM7PRH+B9;w&uC+Bw{ce|D!?mX+%EJUyWIKI+<_S0_~)V{F+L08a!v_jinY=nTE5*Fd6L(6=d`_+aMe$QKHa zNCw+i@^2G(GISj<(fx+42k35nSFe{T!=nAxoQk+#6DMGBjH+-j{UE$9d-vpb&#O#^ zYD=Y^E9)6w;!se;xT~FGteK}*(sE;5xF4EKop4v3y~f}H*RplnipvV8L_xU~GN8JT zz%@qGMTvE#6`vlMTWxNee86gs9Iu^0_hWakl59L0=hNBWK9kd*wn^&GnS1Udu1Svn ztx@2^nDP`+kiX${yxvYJi1B3k=0$bp8ER5zY>_jXbe`H6uiw@rN(8e?fA(v;d;7;W zi4Iy})MBxvGPXh~-tHro; z@pAQ&&yj{Z62}jCm!0w9oAP{KqzLTbG#r~;XQ3gcq3t~EpyFnc>z#F=*Z%E{@xWCa z$ki*=aX~G&cK@lE8L%Z|Fji*%nDw&B# z;7nL(eEeU|d24a#o8%tdd@8;HCEZom3e%*^eFk)048e){`zTX_b2#xDh~B&RgRDZ`&%JzU(<&NTN2`6lzz&rL*IT zsy2BM5zm^pVq`ng&BL&d+#hW9Gl9qbtb~!}4k%fv5D-qA0Frq&cgg?miI^<@$7>N> z11=v-9h5CmDyBpq+^z61?t6~JL8~xChXu2SZ2lVeX3xL^`up8q|0~ALH8tr^qq~#!6VgaU;ea7l~TkM@b9=m}kkwn-M}&Ex2eJ=fKJ7yg+eIw%ksN%3QC){JbtdO2kC=Y%mYyG)W`CN0jg2QV~AgRYtodc|s#yj4seo$%Mr zs=)9zOJt}R601<0k=9=@f?dO>D%fdH=lFKJ*qw#Y2JurOh1d!xKzUumbD_{giAkf9?z26q2Fm#?*Y zGXnbMDX!)m#1c=Y42&!RTPV!qQVb&SzgJ~STslY}!w=JegiUehqsAWWlfi3x_@ zBG<=QDj2>pJkl4}9ik_ve7UsXcTsJp0Q^0tqQn`MklG&pSBV6dy%bQ{x&D1FnXyim z@Z*c^^P;QcuXe$RG+59EWa59RG-o2vHZ*9IO?DI~=F?kGg0@%$Ga`2TSL527 zK?}$Ilj~)rDe&vj@Q(mH%fFnI z9ox_zkg=dVLH$-zYfgdW((=}zf}zwxRH+axj!;umy+uPF?IW~*;}H|hRr);DP*K?o zLs87e>+k>VL%uKZ-iHEGr1jVeyjN_gIPm%|Sbht|PLp?|ARP40X;4$snNtngENY-p z_M1}&B|1jKWmHU_=KIUYZHp0S2X?nPIFSmti0?ic;jmVl>^GbLicrCa6vPZJ%uU{{ z*~MZC!1X%mmsZRSngPfx2-Nwyli3iSqy+dN%A+)<`7Hqpnxu+r-75AB;Ag(&2d3IY zgY1lTul>?RR`y16dwmAFsvUHL^Gbzo_YwgwY@litz-mC>y^8k0Hou~b;^P=+3c8X< z{~TSP|1_jy6t>J#h1xAgL`>RH2G}=gBWJMhugv#9!Ab(R0kvRIc zTyL{eME~x)=P0l%(!=4@%Hvn1q?*e=vukfb#t}m&e2iOV6xJ_7F;fw8rAZTN;gJ;|qPE75awi zA_PtWyD8(aGd?_DgNM*ilE9FzEu#hg1AXhz-up7-kGismVG7I&(S8jAt*r7$=!Fuh z>&e4Ryb`W2D~T*$<8%;q>pzFDj z9m_(oNQ>|8FYiwo?>TQZsPH}7T!3o~?;Sc`mC?Y31%(PD!BdeJV6xTp;d?hZeAD`czu!p#vt7qj~M_dyfvn`tieM_Whh|O^bMm(Ot8g{JX%2MhmD&2Xs$FNY% z_5z370JEf=VQE2W*;-s0#uex~dAD_*#g#vFtVMoAgt{ek<9*X~bHgfo!}|#RPXpHx z!0vnSN9|q$j>j7%)fQyffeT)Hz(mGiEr$N8yEGN#7kn&ELG0?;oZzD5Sr2wx10HAZ zhIMWBNfByiVq?%sW?1Lm4sEw(puaK6`W4r^5pDb0+&|FDhkxb!$J`u$tV-wM(boX! z!n*v9PiJq?lF?L)ZQi*nNP2lDTu)Ud`k%Z>Br{V+%t-HkWV4KBS4(j>&*vq3|1#@v z?z{c=-_WwfBaVA6PV(VajW2zDOG3KP3W3h0u0THj;_{Ph>I>A%P3(&wS59~P*dz3t z=r7qEn{a<1r!u92IJS4cdkZMg%RoNeru9aK1FQQlc-z5=hLko~Cukelw_uACUyP#FSo2YfH6r0rn$zqQM0=xU?6wwr|v=ur@qziIa|1 zXi$*Ef8^3%K}@fMBEtbT_~P7K_94I&elB4 z;%D2mRlCoU57dW7^7cCpqWA*EW+bb^7aOrJE=JXk^JPTk0#jnU%1ONubf(Wg(S%F0 z|5!tcj$8MK>*L%->Bh#7>MHcH`YvHTJb|WWkJ}Snf8Ou>kFMn5Q5r|5_Ko~_jiWwT zPxp)!hxCGFMqqzve|Y~XD_$mj7=1Z|txZ0_ZX@Tdj6wcub9c*+2GVMJ`^wIRYVaP( z?gtydd8jl?`g9B13Zch7CB<5fRzANh2R-TQdfeu(0J|tP9*&#CykJJL;NmSU0v6O1 z$(VSfVSr5KP1Q{G{?o|?i{3ke{wv~;q|_Q7L^C1+OEV&p&alHDeRtcH;m_aXVN@E~ zKK)?J)xW54)E8opX!X!jqk-N^t7#5b$5e&q@n}z-_@4eBF-*T(S@Tc$h3FCS zPRoTeSKT=J5h!>)))5tJ{-+#@DHAVrtorx=@W{b~jPKuq0kB&rNAH$*H0&@8r7!2< zfMj9d93@HUQ$Eio$AeE-oQ2Uv&^?V{PwossD##OftoFJoV6Fo(1?SHFcjV^*ZjRVJ zMbQYd{(mU@p3atk-qy(x1+o9*Ex>8ZyX^nr?ZYhk zhX`GL^PJ;NLE9l7=6otF=sl3O&`yi#|Ly^>dEk4G@oGNJ!fbihCowJ|qX%Di^37|^ z)#RDsBM9a)fAoH# z1VT}_NdCK1IsbPtk&X+}kNWmltIbEuqb5?uKV+N>&0VitKif~Wsqc0S!s^^QI|rpg2Py5YShed7062R@C)cyXC#6=BW3lz`&-@7SVInJd!D4Qn5)1 z1+O|UjbYbSwDfxW@4qh>mjo5OK3->7N%*%Tm4IElC^=+);)6?mWbgL58?>}aHt$2C zH?+k3Fy0`xIMh~0f-pX?HTAehaXx7(KoNa3Qs=3{+g*oG6)o$KAK#t)t&KWKO&1rX z)+)s}#wCVcF{Cy&dTGQQ(%#}_;Xhi{M^>@4c8)#Uefg@|Yw=;Zf`?AW-eM>mLgFxq zc-+R_mca<#J}25tNJ6^$^PkBmq!d_*u?i|G#Jx9EoW&A{$U+ZE{%ZZ+|J0YIZ%&j9 z=Bfy+v(Ew zEMniI0q?*Q^)RZ1e7N0lK|?hk!yik6tbfk;kxX}!7(L&)boM__s2!b49Vwb1^+TO? ztJk5;aWWOrj!%{AY?%nrf+lPB-KG7>@8bLCH!}axuQQ#e9P*s4XM1aFYi|~6*ppI6 zNINNR&J-Li8bqezT~#=Nm$T7IaO2M(J+H?@0z$N-Y82I7Ey2d`y06i+YXSV{MRgvG zyqpyQx5pQq>pGSDG?WKkial`V3}kxW2A|768K*(S;(x!Ay>EuphnxjU@)utoJCxI= z3lOw!ojhjW(UTV(*{X3-F-9XJZChR|lOl6xxj04-r(J326sfV-y>?i!(trY1%A#aY(cvSREW6A?2*VForp~N?go0W2Dvg*tFev_ zWgFj+g?)kezfLaWptLa-Fl5IJ<+>*x05w=lj0(s7>8{4HJM)qvu%3 z3NM2jJI@2MOEcza&3<7A`e$(EnQTUpW$5B=7eP42PporMKVEN(<#yCu)Al+>G<2HF z{fjp&_P+ruFJ@xU1v{@%sFcz;xLnFR8NPxt*FH%0Uzd%v6d!bd+TzheD_TVpa^0Zm zFZPSQ7$ZXYkzDuM2KhfFge+#;okKzFqz_~F;(w}d%gT$#Mo(YCzMj6H*9@9%_1<)$ zec6Z~Ot$KsR_eVpcXyrK^P6(`*`wtr7?Y2Jks-fo94|+%{qrm)42&i$3Jd!=t}~bI zu_Dnr92^~t zo^i1g<&K|KHfToB0rTQVYi2c^Y;%IrOs^8x8ap z_WI{JpG4)2@wqV3Ab{;ndcpo+cHb&UbsLf?J+vZouw4Rg!`H+v-*F#3Am|tnEc({B z-m4zj(bgEle(``lMS_T8W$BYz08bWD^(xiqI?H6?q0Fe$DK6XQS(ISz3YAgYM`N@c z9-wHmq#Ei~rn{^!Dlywq!Ts@rRy>8Myx$kk{8wFE3?$gpqvwD3S8h^Y%&eU31$B1^ zZA4#Yiv`o-N0D+|b(~*vRoq-=cRJi;&$MS%7%nT%1?#2s#E}VDEnFOQaUTkg%d?;d zz`MWvJ1v;QldbLoV$GB$8gDc2=Cw2}C{}ZDMuFQ;{_FhJhh4BtU?}A#)jrf-*sI^7 zhyHMSoRcK}*>YO>VZaHmbsWv&q&{XX3JjjeWJOf{l>|#AYNnyVi+rC4o#8m zQSB!ec(C4>bH3@FpXt0e2;^ImKCK?fpS1B4iqumuI-i*&d#-dJ>%&7LKI>{$u_ z-v9n7=}ry>7yk3`(B#4zDqUA!9eei1)3ZZF_>xOn;&A8FC7u-;B7J4j@?M4>&ZkHiv}^>d z%s*UZ=1|Pbmv)NE-?hU}MDGSBmhg<*Yx)AprI*Q7 zrb`%qk9hNB_HS{a#E5X-! zK6l&=o7zz*O%U?IkZK=!y@I!0x}8-U*_TG9_Yv;P=W$+;R+eR-;{=BBSQgTG>$@jO zB%jBu@5zy6?`+{`X3k~LK{xXxaqXqcs-2n`2I?991dLB}<}lHM6pHPF#YQEP?zZ$g zs}hN@ed@TGsLuvmeqhx$_7#muA>+ zBu-*8sFY8bA>&fa1OLEEa^XSqvGb~Tv}|y@R#f2^2Iv!fsP+gix_H+*TVDA^mfdck z9xL?TRbEhxUx;uT#&z)~C=x&fNy0u~0c4?S4SfNAx7?}Bu^>Eh1ZyUI&^k?~QaIZz z{^|1L8qaa%^5<$FeA9kEoHm7?@arYakS(&AC!3n4w&SvXv~t+0Hg%(ksHYRvs2y{m zfa}1PKgMO2wc?3TAL7}(CAYkG*6%c3i;r2dlcx{+s%v~Tst4;dd-$=FpP$jQ6E8w> z1pCr;i!9#z5X1i?2JfkZ(8;046kt5R!D0(f{qpO2Po#6Tt1}jbLL@Res z`tJ1WguT&Uu?vwAtwuOyDqyG{QU6H~ui*T>XhTALbl| zmlvm@@DHQC^NLrty0MImpDN~z8=^K%Zl=`|)qHbX&#@~hC?N{S(R|As3R zdK_qB(4QZ2DnsAfW0o~Yzx70D5#uw(dQ%p@Jc;q0wSk8>yGmmtO)+`@n=_hp9R!q9 zhxM2#HeNNm!~Y7x<7pVLJy0dEed(k_Ugu7Pt?}^ZJlERzVR3uv@#gCQQ!WWo;D~Yx zk}mQr(aHIb%v_OVCs-uyAqL@Ha*srU`pVtjcACW14$9>KF*e-X@Yo*v9r>DzG^JCt zR_XK8vIG4F*%i@0s*e~{Q^sKuhuNz@@5!_FASED+b4@kv)r9{h2rhCoxo!RV;7b!U zMpBQss%jLCm~Zmee6La&37dr*WIQM}mCawxTCQK`em0$a&{9WShm9zf<3`2%At$GT zu&UIlY|XEH;TYy@bS(CLnK~eb-d;x8mXy!n2DEK&W(@m-a?M|)e}m4{_+a)It;+Gv z)=x0N!>E{E3Hi35wu@$9Yd7;ASEHwfS-t5r$W9M&Q$wxX2deSuPZNMH$N0sH`hMVP zg?+vJoQT?bC;w^6hLw}Zex`}*Eu?He6lWwwW{AB%!ro2U-@qq>d@qEK-j2ijVs%yh z6r?j6Va##%Zw6rJK~G?YmeXeQd_%{ z7?I6yVo;IJsyq@?pqZ;qIh#YCBjIED#RqdWxf=@-aL;+lgN_BHdp>%zqXI;CD0APE z>C{{f|5C2JS*|GXX zY97^Tlehf&`lQK{*xor58fdMO?T{Wz*p(mAba4nRtbg%&;;Dpb<{s2JDAi}(yNewY z9uZYoXB++_#uavu74ug+-EUDDicK7F-j(ly{GK73l9?hNaThUHAnSh{dpubV@lb!( zSj_fNi)KIE5R!2|`cY!+&+3C1HmFnmPna^@2{d?I_)l;sdq6c-p1+!3bMX3he1Ues z^li4z_XFn&Gv`iUZ{}z3<9a;YP=tu9Y8W$HD&bl8*z>QuUYW6!TWj6$mbJg68sh7_ zb@lVoC)B6obkn-q7WUB7;}V+{QK(nFcI?E__`Xao%$zdM4|Byl)Ct58@H12#%(sP2OHbD z`~E-KcFNQna$*Tr6;}2Jn8wALdnIOsT^ts42R>*9Y=1XPCs~sS!c{_cwsjPYaVt}d z0BmY0J(TRxH$!MI5YQZ5MjeUS5@{WKChz?<*oum_`QqW2OPGyEm}=ilVdc#2>Smn5 z&E8$T4&7Ck>K zq4d11e-e~?vqvV;`mpv{|Ar}Q4rU#gja*(bQolP=K|Q}DVne;?_r9S`6w-BTH`+dXNkglCQHiP54ySX&{o?9({FOF(LEARK;Tc zi^WRqh4@Y=d!x0#eO5{wk+|7>hC3OlqY3Xzi_1|G%g!9O?k{facUM8u#jdK_B3sq; zN!R{&gUbSudM=-()x{q-@?RC{!0#b1Uve>#{|J81qy-4kanXv@7RcrtHcfxz+O{Q~ zboX?TN+KoIFe&#}!B3}bOJb!sXb+!CaQ>Ql^hF5`Y{utgG%1`~jF|E1`(sRB(b80I z%zCJK(TMSIJNQz{OeP@FqdppO7|OLMWBsET`%}~5@c=O(`?8-`wSv>qWbJ49vrM?V zzyGTfzPW~`w&I3AG9AacW$f{BOG-(b0u>8-4o`%vpF1wvgo_{r9)&^sB0thkKTBs~ zav9l7yKYGQ%6*A9jHeC&%)CV@&phnBAC6g0Zkug~+;&7sq3qII7hR$FZ&q!G8pz2Y z2mCoSquJls|BNPXPIFQ*e$*XaJFTuCK1a%-e8!_&_XE>|;1v0!x}Op;_)!$Be7ycr z)K@|&5-#w#K_eFU!$jc6>|A!FDjT1B&-$8?)b}QAVAdTqD!GMq|at8ZsZ4`|p}Bj{O`DC)0)sFYtyb{^`#b zb#2=n19@mj}q9jG+xkxMf`OQR0s_gMj1V}86 zEr+{F*r-3UWwbpuPT}$snbU$pSuLb4L9cfI3A+g;sHTB-bNBy@3b*Wb*^om(=B9{w zUhw=k9aYlcTFRdr-~1 zU5NP4504IgTJE@+J8wDi6y>agazX~B`00`8J4O=JmW7NysI-0&3YW$ChaWz#1voM5 zULMjs7gPkB^l^Hc+4J_g=po``TH=H6^W@$9V65KaaDv|n*nl#2QvbH4ZzFNvP7woG z>i>23)qhny@7ss2w{%DzxE-nkaJfU66F+fE%!8n3OmiO;z5XC#w``e6LnxN@)oson`3vUt51l5kd# zo_4k@GM@wb{&UWKxGB~H2JOY{A1-ThEWfGo<#RwsygcqP#KnD20<28kkGdCboI*J? zWE}IA%7~#n|JHl2>b1HjNy@`?yS(?-*lCTYLT_?Dv>Zr|>irStAaz_xX*1lMJTt!a zp2D?F4&0Ie0rUk2FT|dm-+cqG^Q~ip%#&Wt-S%|V<_Re#E~BNrI|Q?^HOCg*;Biv@ z>ylQbMTCwmY3SQ=dtcVtRooc_Hlcvq#A%S=8C>9&Hk7yVY2f^RL_pD`o76d`Pluj^sn;Z|6hI)8b7YFK zm`6^drTlEL@(NG+@5Kv&WrrrPhMH~Zdg58i=2_mEU>5)vD$bB*tbH5r3>xUD*ZPMm+R@=S zA1}9INO%4EEGiw<%i$^{sC#+Ikq{C0mC0E>T0+G27!ydVT;G}|X<-CNw%MnS^=uSQ zLLN6vz$-38DMgDa`g_;HHlcwFtLp4rZn0+%P5b;e0^eZ|ib+W1RVTfX&-~*4M{R2i zZIBnpv*bS_^{6`L`Q8z|yUs&PJ;f9~(=`uy1fPf>1a>lS&Sh7e#GA&;Z8KAxFWz2K zHeCVyCNPK#XC|wS!OhJE=IY#%P33tQd&~BHtT68B8FjHJ-hHS)VuX#mY(y#(^HQtT*ICN(;5|(44q|u(Mzo_ z1b$mMZNm4zs-sorR8_u-&I*ZLCjWI0-($Iy&wdic*!^am_0?M8H+2E#RM?3TJn&<22Kh(oHjiMhFu{Me&*IWMN`E_5UWeHB$ zX(>KpG#@)Q+Sen;XJMcfw_4=i7*`f8#C>bV*CO-@qs^hdK07+o%sW577p0`Ak`Gut z%y!dwYcOK0CE9wv3=VA3HWtSk3T(;$^iX2ai{Xo5t)qiy)Kni!+nCig4o#~+arr#?qQG)xLs;X@qNH; zYE>KDUoi38_0*G>h;v?!U;aTy6}9_q#iz~sQFD#pB)sivx%H*Mh|di^`cz)3(zGC) ze?<*y!8X-8#7`^c3eUNZ8EB&W^H@J_2)5pKyjg^Gk1xW|Gz|>MRxXYNZ;TPc^Zym| z+Su8<|K4)$WeY?6_O&Z-d^^>+YpcF+|M4+?jNuf`;@0e+wdb7uccaTt_}q8 zE^~I`&Nq6&U*FtqEWl#6rc{E$#ijg^I~Ei7kgIG6=S!L~VpE1`?A;_sW{91-uve zdBA=^oc9Hne&=s6)q$5K{X@J=ZQI?2FP>$pUopd~n4wjCzxpgVUsf@Q#qN`;bizg+ z##8XGj9$nAq2?FY9o_1P<)5mp^aSw+rN86z1c`=D_vKHTY8(&So>yz&&fln4G;ymD z<+b={`8A>oHbqGL?EyNY8nIL5eAzs`y;tRutqs-6OJ|vz40RJ-8T^xJ#8iG!k87Oc+)r%u4ctC1kC6wg zstVro7&X^_8!7%|=|^z}PwHL#tUnUW6e2gz2e~Ed5%Qaaydc$j49#iy{sZ+@Qpa8Y z)=!U;h|8WNVuq_oWE(MbXb*YT2`4mb%#OVM93}7~ybVsarWyA$&$Pf`d*NnQ+~KBr z^q6_&!c3wRjyBAQ$mm2F4A5ZyXQE?vT!;8AHSRhH-%~8P!hFkYrgwLk4X6KnZ8%J+ ziB4h7G~L@aqQ>`jGm8aw73j{S)J4j2 zg#Nzy!-wu_|Jm$g`q|+bLpaOjs3tFs@k6WmFVJxj6*g# zlci;fRQs{XXXnx$K8FZVn^!uo*3d!2SgfG@3Um15Wx&!!JtRMR{jz6hWJeKA;&P{h z*YDHyCp4c`rz(3P+Hn&{yR$urC|HmPm)35Duy5*`av8DLWAgY{^)}k}gY9;rrsqfl zy>rD}A8)};rkZNiyr1I8Mgcg`b-5w0YlGvl07X!Bm?k2|or53!$pMJaEJI|gM^St% z@5LW>4V5R}3Mj0v1x9FndKG~g^e-j<0n6Y%z%z!lW?hP`d-q(m5@H|2n+3*VD`r2C zu=^mB)>J)ND*eg;EKK6vyxqQN@#YW`c4@_1`};MrkIXy<|MB(YeK>~ zo&JE%%t1J`_va+hLHX;~yVc2?2@~Vn%e8O_;?r)d6PM-l2rCxCTnYJ~6NFP`k&)2Z zz>%%DAQ~rwm|c^rGPim6jCYAQ{DKiF_$Y*F=rQz)ompPglcX=Zg?_+JwV`)-)PdW)9?c2H0r<1M$kD)w%~PI$9F`5nDz*k6WpI zR^14Br$*oJbH?VHdp}$`Z?htUsy(RQy|_r2bkR?Y7`0WT<#_2c{yoqa4q99h!rlTW z(`kStXX!#!AKiN*p9LL6;$4gM5{al`d%;1-byB0 zMr#7v53CTk=gMx2l`#X`oW{wTxY*|VC8B5IP6#jK>*|nlCOnq3)7B1`<*Yno_chA0-o~wDBsMhXBMy_QXL_Y-F7*0j; z(kyQ9%swbA`RcM`*;7&qzJ7v;A~=Vwt{b!nb=hGZ4Mkai=U$r3`%HrlvP1zEZ~I3( z&Wk}+u0qxM?r5m=LbsP=0%MATOktbjdrqa9gUxJu1+{q`jUzGL`3dVvcpl;)Tsb%3 zs!n9Ug)R4OdN-N^n(ND?d8vX$N^$5>!sM?Sn$_p)ggf2((MX;(j@5^Il6Bu zKlHKEOAE0tBUUW2uM~O2YmG_%%&|jSG8?z1!p%ervAhB;s^I0d8fpsyt5ZvA=S%gD zb4tjgBt*g8jV+^$l=9pX$Q9`SzPOFg{ynecIhuPiDRdl4rJ3!oMekXwEL3jFNnUvH zEl`KPF;X8mQZKbduF?3`^Kco+*?pY`u>qhHiD*4OvlQJQ zxl&D}qd)|?0;hi&MJu>xPT!(|T)+9Wy|661=8K1xGXtDpQg_>KfN*PvZIkoCVR`b! z3lF&4xOhq@Y|cP`39pIStWJn2QL!pnBqfbz9ziTXqR1SHoh%=~6u zd_t|KC4fY4>u(qKRP&*)12ywdZL$GKQkYN*jkYr$=@n7mI5KaNVzjqdDbKRcuY)6p z`Qpy(`bCCUCYaP3Qj9r`Ws$Q-36=E9H0rdkn>;+=2ZOb+kn^?X8@O*k!@|8-0w;Dr zi6!6nfld!%-rIc?$35NsC3mZN0wCu3Hm#9Qvw0&-{Mib##_(_IKezXz;oV)d#@NboT;#I`y2UmZ(D> zt#gtmuUtsdDQJ zUfD(g;vC8v> zKeWeYxix<}wrA!_QNkIiUpNlJ%uy~XwaFwB0-*Jhq>7UVWaebLH_?nMUTsH&;(8-0kbl+q8Qa0xCZue!nt(LUQ$c-z*OI434Id_-ZJ~X}QsHiT_*a z&3OHRvai0)%C(Klirf`A0u|W&855td!;sOsdA1@$*IW)s7ca*2I%>yuy+y8X9A}UaH5rzb>O4nd;dN#;}Fb zMo1S5kafW`>Z;#OX8KYx)pIuK@?@hxR$|W$3%SKB1m;}BGuiNt^CeCgr=rc|@CUp4 zAM^HuhOYg}@&fiE90sh!#X5sH)hSke2s1mxgEdnL19pH?yro;=7|GJ~O0iY-cw{m? zxtq<6Gsg??8iVZ-Mf6z!X0&Utm$ZaNsrUu|kJNBN&w} z3w5e;^1r41^heZ`GaSeJKL~=D<(fGT2HiRaM`FHJ+7K4|HSV+VLM7h;*zZ69Z-eJZ zonIAw#CHCJs3OAfY2Y-(Nq9mH3ug`zL7JciQkm8!C3uxdO6u7__Pn2k<^ILXUGu>! z^q5R zpliV7b?cBi`x!v#;ZPI~c=6pG6Q-VRBR zEm9oAvA0QAdTbgN{fQRBE=8y>KYdh^HepU8Xm)t#?;ql|*`F3~zv8O>`Lz8-Tl|58zLIF zghlhIOj{%b(VwL3Fkp+qSuBZ9rH=Tylp7ese+g>!=)S(epa(+H&bBeVsXP1A^0o5< zHAQ<3)x}I%+$Ha)*(>zwb3@(#zH7R&?yO0f0w`Tt&gqW)Hzg>J9eAUHsg)D3{jNHZ z%Ox{HyK%e>McTLPIphT#mlCV0>~2!7$Z(H5mjvdF2FC__@Uow$a9+r2AA^r`Gcs1sfm>6d9BTZXt%yiV^Ce`rHY7v z_zJJ2m*?%rBX9(x1^_=Nn4%}~-r1Eece(Xxd~Elcj>2)w_crQ!6sNo$wmR;9@RLe5 zY)t;Wm^S^C)*b0u#NhrAX)>`U+N~mUs{XFCREo=>P9$(MPWh`~j=!4a=+doE39?xM zMBwvN9V~-EG4cCcg;jfA3%6cTSh84^Q*#Q4^v-dm0HfzllR_9u)mGkLEPRJIoNR_% zVae26Iu2P^a$2mN!}Q%|$t(t8jtIntV4u9k4~9lk+;N`WkyotP+LP=3=@fVhJ4?H)G!uwn}U7+u8pK}G4yhRCb6_>7$&!@f^tcjp-GkwYAYtzzN z-@u0{7>F`4CG7hW@#y{MLVYhDCz^g4I)EdG{Fe)ue;3o^KgxLTwvQa#heq>?OJD43 z1$2Hyg|L9?tu|z-itrDDuC{Y+lIx*U^P>I%o_h4p2ML*mfdcrig?pp}k6hSK57WC; zsHom|>Bp?_HwH*H8Ecxw_Y(wbgS4hr`sDKJk zGqm>Fl-lKIGNImZN``lZ*xvPqbCwVDMjuiIQr>9Olx{>HguvKzzpY?(jqi}`L8jduBy9jUr27#DY;n~W;a zz`&2Zq16-*e62DzkLg`9q!yY3%WXYPx;cr5D0*`0eg0{fBahZA-SD99mLY8_W}2d| zM9+og4fok9XBr9E+BY|OBF3Y-_kudU9x3c9+WuEmnwd$w`(Bb8DA+Arg~{x^9eP1y z0)4}xYd0n#eCNaR`MGd{$2hf?ZcY=eAo-i;_L?c&$vQ3067L9(XNJ73Uw!Gqy)#iDezSvz=-ZKK<&W)W*|s$7 z)Zii1D)@#cJ&uK_)U72$gIoOY`#B&{=lV|P>H#kg>EsH0E!N)9k+#&Ky_%@^ zrI&+hGgBpkftf4sLkQp}1nJ_8by?#0)lDY3En9}Sy;9{URb<}5nOZa?#dbW3Lx0U~ zXZn2ePV|=;=DCgAci(KzIqyjj93y34SUZ+xe#gN!3yFz+?HKlI<&pFB`E1JA;D92~DZWHE_Xvi@yivkL+~O4F+f zPy0oIJB(_sFJh2)!_#VrVM0rWKguwPe|buW7N8lY4cVboBu21-N=fhy4ctPL1$KFV zHE#bwD63MA(G#N%RQhlYMiUXKpk_5Ohl#cMF*jloYHoap-o#m0YQ3 zjrjc{@aPuz7pD}Pi)&(95ae2(jg<{`kg3c%{~zgq{k9evV9~N-BE!F|=w@qI>!++j zm+;zBkM)heHo*3^kUkQ#krNqxUftsAGY?Nm2ls*18IviaG5lAJ&cr*;%6vA~*11Jo zTX~sF7zIfgTvsAPc}bsEdIzChTFGEa&eWK?f!{!EyI%*-eEtL7gR*uvyZ#;L4U7%B zXQw{yTQLNwq`qg(KDWC8K`SoGr{r?>47QnntW)};x$sxyGZD5F+o9lpHAY%Ysm{pRv8 zHl5<)imz;R`DQIGVvh-}Rz|yjr*l3~3zAaVisG-sF8n>>#k`%i2QR)kEaNgI4k>zU zC^)$mXox=P|HKug$qeBmv7Dv7hz|Wl{g!PVGfImJ;?AQJ*9)O(7h>2*3y)9#-TN4e z7$+J?K4-`7^m{$<>M!^s4;e>Si9e`Ny_h)2S)VFBRe3!o^TTfxccHuCR0sACM10(i z!!^2X%)XZ;Ix*F{O#)!$-zoj&$7`*&BlFy2i$4?T)AI=0zDi=eVO9t(op~d|H@NuF zph>C=*qR`~eeVBdxTKHwJJ5GvoK*aIXZv>1@T&|+%ta%udX~>5y#mh^HTVig{(-W@ zO8P)yBqy+Rp_D))MdLkYjM<5={2^~tP-E;H{8VP`(b;tFgmzlM-faD08%FVxw^Vn{ z{N&S^vemttO(D&CrQSPr=A@%+z_8S=G)yw+2#{&^IgFAv@;Bh$}V!WN5TvUzxt*~+`}hax~h zIBg+pv*qX=3iH7EqbSbjG5vi`i@3?UuyGwfEgk5^b6Mfn|m=pr=IB6iEpwag;@8V>S)6hZ@&GQYpxIaP?EG{x)+O zP1`;HJU zKq2^93N;|$XBKiFo?4^M&vDb26!jop%KZ*w8bB8 z6toSuXh^Pv8E%q>l5O?tXOv5|0vwETdR(R#4^wwJu>U#6+xIR>o}+V)mVSTPjbfAZ zAEqIhdg3jMe&>lz;~CKA)1%8eg5%BS^XiWCTv0L)@R^Fmv<Udwj9{_p1bJPIlx30Zm=qh6x^G z?8eW4qu8HjBm{=T`&o*vBuXC2Qi(Q)oxe}oWyp4qhlJ$$e#Y$vgX z%{v{WP)K@M*KazcYSOX_=b&$<(RP;`JGCBdM8Nny>Z#=`8sO}I&lT5gb(`zn7CQ29 z@^d?%o$s+7bTN-ITIxQJG-PII(x$f;;-#E}DNbk!?L5BDxyG*lvuO;w{%&A1C9S2h zD<<((QctT~vUZ}TN)`Rf6rUL1N61a@>TL(Oy9d7U#h(pwF;s7_#27bsmbTX#GOdR! zK6HxLn?WD-0Xcz?=YwLh_S>1ciWS|yBVsN4-3`b0=ekUr;OPEfukP~@>V1a}XFnh= zBq1Q0*RvCyCdJ|skH!8$pX4&+-!B|Tc)$uUub;VL<;SI?kT}+fy>F3xN$!4o)NNvOe z`&U9EqmX;a-g$a@T2f}t-x5yFl^JequREidKQ>%IjWN_17Y7>D$V*t68vtHnk%=6|ONEZd7*#hdk{5lKHeJrv+?I@yZvCraA?C8LD| zBuokv$=oFl*x^)IkHjQsL0C>tiMU#!rjwcDzBfzj{^no0I8alDF>IGbgpPN?s*e#-`S+BGrwUB(tZS{FF{OFJ=x_s}`zESa*asdAv?X0W$|4N(j7*@)FOR7M#f}KGm{x{)W2(Q?0PGpKCT( zoq1#^cgl5I06-N<&H1Q7yerE8=q5gl8&8RWvsXbpRHw)q-o@VlrNLxvpcMh{H@TBYEwbJSAJ9*(Pi%E2 z9Q`tj4BM8b4Yk=PRYS&NUa^o3MBOm}f}&p;Pe+w7N=*;FFl5G(rPkq=^>%qM?dK&cLCBg`6K{3Yv`HFiXd5WW3z;Z~`Kq#^S1 zn%B(fvTl%(TW$zNBdY46UmX84>ksBZzIaw5?AoDs2A$zcMDUN?wPwLYcVmon;}%yO zd0V%NN9zdGRoLM=3y-Mf6$mpj;L}$DLAT~TdqJ8pe}!NQgBP%k3s(_^DUc=pWol%{iwOXj6T=|UkN~8Vadht^v|}`#xs2q zO@oLi9+MZuS=~rs8K=4lYUU3hqq3|G-AG-gg&^l7VfU95Z@$K7gyzfst(?*rz`22{ zLu4?(P`X7bD^HnE$g=yTJm$@s2SPDL$Y(TzZskhbl=LOhAvOk2{lfQ#^G?f*T;Zr` z_at`p`TvpD-4JOigDmJhYMJhb6Pc84u?zRmq|D@!zaj&Wv8>T4%5~`7nVs=vW?x-` zsjox_A_Y|fk+q)YMqocMDYX%8D8d!~HT+}%9FTw33*Ubif`W3ka1fC+VmY)`KogLf zEz4EacH?DpN+PJaUE#39D6=W?A+AC2+ozDUp=phQckUN(@I*m4{Fg4wu|ncx8IR*l zJ8_pzWpDXbn&zI8T~zj$gEuxW^uDA4*>W$N)xPLVP`TtoS}e_>cjLi_cokepM$~;` zp#@?-PQCj)>EZd~iI~R18BFWl3jQ`c|DElsM?Xh3A|w_ZKN`NB_PA!wff!BJ9XHo; zoN-3H6Ms6N;tt-m`Q`06Ts+|n_a&;NPs^2z=~A+#x7;l4>=5_WWtR$r?hI0=)^E0% z+V5b)*D;@ZYdwUf9{FPLTc&%z3|KeX)hIHX?@GOGrZv6Q!7Y6plKsWJO}){ET#2uu zeYHWy>V%WWHVS@?ye^HBQC| zDYpLae9!cXvdqbS;4(vQg&NqZ;C<)QtVE_34Ell7bs@V4zdY^x`Dx z@!3g)7xh`h?VbBTJDwM#E}iL%ys$2Uhm(X?_h?2>J78aEV%9AyHntw`^Kxjf5U%c$ zVrCTYE|XrI-4@i3;mbx1veK}Y|>aurcsf=#%+=6)YzFJOEm3cGcyddJ;i?>`b6S~0+ z#VY<|-fJ8j`O=kbPV903#~nFE(&Ytn^>@rF-^5Zwp2jP^iExC3ybVVH#&no6oeFvH zs>1T@c_Ayota>Kx(!reIvpQD2K*K_mmfWu$m)^jefk27Zxana|HdGhxmA>#Xunq}o zVNcDx&)(1~zx^usP=X4;9z~Z73~-Her1yAHrP(j<-E#+w$$Y`<6+43};!Y(7>t^3; zP(8Tc>??NB#nlYrF7(YV zk3w+&Wpbz@k54vuI9U-RaOGOawmke-gI=>TuR&-W!CeU?o%7&(H)}f6d!f^}r{ssK z_6(oLqPN20etzHfPS;g7AIa3Qae)1{Xp$R5(FlgrQn7IT&uo1gGw+sVH;&fjj&vL`a&$fz543K^hM!x@zEgp>5L^xT#73I`vGNa? z?EXgT>wxfSY&oq_YO^7JU04Bn32W{S@L=e#M3F6gt+X4I#I_2;e$3WHPaI0#@3Pw@hP{1YF1{D7BSi$qe`hBEYSl(pw+Dv4)1%* zgsR3Ivm8c*;$w}^f%3QbKYvVLkmdfgKvB4QG|{jf<`uRiSr)y!vU;{S-;4-h(^U^) zf?F2&lhx~4!&OrBU-Vw5@o5KE$so0b=zhYz5tnSOGRX^_bVia>H?)tYlLLsUh6j(r zdk{$wZdAkM8D%JSzUaNlQ-^ z-Vz-zr@tm^gtTEHIR_|^9UPkqpJLhmYbJ;{7G~$NZ*4O0h1C=OLlW&{+9k3Od#fBr z3U2tMJTC(g2qf87h=#>gJY&Ksf&UnKJ(wp+kQAmj-?>Mrr5jl0(JSLZPn`u+u4@RC z)zTRBXi#2zxqEu)5gC#z2tRsY5h)-24qafR1AMffeiGP z-Zcg3TMR^Gp4psoH5Jr^{xDF)1{pvTF?d-^&IwHwKUz@o0qw9xPeIC2`17gG8<=F0 z*ip0Cqn$sNPJtN;YUqtL>Hg_nT|2^*mF;e8%#DmtG##IP#n?3^Gnf9hGuZ`lgsqU@ zvV8$=BIUfR5x%Q&4Qu%GryV_B!kGNM7Uo93F{pa^pD3xC(@`Gd6hc72vJw#$3Dc=Z z{#cJtK*wDzX=+!+bo?)rP71iIG+=bi*lmzqE8u+vvSdG6(#~Peto|*j0-DYa{r?Ca_z)(xmr{kWM}Y8?CZ!UTp4-d7 za0=9vW2jILU>nH-Lu-)k^)c5!45U1B>i9l23@_Dsp?778bevZCn1VldYfS*i2yFF3|1jR7MZ zxI~Vg&hJaa1c4P`(#cMe$7lS4CdgbGNw&{d@t7_Y=nM3X?b#enva3hvw5AXpo*iSW z1&S&NibKf^zQ;5j>)J8`DnmeO5j@3>DcXoPQ}gg&TS1)_2{^OF$gF)hC-Yo-FgCU- zJlp;pWDZAmdSF%$lc9$j1qt1E94zEfBsmW{RY0GXXfbz9h%oIwnd}EmRSG~!3&eXE zj{*BHA%8uO@!^O3gU%0R4SkD#oSG&I);*N>a(@AKP|&0Fw+a0&tKycfCarK-c9iXZ zNL0(T){{ragQ)-t=ed8N;xQBW!orHg*&J9j`Ud_y;lk7la|9;2yAe9nv)m^?{et@K z);a7i(RP4&lVmFLr{glfILa!5g|BHQA_3xa=9nMf{5b+s3q8n{Am>u(&i?xr+0!L4 zN3@m16J4>-c_?~6%&;SzF3>w2OVWRuch0N)(6IQpQY-!v2)Am`=f zRZ5``%Z;+dL!v6eoSmZw^Ok7%*s6S?L zUw}f>Pt<8ZeM<#XM+BZEE}hm(I)uPz^{@j!h4vt@F%jl~j$+9>+GJo($~ZT9VlK}8 zikC!SIRJCY05TKsE4$9auOFgVJ0L3XG;!g-1H=`0y|lqUO{TwT;KNx^uzTH;L00sy zCm0CF6l?y`euUBEOY8o*3cr9bVIrxpP;HP9p_H7`z6M4)+%|*vGZ0`POOGVX4>tKa zixk0t1XM=l`;)?DZSy9apVi4iL8Sw~^CQ~(?YbE`k5~24q%K_yWq=x$SB(Q}dL_XM zvUIJc?u~Bv9`I=tnjms-tZ3C&B{u_q(7h}23fKppa)FuqYq-tV zfFl2lx5ln$@l!_1K0_!gCg(k3Z<$JOA9Y}!p!NQRvvQ5Zq!1&5t)Fe_1kj3|CED-uhHQ4Vi86>BUsg~ zLFBLY)~8(i4-uLZ@TRg&7x>*DkGZFg#uc7L9l7ARmvCNG?A{Y!l#`|YA8>ihOHI1s zD*iN_OwHuhIe@93LL0(%3qwA{`-CCV&ivv=YHXGqxqz=j#k#mDdd`@=_~}N|3AB%% zUJEoIL}Y$eApMqHAJ!Kx|18=A;XlD11K2&?+*GT~{D#X7>Yw~&Z}5UGy0~qT-x30v zj6{r&88jTSO4C*;DbL>R-@mt58)k6Y8MXjb_`QDKD4D5FsXyT_6TEp5l}+Wuth>OT zFUemN8=kxh`^oqy&dBHQZjlcg+d4PB9VY<9TyrBiXWREa%qJxVG8MO`Hhu-Rg)&R& zKJ}lZT8VSE@pD^w15a7qs*TGW*zm-8;=f41L*a=fCO4RsKHq7#Jb&(At$kGhPDKA; zlO<`2=pF?-7@C1_9tDLhHJb#6HbM-9_Awx%c+%0sE^Eq(s2E|0DXd4pDh`y^uw%&; z&%W=g;e5V6Tx-tVa{G8Y&Y%Gz{i?Wh>O%6xY{XQB5)~wyIn@=T;+iK^u%TeHjtoM} z|BnJNKY;eqV!ARKy|ak7KErQ|=YE#Z$?EceB;Qy1Q{$dsG34w2unH}8dP==@z=U6c zkV{U8kqVcXe$?PAb%ulnRN=eYOmg50{K3q^E&w$2G*Y*H7Jsn`<*dJa&8{xnGov5i zVNd&rQ@D@azyEWK2PGeroJapE;WpFOxMfDW&yGLR3;$3#EICyZRodaBD;wP$0qE6w z^3hfFrV?H3iXb|U_;=tyOoGB6dJD*yVJ}bv$&varbCW}#LUr;Bd{zZYW|kvoe!Ly5 zDiz-gwfb1dWqEjBTMcMVa9YDc%>V(LeDk!n!8cZhLb03Y@OWjFWFt2=Ivs zuCZ%cUB|&iORO>~$1Er8(7L9)gEP$1s$f2^R%0^(PFpRrZe7vduiF?J_5)OF3PI1I69~n zzXg_vgddQ{cp$^aR`nHah?^evjrlTJ;|e~hdq#eAHm2eu<(pU%pY=902w6io_luU< z#_O5+8poODrLvNKadd%irO}HnlCqRxrRm@3h3iOaDM4j^%-O-r%8&`!qTm|>`bE{u z+&O9LnPM;l%j1go{aV5HicTflYky2wM6f}??q`ex3*Ls~<9WWfhqLT9PV|KOSRN8K zw)oP{VEPjr5PQs&l>z2qf)*Z*!qXQ8g-Qy4i{dW-8%A?aWn{3n1c-oI-QF$Y<5V~~ z2*mvBE6O?p^E!)j4Ip;VIy*9R794E7j|?UReGFLO=YOoZ`M1gZe{*SMTp&;u{4Yeg T;M5ua{*|JfnryZ7$Kd}1oBc97 literal 0 HcmV?d00001 diff --git a/resources/AppIcon.icns b/resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..7e08a0bb4790d16ca8495f7fe003cdf0cb52bdd2 GIT binary patch literal 233049 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 z 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%0D}vvWFvT$>S>*6YDK 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 literal 0 HcmV?d00001 diff --git a/resources/AppIcon.iconset/icon_128x128.png b/resources/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..a23bce8881bcc978ef102cb728c8bbad2c20359a GIT binary patch literal 7685 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

ee%xJlf4o8G00QSUQ` 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 Hu0mjf;2~DW literal 0 HcmV?d00001 diff --git a/resources/AppIcon.iconset/icon_16x16@2x.png b/resources/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b2ffd4e2777752d0263d8dda3946c609ae24c4a5 GIT binary patch literal 1728 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

ee%xJlf4o8G00QSUQ` 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 T;M5ua{*|JfnryZ7$Kd}1oBc97 literal 0 HcmV?d00001 diff --git a/resources/AppIcon.iconset/icon_32x32.png b/resources/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b2ffd4e2777752d0263d8dda3946c609ae24c4a5 GIT binary patch literal 1728 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 literal 0 HcmV?d00001 diff --git a/resources/AppIcon.iconset/icon_512x512.png b/resources/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..f76a235cce197349218bc9e52c0fb8c70038b227 GIT binary patch literal 32610 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 T;M5ua{*|JfnryZ7$Kd}1oBc97 literal 0 HcmV?d00001 diff --git a/resources/AppIcon.iconset/icon_512x512@2x.png b/resources/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..79c12a81a8b054e2ae9191ddae7cd88ba3035aba GIT binary patch literal 67611 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%0AM9|<($JqI6ii9Sls;1 zhJz2-#F3##tQ``vx2oZH_r2DXe;#`AoV- z&Q2*@mgo&`bYn@eFW2>h0f zI@*_F1yBkz&4DRbV@1{}3-i_hkp72a7cyO&brpXp+pNU5vLMed;jRDJDjG!m5b{Z-OYR{nAQuZvv{$x^(d~ z&z*Z*g}~ETu4P5ffU^%z)RqLw0>YVZ4RDQ01MC0og}tgaf-`LkxuAFG%nklzGluY- z5{5!&rrp>W$E9&Yfe$C8CU=|ag7=%QW;NsUU4V{)UGcN>vi;D0Te2kwXs?G%kq|Mh zM=wf;y58yN)*MWNROBRFlmPZmXBj^E9ADlZ5m{5%2U5KjY02uXcinI%mMA^+mr-g+PsuMr#tC34euFM;rrpAP=8 zxFDgj3n_eY34UYe96^?olXIdf@&#;r6(J6uK1`UwweM;wXo@}ES6f{5PYMB(rpvA0 zU9Ts>{J65d%3Lns-~)A=kVxxGVpV9Az1RMQi($6_zbOw`={Sb~VqnuVs0{hND=}#_ zmI~Ot_8MW4bWPQ_b!Pz6Bi%!{CT6oL``)@_9gj3#$wPYQita@2NwiQ(?)~=sU|%b}3O$odq>; zNMcvAk6P3(Qg?@2Gr0iB$Spi2Sgj9WLUnYnmDv9{6IAWT5IC8o&J8PMa-l`{3P|A^ zgwVo!czd)YW?oR1sC2)q;rr-?gL@7BC*n6zeT^}ky#mC1>gNt`?fct_2q-Z4$Gc-^ z&fh?;WCgp@pBN{kh=uf3SA84~0a6k{w=o6$!o)rmxxW3@BO@%mI~;$tFKC~K!+=LX(uSe78UDr!xweF2%OxB3=rbtJsD!~SMti5mdvw01=BFe5Y_IMrBzjxRnvWY_P zxQr`LnkW)L#a`hpSGyW-$#QX2PgJ#Jp3Hx#xQ153D=^-TgUK4|yTfB^Q8o(5%OVau z0q{R95Lij;RWDc+7&qtQ&kvwMLMcu=z;%$(! zORKO1xL{Xo@#VdJN5eAJ)P@9h!lKB_`FFi=);t24B9B6BkM`{fVyP{Gf@ZC#<%9jo zx~j!-0dw}IIy28_;04a*cB6+)ba};ZZr1o)R1SAMmqoIYOGL#5Gj#7*hJ^FExO}MK z;kp7T0(+XkK2o>(*98YnK1y1C$KIi_;7zC_GYwORa24l0*5<$(yBy`KPXPFn%W+p; zh{)gSsQla{Ht~BnU3U5(0aD~Q;XfmO0r>{+myNFfm>Joeujmuu>C1gaeE-pzHr9VD zPq<19B<%b{)|T8>9X>ycI#sax!yZEcp#$?luv~&|fAAXd94#JHkKP` z+F_Ntv&cD}zTf{L(w>dg)B8%HARe1@jS3YB@=fzYxqF%0bC8zY*Oa!fs~l(3JqF!B zle|6C!Uz=LOPBg4@MCrcmX_{;i0@hMPZa9kg-U2KZEjR#8{cz2!+=(`p9V}9-@#fgc6DEkvp2MNm)QNcO52{9W=O~c zG~ftrJTwr;p2dENMwMtx(0F%SWme9UN{?O&LS*C(?r?w?g^8!LX>+f_zJB zWFl&<;V+5z$1>15WV<=5o2+@We)aN{v^23>UlJSrj)c29G_RIZSaqN2;S8bR*8w*o z!R;ULcmM4?JBBWGs5L!kMPc3umMMT1i2y${YZsVNeabfb+hf_v0*4;d{x`5L@ayaK zsQ?S`r&$pp#w?Vj{GfMNN3fhgxut|sQq6bOfz#UDD2-G?=trhY>#2y(5u7!mTN{l% z`+E7i-bGZ5729^ z6|E$cH>iPyh56$hdc*UT-LJJdN{CA3K?HWYJ|%U0Llj|-&o)&Bcdn1ludR61 z$1aNcQ&W)Vi0->$mlXS48(t5!j`48R(g3 z18!1qoTaoK+P>7O2cHC42hu1pnTD6?s>4C{Cy zQou#0dq=1zDZGHS{6%T)YZA_oZOQPA0L7^c*|&B`E@-tBvIzNZG2(-=E%j0lyH3@n>*4E*dAKu+#%YUct(x>WL(v1`dzbeqnus@fsG}>&`2K}?(b>nlWo>K zwAwb?J6!lsPU|;^REU+kyO*yqAHxjxm5MUL*|REAA@8!MWP<9=#G3RnHg6{0pH2Aa zifnm&89w5taX-u~W~%*Nbpxf7dbl$mEf8WS17`V+*=Kqz`F*!cgeGe&KR{PhNiZwY zZ?jGr#H|0J_M+!4&Q!xQ0~9}#uqOJSZ2we%QbQ;n(ZrjCdxJZ3zw>tp*gzAWLIRkH zQUgo+-MaCmgun;^_db{qaN!n}3||vrBk3a%8>RE+e&*JPGhd`JH5r?d8p3cIhM7L` zNWWhc)UwAP6;5Ua_A+E)P^LmMwr-{U_QwZ{2>Awc#m_vF@vT8L?jCqL8(dWF!;Sg+ zbe`9VzzQI?r6g}8M5cd?@4Z-9=!(HH65vcG)=(dH-k$#pn4k8y-Di&FhE}t~J7}Ih zX=#-sF1&euEiszIc;xrpa&rmO?a5}X`~qky1WJFI%V?C1pm)T`9n^xP9v`3rpO8f< zDYkn1a`hdc4V%^%TP$B=11Zo026R~i&mXe0HJ(_6?=)yl&1ET@-o!xGr?do8xie>* z!Ekr^Z5oi(^_YEVIbeU1$gB0SkjVFqIO3zT;C*Yi+6Un!EFVyChSnr9FjZhXr*7+qF-^EQy6sslqRd-`6N^&KL0kcG6tO}kT+eFxZ zSzEZKjousYTaSOnz~$(sqvRcmuv5s*dGrdmZwd3i_)Go+ap%wT#TFc2IM75Kc<(TE zwML~(=L-|m$NMT<(soi*qw}5&W7h$e(2NhPwyGj_*D2X2KY$0V1S*a*o}_X9kM}ZG zy8IbDytK7`xH3oHWKva~#blnP5z%hlLpsc>CTcg5ZBGHsmtiOByfrnNH=5C%!meD?w~n%8oDM ziWM@lMH;*$(FnFJoyL`C$Z3F8@ArJ-V@E$sJaTeu90rm`NaWaN&X3+^8Td7lKGh0p zig%&u=&BiV$sgA&zdc?YLjB>M%4bgl-MrRB>balzgG^Sqnv0hXU%9a$_Z?ipT&3nK z+&}9*{w950)Pb~3(mmU8TyQZY>7H6yy2YcWBp76Ug8@#y^Q7f5K77Gxc8dCxi99dR z#?)2C2K11Un3@B(rYEmXP?ibJMYi}yVVaqswj|OP%;M&_ z!7dLCWi_WGzO5zm^vb6u4U0Uua`U_c^sis@vmp(q^0@&lqvuti_6k0rI1Swd?%_}#$KKsI7vnpFtgQ67WP~wVdRyXmQ-;;id|7eC z!eZ)r&8Ul+>eZ}cSTVJ9oxQ>L({@1;@C$^?Aszsfw5UJG36^YfiaAQqP-DUvIauG&9VIcsdIN`bRDy1VCE|K;n!PQYl0ezaP7 zsR4A#ThFsER-9Nt!=nqAYR8|XWS>h#TcTV{z4fx>!QU` zb=T2)@fIP0(KXyHpJvm{E^>v~RFYFae^BL*SgdC>A4=cngpmVj;`kIqlxliyoCPPj zme}Zh>>XZnm@-%CyIOY88TN6sn0y+mrm<#}v(IbpxDxMD?vmy5fD`_lh(9oRZK&VZ z`0sRzzVIHFD2?^Mp=_g~HYg6OeT@evoX(0cBVm~cU8>;c; zHzqJAH>ZgQYDQZ|4rJ?m9lqP;%qv_45|*{F-{*sMbV-52K4N5rnRu0H@T{8ea4lnD z`o0&1=Gdvp_RWmXh8lnw2v4^kKwoY-&;*rLUALme$5epEXd+~Bt)rkwg$N?U- z;AQx*-3<-(mj~d-I^cDeG_G8{FYus5{C5hE{? z!|w_|wPm)V?fUuchOteBW+&=~|Y05#;q(~Dzsgv26jFPxAI#MLlKL!4c_3OWQ^yV7z@i|Kc7G0SZXX@a` z{kB@Pzv$%TG^6)IGOcP=a_zdzX6mNswtTasF;KrtPT_sn6wIQd9O)v>Lh$ zRtol2V0pS&`fr;vTyKkHWK+7wnkTqO+4V1o2yF6l1{gYo3phE|CjY{QuC1hrmCwIo@Bq_0FkIL;^n#a*+@ydx zuepdhn4dq<1SGiA@5rw?$1h>7egp$DXaJS265oD{nHm1!;g@d$f0=EOjQ0MpyU**} zMZIUSNA?OQr3PJ1U7K8EiYF}0Fakfts%va`^!a04ev92D2Rh#c%!WjJ0&h_O#q|t| zW^w}9t?!Zkw@8tJE>~6qy6|rTh4Y@PI0^87A;`#|)q4`m2bcYi0S7YXA7WYFM?a zQo%;z3mC9oL)LQg1zLEl>tw69VyCHQf-gmA0W;%BFMNUn*Yoa;u~(FOpEn5zMu63J zediOznyxSIW*(W5j|g2i@TPrK)|6+VOtO{gmnW&k(B* zez+!6H~SORgCfBD-Wrki^zZ!1;l+rx;rLtJxneK>n<^o(yI;8gw)-qQ=>FZtD~hm8 zdJ&f-|76RMd9OmPwwmFZ5wX!4d)Z<~U#=%@<<~~&FFo=2OG!eq`o?u3uuZb+3Reot zj=5jDvgs#fW*WhJ#$mgkw(NA))#IY&*(E%fR@}#IR0=RMl?w_=T=1% zBS)z_Y($M3vFAmygo&YLU&@}xUx&BFinn?2ftPjQ?N}BSn;_{WSH7b{+Sol%OQk}N zT5aw9MmTIw{XFx)Ft2|q%l6I4ls;)yhL6?E(*gaGDIZkSJ~<}jXafB)nQ|a>ew^X@ z69q&>oWsr2HdC`J%@`t@<|F0>uIMr;dffAQ?h~yOaE=|D4+0UYR|w2uC?t33KCPkhK3eQJ_l1nJW!Qfa5uWIKROfth?H8Js)B` zO^YivIKvI)**Ns)qER}rklwVE5}^*@Zn^er>qSm;78POSIoI6gB~n@r9VC1;g)=@R z#sInKMyVxSu|fJr*4XyXE>G1f+rfs^pG)I7Aw?;uC3|xd(hFbag};tWhnZI<=y;EY z2=@IS#@;e4%Iysw9ZEz6ETlsz3sJfUw@OGVC`hV+baxKOMnqzyn*jkqC8U)aP`XRH zYv_hym|@o+|RQV+o6I%>ncZ0SEe4xL9RWUm2!Nm}QOhF)}@JDLQ*4<&cd<#ZfI=_^zx_IrOTy@8#3?PQ(F>G8Wqf3s40;m{6^sscL>@o- zJ1hCQd!SIc(0C10&Pk=_K1u zdMWq_4@-Y5GIO!{T7Ld(OqcAoq=*|Q3^dvd{u(WrWGHl-^`(>e>)Nia`AKE*MBA8`>>?4 zesV{Pn_iR=uzaeYlvKT|-eaEOBh#PC6nm`CE<6%U^FN4EAOwaHK>y*5o5q@m!0;>L8*Yn3OS`gsd z6#Jp~S$&9|9O9}DE|yay27^B$q@mUd7>2mzy6WV=0ec#U~BrEY2CF1 zhlzseI)DZp;W!*yJ6&)W^0iQ!4W!oap$pId(bSqmPs?CLk>_m|pI&TCm#aeB?k(2W z#^zDN@zrM-Q4U#_MF-C==xU`3o*TG8(&Fp1a0=^%Y2D`vJD%H0v;;u<&OyXET$cL^1gMn-ZX+3);1T%yQAxwGOul z52oe9xVUV58bt?1hsvn^^%|-`b2055H}!wIcy!V*A*>u%LO$K}anycvnPJd?AJ8Ec z)AoELO!*x^e>TwK=~O7D%!wyCI-i1j7H1T7PM-Hp?WJvBN5zQC3cr>&?he&DE|0FT zfPESq$G+G+gYIS*sO`=^5xf6(_Kg2cs(F!0joU7bAL98 z$pyMkLfqBlS;;;QmIvn0Gb_QcF566^nw*8{o{ z77oL%-5|c@QT;6r8!rhy<^N0qZ!ddmj9M|u1eUZm50nawH30VbF0INnjOlgG=lBc} zP60A{@(>ZSkKO&$>Al?$W;dvej=T-J<%x?XghqnWs12L&&SeSezXq8ee_sveAXk2q z2DZE&`@`j&zw=DEH_>?LM@2lPeC)Z8^OSs`@fmEX>Vr!HA->O%FhIJN5m4mdWUMe# zVW^e3sXU}Rl$6O48=GYu76}NUJUI+72I7%?&5SoCkXwmU>Ef+nu)Mx~tZ#@YndVIf zMUxv59s|IzD!4YJ5UBt6{6N3d&>CBZheytmG^IBH@n2gw#3a&1Z`QD?fn8Sb^p$;Q zi*uE6dJB$w7R61KgL(E0Z<#UkfUU*?Bme6&_?VEQQOGQKvIYx?QF8C9RfdHr5!Om# ziEX*(4fPF846U`}YI7ETDS4IroUe(%iUX=lJUMXpUNBI~GVR{d6}qF+{3hc}Gc3)o z^6+c~nMl~I-pc4xqHztjBEU!8jUK7|0sIV*sR8Q1Bi`oHTCj7^f1;|9U8d!pzrrmD z?s4~pejD^noZLZzlb05zoKJ@N8}uH&LLp5&U%kvd|6LnTCc1skf2mjT+p*H!Mi;dk zP$6CZo4&7~4IgAWiKrkiy%Zw7;j&rrwPfmGCMw;sM-H!rm_s2iZg=hpsvcdCQIK75V^z0No-bnICz%2`i$^5?opbfg%X~JL zc0OeoQhnr^-f10+Q$MWZ8#LsU$rxOoHdj2~7lA#ocN3ZK%-zrOUJQ)s^jGns#( z2J1Qf-R&#v)B8-=emS1=(He26hteMibJV>YO3}LcyHx36EdUL)`f}mr$F;a{9`iT) zd9k^=%>FCS$R5(ma82GaezC+=SkG(aw}&c7EMQNCzTLvvDSu5m)zAZRCWu6t0Ywe? zF?@QfZb`R~x(+LI@z0+E@)lDb!iIYzyR-u6fmMeti`^k~9q23NWuDg9P;FYo` zpC^*S!H#6DE-E*#`CQkhR&40GS9L^|y&1~N7PFN+#qd$9InQ%}uf4Xj_$Wi%4d%+6<%7pt|1r<6w#>Y27j->`$ z8eIY}21*+a{6GSHh4m$tQ$pkKvJHi4kFdyy)?MERCnox`JS`iQg?02{MljD25i5B{ z($o%k@iNXP#{K-#tNa{rT|qsBja{Lkr`KZ=fge{27r8{eyfN z8zP${4=$!FyhWTO&W5aRMC(940a4E{${m5)LS&e~QN=Lz^h{>W9$9WZ^Dc|WA{_rB zviC^@g*JX1TC#X0@SznHO3GY`#?c9LdqQMgf4!3p_O{lztaRJP3)^0K;nXYcTj}$1 zxYmEpf0g+BL^9jjR<-z4<&{dl7~+ub*2QK3ml}blufJ$W=P>n48v_p6Q4LOiJyr73 zvXHUr7rz7%xQ7fdW&ab@z~lrq_!vVJPAV#&M9XL#07`gtO<>YQBJ*B~UlIG2* zxrCZ8)IU%(yB)95X7MBt42+WIJ;5|NymCKMaxn>T)|8KKLm9zQ)&)Z#VIAG)lnri_ zpv8l-!T;&Yrl9fZwlW+QVH0Zc=3=s$Qkp z`pucfM6cv8B|20wJYx(nCX<>$enanpuog)g9}O|UjqN;s5CotBKTUadQhigB7<;q*eR+aCQGQYjf<1m!s z<9@8^P|i{wi!qw%)VzQOgU$saqel|ZNY_*_p0k+3X! z>(r5#zp*|8%Xy zV)y#^4IBc;7~<^}Wvxt}Eq6064ATk-;7SNFLE-)zG|Iy;#(z&r;z@^}_1yb7q{iFt z`Fp95mzIB!_R)1h;43cLj_VHpSyZZHF4S?dEghL^3U3wdx6T>*IE{{IgMKRkQujwn z&;|UIMe&NnSmui>LOAXp5`8|I<{j z%9I;8!r1E)rO8FSEsqEH=14rm8*#P_y0~E#WjHP%0#U(Ou8$~)fH_}VY>hURNv<|0`{6)3+1CXgfxaN1++2g-K0%@-!WxR(pCbYaH~9k! zHe4{q9{1O5D?*^JIw2lLbNu9dlth;KVC>|rZC@-y#3PGoV<|fS%1_bCdgR7p>{5Ih z`*e@LZAODi|3y;do5DxdH`wNWz!`^hJ+OB7fc_lvZxya$`AqVCyrEaj$ZLQlM+17INdvZkxy-4u zu?-NU=Uk3eQ^e07jr0Rf^9P+xvezYj{zJP_7u+}M$XcgdbuL~OSH1S~^>iW9_w_W| z9LY2#7=|?&LSIdaIY5TD4;`?56q{0+{01{%J2^tGk$ujy+_>2;U#H*Wi3X8gI%lG^ zZroem3wc;aLH{1R6CY|=b@1>(b>C+Gf@$3sWG|EBg9jJkyI~LM<#dO)hw4yH(7TL! zKR$*QcX!T@wt?Jzsb@f#bTVxRPz#Kxw(@<0=aKIe0p7!#;rujnhU<>Vh3^RTzbL#x zA6HL-KL6zYP-r3?jBP_dze%A!u`DRZq4ws#nwfGYeBE1qdMZJ=RpP@!s5DkMl``R< zlQWeEwBDlUZb0Le{oMbyB!eZy^+I@k0D)ArO zTX|pW6kaKLdH?DwhtKFaZOmi3l;v(!!u8gLD61fk-8UiSb_^BPI#-v5h*ZlW<^KeLxcQui?1u;GGyicPcF9@K`0ZO%P@}{Gw^onvgI1{c%@XC37cgT9_BQrAL zo{gZR50&=PHg(Z9xXf!ZRr=3W$+1F))0D?*BZSSdF>G5`#)ZVz4fx&clQ`RduC$|E zc%fbVV6SDfkU55-f0UQCQDnxd$yDAFQ?rM=dpW0Sm!)C#g^%sUF9DT(>4rIHQY_<* zK{>x?JB|F#xwFrxlXS0B5waiu8-ffh-u#^Dt5}Sy_VF6CYxNM5qyr=;(5!e%e1-z% ze{`OSTd_nEw~|J&qo{*^ID7Y^ig4YcvlXnMkZdASu=Yws@KN@Zu$0#Ux^O_FE+INc zMfKbP%|X_PJEX4_Fph>k%KRAWR1{wfN28!sr5exQ4!*`x^Fg;Btp&tJI5tJu^KC2t& zc@&>PLYS8O&d5l!Cm%LNl1#G0EPU&27nC2jsWz}=XJ=wiQ{timyTDCByCK#y*-plO1FzTz3 zCNT)g5dK1&L~Q#zL%bQw9|d{Py$b#_U#I%``3#s$>IN-~lgBWUlu1FG40fH-4GL%@ zRlWJ1QKd_}m^89Wbz#VV=mH{1>|b(F<(X*{K^qM|@}&+t*bmJkj=qbBJJZAmJGgc4 z>|3d?`%eFl&0;q=Mqx~JY*&|*5#qD7{z49Os8`*dV}k`V%>Tm)gbw5Lo#SBQKH)s=j4+JIZ zI|=AqJ}Ob(Bmoo&r9G0%kEwQo*zxmtS;Z(JqsQ)Ks!|~q`E2aW@qm*P+Q;Hijes$n zWT~KlHO2CC_e-ZTbD`O7E`R0sCj9rO+l}R_o*f{=_8ZRAkt4PG|d03^=YQV9dixaxb@?}C>oj1 zVHF~RT%{w0!+&K_pxEwKZ9^2E}Bo?~E4T4$0k!~8=wFX&y(iv36LURLabYDL(ezmFW)Fw&oHb@mP1 z!GQI~Y-|8xQo1hhK$3bZdSf_V`;2Ad`Dg#gyZ(JHZ&<3TM$SB^*k%m=fIUVm^^80q zk6SqwX()AXZvN7$U8i|5E10uupxw+e1}v`Iv0r_Q^h_+f=<}Ic*`I9uQ!@Umghfu)kCB-B}BQ zsF5f(d}7y6MEYmaNiE2p`FLhN*_*x%W#d)QL(erh0JvL>P3oh4H*iN0H9$5-uL(09 z(+tRvo_p;WMm+jyyR)B)&&ubG2K4Tgnq`!r^r?6A$4D6g%Wggpcn^q?{+3XxI*gt7 z1^+vlj$MVEnvVjuue|GCTh^D{*(&Y#T{+^7b5yyQsOZ2u3=ptNL2k|Jre^W=ngM6RFY|XV&bevFQYgt&xL1Vw$NcAO) zFwaTAG%4!Qo2~M4g7!y~4|cX9jS{aMDn9~#`tNIGdwPT$;c=8)J+QBF3H{S(cuqa^ zd*?TIjZomGa{5)07M?Kc8KYppQL1#YM^nOXauU1;(=HRbRjH$Ll=*wklN2yXnY@%2 z6YjioYYHI)EFKMLi_pGv$Y{c|;Jp(NjEbVBXODfce7PRpBmegYAlwD#UjDjyJHSVY z7p9ppJyr1>WCoMi-<+-SZtL+s1uR?dB~n;iC&@`CFInkGf*8B^I)acRGa-K6_67?D z`9fZ8uOS3l-YR5)49taAnRX<&rwpjl5)Dao$)C)th7@{*HhA9OT*i zD3eY>C%w<$fIiAJqi-eA8+*`_yx}b& zi7&h)UYc}D-kHP$U3quvNP$P0ym~V-XWo|{&YM`Lu^8W9#wM|g4fiX3)2F;2_Pp0Z zH2nFW^8|`cQfixft5WAnHsma1iED^U-RWoP_&1~U6j>)(fUtJg61JIBZb$p6Y$lD! zF`Ke*I_~fHBvSUIwl6Qs*OqOj6Xs>%F*)A&Y=P+4s$7~e$kbA+90;72t&cWwWhB3E z!&{kww)xHf`ifj1l)Olckr^37#fVN-ZN3!Z`>wrnPGlFW;LXQ-QXaU6fQn5DJG!!X z?(p@%MEFT2YQtT!>MhsE!a(F3XzEfoe2exD6i^yI8=kDZ#@D> zJ~ipC^rkFpDTr*knY6F8Be48VEYDqqN&IjA1r2doLFOryCTjBV=eVeye4o7$#syj| zQIig7ZdS?RJ5vq+UjtiIm&A~xdB1+&j>ecNy0czMvg543~6{dDxw3AlWzjgK)EKY=qJ$x|n%>1gi*ZfGPFHn7vm5*t=Lqi*( zAY8HsaMF;72?SFmg@`>OrPfEi+bKSl7`dV(Sh}^;N?7fid<<_YqlsQ@PO zXJi==3XDg}jOl2@`7-;NQr>xG6h31&`7(DKGQ%5LntY=2-z8Zgk!P@2kcyuRZ>hkt zUq&rj=9W-CKM&fuCIefoCFVSNzQa;Tw2JiQ$GO63u_yhQBR)nxk8_{!myW}e=@&qt zc7BRwhVrccF3G-&7+xxSJ{8aJjbDJ%vzzd6t(aq{unTx`8hk*+7mluWnIQFk_g@$)MI94jb61kn!>)*K^JU} zly5}VYxB)M6t#-n*j@e6mR3Jw>SJd90km42+;#w#^K}~GJ*=B9mf!QTAs5tpJVkS+!f;gx_@c(zCW!BR;9%Yyc3G@o0 zcZaZU-6^r%n35*l)wdF*)MGiw&5b14m=D|`jptuw@PYn@o2TdN)3}S5by1}_j?SB8 z2BgwZdOfpakf9c~+`ZO(>Z@oxGAts@L+^s-VRzGrgpmH5+iS?MKU1BOumMW;J;uf& zAFI_n+f2`B{!(vYw4Hi$d*jf0q33aA`=F7qj6~0-cbdCTyfKn2E3;=~DNWiZH`_#J zeTm9{l<7GBz}GXwUY7%}{UelM;{Z;_V7z zpEUivGfp_}pjnwge$d-*@KZqMmvSJpbWK>wCwz5a2pAWku?WV{yZw`%TtL<_5&ZGpckP>&#~+Y3NxZ#?;Qz7ygqR&v%G><8#l;Q*-1U#5AMF^ zkrv~#_vr6tuRa#Fmk<*YvXS-ntB}f$^v6q?hppQt%e5)VYagv4q!?P7(tbO26My%C z5+qEEq)fTqgL>j}VthkWdwoQmngiPAQ`C6AX9ox`^?&9XyE|Wu>*vDKv*=@Gn?N${ z&CA(5o=cJ7MA{0&l0j!~?%#{Ww+c9pvfZokbuB&9k0_y_oBpaXSU1;BX1S>4mQ#D= z@>C7iqPAr{i`?ajwi-y6&u>pIJW%B&ovGc6Z=lbw^HZZr^VCuMrp(9A#m`yRD90jZ z8lm3u4Nr2|)o%v^O9~2_8w=>&wQ0_Kg?%vIM?sYyn7q$Nm zIPlZtz+F+Cgz7?Rimf#M#eV~;JBP?mN>7(vE)tB3y*l5Yf-u4x_EE+y*`KN>WSn0? zKlV*D*hELJk1fF&8=Z#AWnX*7l3fSWD?sWo`2%#sz>gOz*nC=(ZYi9iK1H}ePYmsZ zX*t>)RCKdR_sIE*#O{%ENj>mXfF|6hKgu{|=|}qh}H#@VA<~AS5XGW=u%h4z?7z=ttay#hC0(l>nHWn8w{#4-B&z2}T)E-h#g_ zKyPOsqqh!>W$#J}EvYG2K&?Kuj#1dY-jb4dj(%2tE7;xaOvuN$w$!CDQ_@7 ziu_`_g-E$~=k)GDW8&FUP5GFFzKBo;Chp&JFHXYXzFF^9N6<1#B8{al>RM-CCO}7c zgu6RmKMQ7eWQ7uHG=be`8x36VZ+3U>CyHmKI2I`}uW0~&9qtplG<>iW^7YOl5WC|G*y|V5e_G5d!rI{c~Lv8Hk_!y^w z5lr-KGS_SaG=N=TnA6!%5#?MVCxa!(Sm(|EuFeulT+*4`XZ5c7tkI1GjwcQCrwggg znB3xJHx`CFR(XdktNe!FhMc8-?rsO$yV%2zAv!xxe_E5w5ms3 zeiYSJ`f$_BJr$gpkewi%Ey7ySP6Lyd!S#haofgI?ezdt!BfI0W>0f#x!VILt0=?d; z&KKWhRPWnv4ntAxgArh@13o80(}m>T?O4n9m0Xo{Mt*eFUXFX+9@P39O;0xffW zR8#a?@RkkE76wY?!o>yCzG4Dven0) z25evH8%*el1rT0f-0ixlx_ZyT;0hGS0NM_%q1cH{_0HyII0@`DY4>rL!0<|uhg-%d zX(%Uxvg2WQm)`hl;|2Wl!+S-}uVppF)<*e#fBFc~q!$?P!B$8?MpB@3yW$H`t1Z;s z!R%it6ZcGrJ-XJ4lkVVlhPSnKT7xZhy3gg%zDN)rRY9xK4@Qur@=HzI~m`%O&tnqll$=4ew7zyEHdz3C@jhZ=WB>4Ow+rqF!5t zpRNm~8utIjZ%*FD2Tl>(#@;hY-JZ+pMSukI+`d*wfsmS zY#Xoo)eNcTy0NP-MNheY)*Wvve3UMC2R@>+| z^)E)yb|IYu&l=j<6gj_gzk1bScJ*>J*`f_AP3J{|yNg@U6mD?HT#k5*e?!-Jjg_7) z3T03%uTU(x?K*ii$qS3-WoS7DS>$g;uWasg9H;*Zi7mcr*hXdO zLyl0%(3*v@-x#q#rC>!xBig5j?}?k{o>FBma$8P0by*PQiL2xf<5!3GrCwZAY-p!b z>Oq4M_A~5Q0VX(suv0`_*HJL>jjtjs70)&;Hkj5mSOxH%Axi&dOgnyppD>2n#_M() zog;l-w}TmDLCz|Sr`6QajI}>Z3QaXwz`S@8F6}@1ER6B7*|AvpmoD@E{&;&EV~^j_ z!h<#5*4VFf0yFV8Xv&}WX__MF%oOM%D&mObCcfg^s2pLDi>F$3LVnNPuih7S93y;t zSE*yXuUe^+c0RK3z?OMMrhGX%{@kYb{8yXJhx~JG$Zb%9-CngXISn zm;+k@ci+Xiv(f(s-|OQzbm|UxP`s>EXaM*A9Ja7K`-G}zAQMWH9pyKka+a6U3i$WL z?oga4vu5)4gJpwSCbX;jSCe{d)$^Je_#%2|NW7veZf*FUayUD4JDc~0lFp4fL0^wA z2TwKpl_j;DxY$^CcDY~Otww)j>Os5rY7o2n1%&6fdw=iXD5mg-D?|Zz=%Jkj)NUYA z*}!z`6;tI&u;AP*$YIqmJ@r$!Z&_7FNM_!5#u=q|S4?UpTbMayPJ-rOx7Bk~*6DXj z;vc`<+=C_fRJ`EE_$|fe5+UhmK42sG93=8(h6dF1tAC6E8yb4g%dL387t~!^DTzV; z(*4I4W8>dyWj7BXSE!@C3fz04qUU}%=P+*7Q&6=RTbpuJR`ygL?bhBA;i#s-=38F) z4dadQSe>3!E3{T=9IDyK@jDpa7th~hW?|WrDRk6BS(-gKpEZ7Xn0tIUMf?+WjK`V^ zCC2d_>Kr}f=I@);_gverXdNHC(%O7a9CS+Mc=U@DCKtZwc zz$Wa^Jl3}P!2T!NV}7kVYE(k{#A4+p3KN6gc(WY37gQe`#~pW|;4vFfgt`MCd}I#N zCH^gOcVY(h_);`oW-wk)f$m+t-{)J^YZY?a`w#Tz$3oxLx^~8{zBz7a*^0FG0k>-< zKFK$|>za0YXnG1u0YYXgIME$ZW;#_Xn-~{&sUB)b+zf6m2DpUmPP0y7Ok~y+)7wCa;LeU>CQFubiE z46Z6)kCXVoOfzgMKFG2Kt?!OSnaUfQRsJ`=NBen9O308QWL8M%{|3$1!xPG9<0gM= zaHJGKMI3F%{M?L3${*2rwicrSHrQ$YihKxOrMP&E(p^h8&*}{Mg2OO37P+{(24e~M z3E|pps8&Wi)lxZvJY~0AlArxP*9q^0+KWy_ih&39z@(qirwzOC)8u$(x*lBZrZmVd zpMO&Sln-J(wv0vx*&iMj=J`%FZw|6Uge}c!^Up!yKhEQZk$Qw4)RfQwaUoU5mM}T< zjc6+J7(SEuqn|m`J4=+AS@DU5l+@=(SRZV%a4oUWI3@XU_MR@*bK<4h2?SmljuLR#0&nbJqA!i1y%Eo9hIW18AF)g0V2*(!m@^(+IA+ zm%vSe%?*ybjE(VQDEusulOyqqG~jT}+u!_3wr8S-*Tm|$72=UOM{yI((#(vG%U1kk zE*Nxvjcny6yjFM)1;_O^j;#H_curDLL9J6ddE|#}fEi?A!83A|@4Ag@(i(HGX?KKd z-{WFxd0LWl#=S*+tB3Mj(MQBT_R#O(|=sPLmT+3H;6o)Pm{;0!3G#ci$ zXv|8K-ES~q4-$qfe_VdV}kXhE0Ss+ngH+p%slaHtyaLW06VHD!vu|G9Z zo1Z4V9$~Gozc;&x8I#*>Mc!$PKpJX~KwtKhEtC(Vn~zRl$yk7U(mq0ZW(CTNu`kDN z?z5>!`1BCyL70HD41 z(ND1D)L(9#C91fbPCpfdz!S=U<^H&FJody^YGJ#;)|-d1u~m;kUSQ7rJg6=6UkR~o z{-8r_TtE}YkQ|;?oWM>*QtFQOPT^9IvqhM$2w3)CVfg167Xd<8#y^KGTftMWpfLf$dxMK2Y&K&qKoj z#x0-7b^=S}``yvSsjbLclh~pv?D6z;Ub?G7TBx+=+$*W-Ea%)+W2_-A@HkUg&NAHk zlK8&jSJ4*wx23F{oMoKQ7+S9wF*T_wl^Q2;$5^+({CDt9#t95qRLiSr14|m-_R#<> z{)%)+GhQ=Eh(7(`BeRQpe(WL|GdWP5%L`fgn7&Dp^1*{q_n|E0T@oYa;CX(=!JTQq}oJ9OGHpKLiIWR0jmq z=eob_N#44!zk=BZtLUF9MBGt6qrJ^>zYrxj=VxDFw)SPR5p{>7@H=Bay*w+Pw;s}U z#2_^89U|K}f$hLKq@LdTlZrPrmEjL?Lq0IGYfMEyu@q{)V zVz&FPu6cGKV&c+J>X9zblsFT8d6udbac%E|YI2oKs++j}J(YadT^V?bMF3h>ZE5_g z_zU$c;SOYX+QxpvteaWMV(=XI;s`}>$yl?~ouLuCvAkG$MYe<=P9gpV*SCKi6qVa^ z{5Yzz*Du~)ER8o_PGMI`!+BCdO-#x;S{uNc7U8_zHakGH^W*x5ua{hGLrwALXAvguI2lF4sM_aDJt7 z@RP`ks_{BHfLDE-hWGbfL>%;UqM&hM_zH;O3XQgm$7MHwKdhk$HtU};`svrD3J8hO zSuiVOwv58%wv|9=hW@wkhK`zsx;h^i}rjG&5po zjB&p+alPBPSmd}swXM2d!?VPd(len`#ZH8k^`3g5cV=>|a)!Uc0Gx=8m0K{F|K&5U zs-AKwN0G9c>yiP=DspvfbZy4d`M8Rn^976d;SLGS9K`N|@^UfueVu(JeNGScZX~D` zigzSGQ{sPi{N$C;>~M%0e&>%NKHFV}Us(Ss5VZk@Bp9HV`9(Q&G9g~pYY-awsofsy zXS)$yhY+_hO$ttTs@}M+V6F)4z^8hb(d|sq(&BO9yK{}HekNtF(sWJ}_^ZnDyca%i zVfMOLm1?&Q@(_i@<0IF-)#af-g@womoJj4Z>vd~`K8w_fcW_>py%ob9>ql6~udooa z$A_2RW8FD%P@Ij|UPi&-AkSgEUivc_`xp7zViWt~=sfARvh5ZJv7@&t-p}cld4hol ztWtLZ9USthC$U-`PC4RN&YL{K=qH^#>5r^ZQC}YpA!y5ko2!Y5!X16>f@+9TYlRh! zyy3*3GX|G~J#+3?*^Ox|4~aCbOnpP3{n(Gaj$HRwSLb?~wL*COji$s9GW$MLDVe@M z4>p-Y^i!mLJM!w$7|%PX?nDirwL(Vp4;)U+_ArMVW*t(2&4IrMI4=j#SbRDeA#sbpX{l4Hrif;FAOKjtR`pH^cI03 zl*yQG6<@K~1G|>+`A-sOQaL6y#5UWNAwO%G|9g#@NEJ?$T-Rf5GMT}OoyitlaQF`} zh#uD5O)wYQXos*Ry9b~*VAN0`<`{J+!RD+Cj&L9=bT=+G#{4v&bqb$tF-}wNcvijj zP>gx?G;E(Ve~Ixs9z7o`zHtk?fj;$T)D>aD124C;Jqqlv)Z%n0DqU=Lj*MH0m)(M| z-yoY?9vW)SKq_*^%gOnsEU1#le1)Zf;>w}RGZ+g&f0f<-5ZhovuV!s){S6GcDCuQX zYr>i~_q->JZBSgi`?%oqa#1+f)&gJ*&bOKz_pRVvh}4?#q=Y`O3Ig|PChsc@=IucG z-tJqwUVQL~YZHqFEhBhgAwAmjlD;xWKlZkl=We@S1R$XjMxSF<-}Nbj{FfON;IM57 zLkS`TYg^lIt*KD7gzx>3@iO0vAJ+?1A5a;b&ll|_4CNSs4h4zN=G^|3jhFq4Z`z)D z0&d@-<+|dSHY5qvKU%dPH}2|!E(Jo*;uECDK2zBx`XVhFB35DoSq0!L6?^2`kHf) zTAYP)8%}aPch6)@x<9c53E*#y?i0on0|# z%>!e+G;}GbG14E4Ws5XN?kVdjl7op5KYJ0ff!sUmHL|UG_W_` z`ZqHjC5+-wC>9a=a1V<1c*;T>YmFKcpX^je@AUVyz>cohA4i1mrmRT}ZI)QxPD)|E!)Jab z6?(x8+t07HyA2Q(bsV~A`JnTx(SdP!I6He=%p3vzhUwH%NoI*T)uvk6VgH=Z<#!c>l)WJgEj3HuvVppP|MEn|I$W{l?_R`JG#_AlRD0|q= z+d8c0JQQx8)F?sqTM1R_C_^DnL5k>j+MvLQAf(&vs3NBrNZ)vE_`P_kQxLQhCkF95 zW#V!dyN^*JVEO>q*(0Al&*k0Z6~WBa9wD73!T}C>MH=&Ge=u+3cEu^b;{H78E|H{f za>u96Wh%+-&(dvM^qfw=e}djmuZ0m2?Ro8}F1ioKaT`zu9n(2A?_{jHNuF!hpP?@3 z#@_boZVx82`x{$-iVM4otc&3p3YHabkt#7_Kl_^r%RzSY3C}@x3vK&{CQ_$Of&0cG zkAt%ezX|Em!)W~}!As0PUq}#b;#@P>)e?;0h`J#N4NYwK-m~{;mGOM>xe+ae>TG6_ z9oqf0G&h9F=vG95bmL0_d5>$)evfPK{*B500r!SAo)Pda%=L)&{`(iAjm}Q&p{?GP$CwB8H5)CIf%;1T) zO7Heyvb!}<7;WNyC0!*)<(*2oaEEj9?g?X0Yh`+z9~w!BaV=*p8@?}gWKm=NDS_-NZ8ic8`&u_#o{}3IU?`>(BWRKHC^c{{}xzR#RpGwSFm|N zuo?XCbKt&dTCx}9haL<)8p;99^=*vYzX74!Z?~K$;AwY1?MkfJ%RO=pxeDJ3Hjehk zZNmG|FLM7+?|!rbfGPz81zNTnbs*o1FDyXU+)T$gSC>i!>4l8+QtXmdOnNXg26^2o zD3o0Hm$cKGx#Z_Hz)Ox`z!6A2C1L)pbfp^)FZn!=FtWC-lZfkVf0v2}^FTjck|axi z@>dLK{J)zu?NH1N4{)}YUvsp!9%eaw<9oSse%O&);GG<&uh9`<&vRurb)_(4_0wwR z>HvttXhpRKd8E55De|RzL21#3T!Hnj-Efgk+A{mc=j_Ki^@a4Xg_AO=zi{(wg|N=@ z+tu6C9;mF(#pLqsGbI`AuI24MQx)x*KE*g=EZN$_zmnXK`O@uv6G^t@wN$xuv?LX~ zLYLLG9N`QuP-0(-+-Bn@yQNg(<9M0B6ROVQSL8)Y!D2`ws=Hc3wx25MzsYBg*u~vA zPF#L*Z|((LI{$Z7hX#%r^GSCMBR=q&$>c;`PF|ve!fN}`7&01i8s;d347xPG0DK5i zb|9(AM5Dy)Wu^dSlY36rz4||SL666YXLRsV7O@QMn6YfH3jQ>{6>|XV=_fk!L~gb_ z&4nb)Q_9cvl0+;=ZtBaLQ~K)PEz>5rm>4wAD%Ay$%ciF8(re(bG#^DUh6?M ze9pXokk{S8fHK#7hnAuXFF+P(WrmU8?qy#B1CyltT9-e*^L$e_;$D9Re`l}HolKsB zWco+^G>-%*{JgAK0~lFng4pwoD)@!qbcVl0VYmPH)yBS)S|g`5m%-sF2lldCOoR-w zTTUbnvRg^@#oO=v`diL&??VK%o`tb6oDglgel->uN1VOfI2*>99Ukd6G@U*1SLFU+ z3^i7@ZKa}(+_TV?)+pU6b3R|@rQQ!*3@zdSOl5z-4Tu&W1Yja0ns`e1J+siReVOW8 zY;Ze|BbjzbBKqR0wp!(-iL{*sVZn`<2jB+>x9gWLpKVYQ<7|W5O!C!=D*rn3-%7r= zBrLwiBrSNVi`G~$cb`O#xWP$|Z3@)R-i@haz!(Fe(ianozR%CDR6ivXA&rj(7totY zC)+>DbME_?y_(Ypq^ly#IQ^>k_1s~}*2Cu@$m=rA(L++bw6d6ZFQ!Wv2$ha{7i#bV z7EUVRo4?A77i^{ydR`s5?a?ABY2(`Bow@Om)$#wi2WMRrEB{~@K$zC5?g|3)m@gWw zu4+_iGTvDl8=I2X*u(TFdno@CP;HLzeL-L}%=6%8WR+<_Llavw_oUgRmxWt@&NUgF z`kzMs!N|tez+4Pnd|gsM1YTCnocz8_SGJ+O8sTcMf`O%Q@|6fXRTMG+|-=p8FSV zUSD{$Z^zJHod18Z^&VbLHeb|eXd`BOaHU()*$S? ztK+qh=;yJB?XN%|D|LHW$+s)IxP>jUXjCaTsMdv#342rXQT<41x0>mDL|N_I3g%_d z6Ek_;d$dS@jUlMjDJ=t$ew581WVp|w0WQKi=nBLOMLMBI+@>0Zu`+0!J7N%{&05rj z*~&x8E_SLES?SMyV5(<0$Y}^B{w^o&A|z3|n!+rYe^WU(-F^j%WW)|`p#=%~c6k-z z!nba)WxFVg27t68&uoyjt7%&kS7dblp(i#A{GK_s@ki7pUQ4_1NAlW|uYS17LudK|uhx+`NfV2H1)s;^K3I8-;7_G$Ib$kEtGKoOPsI*Wa2LrcWZK zpb7TEte`y)Q1}xQp&puP(ZL?aeEQ%2?+-{@Qc#~3_(9Gr4V{9PPD9p5SSz%Ym+?>U zq)qzJ7mMn3CH9MbcvLRFcVK7TXA^ZuYbHW%|DV9P4^>{nos`q=JRVwmWlcG@S{>~f ze4_h&;%@WGt8)M$2+@g=fDzC*PXw$89(DNVnk}=1K-nL4^9J(V7K!DBuK94sjajep zju}02*6LA@lkink1IG){93N1|gC0J}tF^1vS1G^o-e|fu%X9=ZUi;e-!t(ph#>=1f zNLId<-Kvi(uhBR+L@!1OCZAG8!n(zD_65}>RlPNSKmY)}+m3m6u5`)9Tlw+=%j?dQLAx@<~4F$c8O`S*g6#VI>0Jk3M(I2IF^RYzSi(yuhXN>eoti_ z9_eolfj4{Sp0I<(`Xv}G=ej!&4|-M0c^S&;XFxUo&G)7suN2lMsy`QIi`W>S+2W)` z!AP@z^Gd4C3YheElYO}S6fO)g&z#1**J1ZqF$=4yXrJturJkDX!d!y+oqb!-q7sS( z)zG+LNMEL3#pQcNM%O_;3wK+id1ECHc)~=d%d505&F!OG|2QG9XqXI8OEwsSU^|fAP+jcuRY~D&#lapl$VMfBZDBqaX*)$fRrJ zZ~V<^DANUoMjCB-*Uv1v7>kdSn`j>oN$9?@I`T@|g}t4Htaf20KNT#fAF3Q3My&d| zg>XbLY58uVDlS~;(Ptf9E~C^}gKGYh^iC`x*BOX36lVvF#SkYa@hz(;o_caensjPk zB+m`AjSGy%&g!djPt`&+0z#9TK#a~GLz^?ErnVso@31(A$=IDRc^4S*e`}f0Gp2A! z4qu5+n#dEdx(*qyM ze%nB1q222)qK1o%zl5rw!sewPAL{bsqnqNKKBEi4VD!ezNpy@byaS>PNvTeIm6a#c4NT+UMS&Oi$D?w zy0N6+V6f{HoAIEQme%o>ANmq#89E+kB=fT&VBvZ7@W>!l;Rtd~Z#X&n5<5kVF(=Y{ zv(fqOzE{(tXXj@03>T`2YwPgu5-jy4;Ba!!<9G)4WpbSRgI!tu&p4hb*h@|8Nfy^( zLRZVDB!5o`>gt`}?UF&s*C2D*M#U3%%KR*-K?l=YJfFY2i&G{n;fsBk3g5-aI%jr3 zBl=NBTH1j^1WkO@ z;}8I4dzFyOF{rXKMPe#~yb*_>bWrd^`Tb^$seLteTArmFsMy*bQ^Re;`h$)!LU}6V z=*hGgjq7NS)8y=xgDvnHsdHa>r-JkeA7)hN5=z~;IMUPjfB)6mW3O@&%F!`av2jrn zV%0{>H*%&xy1YPFf2b!A1y?FmI18R6JQvY94-ybN!a>d`_A{Vp)|Mts@OM#gjkAC+amLdSch|fH$m666LrHxM2=5o5OzhS4-F{D&~_goX6akDv^pRm3od{XN~<0_jYq&rb;f3VdujM9$0N>)zn6VOw0jw*T; z8}OpjMsw-^v(m$(&&AGVS35F~CkG><;*$>H26eRmE>NHJeRw97o|=?fVWu>Bfc6lO z+Kp4B?`3aRFarhM3VQJT+YOLWDFz&VI~6c}?G2gnsPO%*6KPKPk!HK+@hYR=TKncM zEI-j?&?E_We#P>1#E^g@FYQ04u?Q5f`BU-AeqLk)qiBiWqRFdpLbhHgwMg^Psk-B9 z<5qqZ+Eif9^joTkjI>r65xw?f^~H;D%Eb3O!wDttiDPe~#)(-bB<#@?yty&)#{0Z4 zGFCU$B!8?dlqd;(bh?FwLn&huNTIaU%-tLzIYKdop|nu-PmQE8d(dLA zBfE7>^vo&@*M}6Bwm22Ut>0DUuQ3aSyt?+XWKDp4vUOCCT@9=tkhUJ8cf?e-!4MMG zpokoCQMEd&ua?xr*BT1Lr92gOnwo>ppUi#SLVmd|;X{>uHV>7ZT6dVx;ACXszs@#= zJ;i_@ZEsAF{?uV$iUp2(QYo%tJ{rVS&nus#=WKbqEOYu5aVF=^ zu;kUf`Dah0idfcjqXn;;a=e<5uB2Vadh5ItSWfu=TO38X_OwDX?>7e%UQl%8+VSq6 z<3#_XwL(4`6o1L=R-o%9DeFc?EP-AnSNe+TWbhFUNG$)*1~3hDKyN`K!)PeAn=)7N zK4T@F5O0ELK6(Oe3THpgf{szFdxY-cCd%cGlkqVY``{*4q($^WyTH}AT7`a1o`YTX z^{}fc3EJ2!XA(mvQfPm=916Qi*z+vKWLFP(9! z^xvylrT=h99cNW;AabqmS#|0f_V6eLx$64^jhB66pJ<6R9D5S_=}wIQ8lrcM@{lU% zc|H9k9SINjjzRnWYD_NOuh%{=dw%$$GG{M)u4G0eOdXhiJ-ZEs4iCUCN6gfOks18A z_oNugzA9|6*i}ZF%Q!SeX}h9oI(#7CYd(Jh>btNE;jAbB>c^!H#P2vNrR%(t4TAje ze42;BG9R*|^uw2C(qzu1?C*bq2~zv!>3M4mC2yM#kLFQn+AloB)`!^(4)u(>-0>Zp zQ1@HD`b5|`{Ks8j>U0TNB^=xtPX@1s8tNiTs zWdWCKZ1j|}>l2w**oiP&q=Jv9K_*vLe2LMK>i4S+q4oUr?9CiOYv*nS+ETvnPQ!!Y zpsy+buQ>Y_;-GhRZwD^fxK#De(UP*5V{9G};O*i)o|Y zhx`sLrO^S{VX2rxyi)0~!)7V(UbZGpk+6rarLa%GwD zN$Xh6+~~Bc*~)ECg(Z#H+J4-Kyxk-j{x?%|KuPWq_a)8#Z<-=`@fgKMX>!K=?tm_A zlboO!5E3VtX(sLWy0dA~^0Rv!N5T9!Ic8O3_dAAu2@3Jfxy})88N4hR)k;SwvVMxJ zD}WsK_vw-7gINDNyuWgA*{+J0LS2U%lD)`X!wN3c=_!Y8Of6yaW(`47yFtlwlTCr^ z0P}NeD(l3a!Ue~sbkUcOOY|1rOhlaMieQ!tEMTp}-(3fPrTcaqB|12cI^!~)6d2{_ zqDofGTm|1f0v;tU@%g9D#~igI_{G4aTHqz5A2IU6rFC0=5!gW3I$PyM(s?EP}?bt|_iq^E-3CRnm5S7#jfz80! zpz8$y(Rh6E+0t{RfJ^L4<$qnuq9#rdFwd_%0hqKGmM0q%TJ>Dp?jBPQdCkjPDfNpi zi?2%Tni4#~yNwrh6!sd(n=q%%9#8R3y&%?hvnxhzo(Kirq}^$Bmc`Rnx+7itg3ylN zG#{?ydA=?aE{eaFW+XCy@FQj*FPkQ8<~v`|H0u z?Q(qn4wfZ#jMqfa?$@3rbXnd!v0&tmgYXig7D_aTV}Bx3vrCblxp(nx!bqBC&C+FM z&_k}XSAUNkvE1|!O?Q1OcqTmR_B~xs;^ZUIAI;g#fv8hD*^d%PRYZZq5hBF@$;k&O zw0SviwBmGlA_j`7Qv}OP<8Hhi@H0c@nJ~UC?QzCkCoh{3&K-hfTWsp(d z=*5UQ<=5BmpVGJEZ_z5!WYrhIUVf<{@yKiY1^hmRPL z2M?9*8%_H|lwfm}&4gl&g`Q(|aCz-Y)iW2~wNMz><%;LY3ZT}hv+DAptefjrJHIXK zHU0}sR&DV@gE2Hmyko*ehg<5d5ae~(#8Su<1A|70k7^6y+ac=$om!a6KquSgZt`K0 zGs(OOHT{16d4nuzX^^{xSnRmU*pOb1@uXC<7f2;a2!}OVHro!uF$tJm_Hvq5=Zp2` zdmzg%OuLI+vtryd1vd(j>?m)S54K9N|SuH{qt_Fve2Xse`Hy6$Qs+w@Mvrd!!>)-W2{5&a>U-xROOFiB3f8wb5m17N*`p-u<@?5Rrv4a zLn+gO=UfQe4&VgSEniT7w!*PaxDKg8o~$6!Zz=r4o&;W%*I!US7CgGx1#hsg_UXSt z;F&qq+4R|-cM?v|@&nG{O#Y)am7YAVE-cGRLA=GuO!;6+Y;bvqp5h3XScBD@J1lO` z9yj-4lhU=CH_^q)63FoeoT1&ZPg{|(EZ?Wt>ke0SF$ zT*Z5g0e0G}-O0A2*QVOCg^tW5{4?{X+$fiI;J*Ud0O&5s4|2M**(g5n;`r^k{Tv65Omff+(LBSF_P_h@svX3;BM0Uv+wPIi52_95NuX4BA`;0B`(u>Z^z zj)yKjw%!DTOgN#%fI+561&&>~zyD7X@uD$GuMWGjS7l3x;Kz)z(i7hdtDA`Z`9<_^ z#V4N^8>zJjH}`JkKH8fxa*m6zL_U{j#1&X?blp;0re{L9=;+W*{cfKsy65brw_cwS z*q%I=<;(JT|L?|9UmDhJ{!Ri9*k^A-V!dZSme%KuuG{#G58?DDo;Yp>7Y3gaB@I85 z-7?%W(ItrMH%``O0~>6fK^~bJ^*0<~!HTt_-2Rg(+Gjym-@XbF)OcB^bz`WjpUm*GD=ZpO}m#G{%yg!8bzV(6BjP8U}8~L{_i6aXjkQEl?Z#qY^FJ1v#@YaSqNP|{k|J1mv9h0H=MN=P_x90M;+tl|2 zj+SgHkd8m}%qU=_O8=~@zS*T*@s)cP2Dd>9W_W58rVda*LHv3b*CMJ}M_P6OpFwD# z=GHMzr{}c9C8hFZtd)%9xYoPTK;L(*Cv{hwT&QsEJi0|f`LQt*d)miojadgSQ*|m3 z#CIVE$yh6cBdEoAv3tzN9%uEK<~??a_x~RF*~%4Ab5(_4gr~_2WXEIAt6?zESFz-! zYWhnNhs{2rLALYbCxR?b6xN0=DtLYbyaK7_{%#OO7(tcj1zrU)RfDeXG3eYquR+D!had#V{y{y=4 zew){T0iFd1;_vJvrY|pUu%0V@E*^m9Y2MRs( zp@-jLlqGq4LWMXxYk`8KB@bnp{DdJmTez;v192<$`~p>|%g}cYpUDddCO~5~Px3Jc z)M-EQ8Z3XSM50;O@}Luv6g7dq6_RSssU!xUgWpE%gIS!zrT$seZ@fB)XVO^kGi__s zp{`E_brcgj{{E>Tp+m5Ne#6JQGXKQtHsV2uS@6`T*>*gO;tmCUusZdt6KnWt56`XI zc*mg&^NVN7@9ylk>Q|Zm#qMe7>}Qz@pzXGeqhbb7W$7>Y8*g(XDEudyycMHX>{xcS zx}p9~cA!<&uI9sui?IjqM+g06p$z;(2br`&%(R|qQ+^4Gzy6hA-_J*bXGWA}y@W;| zZN`H#-$n6Ll`muIC?yh`X_?b|m@yp`^kQi%UXDtKD&$M##T#Wx3$TnlxB$kcmX|JoV!Uk0*f-j!}yk9(M4U_?uf02J&W{ zksHh7quk6rm)YsdYC%2MKE(M*tD!vGMaLBnkrPhu4?ojP4Xnp&KW9KSq|ny4Vrz*< z7BjLZL@;HTiv}2U>C}9)vZ20>6>DE>T+Or&5sGKtnucW74$pa9R2Ymv$Z~eGeRC)W zv~TqEA+7+7J*c%K+27B}PE=`s%2h+mdd2d(Jf2oaZElR92~r8$i5&CDl(8`OWNu!? z&7erp-Wkm2^5v@~RJ|P+^TPf^-UizPqydb&TZO!Dh|lof+QTDs-MsQ}sM#!gE&ftZ zDm3`v?ZHUxKWzIi(3e}es+f)oJm^lkGC#&2Hb1-t0{Ohr^7>8%dgvk11*4(Jja%}; znoHt>DG3Rn!Ob+8l;6RpSdeDeSVtqK*8nNl)x7n914%H3B1>^Wr&8zA964%EY|tQ5N7P z8&fL0L4}eBjjgPpm(SFF^}4irw4fwN)nU~ppKKDV1;UX$$j9)qCTDgfQ$qS4Li>_d zvQwLJaiCvtj*woF=d@7XzA=TwlyMZe2tIAA+Ser9%uLjlZ?$|F`hVHv$vp2fym7cnx{`D0j0v%i5q zyTS(B_g|aA_sAsHoNko;154;K<=hn3mJqxM(g@D`^bwP#x~cU{#ux3?JI6=kC*G2|Tv zO1W&ZGh_ACP%*Qvwx!{(gD|)693YYo-a$gTV{l{!NA;2gEAU71MzkYAhk*2Vpac;& zY-aerbJz!EdNFA7XHCOZ8RO!jTQQkQNRLzS>ZFT7VuMK?)fnBOkC-kXbKN*7{5Be(( zXZH>$CTWBH>jrYpWwpieoQ0R9w$boJMuCsz7v+sCNXhUS*G_rwf(i9EhhK6dq!Sp} zgCt>gk84NpfzTf%h)K5(OYV2#Ij47iX$WSX?5Al+KZBW=ZIp@q&c>jkebu|IzY9T- z1e@Kr>@M+6--QA*@E7K&0}$5p4PoyZ_BQ1gdf)*(_kuiLCyvNT4U6W79$_HsoE~^V zliL>|yHud`fK5tmg>TBf%alOF`U@8lIPMw{yYpl{wWqzfO8U2*3XPN)=BWMH_mB2V z8JW2!{Yp2oG}T2iuD$JRyZ*vZ!W$iZu4B3o@$kWReh!ud$KKuT0xKj~`W;LdSsd+Q zXSS<&9>tr8&!gX{tq8iSA-0srozsZ1m^~#I*5Zv&LQ~4x7xO%nbHdf|#RcYoqcrw^ zgozl`ec9bUM=iHQho3JZ+&@r=a`0&q2QS0+yaoM|iY(OW3w^JbggrKf{(AH((@2VT z4E+EXhRBBhdhDMP4v}sd!8= zUh=tKMGgp^Fkwgum9E|aJ(KNihPXVQ0XcZ8cwPn5G%eRG}R;3*f!VNDs*A38Ih!tCjUv}ts7#Ft_P;;_OU zePJTDe~cskCEMQ74bx`uN}IfUM=qUJ(nDxesoFyYZ%%>L^+kc)$Yr>B{A%o z{kbV=sm{XlEnEc7yA6HpvW@jb@=;b#0(OriQQe9S8{X|dm=5)UC-!|I4NwviZ6-c`Q2YI50aa?XoA=f1CN<4O+eOhK{8oklq(z0T2n+mU1j?;DK;?ga8 z`4@7$-#MY^Ls2Zrsmz_8nJHr;>gXT9Z@vF@{XU|+^|@mvgU3u?-@J2P>hE|Ce}j&! zlZn#9_?#Y{sMDg%k8*L}u4Q!PO_(>#OpmDj=1yePbotky0cfw~f5l(g(`TOstX+OQ zg*3E5`fn75B#|A?W&36+i`VearJT|)w@ldB1elqscl@ea%zLTS?1;)$;F1J@ zpmar`E*}yZqY!J%Jx9}W-}b*39#~$)fW9TLAnvr5nvNwWl^ZnmUF(y&FVo}=$1*z^ z!(hk60rxRVY{_ZC=mNjK-PvBo=9*58Sp{i(YBn$5oi}g#S57$!N$rQxaB|4>?;NS$^K zcAzoc+Hl4VE^DQOnfB62p8kA~+qSTZi}KXdnLgmkur-#bLRJ_!=Z7@hf=kpx@&6)i zrKM*D$;)_IrBf3AAaR0{QbKl7dwem&WK0rv{*QS`Lg3RRa>+P@3tsp1D^?^qa*z)RU+Q(Xk*leF1>Pji} zp@&Nvs_$>1x$9_*Mq_~)V|728jB>sHTIkVOYLB{cm4$7x=Z&C}Q~8XHjA2dN4gwJj ze@$ZMKJ~6>Ld^hvuV3xSenE|Pz`uMu3q2tTt_^S`t~O4e4(=^I1N3$C4dTayKU1js zV~z$*&#(@yF1jCEIVNK2iIQD(X^16BzjY4J>(hUJYU6F~N{7`Ra2({y+c}0CCilYSD;6Gj5K)2xp4o%`0o!H+ z5jJ?#98Z+Q{bh+DpdXzK{#tJ?v9%TZ)LQ%7-G+4ZrJ z_TsnR{(fQbdF#Cu=Y}-P-H{?QOYcrkKR>&~jQ;o4`mD3*s9SHE6TLSV%!H_TrQxPO~&gu&!DEsj&{UelJyU z8bwZ$+~=A^6kxn)>TAhkWfTXG*4Ca{Gz`!Z7zIc$jwzP+!LCsL%r%$GZvd!a2}!k+ z6b+N21Eqt{BGcm!D1DJ6z6;OBW@q1&BeyzIn=B8$g*mfBzD2aXT=wv2eAz|FTzz%) z22cc9QV5(h*+>sV zS2r4niApMe^`vfO=Hn3URd86f+EHAP>gUd`y;^ETo zro-Mr3;#FGVz2qb@e&>RK{ieJ0DVH;jn~Otj>OwHtEe!{}(Ctlo73 z{$!&Vim!)`ZO_G7OC_-kYWy7F5$_H;<$nu=prhWBIw$k-8=#$Wc-SX@C;XmNylCIx zEr7CdCS~UcTUq?y=LB5iExLHf6BY@RTdDcI0ZS~vb@Y=gb{IIByX zn3BBg(x_B~0mrF+GXSP0Cy!7w2V(%pKArPXB_4_Id`32J$6lnSHH|ZaVpGpAk$+wB z^>)&ez~kbr>~qI~Y)7qM{81NM#4a#x#kcHhb)HQ1TJ0>~VRPB5a9LdS%`7!T-Ggu9 zU|O^8o6e)S42-Uch=_4U`8l$BhDJL?jraQurGl>ESLpYHT>D5NH)m32!J-%W_eT|$ zEaF<|X{JAsUa_1b8#n1mNm82tz(tIaCAaG=t?SxF@`$Kp8Mnw7B{$7LPC(T-|5LaQPKa)9GH z`m;&=sQk3}8QJLc`iJ)zmhHy^9Ee2}p;0t9R`jovM(f5noS9QeggGWs!>-K^z)K6M zfZI@cVT@d85N}m<6>$P_2U?{mXz$fONyZlP6H^$DYfQcYr_RKI(72a=#r=KP0t_Q> zv-uRHeZiWe>)@txq{_Sg_K2f)I02oHk)G6v%oE=tN~J!$7RK&+r5fUMY3@^oS)D=lrbeAhfphLZ5A#E`dL$hkV? z5hlT%>5A#wYe7+=$y^xH7xvHmYOo{KtI4Kw-ZUD$`szycjwoW+O4N=VDQ`u9w3!Qx zx%#LI>^^^`pDGIinY=$cb%u$OZ(1(YnW(gcu2>z0Zk|LH$Qk<4o#&(XrgVIiXr^5}rV< z@hjte0y24C(}?_TB5?gYwEzVex<32zJhIJsC2zmEUaa@CPyw5A7WDwC0W<@7_*~ZR z{d=Pjt2+;5Wee;aWd7nW#2DV-5Yd74nv6^PzHNt|?o5v>FBIrY)e#s{xu^L9W1~Z< zs2}=evIraI?_sNu&~6xqDnHtVhJJa@$!I*ckPJ*WpZ#ie>7mM-T*eS{v>FzJVQpFK zFq4vPS+dbjG55BSQ03DaFeKRJ`={^@iC#bE>3lE3KV{XF(cSOkI8!SubHWJ{&6Jed z-&bT|Tlu3?6E^9Z$INK2DxnHx4)zHk`&BeO9Y+)_ernZAe?ncB`;u|_EF07F+i?dj z7!GhzOL})D&0)#{5tRP}9r*V)Kn7V1hpX5XG_1w{Tv{z5O`;I(A1403LBZU;aOW1IB0U9w5OOW!A8p@vzkx8ZB}t*DC*wI8XZ$3 zKfty@8D8Z8^JE&1|Azi%BLbpi%K4FK%#cB%piEU2{zJ?F3<`P(-r`F+RD2Yhnk0F| zlMJc%Z@N18R^#!+dKO^e1H1`aJe+X@%9mjPJHr9chYqq~qqiPodg0ivoaf_?WMEn@ z-`|BLNy^p=W@ddsK^);^Z2NC?^7d0*VdEBXU^doUb_m~LmM@9g{q}GAU{k|IzQJ6g z!Y*HM6FCiUd=vCSp=E#@;K9lqz7UyzV8$L)vQNLBctMe1apV1p5`$ib0p$aKu5vf( z-yGf1_AbWjid%AMoQ*-BuK;G_wF`&8U zS%#J9p*wLK!)i?#>-F@jdzV5qP2$`B)texq(wLfAg?ETc#6g7=>&IT<@Vs_NL&We_ zX6D*CVAJ{!j6oWaN!nMt)jSysxZ>dQY+&BMge7ykT$lu5j zOU`t_7wTS~6=yvR+N^10;ZwG6;T};_F?E~EQGx|0$Tu}5>W1*nTf|!Eyx_mn-k`>? z_bL@IZ|WQ3zA-}xNL_=g+o}-SzrAc$Hf`oL)aGn%m4SBBks5Zd!Y39`r1;(y;6r&H zacRHAh+z1Up4AP?HQEojO4Acm&lPIRN zvUsew`CBg~5Y8>JrO6s;Pp(!TK%{PE`FdAy_E{H^Xd@L&dQB2lzeNJJU?Lev5fk>m57^7ma6yE!fS4!@SumQ zH|s@PfjoHyg!8VDz1^@boGM4G;S3~hO6~WxNXbj_P9L@F)MX`}f9UcR^7A^-S|Oz& zTp~V_Maz*gO|IFHpC&_N*(l$4JI=o%H>0?kS~%*2%p8bZRHavz{q*`YyJWFPH-)V` zl$#{t*i{*+9)_l*eeqGZ?sUY*iyC`r+`4Z!rm}>rwJ&R4aAfWRvW=a`PrNBKFW)4N zOq=Y5ug?b@HH}8~a=7Egxa5GW%&i2a`p!k*(yXi{Nxflz3!#C{L#W4U|Cy-_ZGRbF zeX@Znyg-q!8=L*fV?>ULQeQY%uhRbZgL&*${DCn>fTJ9##V8FI3e7tH{11u$E#)j}E8e26FHyMrSXVQlH;eAwey-fQl5;8iWLHjxR9NV-OO!LbGljQk{$ zXFT((?^_|%;SY9XG4<&)ptGWutK5^qL)?S(peKX)EdhXy3hD)>UV`}NMGoHF;-I(SC~we^Xd!S_ zk$dD9>d1E?BJ?D;2eXC;gI1o21lb*U42k1NUfpB)Q&7rUYy|%J-IV(ths|7^WcQXI zMOWZ7>uL#Wye6VMxs1Kv!fp9M^+fa9=e`vtNfCQo#?^hPVtCeGkLzv{5RHd9uKH!_5pSyTO|@nhTWe&0eP0R zDs{cICQwDql?T|R-v`d;jCC=f!(S+AD+t`cW%mP)K;t^}Zb!XJO`@dEA&iRhijHKr zFW(CuoK|J5&O2U(_;;FF_eQ|Yy-BN~|2Eq8SY@%Uq@_l2_LTUrT;Xebaw!>O9h28C zo$4%w6{rvMuXO(&TuX6^FqnHVtq|m}4c;Zb-91K;b(=3Nh1fm;J!J13VA}eVx?B5i z(0aKf^z)K&Inuxhxg89YShs?oNZpFL5Y+?l)H`YDa*oBCx3RUy?{0t`0HM_&kCkbn z1<0sJtM^bIu4}ok4;KhK)E2h2koSGGT@fHww35ISGSw_Jm{Nk>H!If4Ez8+Hj=Xw_ z&DQ-xMQxRY?!2$L1@~LNst+mgQwui3Z$rI(Wu2oFv3J#i9v_VR$5*je#Udftd)^-gQXXoFfCIzy$00LV&E4%&kc7KHrlw`;Wwu&^66?Ye6 ziLFq8wnukg3=kby;j0MVPc*M~u6E&=Ej)}$^ZAtBRGBO1j}FL>^im6Qc@Wnk_P*v# zJZSG11u1CLNq`%*5hH!VWb(;53H6d>D~>CDya*jgR|O8!wrDmpF!5lYaHW zHJtB*26d)SXL~QpHGhhl+UMgV@nhM7W~|yxko~2zJ-yw|N&x6C2oxb<1U7Y`_q97^ z;;7=~HfnBd!I##Ob4_&pNCJ`)As3(WRq))i9W`n(?-=2?8Zz@)>9np*H0k|L_+@7T zEQvzDI0t^V9n-vyzrYckek4({Zisk!*V-TM_nrY>t&O# z$|heG3jYatIAu+$fS8c$?A((y5K)+@n`IMap5jquD~H~7nRps!22sB_uzrA)D6f#7qrjQ>RkA@7*xxd zn*7nn&-^i%XTRh*wXa|CW1|d$R1p8t^pQRw+a+kX^x9Ut&btPvh<>o2EN6~LaU?H& zk8|5u9H;D^^cU2-*}YF=_tyCXzsRn0JKZPCm<6=g9kj@DPU56A2LIATO!sj-sFe|z zpqu*_OldQ3z6g16EZMiRarYRaD(cMW-8b|1PA6bFZNkEv_-R5o!NXVuD_^Noj(M0f zQRBEc?YUSr5srE1vPv%*$HFBvb#eet6r|wbe^j2$fRjU+$i+GxCw4ZRszx*Tl3xLj zbPX?Uup4dbnA%lnqxbf6CI9Y;r2A&-#WMivm$X)VbYxaH3#B7-?6Mi3t}A*k zC;qBQ?D;P#Lx#zmeUye%X4Oj|lc!Ox%a^h*)hA`XDdLr+M8&9K`6tu@^SNipkcSL+ zH=rA8iH!3hhpF@NT7>eAmJt=BEH#iMa-(@@i_?;eE8|9GN?FFZqa3={$@f)j8{;@j z39Ye9^0Oa`{BOSV4#M{!OAQrBh`YWD|5Srqd0iETXQ2N!RZ{J;42dYoe$>e1WP6n%;MEt&4YzkV~6BKzg%&*hga zY6Q6YuZ?$q7e;Jl@U>d?Y$j;PtRqU+i8gtmm$V2R)19NnhXWgjm)<^q{``)BXE(@; z+#TdFv}_vwG4WU}Zo%C0Q1RGzDgY1eUTW1}5FB+|&MF2EnDgHS8)ie)__#C#ru1n~ z3Q7*!@^op+mI*nmyVL!f?J&$Ers+wHkghQ)Ch`i-*S#&1PJHVxL-5I)(7nO#jgesW zlprN7tDrnukTx(@7{q>otl^PRwbN+aYXhScU#$u`rY`>=%#H>O%O_w{KO1sIJ}*1S z&d^A7e2o_Z4>?;X3Z!!Ma*}>g>X^ZgVrN|!b&ee+Tb_?#FcQh}mG<*^*X*Jn^TmmRLISKBapKIfVlNQ9OFst+ngF31`N7u55G5X*F z9sh76*az3J(zQ>29;X8VjB}_{&<^l=;-z}&$P`KeT~sZqZ%ebsC$W?EA>nT9gL`JY z4@CHTKG!YfwS07MKNbDadFjS73#iaNN%w8!E~(Yq8}`|w`UaltN6tBF!Unz`>l*%^ zE8O2r4n+xZIU>60CMbpb7Vk?+XT5WB8z;UvUMTX`YTDFi6<0l<2yZ-2!dw@nspc2E zo|?9nGOWnlyS;}^_+EfdBKE1KUsd(qPyG1EBitD^^h5i?Aujke*tAl4$>0OI4YkYN zVo+1Hi;27EyWaR%6|Q_O zsvJ*UsF9xH7zPYK7A#3zRS$XBuq8-&`d$W_(59(v@azIfKEW6CTz=j8pMt{+zwzGh z-SHRw^GuuW8(t(PbSJ9i>J9h({X`-8ahn|A=(jAqJhxRx_7oX{KdH9$>(xlfJ`$2g z?I5Z)Gnv&JVJ-Vkjq2}ZEyTGt>om;pa%$d(YK?XoCMmRqE~$BmnvtA|m3Ku`=Ud`| zl4FNPM|EY5yL&LQ1G^|FzmTi{XxpxfXDPRC6JQe!3cOH7aOeD0T8BP}%nz zwA!_ag#u^WlJ zn1k;xh3d{Yp=+;%fc#1*JH+(%t~d+FZ}o^ z$leynA2v;9s*wymT#%mXpFa#&SfiFb}u_6kN<#mlX zF40Yk60JGuW0(nib!C~3C6}f1oKZisbkizlzUN5erfi?Tf6~o0 zKY-&7Ijnp8D^f#Mng2HuRkhAlv+BTD=@ot40Z{C+nFsLu55uTWos9a(DL^y z-JOklC(Y^t3FQksV4iCiiR_eL%ij%{+JBSwg^{o)O){n_Od<=DvtOc~-V;z5AJJ*W z#sWicsa9&4>0jghud2ZXMAw53DKFF+)6*{(O5E6v5x?=pqb2>%hZlb%jrTzVcjsVm zPw~_|J+(GHHM+1*o?ft&I%kysRbLZF^$lC}mDaDS!g? zF?*CU+LMQdLlEw1yQl2iDpuyL5G@%)(I|r`j(9&Ew@nfraHQz$5P06C2nu86P`LFv zTKtAH;&iR9AAn_in|KPB|&d8hFS!*NVO zIivv+RCU!Qf{{Ps-|vRTcq+K!CyaR}5lxDWcic7!7A2H6JY;?Gg@@7QMMm%wcrQOy zbI-fPMptRmA+Fa?!>(zZvyTx!_d)JCb#`5Sc85D+ALtj=xABC$F`w7rOK@w&P9Vzb z0~hH|aO@Bz1OR}z<1#Gb9vGRsnslum^!?4ni?q8$G`Eg=t3%f!WgTKVWn{e{6WeZc ztS4qhpH9y#%_&>6UVL<+@@AJHi&}z%V}z|;ehZJn{_BZnSn_yri(e)b2GJ4Fk5^4x zRTN7fU>5VL#VY6R(kGFV|4il8CQvOaKfWwTV{$vlgH^Wt$2nEdUro-)q|UcA8l-}NIkVzKX;UHj^pK`PP0|wu#=>TzH&3v_xix!bnP3{(4&A5e~C-Sm_hd^ zD1vXfsk!gQw_q*W8|y9;JKxL^LDf@9tegoBTB~w?u6jis4c-os*%?CMA;25pI#M<)W=t^j6)#xUVtY`2sFJtKi#n;OZGA^;@7ozq<&s@Mc=

E`ifvkuo##%`GiV+*`zl+^Y^ge^kktZp{((rblticcpy88xzeSNFSJ zRwbThcJ8&cHjMAL(E2;J8$S;)zdWpJ!D*>G-bw^(LbVR5aUgsTahX%Fj;+sgm#9TGpC_cUZ=<^H6_wV zyF;i=TpJ71CEIqZcvC<_fx*fk?aud>!4HdHZ0s>2d|lZ-RWlx1v|f82$1y3`aS6LdlFwk{Gy_tW)&P11y`bcRG(%c5OwKaqlA8^Zw8u9;4-_Z2_*4?ZtvGZm_ zWDO>mXf5Z=5@t{X9URzaMjmBs6=eG7`ng-7VZXUB9{h2t1~l*iut{bs>eM&W4W#XN zYT`;3`pieYO>I(cBFYBvf|StJjqX!veHH+ zy3E3ZD3d?KyjZvQL@cP>ob7eb&tbfTc6wBe zZ#@!q9Ih~iS55t`;ra+OV=aJenx|z$Ob_d1a?Q6)dmQlR3RVox>TI@nKtcj5A5 zZ_@0~sv~-P2&92;gZ-e_O&8OtrRH$Is)tEdby#SEC2#xILFn^&P}u-SzNFQlyHmEW zWJa?Y(n8 zDD5V|tu!HVtLOmD`>+zsX6<2Ja^CCEjy#zQv-4rw%q{wdl1ans$4;hD`jcLFG%32u zBq@s9{T-+IX6%RgV}hVnC9yYJT-MzbTDh1YRxqy;r>p7sq1`l0I{CdEL3UUvjAc@J zNNYL{r;+g{H>R$7${H&liunTQGT}BsgHbt3VWqJCvSnS~*4Fs`!>1UMv!9-G=UFA5 z(#JabA`&H1Wh<=Aj*gDCmsZ4uN(0cG)x;1`^7-6KWBJpOG;a~mEO9*}MAwFf+CCUq zp3<@#?tcB9DsKs&J9xkaVJO}6`RU)le=gplrOso0=xE~WCbvqs zo@0Lr0cjdZ%=pSKg_Jj{IQ06wjyC$UQZ)3%Mg=w#yEdZ(w<;xiJt z?RAgH+Dd$toh(pmyt8VVgQ)u*fvp#9Hd!@Jw4aP>FW3_l)8GEgZJIXRkL+dIyjaRZ z?Tc4B>-XXhxqD8)Bc~I@%27rynalhm6`HUx&gQLrbj}pG_jh>8PxPVRdWXYjXOK) zyZ&o^U)Z4+Zzo3=lSCF|FtA_n$Uoyadv9X$8izv6@Grd`<3L@puv# zJUA)%knOyabJB={6}5#D{1~dotf69 zAsK16YtxEMrG^FtAlL*Cq+XGQc05L`kzLTPPgmMBm>jYQ+ERfSAcyKrA~*nQ{eQPS znX#(v=>iG-o?(*kAus{Ty5WT@+aw1MH}~s(G*siO1dlUWu|xqOrFPUxs^uq7o(oZe z>hfyu#KbShL~On1j=ow6sh^wsKGW>zw`Uu;N%3I8=AqA`90I!F)EEFy@9x zShI<;ZgXdFm;E8zu|IkvzVlH{O&e38cQt?9OYZLgfe6J5R73aoPORc2$Lnk--5IPp z6%z9+?^8spG}LwD!f2q+e|38dw9bK_nTXz&K6QtqzdYgXA68MVcUUy1CTY_v5`p!V zB+qvIqC(xz`;o@`x!*ng9@Dul?MX|DD-dbdr^9tCPeb)f+_kz)l?29Av>S(L_ncH+ zCJ83mdm3Npgle#CxCz{^#iXC#lUX<3V5P{eGI-kaaIF6`Kd)aAa?oT~wCuyj8b5yXA*K zzWp55ul}6x$XHW|UR2GXfb>=$R{O?so^}k|X4yl}ih5hV&@;XrdH;TyU2Sd|r4}vo z@T=S81Rq!yO_BX&y1%La*m&j74-fesIqN(~1d;5}|F)2$2(7I>v4reuE!qyKlapF; z2Q<)0Ev2BX%JemCpsaw^SY^8D*Y`a|Ze#o4Ld3)tY%7LsT~hnGeBwb}@)0s1Qb&hp z?KDxwzjkFT96s9ytXami!y9f**E$&h8UrQJMg!r6!?Bu5K0Q+zfB$0s9$If5@d)z> z0PgB%X<7NZj#~g&Cg&?{!x{=e>i0uE#wET~>2a4SX;#F%uPzofWzB`IaY5PakPX8=k>+@ZSZ=NtL7BJm6VI+Uct-6k-6j>6=hfvL|;ECPG z!9Ohq<~5(25EMd+{mXMPaKmoh7v~4kRh8+!`EkW`fbCvK#{0YR7Blja(&E7TR4gTuq#L^#_@hU^D_ZNq=|A0MQBq>c7=4P~!S zN^uTYu9(S!2HF-N@!z0jkG2|Iu4G2XlenBKy4@LMrK*#8LFt*?IAgC5XM?|-?0JC5 zfx3KgiM$X>>W1%$AcH0%+52=&yXE?^1+%kx9JynspZA5VULUpzpZ!+hO5-%5ZhW!y z$Dx#*)sD=1F}~vMd%Kj6AIrXQXA@!UULd2+2s9Zdgh8i0J?m^rG44hZk^LE^d%FGrv$7jF&pvZMRNz@Z)LeE#>@#wdFI1W)OjM8GR<7 z7nmijm<;hIo@E9|hY*Qn?^5?{3^e1-pz&*IKXRY~jYITbxZn@f&yE0>X zAHL<@B(yL@<*&HMNsu(T-&@b7Sz3nXXY%80K7A_+0}j%9^qkZ+o>^6FkN9sa^3*%b zr9Qo_Ty5`RUR7Z0!FgLbihi&uf<-5I!9I&g?b~#MZY<`#L#{?b2Iq^51L*ILaOtO! zUvw>18~=n8kH4ct#I;y0J2n_lf-DTxq*fgj=bL&nLLXlG6577rJbGs2(~R~4;8s9Q z$AM=zpQXpXPx}2eH>sK#ws1E5MZ-^WVr2gd4+T*xi|QN?{zsRcX%}lX!iOeXxzkyJo<2uVxSw>==IQUg9SnCiMmMMNX^sZJqt61&SH3P zV|&j6;FRcakE})z@Korr*8~pk^8~)bwrh*GCuM?s|0UBFUC>izDKEc-u8!pCPNsL4 zs4|&1#){jA-wB$D9(ixp;WvE4Q%$Z;HyM1_hbQrfm@8R?^y?Y_*MJqu$dl`Gs>k=K zy^IQ*A6={?(Jl2jpq$%|0WVuQnotZC?X^pMxZM{pB`!$i3fyU_K^DnU+!C=;)I5{p2KM+W3(cJ9sDf zkW5*fJTlUO#ca;roVGNU8{IctYyIZV?=SF6!w_ipPu1<@9q4h7et2k_?jfA@am5A3AN7-6_n-k z^r3f)3>QvT)y(5i+-xzk3v#!7R9x*|yF3^)WO_MzNTwYculCagCn}t|D z{Zsx`wtr)BZTju!6wZmyvf>}l8yX%{WP9@e+NzY5DL*1E|3k|X$4WR&L}KRzn{4zO zYME@8AZc_DmSE>Wi&q{Pu0(k#{UdoT_27r(cs5;WrM**$Ezk2I<7wUQjRb;Y!Y8k`9|J7PS zDaxIA`WjKNI$eQ=yKY0P+ZxaTr`6BFb~#&FK+hi|Y9cR$A9&r6dw1#a$m_-^HRyS3 zKjekk?EbZDzBzB{8P&rb3MpV>E@-1_|ubxYi5{M2ebjKpz1&Rd1Q7)$$ZkzM_NkAOYlT3cCeq(`mIY&`0 zv}9ZKYA?$p^L$_Cbg8p+D@Wb0@uBnnr(Au#KrztO!9D=FO*r{kc7mypqWDP7PCfp1 zp+7}X*oS?7iW3QejS7DXm-jJRPSrZz=O@1r0kAuuViDn9k-s#=Tbes%F8;!2){CduqUKtX-5)I85xvp1pm6OM;YraUAL1h4)_E>bm^1yId;Q>n)-1u5ogw& zx?X*2>8Qus6|LF;v@QJ9qCq*@=<|Y#`#KKETiuC0Up>r^{#M7Od@#R9Ykvd?Bz0WL$r3>a~EeqAHsr z*0?ijY7E!it8cjl&$=0d%;a4=zH-3@|65#)?;4E{9`6Y7n+=a+R0LLkUI%%hDt!>p zuqU(db7Hq#L+$aDd=|oK7fw~{(R+>?6*}*WXK#Oke~(Oyc1rTlpJy#;Fy&`}@F!&R za`Krk1|C?NUb)5v>2ET}$VJuArP5Dv=lL-v$FDOb9b)2?1K-QF%B8bN^v4RYxmCNH+#WJmn9R!$xgD~60mUxq_-w)w29Pqg`wi8(8mLp#jwDHT`~9;+%`7A z?fu$;b)7*!gOQ(H_5i!nhfTAFQ$F5>0KO>8n=3~y&Sv>qnn)nv6^r%5*+YFQwvdLM z`vieN;Op171|#Q=B0?*wF>#kqI~Az>Jf$WBT~L|z021oslPoXX8%b1j33`G>QFu{C zA3$^3MJoA3Mm1f8JbCM-xPBG=qs%Msqq&PNJ>uM{iOq?%6w7zMA2rRHO6O~))MS_{ zU9G*=Lou+k@sc#UNlaj=7}HO&`~p;V&`6+KVRXsQugv9jW6I47L<+V@B?z4SQ&Fso zB|i4Mc~(_B(xUNUWOw`&^;3ZY{rNXT`|mqQCq6>p;DI0H)=aHZLC8F_y|6prERjZT zO)r2nZs@^-xOVK2mC)Zr)d^yksGI<&qfFj^?!Lah{W6WtUcUQ#8IIRydrn!YX*Z9W zz5_2LXSGBxbH7iUYb=?&!A?UC57Ri*ZgxR}xc^?xi@ZFR|GY)kV<4a@fc0{n&6!pD ztxBDRd%?HioECl$k*iERmo-2d?A?cmBn|Fwa8o4wZb~xzE6BX$NF3|-2YWHesPXp{ zN7?4)+lUK9;`o_>?hj<>I(nh%uVF|p9bZ@~VWmop@Uma9&p8uzI{5Sv9|sK?vDZTU zPtpr@fm&y4yJy0}FYZ~_)R;X8J48r}Hjc2$n}jTO38M#kh)>A?XJyn8mx66cC8&2} z_%#!lJl+Tna>Pg;ee2KA48b2aq~4FnsC(z>S%f?#Aq9~dbc5I{kYm^TfPb9Gh$93 zqm<*en;-j?sPG1wZz}G5>Q1Y-UDi6Wh(6s+17!X0t^(KV>o+vFcinmY`4+vH4j0>D zw=TdIMZ6BG<-_+PY)O--2#zD=6A0|OEoAmGxxGfaq2=YMf_x@!ek~=xo~erAm_29! zE1M1Py5N%w6FrIkg7=U;>XO_eZy_F9FM)3=`9E-og%ft{C6fsH&n(panT#kcMRQ33 z3nFqNph4$Jyh=BYmxjz2ui)gWSD^}bSZ|Ct*`YW|&cU*;jlK=W%%;xYVI?z4p}kF8 zt97~W&p!4m=mQoPB;N4*SR`;SW%eAV6N0s`gd0f6$b(A7ybgJDVRPDnmJ3Tw?>4yu zbjm`A5Jyp2Q=Ao*+_tk{xHM^>kyxZMfvi~Ok!&*F8y$@2?HAENhQ^Lpd=;ibZ+zxw z`)^#k9%LRkF0z|6q26dJs;aAA0W})LPti5%Ui6G&-_p|P)z{qI&7`y9_}`bjzW%dW zzY=x^v&oezm)T1 zw&xk+R6*SZwyLjHm|=zlq`LdB-%glRgW=iMaLZwuT>_y2@-bX8g5NA}UvER7?Qn$T z>h|VqE1^9u1C|*nsbzES>W1I7eXH^$i0Vh2S<7XZj@9_f;8Qe0&yg@_T7~YQu#& zF_rYz=U4t6MKGF?bJkRq;R;j*)`1HRaxt^rOytU&sNb~j4a!u%la!*#t8yo)ErF-jCej z)rro-tHLL*AF!qo$JsRANB(7ATx0V*ga$A8TFf~90kYG91U8S}IKj6kv^USrH~_54 zJAM0_3sOm0fxi-Y*z(;e1RQLYyVx^kAtdq;mxu)OO~tKi3->7o-^n`oL3rbX@OWpA8EKwtSh(k+7f(`30$fyI%bPF36o0 zYU@U8>{S*0luYEIOYo>)34-ykqR1+QW5@X%5S~FV?f*>pg>*7-o8t>?nWa^^ZQaG! zI%nW)8~zrxTerEPMJ%9V!Oi=|Gz zA&&cN5{Gf*7%60!Ii~B^V*8(j{Xo>OV$3F^42ZuGlO?#)_Vr!4kbd|@&tV> zpY|+59@Rz=Nz$C=_&>=8?q4Fw7EDJMrl+aP_O`ie>%8P1i8ydQ&<+gsA`Q*S>FjIT zDHW9#-zvU#3Y4|c$YR1Z%skOp*!iI~4MbcHF2HCoD}AnORn{GmeW928VoxF~zY5q^ zqR+ZmUXy=+@I5Oky{P7b!r*S}2-NB2UGvLp#T0l!tFzC@jpXFmq%+CLT=TStQWs

KJg%f7l#riz{a)DS?2tXPS%u-V416pY^(Fxccxaax$|zn-D0uDxRPu3+gpU7 zY$v`$HIE6eH_rO(?9|C~mK~S5D6gmyeqZlaTEf$DtaJD9C!~pp=nORJ3NqG`)Zga8s7P5YOyap_hP$Cp-^i9F>U_mcfImc z0Cem55;XI{b%xRblb2k@$;-N2+P#`ru{#l22R+67##T{>fzJw+VTc7?9PGtXXF&Ot z2arq-6m6!dZ=bQ|-W!!;KstBG-OCF4_K!)c`sdpj~^H=XMRhI(NIqsW*45*WxOGtlQlD zj6wIJuDnv{N_K7_q5<$ENH$b^h8H_&cG7o*;!X!ybbkdI_#kb5RX-&OGkrg3^V{U7 z1Ax@R^BfL!B?UotGCgIQQ8t8J%Gp&Rz-`fIH@4F6bne~I?iD=0>|nzcPw@t3b7}tz z7qx$P#51-*!S4fHQlG5~S|MiwU__v40X#QyB_HIsQSBuKn!TVf7!biB-B=B$we2bO z|7Fu}()0fcefiH*tVXY0>V^Nt^kv7ofh!AWO935HNf|izFwu*Q;K7ZXc(I35-rewm zahYUXAc?DO#$s?GQx7@ecRHzB^-_P$?!O?q|Iy_<&>yVr*-2MXFz2Aa&31e}1}ghy zm+RDbkFIqxBlCy6{qenfK6fC~P-}Q~ee6^ulrePL$t$9OkzzB`M}1c3Dyz5)nezux z0(a;j(8IiQ^B)$D-X3%FgIBobR~Cj)lQ15gesrwX-o%Rde>B+((;Q~~4!g7i9l&Z# zg)KonJ`u@cT(_-s{=wRNHE>EO>JXOTtyf)`{D?O&WBiO2DCcmWTWn5@G3b10V|WHOE#+>Xu<^ zPR*s5!5Ud};&f6{f|cf7fp0dwW7r^o+5@T7PZEn}I2kG2_Z0ST^pue4jD7U}Q zVNnf4)(QWai0|SV`Y-aED^(IUc?*g;*1rNBge(`s1<&A-)m~BgK+0V}bK7)qb+)mW zErW}TjS-FW0~`iO0i*s$p(W__@^-TQNhQ>WvPdp|K0L8ORRB(I=Tjdo|A6%k_Add9 zr%|k+SR^rG8$auas2WY5T{xyig{>SbbO{HKf=9(R{x^2@37ldgkR|05?h5>B(f81C zmEWY^F`oiI$+8f2Yy%R@UyO_W4AiTMn8c{ZGR(c}_SfRvjmPUHsB$odL-bN+Mpy`m z#py`EeC|IaBn|3WzG2TnRdj9$xy5`Kn0|ZuKT-#QNH;l*Ir9iLcSe5~3wa2Utd(o3 za0Z=BXVKfF*K?7C;$t)T^7=Dvj+roaGM5yrdu!E6OJhdT2c7`M1q;8_IPU*kwfJOx^g01TQ}O!#6Pp z9Z$JiYyF#>!JC&~K+oyLL1|XT2rol{rH~Q4M1S3rN`L(34Yu#`M(( zS^f)Eg6Lyx8Sr&Mf>FR1-mBz<%JD#WeI}_Wg?Pq6>TT(>0A;4V!PAyft?%7<0 z>#4kvYf%HuxZ`Di=hfch5rG=)J^ymg_&I>wRuv($@U^yp*R4q%{upi)!V`gplVVL2 zs)WbZO~R{wtM?ekx#NUM^jY7yN1A7w2GA~)zykB@*oyt}qXt5Ql3vS6(AWp4dU^v< zHY0g-fO`IT4VKUKN=hN7kdUnnokK&34BJ{7aH6#W>cCoz%9v+Tcnu@r4`QsvxGGj*B zBNBkz&XkYsnbRhmbZ9_`3PYi9N%RvdDiDHLqjd(00T(yFVJ!4#CLHnX*C2NiaEih93?{~ z47lb9-hzOPMr19JUE~NPALXEWKa!(i7~c)q?b2&~o?O0IBr(Jk$tUmi<8z%O(A4+Crl^nulLN-|@sj zYm4RQu^eNR(+xTy&`HdIFf^JOJUTN+W^NOH9{2W#IltbToX&Sf;C&PCeq0t}9$nc! z^;tjZLbR1$laPWMS%PGQTo+07cAN{{pkLGP$pW}MV4_TNv}T0Zt`-L`Iwx@wQ{#9OY>&iP5a(kM5-qMMw}f4gjPdb0ww+TR?`URt-u7&;T7# z%mU^rdGo*VaWkP?B5^asxWBFkP?8QAH2}SB_LjrVYB2#clh(k^-~~>ae1M-d1J0a3 zna*WC&=(hQfxFW$SD6aB@5+8J5=)rbyePxhKb8|M^otVoJ$BTFc7^6CRhtx}h*lIC&}2+A*Dm??^$znytfj9V$U$hZ}0L zEHL~en#8!8qHowjpN<|NKY2=+A|7>MM&~Fk9G)tA~DPnyGVbf7P(Q zG1#@l#PO(o#voNksc8|#ik~Xb_crf#=6u>E%`3^8d;=8NNS&nWfo38GlI<+Y~8A86viE!)tk8uHQ zlatY;E(GR-+1WsNm3$W26XPKUpe%}%*)4v`73AakNq_ijkd0I{ivI~1f0bYB+zdw5P zpqK{Pm{*zffFQ!?8EjooOlSe;`6%_gmr&(P069P}>yvH%BWXPZhZ|?Twhw`hQ%)F+ zUtO+Jf;G?6c%5sMppTubqQVJ#B>vk?%KE8$O1YEhAkyFHwxR2!^RZu>4$f*)! zSB87{5;l^!3MhhXP0i{%DBp#s%w5hl>z4=n<&mUVO?P0$nugScb^*0%sMCT{Wo?$m zQV~hw4c+cl`C@(&vY+}FK_Yth@X(a5{<6~z7zfOEp^4(}gx6Au{6F)gLULqUr5hz) z*U}1C&99L&qfzL<_oX-tjK6Y-DVc#3RC?ips24UC@y^A{LooJ`7)~!4&g251MK-v5 zvf4Eo(%PMM<_{TSe0r8*t3c9jY#6rN4&bNojmJk4f+o^&uOOK9RaUSAHQ)q;!kjKA zl^|ZG*M^A{P^m;Z`hry^?pPww*2}sX=jJPsHlr@%MivC?P47%#&a%PA*xh~Go;zbl zam_C+J~tavel=_J$*UNbf#jPzI<~$95fT7;r?J0W51cOQkvk&HaIFuQw$U=dOO($#}YzT;nFP@sStlK**F;>O-VQt>7w?M>sIndH=Oq_leI99FADg|M(lc z+?yF^EV)vEO9**h{AK5}CEmJ63VPmuJZ{|j2;oTkfSuHv58!KI$c7&1(}mu^oKdVU6s;CuVZ0uLV8)2c%ggJLvx9SBg1nd8D0^aOEUFgRG8vQ*FTFoFR zZPn*&L+bq`(i6vPWzeyWX-?6ckG-U zxWu>cescIZPs_*{5~Vjz61$Fx+d06-Q+X8-;HyPGPrTGCJ@cR~o6tNlV?AR~+LW`A z4l47)GcQX4g)HY4qbdl9!CZ(sOtN*W8tvE z$wqzw4!k{6^JR;!B2cF6Gq$C_nXG%R;AWTNud$;a--5DPDZx(CL4sT7@|!KRw{MjO z;^i1QRKp|AxCybDRk>I;;1h{7em#mTT(#oBl|UIN>6n%w5!_1!o;PnsIs`;Vpd+=m z?Em}h=4H5Oslz}egph|&eFA9sCf3d#SRbxd`}CCGu*hNI=?1PUcwQ14euEh`3dU8| zoiW9%;{rVYAzW7d?9LUGAA^JRY92p(7sLnxksuZ2Uh0&z*fD1(uvi#823$rcH$|{s zzjhRY$>l9b)+j(45h90ZfoVI;*=*Q;zmv8Sv3YCop;=M_%XB4k8e zWhbM^pD9?z=pmTg#f|y#g0O51*5}^!U!`rirYDcF;u847=Xjj`fpR<~(Ge*rb zs?@Pz^{i)e6<2Mt13<<=dYtbl8H#?!Ptyg!W!VtMqZQsvOkNcDn{|EA|?@`ngHQsQvoG=+fT}{f^kak%%2$9z6uuMUWDxW$=PC zvDJQpI9;jVFIsD>SH4eqVApm}*%cz|5gBw1a6mNGUW`=irZmp*=VD(&Fo#9)gzCH( zf%~7Jgp2pz0%o%RW5>-Fm7Ky=5@^!ZF3;K47x2Xz-x2_>SqfV2rj)`)YNfHpt^p)(nAB(;mHz9RLWj#8VV+#VYp(8dJ`3v94K5$YB36Ki^pbalmdl3`zK_(<$Sv<+AZ^ThSr2xwSu~&+)btHHN4G<<_UEjxF+s<2qhOvg zdx;rWWg;3D!ax%_vnNW9u0n)dghVB%Z7gCA5H%X38+dN6p7dx%|IW)&;T7hid2J%h z1cbR5gP;T!m&aXDR>Z+>`*!H|{2++?><+72sMd_}4~gsh6b00Rp!yd_f;fOXBM zl+JT9qeS(fiqz4lKIi63Uv)`8(Q7Z0 zm$Fk{i%d=Jq+&<&AWsN^D^O77QE}=%khs-~H2Bq)eaVnAYziVj&dYrEDqko4yEy0K z*9QQ@^W%%jE{>^m6cXO#{l!PNB{KKD2j-K zn~O~ZcpA$q*{YdCnX9!+uY%A19dabC+a`7g(mhO*SX!q#AoPeLgJxF&Z;p!KJmr)J zHWiwBj#T;ORhImkdPt74KZi0E*iS2ogw@#)1o=);CJjyIBLg$Q_E!qqD5)aH8$p%mq*rJSeEB6orpX=EO`MZ-GT9Sptf?d=P( zX<`MAyx$qfwj3Xb9vW}bvXYe5DH&saaHLj?^cTO~ADa;^AU#r_^NFxvvZDk%MzZ4V zxhF7WtG(Ys>U1sVe8w2(y;UQym@RN7@#^0nv%Nc>#r!*N_Iw^idJ@dm+buboMniXF zV*#CUL16JNWN|PNICom)H)XdMQ_d;Z-E>*@lPJ5EjRrD71swxUdMGp-@bLftzyGhz cfb0Q|ME)ZN?Dq1~2S0X2c{RE6R~8}v2QH6-R{#J2 literal 0 HcmV?d00001 diff --git a/resources/ddi/personalized/BuildManifest.plist b/resources/ddi/personalized/BuildManifest.plist new file mode 100644 index 0000000000000000000000000000000000000000..e68b3271feea8496f7fab22cae0c8cd2332a3a0a GIT binary patch literal 801505 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 CJqU&X literal 0 HcmV?d00001 diff --git a/resources/ddi/personalized/Image.dmg.trustcache b/resources/ddi/personalized/Image.dmg.trustcache new file mode 100644 index 0000000000000000000000000000000000000000..dcec241e1a25ee8b8d25550276dbb6d9c5fb8019 GIT binary patch literal 1895 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.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" + } + } +}