docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
No files matched your search
@@ -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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# iOS Virtual Location & Route Simulator (macOS)
|
||||||
|
|
||||||
|
[中文](README.md) | [**English**](README.en.md)
|
||||||
|
|
||||||
|
A desktop tool for **virtual location** and **route simulation** on iPhone / iPad from a Mac: draw a route on the map, add waypoints, and drive the device's location along it at realistic speeds.
|
||||||
|
|
||||||
|
Supports **iOS 17 – iOS 27.2** (RSD tunnel + DVT `SimulateLocation`). The developer disk image assets are bundled inside the app, so **no Python installation is required**.
|
||||||
|
|
||||||
|
The UI is a local web app opened in a Chrome / Edge app window (no address bar — looks like a native app). You can also just open `http://127.0.0.1:8787` in a browser. The map, route engine, playback engine, and device communication all run locally; only map tiles and optional route planning need network access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quick Start
|
||||||
|
|
||||||
|
### Option A — Download the installer (recommended)
|
||||||
|
|
||||||
|
Grab `iOSLocationTrack-1.0.0.dmg` from [Releases](https://github.com/jbjf6666/ios-location-track/releases):
|
||||||
|
|
||||||
|
1. Open the DMG and drag `虚拟定位轨迹.app` into `Applications`
|
||||||
|
2. If macOS says *"unidentified developer"* on first launch → **right-click → Open → Open**
|
||||||
|
|
||||||
|
**No Python, no dependencies** — the Python runtime and every dependency are bundled inside the app (~80 MB).
|
||||||
|
Config and tile cache are written to `~/Library/Application Support/iOSLocationTrack/`.
|
||||||
|
|
||||||
|
> **Apple Silicon (M-series) only.** For Intel Macs, use the source option below, or run `./scripts/build_app.sh` on an Intel machine (the script is architecture-agnostic).
|
||||||
|
|
||||||
|
### Option B — Run from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jbjf6666/ios-location-track.git
|
||||||
|
cd ios-location-track
|
||||||
|
|
||||||
|
# First run creates a virtualenv and installs dependencies automatically
|
||||||
|
./scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI opens automatically. Equivalent CLI usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python main.py # normal launch (opens the UI)
|
||||||
|
./.venv/bin/python main.py --mock # no-device mock mode (demo the route/playback)
|
||||||
|
./.venv/bin/python main.py --port 8899 --no-browser
|
||||||
|
./.venv/bin/python main.py --window browser # open in the default browser instead
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three ways to run it
|
||||||
|
|
||||||
|
| Form | Path | Environment needed | Use case |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **DMG installer** | Release asset (45 MB) | **None** | Distribution / moving to another Mac |
|
||||||
|
| **Standalone app** | `dist/虚拟定位轨迹.app` (~80 MB extracted) | **None** — Python, deps, web UI, icon, disk image assets are all inside | Double-click and go |
|
||||||
|
| Source | `./scripts/run.sh` | Python 3.11+ (deps installed on first run) | Hacking on the code |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild everything in one shot: icon → bundle → ad-hoc sign → DMG → verify
|
||||||
|
./scripts/build_app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The DMG contains the app, an `Applications` shortcut (drag to install), and an install note.
|
||||||
|
To customize the DMG appearance, edit the constants at the top of `tools/make_dmg.py`.
|
||||||
|
|
||||||
|
> On first launch macOS will ask for permission to control Chrome/Edge (used to open the app window).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Supported iOS Versions
|
||||||
|
|
||||||
|
| Version | Supported | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **iOS 17 – 27.2** | ✅ | RSD tunnel + DVT. The app bundles the shared personalized disk image assets |
|
||||||
|
| iOS 16 and earlier | ❌ | Their developer disk images must be signed by Apple per OS build; third-party images are rejected by the device (verified: `Failed to verify the disk image signature`, and libimobiledevice fails identically) |
|
||||||
|
|
||||||
|
**Why one image covers everything:** starting with iOS 17, Apple switched to *personalized* developer disk images — a single base image (`Image.dmg`) plus `BuildManifest.plist`, **shared across all OS versions**. The actual signature is issued online by Apple's TSS server per device at mount time, so per-version assets are unnecessary. This tool bundles those assets (~16 MB); clicking **"Mount Developer Image"** works offline (only the signing step needs network).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Device Preparation (important)
|
||||||
|
|
||||||
|
| Requirement | Details |
|
||||||
|
| --- | --- |
|
||||||
|
| Cable connection | USB recommended; Wi-Fi drops easily when transferring large files |
|
||||||
|
| Unlock and **Trust** | Tap **Trust** on the *"Trust This Computer?"* prompt and enter your passcode |
|
||||||
|
| Enable Developer Mode | Settings → Privacy & Security → **Developer Mode** → on → **restart the phone** → confirm after reboot |
|
||||||
|
| Mount developer image | Click **"Mount Developer Image"** in the UI (assets are bundled), or let Xcode do it once |
|
||||||
|
| Close competing tools | i4Tools, 爱思助手, Xcode, etc. fight over the same RSD connection — **only one at a time** |
|
||||||
|
|
||||||
|
### Mounting the developer image
|
||||||
|
|
||||||
|
Click **"Mount Developer Image"** in the UI; progress streams into the log panel on the right. CLI equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check whether it's already mounted ( [] means not mounted )
|
||||||
|
./.venv/bin/python -m pymobiledevice3 mounter list
|
||||||
|
|
||||||
|
# Mount automatically
|
||||||
|
./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <YOUR_UDID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just use Xcode: connect the device → Window → Devices and Simulators → wait for *"Preparing device for development"*.
|
||||||
|
|
||||||
|
> With Xcode installed, the system's `CoreDeviceService` usually mounts a local image
|
||||||
|
> (`/Library/Developer/DeveloperDiskImages/`) automatically when the device is plugged in.
|
||||||
|
|
||||||
|
> The packaged `.app` performs mounting through pymobiledevice3's **Python API** — no command-line tools required.
|
||||||
|
|
||||||
|
### About the iOS 17+ tunnel (RSD)
|
||||||
|
|
||||||
|
Since iOS 17, location simulation no longer goes over usbmux; an **RSD tunnel** must be established first. Three options:
|
||||||
|
|
||||||
|
1. **Rootless (default, recommended)**: runs `pymobiledevice3 remote start-tunnel --native --script-mode`, borrowing Apple's own `remoted` tunnel. The packaged app does this by **re-invoking itself** (`<app> tunnel-helper`), so **no external Python is needed**.
|
||||||
|
2. **Admin mode**: click *"Rebuild tunnel as administrator"* in the UI (macOS password prompt), or run `sudo ./scripts/run.sh`.
|
||||||
|
3. **Manual RSD**: the *"Manual RSD"* button — enter `HOST PORT` obtained elsewhere (e.g. `tunneld`).
|
||||||
|
|
||||||
|
> `remote tunneld`, `lockdown start-tunnel`, and the classic `remote start-tunnel` all **require root**; this tool never silently escalates privileges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI Overview
|
||||||
|
|
||||||
|
### Top bar: device
|
||||||
|
- Name / model / iOS version / connection type (USB or network)
|
||||||
|
- **Tunnel** status, **virtual location** status, **Developer Mode** status
|
||||||
|
- *Refresh*, *Connect*, *Disconnect*
|
||||||
|
|
||||||
|
### Left panel: route settings
|
||||||
|
- **Travel mode**: driving / cycling / walking / flying (sets base speed and whether real roads are used)
|
||||||
|
- **Playback speed**: 0.1x – 50x (affects route progression only, not the send rate)
|
||||||
|
- **Loop**: return to start at the end
|
||||||
|
- **Realism simulation** (toggleable anytime, even mid-playback):
|
||||||
|
- **Location drift**: slow wandering that mimics GPS error, radius 0–50 m (default 4 m).
|
||||||
|
Implemented with multi-octave smoothed noise — continuous along the route with inertia, so it **never produces random jumps**.
|
||||||
|
- **Speed jitter**: instantaneous speed fluctuates smoothly around the setpoint, 0–60% (default ±8%).
|
||||||
|
- **Send-timing jitter** (on by default): a real phone's GPS does not fire exactly once per second — it jitters 0.85–1.15 s with occasional dropped samples (~2%). Perfectly uniform intervals are one of the easiest things to spot in the data.
|
||||||
|
- **Derive speed from displacement** (on by default): reported speed = actual displacement ÷ actual interval, matching how iOS derives `CLLocation.speed` from the coordinate stream, so **speed, displacement and timestamps stay mutually consistent**.
|
||||||
|
- **Inject gross errors** (off by default): probabilistic bad fixes (multipath / urban canyon), for testing your own app's `horizontalAccuracy` filtering and outlier rejection.
|
||||||
|
- All toggles and magnitudes persist across restarts.
|
||||||
|
- **Jump to coordinate**: type a lat/lng directly; save favourite places
|
||||||
|
- **Route summary**: total distance, estimated duration, path point count, waypoint count, route engine (OSRM real roads / straight line)
|
||||||
|
- **Waypoint list**: drag to reorder, double-click to edit coordinates, delete, centre on point
|
||||||
|
- **Saved routes**: save / load / delete (browser local storage)
|
||||||
|
- **Base map**: AMap vector (GCJ-02, Chinese labels) / AMap satellite / global satellite (Esri) / OpenStreetMap
|
||||||
|
- Actions: *Load OSRM route*, *Clear*, *Export GPX*, *Import GPX*, *Restore real location*
|
||||||
|
- Tile cache: shows directory and size, plus *Clear tile cache*
|
||||||
|
|
||||||
|
### Map toolbar
|
||||||
|
- **Select**: pan the map, drag waypoints, click to edit
|
||||||
|
- **Add waypoint**: click repeatedly on the map to add points (the most common way to draw)
|
||||||
|
- **Draw polyline**: click to draw, double-click or Esc to finish
|
||||||
|
- **Undo**, **Delete selected point**, **Fit to route**
|
||||||
|
|
||||||
|
### Bottom bar: playback
|
||||||
|
- Play / Pause / Resume / Stop
|
||||||
|
- Scrubbable progress bar (seek)
|
||||||
|
- Elapsed time, remaining time, sample index
|
||||||
|
- Live speed, current coordinates, playback rate
|
||||||
|
|
||||||
|
### Right panel: log
|
||||||
|
Live log of device connection, tunnel setup, image mounting, and every location push — makes troubleshooting much easier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Typical Workflows
|
||||||
|
|
||||||
|
**A. Run a route along real roads**
|
||||||
|
|
||||||
|
1. Connect the device → *Connect* (iOS 17+ builds the tunnel automatically, up to ~1 minute)
|
||||||
|
2. Click *Add waypoint* and place start / intermediate / end points (at least 2)
|
||||||
|
3. Click *Load OSRM route* → real road-network polyline (falls back to a straight line offline)
|
||||||
|
4. Set *Playback speed* (e.g. 20x completes 10 km in a few seconds)
|
||||||
|
5. Click *Play* → the phone's location moves along the route; map marker and progress update live
|
||||||
|
6. Click *Stop* → the phone immediately returns to its real location
|
||||||
|
|
||||||
|
**B. Pin to a single location**
|
||||||
|
|
||||||
|
With the *Select* tool, double-click anywhere on the map, or use *Jump to coordinate*. Click *Restore real location* when done.
|
||||||
|
|
||||||
|
**C. Import an existing track**
|
||||||
|
|
||||||
|
Click *Import GPX* and pick a `.gpx` file → the track appears on the map → play it. Exported GPX files also import into Google Earth, Strava, etc.
|
||||||
|
|
||||||
|
**D. Preview without a device**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python main.py --mock
|
||||||
|
```
|
||||||
|
The UI, route drawing, playback engine and progress bar all work; locations just aren't pushed to a phone (the top bar shows *"mock mode"*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CLI Tools and Self-Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List devices + Developer Mode + DVT availability
|
||||||
|
./.venv/bin/python tools/probe_device.py
|
||||||
|
./.venv/bin/python tools/probe_device.py --probe # actually open a DVT session
|
||||||
|
|
||||||
|
# One-shot virtual location from the CLI
|
||||||
|
./.venv/bin/python tools/probe_location.py --lat 39.9088 --lon 116.3975
|
||||||
|
./.venv/bin/python tools/probe_location.py --clear # restore real location
|
||||||
|
|
||||||
|
# Offline self-test: 83 unit + end-to-end checks (no device needed)
|
||||||
|
./.venv/bin/python tests/selftest.py
|
||||||
|
|
||||||
|
# Realism model (GPS drift + speed jitter + timing jitter, 40 checks)
|
||||||
|
./.venv/bin/python tests/drift_sim.py
|
||||||
|
|
||||||
|
# Tile disk cache (13 checks) / browser-level offline tiles (9 checks)
|
||||||
|
./.venv/bin/python tests/offline_tiles.py
|
||||||
|
./.venv/bin/python tests/offline_browser.py
|
||||||
|
|
||||||
|
# UI acceptance (20 checks, drives a real browser)
|
||||||
|
./.venv/bin/python tests/ui_acceptance.py
|
||||||
|
|
||||||
|
# Verify the packaged standalone app: clean-env launch + all endpoints (22 checks)
|
||||||
|
./.venv/bin/python tools/verify_bundle.py
|
||||||
|
|
||||||
|
# Real-device end-to-end: actually moves the phone's location, restores afterwards
|
||||||
|
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 5 --speed 20
|
||||||
|
|
||||||
|
# Screenshot the UI (visual acceptance)
|
||||||
|
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8787/ /tmp/ui.png --wait 9
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging helpers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python tools/make_icon.py # generate the app icon (drawn programmatically)
|
||||||
|
./.venv/bin/python tools/fetch_ddi.py # fetch developer disk image assets
|
||||||
|
./.venv/bin/python tools/make_dmg.py # build the DMG
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause and fix |
|
||||||
|
| --- | --- |
|
||||||
|
| UI says "local service not connected" | Server didn't start or the port is taken. Check the terminal; try `--port 8899` |
|
||||||
|
| No device detected | Cable + unlocked + tap **Trust** |
|
||||||
|
| Prompted for Developer Mode | Settings → Privacy & Security → Developer Mode → on, then restart the phone |
|
||||||
|
| "Mount Developer Image" fails with a signature error | The device runs iOS 16 or earlier (unsupported); or the network is blocking Apple TSS. Check connectivity and retry |
|
||||||
|
| Tunnel-related errors when connecting | ① Close i4Tools / 爱思助手 (they steal the RSD connection); ② use *Rebuild tunnel as administrator*; ③ confirm the developer image is mounted |
|
||||||
|
| Location doesn't change on the phone | Make sure the UI shows "simulating"; on iOS 17+ the tunnel must be healthy |
|
||||||
|
| Blank map | Tile source blocked — switch base map (AMap satellite / Esri). **Previously viewed areas work offline** (tiles cached in `~/.ios-location-tiles`, 1 GB cap, LRU eviction) |
|
||||||
|
| Location snaps back right after the tunnel is up | Another program (i4Tools / Xcode) is fighting for the same RSD connection — close it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementation Notes
|
||||||
|
|
||||||
|
- **Transport**: Python stdlib `http.server` plus a hand-written WebSocket (RFC 6455) — no web framework. Device side uses `pymobiledevice3`.
|
||||||
|
- **Virtual location**: DVT service `com.apple.instruments.dtservicehub` (iOS 17+, over the RSD tunnel), calling `LocationSimulation.set(lat, lon)` / `clear()`.
|
||||||
|
**The connection must stay open** — once the DVT session drops, simulated location stops working. That's why the playback engine can't reconnect per point.
|
||||||
|
- **Playback engine**: advances by arc-length integration; the speed profile is generated from turn angles (slower in sharp corners) and then smoothed, so acceleration feels natural. A dedicated thread with a single in-flight request guarantees strictly ordered delivery.
|
||||||
|
- **Realism model**: drift uses **multi-octave value noise** (120 m slow + 30 m medium + 15 m fast correlation lengths, approximating the power-law spectrum of real GPS error), normalized to the configured radius and clipped at 1.8x; consecutive fixes transition smoothly, so there are no random-jump spikes.
|
||||||
|
- **Coordinates**: everything internal and backend-side is **WGS84**; AMap tiles are GCJ-02, converted in the frontend both for tile requests and marker placement — otherwise the marker and the actual location would be ~500 m apart.
|
||||||
|
- **Tiles**: server-side proxy plus disk cache (`~/.ios-location-tiles`, 1 GB cap, LRU). Fully transparent "empty tiles" returned by providers are detected and discarded.
|
||||||
|
- **Routing**: prefers OSRM real roads (`router.project-osrm.org`); falls back to a straight line offline and labels the engine type on the map.
|
||||||
|
- **Standalone packaging**: PyInstaller onedir + ad-hoc signature. Tunnel establishment uses **self-invocation** (`<app binary> tunnel-helper`), so the packaged app needs no Python on the machine.
|
||||||
|
- **Protocol**: see [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
||||||
|
- **Windows port**: see [docs/WINDOWS_PORT.md](docs/WINDOWS_PORT.md) (portability audit, tunnel
|
||||||
|
replacement, packaging changes, acceptance baseline). Roughly 90% of the core logic is reusable;
|
||||||
|
only tunnel establishment and the distribution pipeline need rewriting. (Written in Chinese.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Known Limitations
|
||||||
|
|
||||||
|
- **DVT can only set latitude/longitude**: `simulateLocationWithLatitude:longitude:` takes exactly two arguments. `horizontalAccuracy`, `verticalAccuracy`, `altitude`, `floor`, `sourceInformation.isSimulatedBySoftware`, and all accelerometer / gyroscope / pedometer data are either synthesized by iOS or come from hardware — **they cannot be injected over the DVT channel**. This is a shared boundary of every tool in this category, including Xcode's own location simulation.
|
||||||
|
- **Some apps are unaffected**: apps using a separate location channel, or performing server-side kinematic cross-validation, may either ignore the simulated location or accept it and discard the data.
|
||||||
|
- **Only one tool can hold the tunnel at a time**: an iOS 17+ device exposes a single RSD connection, so i4Tools / Xcode and this tool will kick each other off.
|
||||||
|
- The image-signing step requires reachable Apple TSS servers.
|
||||||
|
- Map tiles come from public services and are intended for personal testing only — respect their terms of service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Acceptable Use
|
||||||
|
|
||||||
|
This tool is intended for **software testing, development debugging, and regression verification of location-dependent features**, for example:
|
||||||
|
|
||||||
|
- Testing how your app renders and behaves in different cities or countries
|
||||||
|
- Verifying edge cases in route replay, geofencing, and check-in style features
|
||||||
|
- Demos and teaching
|
||||||
|
|
||||||
|
Do not use it to deceive others, falsify travel records, evade attendance systems, or for any unlawful purpose. You are responsible for any consequences of using this tool.
|
||||||
|
|
||||||
|
In particular: this tool **does not** provide any capability to bypass third-party anti-cheat or risk-control systems. If you need to test features that depend on altitude, barometer, or pedometer data, the only reliable approach is to add a location-injection hook to a Debug build of your own app and construct a complete `CLLocation` yourself.
|
||||||
@@ -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`。
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""无设备模拟定位提供者(``--mock`` 模式)。
|
||||||
|
|
||||||
|
契约(冻结,见 docs/PROTOCOL.md 第 4 节与 docs/DEVICE_NOTES.md):
|
||||||
|
|
||||||
|
class MockLocationProvider:
|
||||||
|
async def connect(self, device) -> dict
|
||||||
|
async def set_location(self, lat, lon) -> None
|
||||||
|
async def clear_location(self) -> None
|
||||||
|
async def close(self) -> None
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool
|
||||||
|
|
||||||
|
行为:只记录日志、绝不报错,每次下发延时 5~10ms 模拟真机往返。
|
||||||
|
本模块**不依赖 pymobiledevice3**,任何平台都能 import。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
__all__ = ["MockLocationProvider", "MOCK_UDID", "MOCK_IOS_VERSION", "MOCK_IOS_MAJOR"]
|
||||||
|
|
||||||
|
logger = logging.getLogger("iosloc.location.mock")
|
||||||
|
|
||||||
|
#: 与 docs/PROTOCOL.md 第 4 节一致的假设备标识
|
||||||
|
MOCK_UDID = "MOCK-0001"
|
||||||
|
MOCK_IOS_VERSION = "26.0"
|
||||||
|
MOCK_IOS_MAJOR = 26
|
||||||
|
|
||||||
|
#: 模拟单次下发往返耗时(秒),遵循任务书 5~10ms 的要求
|
||||||
|
MOCK_LATENCY_MIN = 0.005
|
||||||
|
MOCK_LATENCY_MAX = 0.010
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_mock_device_fields(device: Any) -> dict:
|
||||||
|
"""从 lead 的 DeviceManager 返回的 Device dict 中提取字段(容错)。
|
||||||
|
|
||||||
|
允许 ``device`` 为 ``None`` / dict / 具有同名属性的对象。
|
||||||
|
"""
|
||||||
|
fields = {
|
||||||
|
"udid": MOCK_UDID,
|
||||||
|
"name": "模拟设备 (iPhone 17 Pro)",
|
||||||
|
"ios_version": MOCK_IOS_VERSION,
|
||||||
|
"ios_major": MOCK_IOS_MAJOR,
|
||||||
|
"connection": "mock",
|
||||||
|
}
|
||||||
|
if device is None:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def _get(key: str) -> Any:
|
||||||
|
if isinstance(device, dict):
|
||||||
|
return device.get(key)
|
||||||
|
return getattr(device, key, None)
|
||||||
|
|
||||||
|
udid = _get("udid")
|
||||||
|
if udid:
|
||||||
|
fields["udid"] = str(udid)
|
||||||
|
name = _get("name")
|
||||||
|
if name:
|
||||||
|
fields["name"] = str(name)
|
||||||
|
ios_version = _get("ios_version")
|
||||||
|
if ios_version:
|
||||||
|
fields["ios_version"] = str(ios_version)
|
||||||
|
ios_major = _get("ios_major")
|
||||||
|
if isinstance(ios_major, int) and ios_major > 0:
|
||||||
|
fields["ios_major"] = ios_major
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class MockLocationProvider:
|
||||||
|
"""假定位提供者:接口与 :class:`core.location_provider.RealLocationProvider` 完全一致。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: Any = None,
|
||||||
|
on_log: Optional[Callable[[str, str], None]] = None,
|
||||||
|
latency: Optional[tuple] = None,
|
||||||
|
log: Optional[Callable[[str, str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param device: 可选,``DeviceManager`` 返回的 Device dict(仅用于取 udid/版本信息)。
|
||||||
|
:param on_log: 可选日志回调 ``(level, msg)``,供服务端把日志推给前端。
|
||||||
|
:param latency: 可选 ``(min, max)`` 秒;默认 5~10ms。
|
||||||
|
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``MockLocationProvider(log=self.log)``)。
|
||||||
|
"""
|
||||||
|
self._device = device
|
||||||
|
self._on_log = log or on_log
|
||||||
|
self._latency = latency or (MOCK_LATENCY_MIN, MOCK_LATENCY_MAX)
|
||||||
|
self._connected = False
|
||||||
|
self._info: dict = {}
|
||||||
|
self._last_location: Optional[tuple] = None
|
||||||
|
self._send_count = 0
|
||||||
|
self._last_log_at = 0.0
|
||||||
|
self._started_at = time.time()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 日志
|
||||||
|
def _log(self, level: str, msg: str) -> None:
|
||||||
|
text = f"[模拟模式] {msg}"
|
||||||
|
getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)(text)
|
||||||
|
if self._on_log is not None:
|
||||||
|
try:
|
||||||
|
self._on_log(level, text)
|
||||||
|
except Exception: # 日志失败绝不能影响定位流程
|
||||||
|
logger.exception("on_log 回调异常(已忽略)")
|
||||||
|
|
||||||
|
async def _sleep_roundtrip(self) -> None:
|
||||||
|
"""模拟真机 USB/DVT 往返耗时 5~10ms。"""
|
||||||
|
low, high = self._latency
|
||||||
|
delay = random.uniform(float(low), float(high))
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 契约接口
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
"""是否已「连接」(mock 下 connect 成功后恒为 True)。"""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_location(self) -> Optional[tuple]:
|
||||||
|
"""最近一次下发的 ``(lat, lon)``,未下发过为 ``None``。"""
|
||||||
|
return self._last_location
|
||||||
|
|
||||||
|
@property
|
||||||
|
def send_count(self) -> int:
|
||||||
|
"""累计下发次数(便于自测断言)。"""
|
||||||
|
return self._send_count
|
||||||
|
|
||||||
|
async def connect(self, device: Any = None) -> dict:
|
||||||
|
"""「连接」假设备,永远成功,不抛异常。"""
|
||||||
|
if device is not None:
|
||||||
|
self._device = device
|
||||||
|
self._info = _detect_mock_device_fields(self._device)
|
||||||
|
await self._sleep_roundtrip()
|
||||||
|
self._connected = True
|
||||||
|
self._log("success", f"已连接{self._info['name']}(udid={self._info['udid']},iOS {self._info['ios_version']})")
|
||||||
|
self._log("info", "模拟模式:定位下发仅记录日志,不会真正修改设备位置")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"mock": True,
|
||||||
|
"udid": self._info["udid"],
|
||||||
|
"name": self._info["name"],
|
||||||
|
"ios_version": self._info["ios_version"],
|
||||||
|
"ios_major": self._info["ios_major"],
|
||||||
|
"connection": "mock",
|
||||||
|
"tunnel": "connected",
|
||||||
|
"detail": f"模拟设备已连接(iOS {self._info['ios_version']},无需隧道)",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def set_location(self, lat: float, lon: float) -> None:
|
||||||
|
"""记录一次单点定位;永远成功。"""
|
||||||
|
lat = float(lat)
|
||||||
|
lon = float(lon)
|
||||||
|
await self._sleep_roundtrip()
|
||||||
|
if not self._connected:
|
||||||
|
# mock 模式刻意不报错:自动补一次 connect,保证端到端流程永远走得通
|
||||||
|
self._log("warn", "尚未 connect 就收到 set_location,已自动补建模拟会话")
|
||||||
|
await self.connect(self._device)
|
||||||
|
self._last_location = (lat, lon)
|
||||||
|
self._send_count += 1
|
||||||
|
# 播放时按 5Hz 下发,逐次刷日志会淹没界面(服务端播放引擎已有"每 5 秒聚合"日志),
|
||||||
|
# 这里同样做节流:每 25 次或每 5 秒才记一条。
|
||||||
|
_now = time.time()
|
||||||
|
if (
|
||||||
|
self._send_count <= 3
|
||||||
|
or self._send_count % 25 == 0
|
||||||
|
or (_now - self._last_log_at) >= 5.0
|
||||||
|
):
|
||||||
|
self._last_log_at = _now
|
||||||
|
self._log("info", f"下发定位 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次,模拟)")
|
||||||
|
|
||||||
|
async def clear_location(self) -> None:
|
||||||
|
"""「恢复真实定位」;永远成功。"""
|
||||||
|
await self._sleep_roundtrip()
|
||||||
|
if self._last_location is not None:
|
||||||
|
lat, lon = self._last_location
|
||||||
|
self._log("info", f"清除模拟定位,设备恢复真实定位(上次 lat={lat:.6f}, lon={lon:.6f},模拟)")
|
||||||
|
else:
|
||||||
|
self._log("info", "清除模拟定位,设备恢复真实定位(此前未下发过,模拟)")
|
||||||
|
self._last_location = None
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""关闭会话;永远成功、幂等。"""
|
||||||
|
if not self._connected:
|
||||||
|
return
|
||||||
|
await self._sleep_roundtrip()
|
||||||
|
self._connected = False
|
||||||
|
self._log("info", "模拟定位会话已关闭")
|
||||||
|
|
||||||
|
def connect_sync(self, runner: Any, device: Any = None) -> dict:
|
||||||
|
"""同步建立会话(供同步代码提交给 ``AsyncRunner``,绝不新建事件循环)。
|
||||||
|
|
||||||
|
``core/server.py`` 的 mock 分支就是 ``provider.connect_sync(self.runner)``。
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
return dict(self._info)
|
||||||
|
run = getattr(runner, "run", None)
|
||||||
|
if not callable(run):
|
||||||
|
raise RuntimeError("传入的 runner 不支持 run(coro),无法同步建立模拟会话")
|
||||||
|
return run(self.connect(device))
|
||||||
|
|
||||||
|
async def probe(self) -> dict:
|
||||||
|
"""「测试连接」按钮用:mock 下恒为可用。"""
|
||||||
|
major = int(self._info.get("ios_major") or MOCK_IOS_MAJOR)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"detail": f"模拟模式:DVT 虚拟定位可用(iOS {major},无需隧道)",
|
||||||
|
"ios_major": major,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 辅助
|
||||||
|
def __repr__(self) -> str: # pragma: no cover - 调试用
|
||||||
|
return (
|
||||||
|
f"<MockLocationProvider connected={self._connected} "
|
||||||
|
f"udid={self._info.get('udid')!r} sends={self._send_count}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""运行统计(供 /api/health 或调试使用)。"""
|
||||||
|
return {
|
||||||
|
"mock": True,
|
||||||
|
"connected": self._connected,
|
||||||
|
"send_count": self._send_count,
|
||||||
|
"last_location": self._last_location,
|
||||||
|
"started_at": getattr(self, "_started_at", None) or time.time(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
"""真机虚拟定位后端:pymobiledevice3 DVT / 老版 simulatelocation。
|
||||||
|
|
||||||
|
冻结契约(docs/PROTOCOL.md 5 节、task-2):
|
||||||
|
|
||||||
|
class RealLocationProvider:
|
||||||
|
def __init__(self, rsd_host=None, rsd_port=None, udid=None) # 可加可选关键字参数
|
||||||
|
async def connect(self, device) -> dict
|
||||||
|
async def set_location(self, lat, lon) -> None
|
||||||
|
async def clear_location(self) -> None
|
||||||
|
async def close(self) -> None
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool
|
||||||
|
async def probe(self) -> dict # {"ok":bool,"detail":中文,"ios_major":int}
|
||||||
|
|
||||||
|
所有 API 调用均取自 pymobiledevice3 **真实源码**(版本与出处见 docs/DEVICE_NOTES.md):
|
||||||
|
|
||||||
|
* ``pymobiledevice3/services/dvt/instruments/dvt_provider.py:8`` ``class DvtProvider``
|
||||||
|
* ``pymobiledevice3/services/dvt/instruments/location_simulation.py:17`` ``class LocationSimulation``
|
||||||
|
(``set(latitude, longitude)`` / ``clear()``,async context manager)
|
||||||
|
* ``pymobiledevice3/dtx_service_provider.py:80`` ``DvtProvider.service_name_for()`` 自动在
|
||||||
|
RSD(``com.apple.instruments.dtservicehub``,iOS17+)与 lockdown
|
||||||
|
(``com.apple.instruments.remoteserver.DVTSecureSocketProxy``,iOS<17)之间选择。
|
||||||
|
* ``pymobiledevice3/remote/remote_service_discovery.py:103`` ``RemoteServiceDiscoveryService(address)``
|
||||||
|
* ``pymobiledevice3/services/simulate_location.py:8`` ``DtSimulateLocation``(iOS<17 老服务
|
||||||
|
``com.apple.dt.simulatelocation``,作为 DVT 失败时的兜底)
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
* **长连接**:``connect()`` 建立一次 DVT 会话,之后 ``set_location`` 复用同一个 DTX channel,
|
||||||
|
绝不每次重连(DVT 定位在连接关闭后即失效,见 DEVICE_NOTES.md 的 CLI 取证)。
|
||||||
|
* **同一事件循环**:全部为原生 asyncio,不开线程、不新建事件循环。
|
||||||
|
* **友好降级**:pymobiledevice3 缺失 / 隧道未建立 / 无设备 / 未开开发者模式,都抛带中文说明的
|
||||||
|
:class:`LocationProviderError`,不会暴露 AttributeError 之类的内部错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RealLocationProvider",
|
||||||
|
"LocationProviderError",
|
||||||
|
"pymobiledevice3_available",
|
||||||
|
"PMD3_MISSING_HINT",
|
||||||
|
]
|
||||||
|
|
||||||
|
logger = logging.getLogger("iosloc.location.real")
|
||||||
|
|
||||||
|
#: 未安装 pymobiledevice3 时的中文修复建议(含本机实测可用的阿里云镜像)
|
||||||
|
PMD3_MISSING_HINT = (
|
||||||
|
"未检测到 pymobiledevice3 库。请在项目目录执行:"
|
||||||
|
"./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ pymobiledevice3"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: iOS 17 是「必须走 RSD 隧道」的分界线
|
||||||
|
RSD_REQUIRED_IOS_MAJOR = 17
|
||||||
|
|
||||||
|
#: 根目录 README/run.sh 的隧道提示(PROTOCOL.md 的 tunnel_hint 语义)
|
||||||
|
TUNNEL_HINT = (
|
||||||
|
"iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位:"
|
||||||
|
"请以管理员权限运行 run.sh(sudo ./run.sh);"
|
||||||
|
"macOS 上也可用免 root 的隧道:"
|
||||||
|
"./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationProviderError(RuntimeError):
|
||||||
|
"""定位后端异常,``str(e)`` 一律是可直接展示给用户的中文信息。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pymobiledevice3 懒加载:未安装时 import core.location_provider 仍然成功
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_pmd3_cache: Optional[dict] = None
|
||||||
|
_pmd3_error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _import_pymobiledevice3() -> dict:
|
||||||
|
"""惰性导入 pymobiledevice3(成功结果缓存;失败抛 :class:`LocationProviderError`)。"""
|
||||||
|
global _pmd3_cache, _pmd3_error
|
||||||
|
if _pmd3_cache is not None:
|
||||||
|
return _pmd3_cache
|
||||||
|
try:
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
|
||||||
|
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
|
||||||
|
from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
|
||||||
|
from pymobiledevice3.services.simulate_location import DtSimulateLocation
|
||||||
|
from pymobiledevice3 import usbmux as usbmux_module
|
||||||
|
except Exception as exc: # ImportError / 依赖缺失 / 版本不兼容
|
||||||
|
_pmd3_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.error("导入 pymobiledevice3 失败:%s", _pmd3_error)
|
||||||
|
raise LocationProviderError(f"{PMD3_MISSING_HINT}(详细错误:{_pmd3_error})") from exc
|
||||||
|
_pmd3_cache = {
|
||||||
|
"create_using_usbmux": create_using_usbmux,
|
||||||
|
"RemoteServiceDiscoveryService": RemoteServiceDiscoveryService,
|
||||||
|
"DvtProvider": DvtProvider,
|
||||||
|
"LocationSimulation": LocationSimulation,
|
||||||
|
"DtSimulateLocation": DtSimulateLocation,
|
||||||
|
"usbmux": usbmux_module,
|
||||||
|
}
|
||||||
|
return _pmd3_cache
|
||||||
|
|
||||||
|
|
||||||
|
def pymobiledevice3_available() -> bool:
|
||||||
|
"""pymobiledevice3 是否可用(不抛异常,供 /api/health、UI 提示使用)。"""
|
||||||
|
try:
|
||||||
|
_import_pymobiledevice3()
|
||||||
|
except LocationProviderError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def pymobiledevice3_version() -> Optional[str]:
|
||||||
|
"""返回已安装的 pymobiledevice3 版本号,取不到返回 ``None``。"""
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
return version("pymobiledevice3")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 小工具
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _parse_ios_major(value: Any) -> int:
|
||||||
|
"""把 ``"26.0"`` / ``26`` / ``"17.4.1"`` 解析成主版本号;失败返回 0。"""
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
head = text.split(".", 1)[0]
|
||||||
|
try:
|
||||||
|
return int(head)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _device_field(device: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if device is None:
|
||||||
|
return default
|
||||||
|
if isinstance(device, dict):
|
||||||
|
value = device.get(key, default)
|
||||||
|
else:
|
||||||
|
value = getattr(device, key, default)
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(action: str, exc: BaseException) -> LocationProviderError:
|
||||||
|
"""把底层异常翻译成中文可读信息。"""
|
||||||
|
if isinstance(exc, LocationProviderError):
|
||||||
|
return exc
|
||||||
|
name = type(exc).__name__
|
||||||
|
text = str(exc) or name
|
||||||
|
low = text.lower()
|
||||||
|
|
||||||
|
# StartServiceError 的 __init__ 不把信息传给基类(exceptions.py:125-129),str() 为空
|
||||||
|
if name == "StartServiceError":
|
||||||
|
service_name = getattr(exc, "service_name", "?")
|
||||||
|
message = getattr(exc, "message", "") or ""
|
||||||
|
text = f"StartServiceError({service_name}): {message}"
|
||||||
|
low = text.lower()
|
||||||
|
|
||||||
|
if isinstance(exc, (ConnectionRefusedError,)) or "connection refused" in low or "errno 61" in low:
|
||||||
|
detail = "无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立"
|
||||||
|
elif isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or "timed out" in low:
|
||||||
|
detail = "操作超时,设备可能未响应或隧道已断开"
|
||||||
|
elif "developerdiskimage" in low or "developer disk image" in low or "mounter mount" in low:
|
||||||
|
detail = (
|
||||||
|
"设备未挂载开发者镜像(DeveloperDiskImage),DVT 服务不可用。"
|
||||||
|
"请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount"
|
||||||
|
)
|
||||||
|
elif "developer mode" in low or "developermode" in low:
|
||||||
|
detail = "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(需重启设备)"
|
||||||
|
elif "invalidservice" in low or "no such service" in low or "startserviceerror" in low:
|
||||||
|
detail = (
|
||||||
|
"设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,请执行:"
|
||||||
|
"./.venv/bin/python -m pymobiledevice3 mounter auto-mount;"
|
||||||
|
"iOS 17+ 则需要 RSD 隧道(隧道提示见下)"
|
||||||
|
)
|
||||||
|
elif "not paired" in low or "notpaired" in low or "pair" in low and "invalid" in low:
|
||||||
|
detail = "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」,或先执行 pymobiledevice3 lockdown pair"
|
||||||
|
elif "no device" in low or "nodeviceconnected" in low:
|
||||||
|
detail = "未发现 iOS 设备:请用 USB 连接 iPhone,解锁并在手机上点击「信任」"
|
||||||
|
else:
|
||||||
|
detail = f"底层错误:{name}: {text}"
|
||||||
|
return LocationProviderError(f"{action}失败:{detail}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 真机 Provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class RealLocationProvider:
|
||||||
|
"""真机定位提供者。兼容 iOS<17(lockdown 直连 DVT)与 iOS≥17(RSD 隧道 + DVT)。
|
||||||
|
|
||||||
|
策略(``self.strategy``):
|
||||||
|
|
||||||
|
``dvt-rsd``
|
||||||
|
iOS ≥ 17 且提供了 RSD 地址(或注入了已连接的 RSD 对象):
|
||||||
|
``RemoteServiceDiscoveryService((host, port))`` → ``DvtProvider(rsd)`` →
|
||||||
|
``LocationSimulation``。
|
||||||
|
``dvt-lockdown``
|
||||||
|
没有 RSD 地址时走 usbmux/lockdown:``create_using_usbmux(serial=udid)`` →
|
||||||
|
``DvtProvider(lockdown)`` → ``LocationSimulation``(适用于 iOS<17)。
|
||||||
|
``legacy``
|
||||||
|
``dvt-lockdown`` 失败时的兜底:``DtSimulateLocation``(老服务
|
||||||
|
``com.apple.dt.simulatelocation``,仅 iOS<17)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rsd_host: Optional[str] = None,
|
||||||
|
rsd_port: Optional[int] = None,
|
||||||
|
udid: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
rsd: Any = None,
|
||||||
|
lockdown: Any = None,
|
||||||
|
connect_timeout: float = 15.0,
|
||||||
|
on_log: Optional[Callable[[str, str], None]] = None,
|
||||||
|
log: Optional[Callable[[str, str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param rsd_host: RSD 隧道主机(由 lead 的 core/tunnel.py 提供),无隧道时为 ``None``。
|
||||||
|
:param rsd_port: RSD 隧道端口。
|
||||||
|
:param udid: 目标设备 UDID;``None`` 表示用第一台 USB 设备。
|
||||||
|
:param rsd: **可选扩展**:已连接好的 ``RemoteServiceDiscoveryService`` 对象。用于把免 root 的
|
||||||
|
进程内 userspace 隧道(``UserspaceRsdTunnel``)直接注入进来;给了之后就忽略 host/port。
|
||||||
|
:param lockdown: **可选扩展**:已连接好的 ``LockdownClient``,避免重复建连。
|
||||||
|
:param connect_timeout: 建立会话的超时秒数。
|
||||||
|
:param on_log: 日志回调 ``(level, msg)``,供服务端推给前端。
|
||||||
|
:param log: ``on_log`` 的别名(``core/server.py`` 用 ``RealLocationProvider(..., log=self.log)``)。
|
||||||
|
"""
|
||||||
|
self._rsd_host = rsd_host
|
||||||
|
self._rsd_port = int(rsd_port) if rsd_port else None
|
||||||
|
self._udid = udid
|
||||||
|
self._injected_rsd = rsd
|
||||||
|
self._injected_lockdown = lockdown
|
||||||
|
self._connect_timeout = float(connect_timeout)
|
||||||
|
self._on_log = log or on_log
|
||||||
|
|
||||||
|
self._connected = False
|
||||||
|
self._strategy: Optional[str] = None
|
||||||
|
self._session_info: dict = {}
|
||||||
|
self._last_location: Optional[tuple] = None
|
||||||
|
self._send_count = 0
|
||||||
|
self._connected_at: Optional[float] = None
|
||||||
|
|
||||||
|
# pymobiledevice3 对象
|
||||||
|
self._rsd_obj: Any = None
|
||||||
|
self._lockdown_obj: Any = None
|
||||||
|
self._dvt: Any = None
|
||||||
|
self._location: Any = None
|
||||||
|
self._legacy: Any = None
|
||||||
|
|
||||||
|
# 连接过程中从设备侧探测到的真实信息
|
||||||
|
self._detected_version: Optional[str] = None
|
||||||
|
self._detected_udid: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 日志
|
||||||
|
def _log(self, level: str, msg: str) -> None:
|
||||||
|
text = f"[真机] {msg}"
|
||||||
|
log_fn = getattr(logger, level if level in ("debug", "info", "warning", "error") else "info", logger.info)
|
||||||
|
log_fn(text)
|
||||||
|
if self._on_log is not None:
|
||||||
|
try:
|
||||||
|
self._on_log(level, text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("on_log 回调异常(已忽略)")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- 只读属性
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
"""会话是否已建立(DVT channel 已就绪)。"""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strategy(self) -> Optional[str]:
|
||||||
|
"""当前使用的策略:``dvt-rsd`` / ``dvt-lockdown`` / ``legacy``。"""
|
||||||
|
return self._strategy
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rsd_address(self) -> Optional[tuple]:
|
||||||
|
"""当前 RSD 地址 ``(host, port)``,没有则为 ``None``。"""
|
||||||
|
if self._rsd_host and self._rsd_port:
|
||||||
|
return (self._rsd_host, self._rsd_port)
|
||||||
|
if self._rsd_obj is not None:
|
||||||
|
address = getattr(getattr(self._rsd_obj, "service", None), "address", None)
|
||||||
|
if address:
|
||||||
|
return (address[0], int(address[1]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_location(self) -> Optional[tuple]:
|
||||||
|
"""最近一次成功下发的 ``(lat, lon)``。"""
|
||||||
|
return self._last_location
|
||||||
|
|
||||||
|
@property
|
||||||
|
def send_count(self) -> int:
|
||||||
|
"""累计下发次数。"""
|
||||||
|
return self._send_count
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 连接
|
||||||
|
async def connect(self, device: Any = None) -> dict:
|
||||||
|
"""建立(长连接)DVT 会话,返回中文可读的会话信息 dict。
|
||||||
|
|
||||||
|
:raises LocationProviderError: 任何失败都用中文说明原因与修复办法。
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
return dict(self._session_info)
|
||||||
|
|
||||||
|
udid = _device_field(device, "udid", None) or self._udid
|
||||||
|
if udid:
|
||||||
|
self._udid = str(udid)
|
||||||
|
ios_version = str(_device_field(device, "ios_version", "") or "")
|
||||||
|
ios_major = _parse_ios_major(_device_field(device, "ios_major", None) or ios_version)
|
||||||
|
developer_mode = _device_field(device, "developer_mode", None)
|
||||||
|
state = _device_field(device, "state", None)
|
||||||
|
self._connection = str(_device_field(device, "connection", "usb") or "usb")
|
||||||
|
|
||||||
|
if state in ("unpaired",):
|
||||||
|
raise LocationProviderError(
|
||||||
|
"设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试(或执行 "
|
||||||
|
"./.venv/bin/python -m pymobiledevice3 lockdown pair)"
|
||||||
|
)
|
||||||
|
if state in ("unavailable",):
|
||||||
|
raise LocationProviderError("设备当前不可用:请重新插拔 USB 线并解锁 iPhone 后重试")
|
||||||
|
if developer_mode is False:
|
||||||
|
raise LocationProviderError(
|
||||||
|
"设备未开启「开发者模式」:请在 iPhone 上打开 设置 → 隐私与安全性 → 开发者模式,"
|
||||||
|
"重启设备并确认开启后重试(iOS 16 及以上必须开启)"
|
||||||
|
)
|
||||||
|
|
||||||
|
pmd3 = _import_pymobiledevice3()
|
||||||
|
has_rsd = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
|
||||||
|
|
||||||
|
if has_rsd:
|
||||||
|
await self._connect_dvt_rsd(pmd3)
|
||||||
|
else:
|
||||||
|
await self._connect_lockdown_path(pmd3, ios_major, ios_version)
|
||||||
|
|
||||||
|
# 隧道/设备侧查到的真实版本优先(连接前拿到的版本可能为空或过期)
|
||||||
|
if self._detected_version:
|
||||||
|
ios_version = self._detected_version
|
||||||
|
ios_major = _parse_ios_major(self._detected_version) or ios_major
|
||||||
|
if self._detected_udid and not self._udid:
|
||||||
|
self._udid = self._detected_udid
|
||||||
|
|
||||||
|
self._connected = True
|
||||||
|
self._connected_at = time.time()
|
||||||
|
self._session_info = {
|
||||||
|
"ok": True,
|
||||||
|
"mock": False,
|
||||||
|
"udid": self._udid,
|
||||||
|
"ios_version": ios_version,
|
||||||
|
"ios_major": ios_major,
|
||||||
|
"connection": self._connection,
|
||||||
|
"tunnel": "connected" if self._strategy == "dvt-rsd" else "idle",
|
||||||
|
"strategy": self._strategy,
|
||||||
|
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||||
|
"pymobiledevice3": pymobiledevice3_version(),
|
||||||
|
"detail": self._describe_ready(),
|
||||||
|
}
|
||||||
|
self._log("success", self._session_info["detail"])
|
||||||
|
return dict(self._session_info)
|
||||||
|
|
||||||
|
def connect_sync(self, runner: Any, device: Any = None) -> dict:
|
||||||
|
"""同步建立会话(把协程提交给调用方的 runner,绝不新建事件循环/线程)。
|
||||||
|
|
||||||
|
与 :class:`MockLocationProvider.connect_sync` 行为一致,便于 lead 的同步装配代码统一调用。
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
return dict(self._session_info)
|
||||||
|
run = getattr(runner, "run", None)
|
||||||
|
if not callable(run):
|
||||||
|
raise LocationProviderError("传入的 runner 不支持 run(coro),无法同步建立设备会话")
|
||||||
|
return run(self.connect(device))
|
||||||
|
|
||||||
|
def _describe_ready(self) -> str:
|
||||||
|
if self._strategy == "dvt-rsd":
|
||||||
|
address = self.rsd_address
|
||||||
|
where = f"RSD {address[0]}:{address[1]}" if address else "RSD 隧道"
|
||||||
|
return f"DVT 虚拟定位会话已就绪(iOS 17+ / {where},长连接保持中)"
|
||||||
|
if self._strategy == "dvt-lockdown":
|
||||||
|
return "DVT 虚拟定位会话已就绪(iOS<17 / usbmux+lockdown,长连接保持中)"
|
||||||
|
return "虚拟定位会话已就绪(老版 com.apple.dt.simulatelocation 服务)"
|
||||||
|
|
||||||
|
async def _connect_dvt_rsd(self, pmd3: dict) -> None:
|
||||||
|
"""iOS≥17:RSD 隧道 + DVT(长连接)。"""
|
||||||
|
rsd = self._injected_rsd
|
||||||
|
try:
|
||||||
|
if rsd is None:
|
||||||
|
rsd = pmd3["RemoteServiceDiscoveryService"]((self._rsd_host, self._rsd_port))
|
||||||
|
self._log("info", f"正在连接 RSD 隧道 {self._rsd_host}:{self._rsd_port} …")
|
||||||
|
await asyncio.wait_for(rsd.connect(), timeout=self._connect_timeout)
|
||||||
|
self._rsd_obj = rsd
|
||||||
|
# 隧道连上后可以校正设备信息(peer_info 里带 OSVersion / UniqueDeviceID)
|
||||||
|
real_version = str(getattr(rsd, "product_version", "") or "")
|
||||||
|
if real_version:
|
||||||
|
self._detected_version = real_version
|
||||||
|
real_udid = getattr(rsd, "udid", None)
|
||||||
|
if real_udid:
|
||||||
|
self._detected_udid = str(real_udid)
|
||||||
|
self._log("info", "RSD 隧道已建立,正在打开 DVT(dtservicehub)通道 …")
|
||||||
|
self._dvt = pmd3["DvtProvider"](rsd)
|
||||||
|
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
|
||||||
|
self._location = pmd3["LocationSimulation"](self._dvt)
|
||||||
|
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
|
||||||
|
self._strategy = "dvt-rsd"
|
||||||
|
except LocationProviderError:
|
||||||
|
await self._safe_close_rsd()
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
await self._safe_close_rsd()
|
||||||
|
raise _wrap("建立 RSD/DVT 会话", exc) from exc
|
||||||
|
|
||||||
|
async def _connect_lockdown_path(self, pmd3: dict, ios_major: int, ios_version: str) -> None:
|
||||||
|
"""无 RSD 地址:iOS<17 走 lockdown 直连 DVT,失败再退化到老服务。"""
|
||||||
|
if ios_major >= RSD_REQUIRED_IOS_MAJOR:
|
||||||
|
# iOS 17+ 没有隧道时 lockdown 上的 DVTSecureSocketProxy 已不存在,直接给中文修复建议
|
||||||
|
raise LocationProviderError(
|
||||||
|
f"设备为 iOS {ios_version or ios_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
|
||||||
|
)
|
||||||
|
|
||||||
|
lockdown = self._lockdown_obj or self._injected_lockdown
|
||||||
|
if lockdown is None:
|
||||||
|
try:
|
||||||
|
self._log("info", f"正在通过 usbmux/lockdown 连接设备 {self._udid or '(第一台 USB 设备)'} …")
|
||||||
|
lockdown = await asyncio.wait_for(
|
||||||
|
pmd3["create_using_usbmux"](serial=self._udid), timeout=self._connect_timeout
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
raise _wrap("通过 usbmux 连接设备", exc) from exc
|
||||||
|
self._lockdown_obj = lockdown
|
||||||
|
elif self._injected_lockdown is not None:
|
||||||
|
self._lockdown_obj = self._injected_lockdown
|
||||||
|
|
||||||
|
# 用设备侧真实版本校正(连接前拿到的 ios_major 可能为空或过期)
|
||||||
|
real_version = str(getattr(lockdown, "product_version", "") or "")
|
||||||
|
if real_version:
|
||||||
|
self._detected_version = real_version
|
||||||
|
real_udid = getattr(lockdown, "udid", None)
|
||||||
|
if real_udid:
|
||||||
|
self._detected_udid = str(real_udid)
|
||||||
|
real_major = _parse_ios_major(real_version) or ios_major
|
||||||
|
if real_major >= RSD_REQUIRED_IOS_MAJOR:
|
||||||
|
error = LocationProviderError(
|
||||||
|
f"设备为 iOS {real_version or real_major},需要先建立 RSD 隧道才能虚拟定位。" + TUNNEL_HINT
|
||||||
|
)
|
||||||
|
# 这个 lockdown 连接是我们自己建的,报错前先释放,避免泄漏
|
||||||
|
if self._injected_lockdown is None and self._lockdown_obj is not None:
|
||||||
|
try:
|
||||||
|
await self._lockdown_obj.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._lockdown_obj = None
|
||||||
|
raise error
|
||||||
|
|
||||||
|
# 首选取证过的 DVT 路径(长连接)
|
||||||
|
try:
|
||||||
|
self._dvt = pmd3["DvtProvider"](lockdown)
|
||||||
|
await asyncio.wait_for(self._dvt.connect(), timeout=self._connect_timeout)
|
||||||
|
self._location = pmd3["LocationSimulation"](self._dvt)
|
||||||
|
await asyncio.wait_for(self._location.connect(), timeout=self._connect_timeout)
|
||||||
|
self._strategy = "dvt-lockdown"
|
||||||
|
return
|
||||||
|
except BaseException as exc:
|
||||||
|
dvt_error = _wrap("建立 DVT 会话(iOS<17 直连)", exc)
|
||||||
|
self._log("warning", f"{dvt_error};改用老版 simulatelocation 服务重试")
|
||||||
|
|
||||||
|
# 兜底:com.apple.dt.simulatelocation(pymobiledevice3 CLI 对 iOS<17 的官方路径)
|
||||||
|
self._dvt = None
|
||||||
|
self._location = None
|
||||||
|
try:
|
||||||
|
self._legacy = pmd3["DtSimulateLocation"](lockdown)
|
||||||
|
service_name = str(getattr(self._legacy, "SERVICE_NAME", "com.apple.dt.simulatelocation"))
|
||||||
|
# 关键:DtSimulateLocation.__init__ 不做任何设备 I/O(services/simulate_location.py:19-22),
|
||||||
|
# 它每次 set/clear 才新开服务。这里必须真开一次服务做校验,否则会出现
|
||||||
|
# 「connect 报成功、第一次下发才失败」的假成功(真机实测踩到过)。
|
||||||
|
self._log("info", f"正在验证老版虚拟定位服务 {service_name} …")
|
||||||
|
service = await asyncio.wait_for(
|
||||||
|
lockdown.start_lockdown_developer_service(service_name), timeout=self._connect_timeout
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._strategy = "legacy"
|
||||||
|
self._log("info", f"已选用老版虚拟定位服务 {service_name}(服务可用性已实测)")
|
||||||
|
except BaseException as exc:
|
||||||
|
raise _wrap("建立老版虚拟定位会话(com.apple.dt.simulatelocation)", exc) from exc
|
||||||
|
|
||||||
|
async def _safe_close_rsd(self) -> None:
|
||||||
|
for obj in (self._location, self._dvt):
|
||||||
|
if obj is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await obj.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._location = None
|
||||||
|
self._dvt = None
|
||||||
|
if self._rsd_obj is not None and self._injected_rsd is None:
|
||||||
|
try:
|
||||||
|
await self._rsd_obj.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._rsd_obj = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 下发
|
||||||
|
async def set_location(self, lat: float, lon: float) -> None:
|
||||||
|
"""下发一次单点定位(复用长连接会话)。"""
|
||||||
|
lat = float(lat)
|
||||||
|
lon = float(lon)
|
||||||
|
if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
|
||||||
|
raise LocationProviderError(f"经纬度超出合法范围:lat={lat}, lon={lon}")
|
||||||
|
if not self._connected:
|
||||||
|
raise LocationProviderError("尚未连接设备,无法下发定位:请先调用 connect()(或点击界面上的「连接设备」)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
|
||||||
|
await self._location.set(lat, lon)
|
||||||
|
elif self._strategy == "legacy":
|
||||||
|
await self._legacy.set(lat, lon)
|
||||||
|
else:
|
||||||
|
raise LocationProviderError("内部状态异常:没有可用的虚拟定位通道,请重新连接设备")
|
||||||
|
except BaseException as exc:
|
||||||
|
raise _wrap("下发定位", exc) from exc
|
||||||
|
|
||||||
|
self._last_location = (lat, lon)
|
||||||
|
self._send_count += 1
|
||||||
|
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||||
|
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
||||||
|
|
||||||
|
async def clear_location(self) -> None:
|
||||||
|
"""让设备恢复真实定位(``play.stop`` / ``location.clear`` 语义)。"""
|
||||||
|
if not self._connected:
|
||||||
|
self._log("debug", "当前未连接设备,clear_location 忽略")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._strategy in ("dvt-rsd", "dvt-lockdown"):
|
||||||
|
await self._location.clear()
|
||||||
|
elif self._strategy == "legacy":
|
||||||
|
await self._legacy.clear()
|
||||||
|
except BaseException as exc:
|
||||||
|
raise _wrap("清除虚拟定位", exc) from exc
|
||||||
|
self._last_location = None
|
||||||
|
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""释放会话:先 clear(恢复真实定位)再关闭 DVT / RSD / lockdown。幂等。"""
|
||||||
|
if not self._connected and self._dvt is None and self._rsd_obj is None and self._lockdown_obj is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._connected:
|
||||||
|
await self.clear_location()
|
||||||
|
except Exception as exc:
|
||||||
|
self._log("warning", f"关闭前清除虚拟定位失败(继续关闭会话):{exc}")
|
||||||
|
|
||||||
|
for obj in (self._location, self._dvt):
|
||||||
|
if obj is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await obj.close()
|
||||||
|
except Exception as exc:
|
||||||
|
self._log("debug", f"关闭 {type(obj).__name__} 时出错(忽略):{exc}")
|
||||||
|
|
||||||
|
if self._rsd_obj is not None and self._injected_rsd is None:
|
||||||
|
try:
|
||||||
|
await self._rsd_obj.close()
|
||||||
|
except Exception as exc:
|
||||||
|
self._log("debug", f"关闭 RSD 时出错(忽略):{exc}")
|
||||||
|
if self._lockdown_obj is not None and self._injected_lockdown is None:
|
||||||
|
try:
|
||||||
|
await self._lockdown_obj.close()
|
||||||
|
except Exception as exc:
|
||||||
|
self._log("debug", f"关闭 lockdown 时出错(忽略):{exc}")
|
||||||
|
|
||||||
|
self._location = None
|
||||||
|
self._dvt = None
|
||||||
|
self._legacy = None
|
||||||
|
self._rsd_obj = None
|
||||||
|
self._lockdown_obj = None
|
||||||
|
self._connected = False
|
||||||
|
self._strategy = None
|
||||||
|
self._connected_at = None
|
||||||
|
self._log("info", "虚拟定位会话已关闭")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 探测
|
||||||
|
async def probe(self, device: Any = None) -> dict:
|
||||||
|
"""「测试连接」按钮:探测设备/DVT 是否可用。**不抛异常**。
|
||||||
|
|
||||||
|
:param device: 可选,``DeviceManager`` 的 Device dict(与 ``connect()`` 同一个对象)。
|
||||||
|
传入后会先做静态判断(未配对 / 未开开发者模式 / iOS 版本),能省掉一次设备往返。
|
||||||
|
:returns: ``{"ok": bool, "detail": 中文说明, "ios_major": int}``(另附 strategy 等字段)
|
||||||
|
"""
|
||||||
|
ios_major = _parse_ios_major(self._session_info.get("ios_major"))
|
||||||
|
if device is not None:
|
||||||
|
state = _device_field(device, "state", None)
|
||||||
|
developer_mode = _device_field(device, "developer_mode", None)
|
||||||
|
device_major = _parse_ios_major(
|
||||||
|
_device_field(device, "ios_major", None) or _device_field(device, "ios_version", "")
|
||||||
|
)
|
||||||
|
if device_major:
|
||||||
|
ios_major = device_major
|
||||||
|
if state == "unpaired":
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"ios_major": ios_major,
|
||||||
|
"strategy": None,
|
||||||
|
"detail": "设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试",
|
||||||
|
}
|
||||||
|
if developer_mode is False:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"ios_major": ios_major,
|
||||||
|
"strategy": None,
|
||||||
|
"detail": "设备未开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启,需重启设备)",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pmd3 = _import_pymobiledevice3()
|
||||||
|
except LocationProviderError as exc:
|
||||||
|
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
|
||||||
|
|
||||||
|
if self._connected:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"detail": self._describe_ready(),
|
||||||
|
"ios_major": ios_major,
|
||||||
|
"strategy": self._strategy,
|
||||||
|
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 未连接:用一个临时会话探测,探测完立即释放(不会残留模拟定位)
|
||||||
|
has_tunnel = self._injected_rsd is not None or bool(self._rsd_host and self._rsd_port)
|
||||||
|
if not has_tunnel and (ios_major == 0 or ios_major >= RSD_REQUIRED_IOS_MAJOR):
|
||||||
|
# 没有隧道、且是 iOS 17+(或版本未知):只有隧道状态可报,不做无意义的重连
|
||||||
|
detail = (
|
||||||
|
f"尚未配置 RSD 隧道(ios_major={ios_major or '未知'})。" + TUNNEL_HINT
|
||||||
|
if ios_major >= RSD_REQUIRED_IOS_MAJOR or ios_major == 0
|
||||||
|
else "尚未建立会话;iOS<17 可直接用 USB/lockdown 建立 DVT 会话,请先点击「连接设备」"
|
||||||
|
)
|
||||||
|
return {"ok": False, "detail": detail, "ios_major": ios_major, "strategy": None}
|
||||||
|
|
||||||
|
# iOS<17 且无隧道时也做真实探测(点「测试连接」就应该真的去连一次)
|
||||||
|
probe = RealLocationProvider(
|
||||||
|
rsd_host=self._rsd_host,
|
||||||
|
rsd_port=self._rsd_port,
|
||||||
|
udid=self._udid,
|
||||||
|
rsd=self._injected_rsd,
|
||||||
|
lockdown=self._injected_lockdown,
|
||||||
|
connect_timeout=self._connect_timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
info = await probe.connect(device)
|
||||||
|
except LocationProviderError as exc:
|
||||||
|
return {"ok": False, "detail": str(exc), "ios_major": ios_major, "strategy": None}
|
||||||
|
except BaseException as exc:
|
||||||
|
return {"ok": False, "detail": _wrap("探测设备", exc).args[0], "ios_major": ios_major, "strategy": None}
|
||||||
|
try:
|
||||||
|
await probe.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"detail": f"连接测试成功:{info.get('detail')}",
|
||||||
|
"ios_major": _parse_ios_major(info.get("ios_major")) or ios_major,
|
||||||
|
"strategy": info.get("strategy"),
|
||||||
|
"rsd": info.get("rsd"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 辅助
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""运行统计(供日志/健康检查使用)。"""
|
||||||
|
return {
|
||||||
|
"mock": False,
|
||||||
|
"connected": self._connected,
|
||||||
|
"strategy": self._strategy,
|
||||||
|
"udid": self._udid,
|
||||||
|
"rsd": f"{self.rsd_address[0]}:{self.rsd_address[1]}" if self.rsd_address else None,
|
||||||
|
"send_count": self._send_count,
|
||||||
|
"last_location": self._last_location,
|
||||||
|
"connected_at": self._connected_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_rsd(self, rsd_host: Optional[str], rsd_port: Optional[int]) -> None:
|
||||||
|
"""隧道重建后更新 RSD 地址(需要重新 connect 才生效)。"""
|
||||||
|
self._rsd_host = rsd_host
|
||||||
|
self._rsd_port = int(rsd_port) if rsd_port else None
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover - 调试用
|
||||||
|
return (
|
||||||
|
f"<RealLocationProvider connected={self._connected} strategy={self._strategy} "
|
||||||
|
f"udid={self._udid!r} rsd={self.rsd_address}>"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
"""路线引擎:途经点 → 规划路线 → 加密路径 → 速度剖面 → GPX 导入导出。
|
||||||
|
|
||||||
|
输出结构(docs/PROTOCOL.md 2.2):
|
||||||
|
{"waypoints": [[lng,lat],...], "path": [[lng,lat],...], "speeds": [...],
|
||||||
|
"distance": m, "duration": s, "engine": "osrm"|"straight"|"manual", ...}
|
||||||
|
播放引擎会用 Route.dense_acc 做弧长插值。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import geoutils, protocol
|
||||||
|
|
||||||
|
MAX_PATH_POINTS = 60000
|
||||||
|
DEFAULT_STEP_M = 2.0
|
||||||
|
_OSRM_TIMEOUT = 8.0
|
||||||
|
_UA = "iOSLocationTool/1.0 (+local mac app)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Route:
|
||||||
|
name: str = "未命名轨迹"
|
||||||
|
mode: str = "driving"
|
||||||
|
waypoints: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
|
||||||
|
path: list[tuple[float, float]] = field(default_factory=list) # (lat,lng)
|
||||||
|
speeds: list[float] = field(default_factory=list) # m/s
|
||||||
|
engine: str = "straight"
|
||||||
|
loop: bool = False
|
||||||
|
speed_scale: float = 1.0
|
||||||
|
_acc: list[float] = field(default_factory=list, repr=False)
|
||||||
|
|
||||||
|
# ---------- 派生属性 ----------
|
||||||
|
@property
|
||||||
|
def dense_acc(self) -> list[float]:
|
||||||
|
if not self._acc and self.path:
|
||||||
|
self._acc = geoutils.cumulative_lengths(self.path)
|
||||||
|
return self._acc
|
||||||
|
|
||||||
|
@property
|
||||||
|
def distance(self) -> float:
|
||||||
|
acc = self.dense_acc
|
||||||
|
return acc[-1] if acc else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
"""按速度剖面积分得到的实际耗时(秒)。"""
|
||||||
|
if len(self.path) < 2 or not self.speeds:
|
||||||
|
return 0.0
|
||||||
|
acc = self.dense_acc
|
||||||
|
total = 0.0
|
||||||
|
for i in range(1, len(self.path)):
|
||||||
|
seg = acc[i] - acc[i - 1]
|
||||||
|
v = (self.speeds[i] + self.speeds[i - 1]) / 2.0
|
||||||
|
total += seg / v if v > 0.05 else seg / 0.05
|
||||||
|
return total
|
||||||
|
|
||||||
|
def point_at_distance(self, distance: float) -> tuple[float, float]:
|
||||||
|
if not self.path:
|
||||||
|
raise protocol.AppError("轨迹为空", "empty_route")
|
||||||
|
acc = self.dense_acc
|
||||||
|
point, _ = geoutils.interpolate(self.path, acc, distance)
|
||||||
|
return point
|
||||||
|
|
||||||
|
def speed_at_distance(self, distance: float) -> float:
|
||||||
|
if not self.speeds or len(self.path) < 2:
|
||||||
|
return 0.0
|
||||||
|
acc = self.dense_acc
|
||||||
|
idx = _index_of(acc, distance)
|
||||||
|
return self.speeds[min(idx, len(self.speeds) - 1)]
|
||||||
|
|
||||||
|
def heading_at_distance(self, distance: float) -> float:
|
||||||
|
"""行进方向(度,0=正北)。用于让漂移偏移相对于行进方向计算。"""
|
||||||
|
if len(self.path) < 2:
|
||||||
|
return 0.0
|
||||||
|
acc = self.dense_acc
|
||||||
|
idx = _index_of(acc, distance)
|
||||||
|
idx = max(0, min(idx, len(self.path) - 2))
|
||||||
|
a = self.path[idx]
|
||||||
|
b = self.path[idx + 1]
|
||||||
|
return geoutils.bearing(a[0], a[1], b[0], b[1])
|
||||||
|
|
||||||
|
def highlight(self, distance: float) -> list[list[float]]:
|
||||||
|
acc = self.dense_acc
|
||||||
|
pts = geoutils.slice_path(self.path, acc, distance) if len(self.path) > 1 else list(self.path)
|
||||||
|
return [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in pts]
|
||||||
|
|
||||||
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"mode": self.mode,
|
||||||
|
"waypoints": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.waypoints],
|
||||||
|
"path": [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in self.path],
|
||||||
|
"speeds": [round(v, 3) for v in self.speeds],
|
||||||
|
"distance": round(self.distance, 2),
|
||||||
|
"duration": round(self.duration, 2),
|
||||||
|
"engine": self.engine,
|
||||||
|
"loop": self.loop,
|
||||||
|
"speed_scale": self.speed_scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _index_of(acc: list[float], distance: float) -> int:
|
||||||
|
lo, hi = 0, len(acc) - 1
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if acc[mid] <= distance:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return max(0, lo - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class RouteEngine:
|
||||||
|
def __init__(self, config: Any) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# ---------------- 构建 ----------------
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
waypoint_payloads: list[Any],
|
||||||
|
mode: str = "driving",
|
||||||
|
name: str | None = None,
|
||||||
|
use_osrm: bool = True,
|
||||||
|
loop: bool = False,
|
||||||
|
speed_scale: float = 1.0,
|
||||||
|
) -> Route:
|
||||||
|
mode = protocol.validate_mode(mode)
|
||||||
|
speed_scale = protocol.validate_speed_scale(speed_scale)
|
||||||
|
coords = parse_waypoints(waypoint_payloads)
|
||||||
|
if len(coords) < 1:
|
||||||
|
raise protocol.AppError("至少需要一个途经点", "no_waypoints")
|
||||||
|
|
||||||
|
engine = "straight"
|
||||||
|
if len(coords) >= 2 and use_osrm:
|
||||||
|
planned = self._osrm(coords, mode)
|
||||||
|
if planned is not None:
|
||||||
|
coords = planned
|
||||||
|
engine = "osrm"
|
||||||
|
|
||||||
|
route = Route(
|
||||||
|
name=name or "未命名轨迹",
|
||||||
|
mode=mode,
|
||||||
|
engine=engine,
|
||||||
|
loop=bool(loop),
|
||||||
|
speed_scale=speed_scale,
|
||||||
|
)
|
||||||
|
route.path = self._densify(coords)
|
||||||
|
route.speeds = compute_speeds(route.path, mode, self._base_speed(mode))
|
||||||
|
route._acc = geoutils.cumulative_lengths(route.path)
|
||||||
|
route.waypoints = _pick_waypoints(route.path, parse_waypoints(waypoint_payloads))
|
||||||
|
return route
|
||||||
|
|
||||||
|
def from_coords(
|
||||||
|
self,
|
||||||
|
coords: list[tuple[float, float]],
|
||||||
|
mode: str = "driving",
|
||||||
|
name: str = "导入轨迹",
|
||||||
|
engine: str = "manual",
|
||||||
|
loop: bool = False,
|
||||||
|
speed_scale: float = 1.0,
|
||||||
|
) -> Route:
|
||||||
|
if len(coords) < 1:
|
||||||
|
raise protocol.AppError("轨迹为空", "no_waypoints")
|
||||||
|
route = Route(
|
||||||
|
name=name,
|
||||||
|
mode=protocol.validate_mode(mode),
|
||||||
|
engine=engine,
|
||||||
|
loop=bool(loop),
|
||||||
|
speed_scale=protocol.validate_speed_scale(speed_scale),
|
||||||
|
)
|
||||||
|
route.path = self._densify(coords)
|
||||||
|
route.speeds = compute_speeds(route.path, route.mode, self._base_speed(route.mode))
|
||||||
|
route._acc = geoutils.cumulative_lengths(route.path)
|
||||||
|
route.waypoints = _pick_waypoints(route.path, coords)
|
||||||
|
return route
|
||||||
|
|
||||||
|
def _densify(self, coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||||
|
if len(coords) == 1:
|
||||||
|
return list(coords)
|
||||||
|
raw = geoutils.densify(coords, DEFAULT_STEP_M, smooth=False)
|
||||||
|
if len(raw) <= MAX_PATH_POINTS:
|
||||||
|
return raw
|
||||||
|
# 超长时按比例降低采样密度,仍保证曲线形状
|
||||||
|
rough = geoutils.cumulative_lengths(coords)[-1]
|
||||||
|
step = max(DEFAULT_STEP_M, rough / MAX_PATH_POINTS * 1.05)
|
||||||
|
out = geoutils.densify(coords, step, smooth=False)
|
||||||
|
return out[:MAX_PATH_POINTS] if len(out) > MAX_PATH_POINTS else out
|
||||||
|
|
||||||
|
def _base_speed(self, mode: str) -> float:
|
||||||
|
defaults = self.config.get("mode_defaults") or {}
|
||||||
|
try:
|
||||||
|
value = float(defaults.get(mode, protocol.MODE_DEFAULTS.get(mode, 13.9)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = protocol.MODE_DEFAULTS.get(mode, 13.9)
|
||||||
|
return max(0.2, value)
|
||||||
|
|
||||||
|
# ---------------- OSRM ----------------
|
||||||
|
def _osrm(self, coords: list[tuple[float, float]], mode: str) -> list[tuple[float, float]] | None:
|
||||||
|
profile = {"driving": "driving", "cycling": "bike", "walking": "foot", "flight": "driving"}[mode]
|
||||||
|
if mode == "flight":
|
||||||
|
return None
|
||||||
|
base = str(self.config.get("osrm_url") or "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
coords_str = ";".join(f"{lng:.6f},{lat:.6f}" for lat, lng in coords)
|
||||||
|
url = f"{base}/{profile}/{coords_str}?overview=full&geometries=geojson&steps=false"
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=_OSRM_TIMEOUT) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if payload.get("code") != "Ok":
|
||||||
|
return None
|
||||||
|
geometry = payload["routes"][0]["geometry"]["coordinates"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return None
|
||||||
|
out: list[tuple[float, float]] = []
|
||||||
|
for item in geometry:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
try:
|
||||||
|
out.append((float(item[1]), float(item[0])))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out if len(out) >= 2 else None
|
||||||
|
|
||||||
|
def osrm_available(self) -> bool:
|
||||||
|
base = str(self.config.get("osrm_url") or "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base.replace("/route/v1", "") + "/route/v1/driving/116.39,39.90;116.40,39.91?overview=false",
|
||||||
|
headers={"User-Agent": _UA},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=4.0) as response:
|
||||||
|
return response.status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 速度剖面 ----------------
|
||||||
|
def compute_speeds(
|
||||||
|
path: list[tuple[float, float]], mode: str, base_speed: float
|
||||||
|
) -> list[float]:
|
||||||
|
"""按转弯角度生成速度剖面,并做滑动平均让加减速自然。"""
|
||||||
|
n = len(path)
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
if n == 1:
|
||||||
|
return [base_speed]
|
||||||
|
if mode == "flight":
|
||||||
|
return _smooth([base_speed] * n, window=1)
|
||||||
|
|
||||||
|
acc = geoutils.cumulative_lengths(path)
|
||||||
|
raw = [base_speed] * n
|
||||||
|
for i in range(1, n - 1):
|
||||||
|
# 用前后各 ~12 米的点计算航向变化,避免逐点抖动
|
||||||
|
a = _point_near(path, acc, max(0.0, acc[i] - 12.0))
|
||||||
|
b = path[i]
|
||||||
|
c = _point_near(path, acc, acc[i] + 12.0)
|
||||||
|
angle = abs(_angle_diff(
|
||||||
|
geoutils.bearing(a[0], a[1], b[0], b[1]),
|
||||||
|
geoutils.bearing(b[0], b[1], c[0], c[1]),
|
||||||
|
))
|
||||||
|
factor = 1.0
|
||||||
|
if angle >= 100:
|
||||||
|
factor = 0.12
|
||||||
|
elif angle >= 60:
|
||||||
|
factor = 0.25
|
||||||
|
elif angle >= 35:
|
||||||
|
factor = 0.45
|
||||||
|
elif angle >= 18:
|
||||||
|
factor = 0.68
|
||||||
|
elif angle >= 8:
|
||||||
|
factor = 0.86
|
||||||
|
raw[i] = base_speed * factor
|
||||||
|
raw[0] = raw[1] if n > 1 else base_speed
|
||||||
|
raw[-1] = raw[-2] if n > 1 else base_speed
|
||||||
|
return _smooth(raw, window=max(3, min(41, n // 4 * 2 + 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _point_near(
|
||||||
|
path: list[tuple[float, float]], acc: list[float], distance: float
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
point, _ = geoutils.interpolate(path, acc, distance)
|
||||||
|
return point
|
||||||
|
|
||||||
|
|
||||||
|
def _angle_diff(a: float, b: float) -> float:
|
||||||
|
diff = (b - a + 540.0) % 360.0 - 180.0
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth(values: list[float], window: int) -> list[float]:
|
||||||
|
n = len(values)
|
||||||
|
if n <= 2 or window <= 1:
|
||||||
|
return [round(v, 3) for v in values]
|
||||||
|
half = max(1, window // 2)
|
||||||
|
out: list[float] = []
|
||||||
|
for i in range(n):
|
||||||
|
lo = max(0, i - half)
|
||||||
|
hi = min(n, i + half + 1)
|
||||||
|
chunk = values[lo:hi]
|
||||||
|
out.append(round(sum(chunk) / len(chunk), 3))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 途经点处理 ----------------
|
||||||
|
MAX_WAYPOINTS = 5000
|
||||||
|
MAX_REQUEST_POINTS = 200000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_waypoints(payloads: Any, limit: int = MAX_WAYPOINTS) -> list[tuple[float, float]]:
|
||||||
|
"""把 [[lng,lat], ...] 或 [{"lng":..,"lat":..}, ...] 统一成 [(lat,lng)]。"""
|
||||||
|
if payloads is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(payloads, (list, tuple)):
|
||||||
|
raise protocol.AppError("waypoints 必须是数组", "bad_waypoints")
|
||||||
|
if len(payloads) > limit:
|
||||||
|
raise protocol.AppError(
|
||||||
|
f"坐标点过多({len(payloads)} 个,上限 {limit} 个):请减少途经点或改用 GPX 导入",
|
||||||
|
"too_many_points",
|
||||||
|
status=413,
|
||||||
|
)
|
||||||
|
out: list[tuple[float, float]] = []
|
||||||
|
for item in payloads:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
lat = item.get("lat")
|
||||||
|
lng = item.get("lng", item.get("lon"))
|
||||||
|
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lng, lat = item[0], item[1]
|
||||||
|
else:
|
||||||
|
raise protocol.AppError("途经点格式应为 [经度, 纬度]", "bad_waypoints")
|
||||||
|
flat, flng = protocol.validate_latlng(lat, lng)
|
||||||
|
out.append((flat, flng))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_waypoints(
|
||||||
|
path: list[tuple[float, float]], wanted: list[tuple[float, float]]
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""用户可能只给了稀疏的途经点,这里保留原始途经点作为列表展示。"""
|
||||||
|
return list(wanted) if wanted else list(path[:1] + path[-1:] if len(path) > 1 else path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- GPX ----------------
|
||||||
|
GPX_NS = "http://www.topografix.com/GPX/1/1"
|
||||||
|
|
||||||
|
|
||||||
|
def to_gpx(route: Route, creator: str = protocol.APP_NAME) -> str:
|
||||||
|
root = ET.Element(
|
||||||
|
"gpx",
|
||||||
|
{
|
||||||
|
"version": "1.1",
|
||||||
|
"creator": creator,
|
||||||
|
"xmlns": GPX_NS,
|
||||||
|
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||||
|
"xsi:schemaLocation": f"{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metadata = ET.SubElement(root, "metadata")
|
||||||
|
ET.SubElement(metadata, "name").text = route.name or "未命名轨迹"
|
||||||
|
ET.SubElement(metadata, "desc").text = (
|
||||||
|
f"{protocol.MODE_LABELS.get(route.mode, route.mode)}轨迹,"
|
||||||
|
f"距离 {route.distance / 1000:.2f} km,预计耗时 {_fmt_duration(route.duration)}"
|
||||||
|
)
|
||||||
|
ET.SubElement(metadata, "time").text = _iso_now()
|
||||||
|
|
||||||
|
for index, (lat, lng) in enumerate(route.waypoints, start=1):
|
||||||
|
wpt = ET.SubElement(root, "wpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
|
||||||
|
ET.SubElement(wpt, "name").text = f"途经点 {index}"
|
||||||
|
ET.SubElement(wpt, "sym").text = "Flag, Blue"
|
||||||
|
|
||||||
|
trk = ET.SubElement(root, "trk")
|
||||||
|
ET.SubElement(trk, "name").text = route.name or "未命名轨迹"
|
||||||
|
ET.SubElement(trk, "type").text = protocol.MODE_LABELS.get(route.mode, route.mode)
|
||||||
|
seg = ET.SubElement(trk, "trkseg")
|
||||||
|
acc = route.dense_acc
|
||||||
|
for i, (lat, lng) in enumerate(route.path):
|
||||||
|
trkpt = ET.SubElement(seg, "trkpt", {"lat": f"{lat:.7f}", "lon": f"{lng:.7f}"})
|
||||||
|
if i < len(route.speeds):
|
||||||
|
ET.SubElement(trkpt, "speed").text = f"{route.speeds[i]:.3f}"
|
||||||
|
if acc:
|
||||||
|
ET.SubElement(trkpt, "time").text = _iso_offset(acc[i] / max(0.2, _avg_speed(route)))
|
||||||
|
_indent(root)
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def from_gpx(text: str) -> tuple[list[tuple[float, float]], str, str]:
|
||||||
|
"""解析 GPX,返回 (坐标[(lat,lng)], 名称, 类型)。优先 trkseg,其次 rte,最后 wpt。"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
raise protocol.AppError("GPX 文件解析失败,请确认文件格式", "bad_gpx") from exc
|
||||||
|
tag = _strip_ns(root.tag)
|
||||||
|
if tag != "gpx":
|
||||||
|
raise protocol.AppError("这不是一个 GPX 文件", "bad_gpx")
|
||||||
|
|
||||||
|
name = ""
|
||||||
|
for child in root:
|
||||||
|
if _strip_ns(child.tag) == "metadata":
|
||||||
|
for item in child:
|
||||||
|
if _strip_ns(item.tag) == "name" and item.text:
|
||||||
|
name = item.text.strip()
|
||||||
|
break
|
||||||
|
if name:
|
||||||
|
break
|
||||||
|
|
||||||
|
coords: list[tuple[float, float]] = []
|
||||||
|
for child in root:
|
||||||
|
ctag = _strip_ns(child.tag)
|
||||||
|
if ctag == "trk":
|
||||||
|
if not name:
|
||||||
|
for item in child:
|
||||||
|
if _strip_ns(item.tag) == "name" and item.text:
|
||||||
|
name = item.text.strip()
|
||||||
|
break
|
||||||
|
for seg in child:
|
||||||
|
if _strip_ns(seg.tag) != "trkseg":
|
||||||
|
continue
|
||||||
|
for point in seg:
|
||||||
|
parsed = _parse_point(point)
|
||||||
|
if parsed:
|
||||||
|
coords.append(parsed)
|
||||||
|
elif ctag == "rte":
|
||||||
|
for point in child:
|
||||||
|
if _strip_ns(point.tag) == "rtept":
|
||||||
|
parsed = _parse_point(point)
|
||||||
|
if parsed:
|
||||||
|
coords.append(parsed)
|
||||||
|
if not coords:
|
||||||
|
for child in root:
|
||||||
|
if _strip_ns(child.tag) == "wpt":
|
||||||
|
parsed = _parse_point(child)
|
||||||
|
if parsed:
|
||||||
|
coords.append(parsed)
|
||||||
|
if not coords:
|
||||||
|
raise protocol.AppError("GPX 里没有可用的轨迹点", "bad_gpx")
|
||||||
|
# 不做抽稀:GPX 是需要保形重放的轨迹,丢点会导致导入后形状变化(仅在极端点数时等间隔抽稀)
|
||||||
|
coords = _thin_if_huge(coords)
|
||||||
|
return coords, (name or "导入的 GPX 轨迹"), "gpx"
|
||||||
|
|
||||||
|
|
||||||
|
def _thin_if_huge(coords: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||||
|
"""仅在点数极多时按等间隔抽稀,保证不丢形状(正常大小的 GPX 原样返回)。"""
|
||||||
|
if len(coords) <= MAX_PATH_POINTS:
|
||||||
|
return coords
|
||||||
|
step = len(coords) / float(MAX_PATH_POINTS)
|
||||||
|
out: list[tuple[float, float]] = []
|
||||||
|
index = 0.0
|
||||||
|
while int(index) < len(coords):
|
||||||
|
out.append(coords[int(index)])
|
||||||
|
index += step
|
||||||
|
if out[-1] != coords[-1]:
|
||||||
|
out.append(coords[-1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_point(node: ET.Element) -> tuple[float, float] | None:
|
||||||
|
lat = node.get("lat")
|
||||||
|
lng = node.get("lon")
|
||||||
|
if lat is None or lng is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
flat, flng = protocol.validate_latlng(float(lat), float(lng))
|
||||||
|
except (protocol.AppError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return flat, flng
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ns(tag: str) -> str:
|
||||||
|
return tag.split("}", 1)[1] if "}" in tag else tag
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_speed(route: Route) -> float:
|
||||||
|
if not route.speeds:
|
||||||
|
return 1.0
|
||||||
|
return max(0.2, sum(route.speeds) / len(route.speeds))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_duration(seconds: float) -> str:
|
||||||
|
total = int(max(0, round(seconds)))
|
||||||
|
hours, rem = divmod(total, 3600)
|
||||||
|
minutes, secs = divmod(rem, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours} 小时 {minutes} 分"
|
||||||
|
if minutes:
|
||||||
|
return f"{minutes} 分 {secs} 秒"
|
||||||
|
return f"{secs} 秒"
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_now() -> str:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_offset(seconds: float) -> str:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=float(seconds))
|
||||||
|
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(elem: ET.Element, level: int = 0) -> None:
|
||||||
|
pad = "\n" + " " * level
|
||||||
|
if len(elem):
|
||||||
|
if not elem.text or not elem.text.strip():
|
||||||
|
elem.text = pad + " "
|
||||||
|
for child in elem:
|
||||||
|
_indent(child, level + 1)
|
||||||
|
if not child.tail or not child.tail.strip():
|
||||||
|
child.tail = pad
|
||||||
|
if level and (not elem.tail or not elem.tail.strip()):
|
||||||
|
elem.tail = pad
|
||||||
|
|
||||||
|
|
||||||
|
def haversine_total(path: list[tuple[float, float]]) -> float:
|
||||||
|
if len(path) < 2:
|
||||||
|
return 0.0
|
||||||
|
return geoutils.cumulative_lengths(path)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_route_payload(payload: Any) -> Route:
|
||||||
|
"""前端 play.start 直接传 route 时,从 payload 还原 Route 对象。"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise protocol.AppError("route 必须是对象", "bad_route")
|
||||||
|
path_raw = payload.get("path") or []
|
||||||
|
path = parse_waypoints(path_raw, limit=MAX_REQUEST_POINTS)
|
||||||
|
if len(path) < 1:
|
||||||
|
waypoints = parse_waypoints(payload.get("waypoints") or [])
|
||||||
|
if len(waypoints) < 1:
|
||||||
|
raise protocol.AppError("轨迹为空,请先添加途经点或绘制轨迹", "empty_route")
|
||||||
|
path = waypoints
|
||||||
|
speeds_raw = payload.get("speeds") or []
|
||||||
|
mode = protocol.validate_mode(payload.get("mode"))
|
||||||
|
route = Route(
|
||||||
|
name=str(payload.get("name") or "未命名轨迹")[:80],
|
||||||
|
mode=mode,
|
||||||
|
waypoints=parse_waypoints(payload.get("waypoints") or []) or [path[0], path[-1]],
|
||||||
|
path=path,
|
||||||
|
engine=str(payload.get("engine") or "manual")[:16],
|
||||||
|
loop=bool(payload.get("loop")),
|
||||||
|
speed_scale=protocol.validate_speed_scale(payload.get("speed_scale") or 1.0),
|
||||||
|
)
|
||||||
|
if len(speeds_raw) == len(path):
|
||||||
|
base = protocol.MODE_DEFAULTS.get(mode, 13.9)
|
||||||
|
speeds: list[float] = []
|
||||||
|
for value in speeds_raw:
|
||||||
|
# 非有限数(NaN/Infinity)或越界值一律回退到基准速度,避免污染整条链路
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
speeds.append(base)
|
||||||
|
continue
|
||||||
|
if not math.isfinite(number) or not (0.05 <= number <= 400.0):
|
||||||
|
speeds.append(base)
|
||||||
|
else:
|
||||||
|
speeds.append(number)
|
||||||
|
route.speeds = speeds
|
||||||
|
else:
|
||||||
|
route.speeds = compute_speeds(path, mode, protocol.MODE_DEFAULTS.get(mode, 13.9))
|
||||||
|
route._acc = geoutils.cumulative_lengths(path)
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
def default_route_json(engine: RouteEngine, waypoints: list[Any], mode: str = "driving") -> dict[str, Any]:
|
||||||
|
return engine.build(waypoints, mode=mode).to_json()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"""iOS 17+ 的 RSD 隧道管理。
|
||||||
|
|
||||||
|
背景(见 docs/DEVICE_NOTES.md 的取证结论):
|
||||||
|
- iOS 17 及以上,DVT 服务不再走 usbmux 代理,必须先在 Mac 与设备之间建立 RSD 隧道。
|
||||||
|
- 经典隧道(kernel utun)需要 root;`pymobiledevice3 remote start-tunnel` 在 macOS 上支持
|
||||||
|
`--native`:借用 Apple 自带 remoted 的隧道,**不需要 root**,并把内核可路由的 RSD 地址打印出来
|
||||||
|
(--script-mode 输出 "HOST PORT"),因此可以被其它进程直接使用。
|
||||||
|
- 该 native 隧道与 remoted 共用设备的单条 RSD 连接,会互相挤掉,所以同一时间只应有一个客户端
|
||||||
|
(即:本工具运行时应关闭爱思助手等同类工具)。
|
||||||
|
- 如果 native 不可用,退化为 `osascript` 弹窗向用户索取管理员密码,再跑经典隧道。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
|
||||||
|
POLL_INTERVAL = 0.25
|
||||||
|
START_TIMEOUT = 75.0
|
||||||
|
_HINT_NATIVE_FAILED = (
|
||||||
|
"无法建立隧道:iOS 17 及以上需要 RSD 隧道。请确认:① 设备已用数据线连接并解锁;"
|
||||||
|
"② 已在手机上开启「开发者模式」;③ 关闭爱思助手等同类工具(同一个 RSD 连接会被互相挤掉);"
|
||||||
|
"④ 若仍失败,可尝试管理员模式:在终端执行 sudo ./run.sh"
|
||||||
|
)
|
||||||
|
_HINT_NATIVE_FAILED_SHORT = "隧道建立失败:请关闭同类工具后重试,或用 sudo ./run.sh 以管理员模式启动"
|
||||||
|
|
||||||
|
|
||||||
|
class TunnelManager:
|
||||||
|
"""建立/持有/释放 RSD 隧道,并对外暴露 (host, port)。"""
|
||||||
|
|
||||||
|
def __init__(self, venv_python: str | None = None, log=None, on_change=None) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.state = "idle" # idle|starting|connected|error|unsupported
|
||||||
|
self.message = ""
|
||||||
|
self.host: str | None = None
|
||||||
|
self.port: int | None = None
|
||||||
|
self.udid: str | None = None
|
||||||
|
self.mode: str | None = None # native|sudo|manual
|
||||||
|
self._process: subprocess.Popen[str] | None = None
|
||||||
|
self._reader: threading.Thread | None = None
|
||||||
|
self._needs_root = False
|
||||||
|
self._log = log
|
||||||
|
self._on_change = on_change
|
||||||
|
self.venv_python = venv_python or _detect_python()
|
||||||
|
|
||||||
|
# ---------------- 状态 ----------------
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"message": self.message or _default_message(self.state),
|
||||||
|
"rsd": f"{self.host} {self.port}" if self.host and self.port else None,
|
||||||
|
"mode": self.mode,
|
||||||
|
"udid": self.udid,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _emit(self, level: str, message: str) -> None:
|
||||||
|
if self._log:
|
||||||
|
try:
|
||||||
|
self._log(level, message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_state(self, state: str, message: str = "", **fields: Any) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.state = state
|
||||||
|
self.message = message
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
if self._on_change:
|
||||||
|
try:
|
||||||
|
self._on_change()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return bool(self.host and self.port) and self.state == "connected"
|
||||||
|
|
||||||
|
def address(self) -> tuple[str, int] | None:
|
||||||
|
with self._lock:
|
||||||
|
if self.host and self.port:
|
||||||
|
return self.host, self.port
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---------------- 启动 ----------------
|
||||||
|
def ensure(self, udid: str | None, allow_sudo: bool = False) -> dict[str, Any]:
|
||||||
|
"""保证隧道可用。已连接且 UDID 未变时直接复用。"""
|
||||||
|
with self._lock:
|
||||||
|
if self.ready and (udid is None or self.udid in (None, udid)):
|
||||||
|
return self.snapshot()
|
||||||
|
self.stop()
|
||||||
|
return self.start(udid=udid, allow_sudo=allow_sudo)
|
||||||
|
|
||||||
|
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict[str, Any]:
|
||||||
|
# 设备在 iOS 17+ 上只有一条 RSD 连接:残留的旧隧道进程会与新的互相挤掉,
|
||||||
|
# 因此启动前先清理上一轮遗留的隧道进程(本工具异常退出时可能留下)。
|
||||||
|
if not allow_sudo:
|
||||||
|
self._kill_stale_tunnels()
|
||||||
|
self._set_state("starting", "正在建立 RSD 隧道…", udid=udid, mode=None, host=None, port=None)
|
||||||
|
command, mode = self._build_command(udid=udid, allow_sudo=allow_sudo)
|
||||||
|
if command is None:
|
||||||
|
self._set_state("unsupported", _HINT_NATIVE_FAILED, mode=None)
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
|
env.setdefault("PYTHONPATH", str(_project_root()))
|
||||||
|
self._emit("info", f"启动隧道({mode} 模式):{' '.join(_mask(command))}")
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_state("error", f"隧道进程启动失败:{exc}", mode=mode)
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._process = process
|
||||||
|
self.mode = mode
|
||||||
|
self._needs_root = mode == "sudo"
|
||||||
|
if mode != "sudo":
|
||||||
|
_write_pidfile([process.pid])
|
||||||
|
self._reader = threading.Thread(target=self._read_output, args=(process,), name="tunnel-reader", daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
|
||||||
|
deadline = time.time() + START_TIMEOUT
|
||||||
|
while time.time() < deadline:
|
||||||
|
with self._lock:
|
||||||
|
if self.ready:
|
||||||
|
return self.snapshot()
|
||||||
|
if self.state == "error":
|
||||||
|
return self.snapshot()
|
||||||
|
if process.poll() is not None and not self.ready:
|
||||||
|
break
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
if not self.ready:
|
||||||
|
message = _HINT_NATIVE_FAILED_SHORT
|
||||||
|
if mode == "sudo":
|
||||||
|
message = "管理员隧道启动失败:请在终端手动执行 sudo ./run.sh 查看具体报错"
|
||||||
|
self.stop()
|
||||||
|
self._set_state("error", message, mode=mode)
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def _build_command(self, udid: str | None, allow_sudo: bool) -> tuple[list[str] | None, str]:
|
||||||
|
"""拼出"建隧道"命令。
|
||||||
|
|
||||||
|
- 打包成 .app(sys.frozen):`<本可执行文件> tunnel-helper --udid X`
|
||||||
|
—— 这条路径不依赖机器上有 Python/pymobiledevice3;
|
||||||
|
- 源码运行:`python -m pymobiledevice3 remote start-tunnel --native --script-mode`。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
base = [sys.executable, "tunnel-helper"]
|
||||||
|
else:
|
||||||
|
cli = self._cli_command()
|
||||||
|
if cli is None:
|
||||||
|
return None, "native"
|
||||||
|
base = [*cli, "remote", "start-tunnel", "--native", "--script-mode"]
|
||||||
|
command = list(base)
|
||||||
|
if udid:
|
||||||
|
command += ["--udid", udid]
|
||||||
|
if not allow_sudo:
|
||||||
|
return command, "native"
|
||||||
|
inner = " ".join(_shell_quote(part) for part in command)
|
||||||
|
script = f'do shell script "{inner} > /tmp/iosloc-tunnel.log 2>&1 &" with administrator privileges'
|
||||||
|
return ["osascript", "-e", script], "sudo"
|
||||||
|
|
||||||
|
def _kill_stale_tunnels(self) -> None:
|
||||||
|
"""清理**本工具自己**遗留在后台的 start-tunnel 进程。
|
||||||
|
|
||||||
|
iOS 17+ 设备只有一条 RSD 连接:残留进程会与新建的隧道互相挤掉。
|
||||||
|
为了避免误杀用户在终端手动起的隧道或其它工具,这里只处理本项目上次运行时
|
||||||
|
记录在 pid 文件里的进程(会先用 ps 校验命令行确实是 start-tunnel)。
|
||||||
|
"""
|
||||||
|
pids = _read_pidfile()
|
||||||
|
if not pids:
|
||||||
|
return
|
||||||
|
alive: list[int] = []
|
||||||
|
for pid in pids:
|
||||||
|
command = _command_of(pid)
|
||||||
|
if command and "start-tunnel" in command:
|
||||||
|
alive.append(pid)
|
||||||
|
_write_pidfile([])
|
||||||
|
if not alive:
|
||||||
|
return
|
||||||
|
self._emit("warn", f"发现 {len(alive)} 个上次遗留的隧道进程,正在清理…")
|
||||||
|
for pid in alive:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(0.3)
|
||||||
|
if _command_of(pid) is None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cli_command(self) -> list[str] | None:
|
||||||
|
"""返回"建隧道"命令的前缀。
|
||||||
|
|
||||||
|
- 打包成 .app 独立运行时(sys.frozen):用本可执行文件 + `tunnel-helper` 子命令
|
||||||
|
(递归调用自己),因此**不需要机器上装 Python 或 pymobiledevice3**;
|
||||||
|
- 源码运行时:优先 venv 里的 `python -m pymobiledevice3`,其次 PATH 里的可执行文件。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return [sys.executable, "tunnel-helper"]
|
||||||
|
candidate = _detect_python()
|
||||||
|
if candidate:
|
||||||
|
try:
|
||||||
|
probe = subprocess.run(
|
||||||
|
[candidate, "-c", "import pymobiledevice3, sys; print(pymobiledevice3.__version__ if hasattr(pymobiledevice3,'__version__') else 'ok')"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=25,
|
||||||
|
)
|
||||||
|
if probe.returncode == 0:
|
||||||
|
return [candidate, "-m", "pymobiledevice3"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
direct = shutil.which("pymobiledevice3")
|
||||||
|
if direct:
|
||||||
|
return [direct]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---------------- 输出解析 ----------------
|
||||||
|
def _read_output(self, process: subprocess.Popen[str]) -> None:
|
||||||
|
pattern = re.compile(r"(?P<host>[0-9a-fA-F:.]{3,})\s+(?P<port>\d{2,5})")
|
||||||
|
assert process.stdout is not None
|
||||||
|
for line in process.stdout:
|
||||||
|
text = line.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
self._emit("info", f"[tunnel] {text}")
|
||||||
|
if self.ready:
|
||||||
|
continue
|
||||||
|
found = _parse_rsd_line(text, pattern)
|
||||||
|
if found:
|
||||||
|
host, port = found
|
||||||
|
self._set_state(
|
||||||
|
"connected",
|
||||||
|
f"隧道已建立({self.mode or 'native'}):{host} 端口 {port}",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
code = process.poll()
|
||||||
|
if code is not None and not self.ready:
|
||||||
|
self._set_state("error", f"隧道进程已退出(代码 {code}):{_HINT_NATIVE_FAILED_SHORT}")
|
||||||
|
|
||||||
|
# ---------------- 手动 / 释放 ----------------
|
||||||
|
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict[str, Any]:
|
||||||
|
host = (host or "").strip()
|
||||||
|
try:
|
||||||
|
iport = int(port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise protocol.AppError("RSD 端口必须是数字", "bad_rsd")
|
||||||
|
if not host:
|
||||||
|
raise protocol.AppError("RSD 地址不能为空", "bad_rsd")
|
||||||
|
self.stop()
|
||||||
|
self._set_state("connected", f"已使用手动指定的 RSD:{host} 端口 {iport}", host=host, port=iport, mode="manual", udid=udid)
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
process = self._process
|
||||||
|
self._process = None
|
||||||
|
if process is not None and process.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
process.wait(timeout=4)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self.host = None
|
||||||
|
self.port = None
|
||||||
|
self.mode = None
|
||||||
|
if self.state in ("connected", "starting", "error", "unsupported"):
|
||||||
|
self.state = "idle"
|
||||||
|
self.message = _default_message("idle")
|
||||||
|
_write_pidfile([])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rsd_line(text: str, pattern: re.Pattern[str]) -> tuple[str, int] | None:
|
||||||
|
if text.startswith("--rsd "):
|
||||||
|
parts = text.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
return parts[1], int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if "RSD Port" in text:
|
||||||
|
return None
|
||||||
|
match = pattern.fullmatch(text) or pattern.match(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
host = match.group("host")
|
||||||
|
if ":" not in host and "." not in host:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
port = int(match.group("port"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not (1 <= port <= 65535):
|
||||||
|
return None
|
||||||
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
|
def _default_message(state: str) -> str:
|
||||||
|
return {
|
||||||
|
"idle": "隧道未建立(iOS 17 及以上需要)",
|
||||||
|
"starting": "正在建立隧道…",
|
||||||
|
"connected": "隧道已连接",
|
||||||
|
"error": _HINT_NATIVE_FAILED,
|
||||||
|
"unsupported": "当前环境不支持自动建隧道",
|
||||||
|
}.get(state, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(command: list[str]) -> list[str]:
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def _pidfile_path() -> str:
|
||||||
|
return os.path.join(_project_root(), ".tunnel.pids")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pidfile() -> list[int]:
|
||||||
|
try:
|
||||||
|
with open(_pidfile_path(), "r", encoding="utf-8") as handle:
|
||||||
|
return [int(line.strip()) for line in handle if line.strip().isdigit()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pidfile(pids: list[int]) -> None:
|
||||||
|
try:
|
||||||
|
path = _pidfile_path()
|
||||||
|
if not pids:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
return
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("\n".join(str(pid) for pid in pids))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _command_of(pid: int) -> str | None:
|
||||||
|
"""返回进程命令行;进程不存在返回 None。"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
text = (result.stdout or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_quote(text: str) -> str:
|
||||||
|
return "'" + text.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> str:
|
||||||
|
"""数据目录根:源码运行用项目目录,打包后只读,改用用户可写目录。"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return _writable_data_dir()
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _writable_data_dir() -> str:
|
||||||
|
"""打包运行时可写的数据目录(~/Library/Application Support/...)。"""
|
||||||
|
base = os.path.expanduser("~/Library/Application Support/iOSLocationTrack")
|
||||||
|
try:
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
base = os.path.expanduser("~")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_python() -> str | None:
|
||||||
|
"""返回项目 venv 的 python,找不到就退回当前解释器。"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = _project_root()
|
||||||
|
for candidate in (
|
||||||
|
os.path.join(root, ".venv", "bin", "python"),
|
||||||
|
os.path.join(root, ".venv", "bin", "python3"),
|
||||||
|
):
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def tunnel_supported() -> bool:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
return platform.system() == "Darwin"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 虚拟定位轨迹工具 — 前后端接口契约(v1,冻结)
|
||||||
|
|
||||||
|
所有实现必须严格遵守本文件。**任何一方需要改动契约,必须先改本文件并通知对方。**
|
||||||
|
|
||||||
|
## 1. 进程与端口
|
||||||
|
|
||||||
|
- 单进程:Python HTTP/WebSocket 服务监听 `127.0.0.1:8787`(可用 `--port` 覆盖)。
|
||||||
|
- 静态前端由服务端从 `web/` 目录提供,入口 `GET /` → `web/index.html`。
|
||||||
|
- 无外部网络依赖(除地图瓦片与可选路线规划 OSRM)。
|
||||||
|
|
||||||
|
## 2. REST API
|
||||||
|
|
||||||
|
所有响应 `Content-Type: application/json; charset=utf-8`。失败统一返回:
|
||||||
|
`{"ok": false, "error": "<中文可读信息>", "code": "<machine_code>"}`,HTTP 状态码 400/404/409/500。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/health` | `{"ok":true,"version":"1.0.0","mock":false,"device_count":1}` |
|
||||||
|
| GET | `/api/devices` | 设备列表(见 2.1) |
|
||||||
|
| POST | `/api/devices/select` | body `{"udid":"..."}` → `{"ok":true,"device":<Device>}` |
|
||||||
|
| POST | `/api/devices/connect` | 建立隧道/DVT 会话 → `{"ok":true,"device":<Device>,"tunnel":{"state":"connected","ios_major":26}}` |
|
||||||
|
| POST | `/api/devices/disconnect` | 释放会话 → `{"ok":true}` |
|
||||||
|
| GET | `/api/config` | `{"ok":true,"config":{...}}` |
|
||||||
|
| POST | `/api/config` | 局部更新,body 为部分字段 → `{"ok":true,"config":{...}}` |
|
||||||
|
| GET | `/api/settings/tiles` | 瓦片源列表 + 当前选择 |
|
||||||
|
| GET | `/api/tiles/{source}/{z}/{x}/{y}.png` | 瓦片代理 + 本地磁盘缓存;命中/回源均返回图片字节;全部源失败 → 404 + JSON 错误 |
|
||||||
|
| POST | `/api/route/build` | body `{"waypoints":[[lng,lat],...],"mode":"driving"}` → `{"ok":true,"route":<Route>,"engine":"osrm"\|"straight"}` |
|
||||||
|
| POST | `/api/gpx/export` | body `{"route":<Route>,"name":"..."}` → 返回 GPX 文本(`application/gpx+xml`) |
|
||||||
|
| POST | `/api/gpx/import` | body 为 GPX 文本(`text/plain` 或 multipart)→ `{"ok":true,"waypoints":[[lng,lat],...],"path":[...],"points":[...],"name":"...","count":N}`。三个数组内容相同:**全量坐标、不抽稀**,前端用 `path` 保形重放 |
|
||||||
|
| POST | `/api/tunnel` | body `{}` 建隧道(可选 `{"sudo":true}` 走管理员);或 `{"host":"...","port":1234}` 手动指定 → `{"ok":true,"tunnel":{...}}` |
|
||||||
|
|
||||||
|
### 2.1 Device 对象
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"udid": "00008110-000A1B2C3D4E801E",
|
||||||
|
"name": "iPhone 15 Pro",
|
||||||
|
"product_type": "iPhone16,1",
|
||||||
|
"ios_version": "26.0",
|
||||||
|
"ios_major": 26,
|
||||||
|
"connection": "usb",
|
||||||
|
"state": "connected",
|
||||||
|
"tunnel": "idle",
|
||||||
|
"simulating": false,
|
||||||
|
"developer_mode": true,
|
||||||
|
"tunnel_hint": null,
|
||||||
|
"last_error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `state`: `connected` | `unpaired` | `unavailable`
|
||||||
|
- `tunnel`: `idle` | `starting` | `connected` | `error` | `unsupported`
|
||||||
|
- `tunnel_hint`: iOS≥17 需要隧道而失败时的中文修复建议(例如「需要管理员权限建立隧道:sudo ./run.sh」),否则 `null`。
|
||||||
|
- `developer_mode`: iOS≥16 未开启开发者模式时为 `false`,前端需显著提示。
|
||||||
|
|
||||||
|
### 2.2 Route 对象
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "r-1712345678901",
|
||||||
|
"name": "未命名轨迹",
|
||||||
|
"mode": "driving",
|
||||||
|
"waypoints": [[116.397, 39.908], [116.401, 39.912]],
|
||||||
|
"path": [[116.397, 39.908], [116.3971, 39.9082]],
|
||||||
|
"speeds": [8.3, 8.5],
|
||||||
|
"distance": 1234.5,
|
||||||
|
"duration": 148.6,
|
||||||
|
"engine": "osrm",
|
||||||
|
"loop": false,
|
||||||
|
"speed_scale": 1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `waypoints` 用户途经点(含起点/终点),`path` 是实际驱动用的密集坐标序列(`[lng,lat]`,WGS84)。
|
||||||
|
- `speeds`:与 `path` 等长的瞬时速度 m/s(仅用于展示与导出);`speed_scale` 是播放倍速。
|
||||||
|
- `distance` 米;`duration` 秒(= 按 `speeds` 积分的结果)。
|
||||||
|
|
||||||
|
### 2.3 配置文件 `config.json`(与 `main.py` 同目录)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tile_source": "amap",
|
||||||
|
"tile_sources": {
|
||||||
|
"amap": {"name": "高德矢量", "url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}", "subdomains": ["1","2","3","4"], "max_zoom": 18, "attribution": "© 高德地图"},
|
||||||
|
"amap_sat":{"name": "高德卫星", "url": "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}", "subdomains": ["1","2","3","4"], "max_zoom": 18, "attribution": "© 高德地图"},
|
||||||
|
"tencent": {"name": "腾讯地图", "url": "https://rt{s}.map.gtimg.com/tile?z={z}&x={x}&y={y}&type=vector&styleid=3", "subdomains": ["0","1","2","3"], "max_zoom": 18, "attribution": "© 腾讯地图"},
|
||||||
|
"arcgis_sat": {"name": "全球卫星", "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", "subdomains": [], "max_zoom": 19, "attribution": "Esri"}
|
||||||
|
},
|
||||||
|
"tile_cache_dir": "~/.ios-location-tiles",
|
||||||
|
"tile_cache_max_mb": 1024,
|
||||||
|
"send_hz": 5,
|
||||||
|
"default_speed_scale": 1.0,
|
||||||
|
"mode_defaults": {"driving": 13.9, "cycling": 5.5, "walking": 1.4, "flight": 60.0},
|
||||||
|
"osrm_url": "https://router.project-osrm.org/route/v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**坐标系统一为 WGS84**。前端若使用高德瓦片,仅瓦片源不同(高德瓦片为 GCJ-02),显示用点必须做 WGS84→GCJ-02 转换后再落到瓦片上,反向转换用于取点。转换函数放在 `web/js/coord.js`,导出 `wgs84ToGcj02` / `gcj02ToWgs84`。**后端永远只接收/返回 WGS84。**
|
||||||
|
|
||||||
|
## 3. WebSocket API
|
||||||
|
|
||||||
|
- 地址:`ws://127.0.0.1:8787/ws`。UTF-8 文本帧,每条消息一个 JSON 对象。
|
||||||
|
- 客户端 → 服务端:`{"type": "<cmd>", "id": "<可选请求id>", ...}`
|
||||||
|
- 服务端 → 客户端:`{"type": "<event>", "id": "<回填请求id>", ...}`,`id` 存在时表示对某条命令的应答。
|
||||||
|
|
||||||
|
### 3.1 客户端命令
|
||||||
|
|
||||||
|
| type | 字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hello` | `{"client":"web"}` | 连接后立即发送,服务端回 `welcome` |
|
||||||
|
| `ping` | `{}` | 回 `pong`,用于心跳(客户端每 10s 发一次) |
|
||||||
|
| `devices.refresh` | `{}` | 回 `devices` |
|
||||||
|
| `device.select` | `{"udid":""}` | 回 `device` + `tunnel` |
|
||||||
|
| `device.connect` | `{}` | 回 `device`/`tunnel`,可能先推多条 `log` |
|
||||||
|
| `device.disconnect` | `{}` | 回 `device`/`tunnel` |
|
||||||
|
| `location.set` | `{"lng":116.397,"lat":39.908}` | 单点定位,回 `ack`,随后推 `location` |
|
||||||
|
| `location.clear` | `{}` | 停止模拟、恢复真实定位,回 `ack` |
|
||||||
|
| `play.start` | `{"route":<Route>,"speed_scale":1.0,"loop":false,"start_index":0}` | 开始沿轨迹播放。**服务端以收到的 route 为准**(前端可只发 `path`/`speeds`)。回 `ack`;随后持续推 `status`/`location` |
|
||||||
|
| `play.pause` | `{}` | 回 `ack` |
|
||||||
|
| `play.resume` | `{}` | 回 `ack` |
|
||||||
|
| `play.stop` | `{}` | 停止播放并停止模拟(恢复真实定位),回 `ack` |
|
||||||
|
| `play.seek` | `{"progress":0.42}` | 0..1,回 `ack` 并推 `location` |
|
||||||
|
| `play.speed` | `{"speed_scale":2.0}` | 播放倍速 0.1..50,回 `ack` |
|
||||||
|
| `log.list` | `{"limit":200}` | 回 `logs` |
|
||||||
|
| `route.build` | `{"waypoints":[[lng,lat],...],"mode":"driving","name":"...","use_osrm":true,"loop":false,"speed_scale":1.0}` | 回 `ack{route,engine}`;便于前端在 WS 上一次完成规划 |
|
||||||
|
| `tunnel.ensure` | `{"sudo":false}` | 建立/复用隧道 → `ack{tunnel,need_tunnel}`。`need_tunnel:false` 表示该场景不需要隧道(mock 模式,或 iOS<17 走 usbmux 经典通道),前端应立即结束等待 |
|
||||||
|
| `tunnel.manual` | `{"host":"fd00::1","port":51234}` | 使用手动指定的 RSD → `ack{tunnel}` |
|
||||||
|
| `tiles.clear` | `{}` | 清空瓦片磁盘缓存 → `ack{removed:N}` |
|
||||||
|
| `config.update` | `{"config":{"send_hz":5,"tile_source":"amap"}}` | 局部更新配置 → `ack{config}` |
|
||||||
|
|
||||||
|
### 3.2 服务端事件
|
||||||
|
|
||||||
|
| type | 字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `welcome` | `{"version":"1.0.0","mock":false,"config":{...}}` | 连接就绪,附带当前配置与设备快照 |
|
||||||
|
| `pong` | `{"ts":1712345678.1}` | 心跳应答 |
|
||||||
|
| `ack` | `{"id":"<回填>","cmd":"play.start"}` 或 `{"id":"...","ok":false,"error":"..."}` | 命令应答 |
|
||||||
|
| `devices` | `{"devices":[<Device>...],"selected":"<udid\|null>"}` | 设备列表变化 |
|
||||||
|
| `device` | `{"device":<Device>\|null}` | 单设备状态变化 |
|
||||||
|
| `tunnel` | `{"state":"connected","ios_major":26,"message":"隧道已建立(RSD)","rsd":"fd00::1 端口 51234"}` | 隧道状态变化 |
|
||||||
|
| `location` | `{"lng":116.397,"lat":39.908,"source":"play"\|"manual","progress":0.42,"index":1234,"speed":8.31,"sent_at":1712345678.123}` | 每次实际下发定位后推送 |
|
||||||
|
| `status` | `{"playing":true,"paused":false,"progress":0.42,"index":1234,"total":3000,"elapsed":61.2,"remaining":88.0,"speed_scale":1.0,"loop":false,"distance_done":512.3,"lng":116.39,"lat":39.9,"seq":7}` | 播放状态,播放中约 2Hz 推送,状态变化时立即推送。`seq` 为自增序号,用于判断新旧状态 |
|
||||||
|
| `logs` | `{"lines":[{"ts":1712345678.1,"level":"info","msg":"..."}]}` | 日志回填 |
|
||||||
|
| `log` | `{"ts":...,"level":"info"\|"warn"\|"error"\|"success","msg":"..."}` | 实时日志 |
|
||||||
|
| `error` | `{"error":"中文信息","code":"..."}` | 服务端异常 |
|
||||||
|
|
||||||
|
### 3.3 语义约定
|
||||||
|
|
||||||
|
- `play.start` 时若尚未连接设备:立即回 `ack ok:false, error:"请先连接设备"`(mock 模式除外)。
|
||||||
|
- `play.stop` 与 `location.clear` 必须调用 DVT `clear_location`,让手机恢复真实定位,并额外推一条
|
||||||
|
`{"type":"location","source":"cleared","lng":null,"lat":null}`,前端应让地图上的定位点消失。
|
||||||
|
- 播放循环 `loop=true` 时到终点自动回到起点,`status.loop` 保持 `true`。
|
||||||
|
- 服务端播放引擎按 `send_hz`(默认 5Hz)下发;`play.speed` 只影响轨迹推进速率,不影响下发频率。
|
||||||
|
- 逐次下发的日志按 5 秒聚合成一条「正在按轨迹下发定位(已下发 N 次)」,不会 5Hz 刷屏。
|
||||||
|
- 所有 `lng/lat` 单位度,WGS84,保留 6 位小数。
|
||||||
|
- `hello` 的应答是 `ack`(`welcome` 的数据并进 ack,**没有独立的 `type:"welcome"` 消息**):
|
||||||
|
`{type:"ack", id, cmd:"hello", version, name, mock, config, devices, selected, tunnel, backend, pymobiledevice3}`。
|
||||||
|
- WebSocket 子协议:只有客户端在 `Sec-WebSocket-Protocol` 里请求了值时才回该头(RFC 6455 要求),
|
||||||
|
否则浏览器会判定握手失败。
|
||||||
|
|
||||||
|
## 4. 无设备模拟模式(验收必备)
|
||||||
|
|
||||||
|
启动参数 `--mock` 或环境变量 `IOSLOC_MOCK=1` 时:
|
||||||
|
|
||||||
|
- `/api/devices` 返回一台假设备:`udid="MOCK-0001"`, `name="模拟设备 (iPhone 17 Pro)"`, `ios_version="26.0"`, `ios_major=26`, `state="connected"`, `tunnel="connected"`。
|
||||||
|
- 定位下发不报错,仅记录日志并照常推送 `location` 事件。
|
||||||
|
- 其余行为(播放引擎、进度、GPX、瓦片缓存)与真机完全一致,用于端到端验证。
|
||||||
|
|
||||||
|
## 5. 模块与文件所有权(写作用域)
|
||||||
|
|
||||||
|
| 文件 | 负责人 |
|
||||||
|
| --- | --- |
|
||||||
|
| `core/protocol.py` | lead |
|
||||||
|
| `core/ws.py` | lead |
|
||||||
|
| `core/server.py` | lead |
|
||||||
|
| `core/route_engine.py` | lead |
|
||||||
|
| `core/tilecache.py` | lead |
|
||||||
|
| `core/device.py` | lead |
|
||||||
|
| `core/tunnel.py` | lead |
|
||||||
|
| `core/location_provider.py` | device-teammate |
|
||||||
|
| `core/location_mock.py` | device-teammate |
|
||||||
|
| `core/playback.py` | lead |
|
||||||
|
| `core/config_store.py` | lead |
|
||||||
|
| `main.py` | lead |
|
||||||
|
| `web/index.html`, `web/style.css` | web-teammate |
|
||||||
|
| `web/js/*.js` | web-teammate |
|
||||||
|
| `tools/*.py` | device-teammate |
|
||||||
|
| `scripts/*`, `README.md` | lead |
|
||||||
|
|
||||||
|
任何人不得直接改别人拥有的文件;需要改动就发消息商量。
|
||||||
@@ -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 告警 |
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
# Windows 移植交接文档
|
||||||
|
|
||||||
|
> 本文档由 macOS 侧的开发会话产出,用于把「iOS 虚拟定位轨迹工具」移植到 Windows。
|
||||||
|
> 仓库:https://github.com/jbjf6666/ios-location-track (macOS 版已发布,v1.0.0)
|
||||||
|
>
|
||||||
|
> **给 Windows 会话的建议**:先完整读一遍本文档,再动手。最大的风险点(iOS 17+ 隧道)
|
||||||
|
> 集中在第 3 节,**请先做那里的可行性验证再决定整体架构**——不要先写界面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话结论
|
||||||
|
|
||||||
|
**约 90% 的代码可以直接复用**(纯 Python + 纯前端,零平台依赖)。
|
||||||
|
真正需要重写的只有两处:**iOS 17+ 的隧道建立方式**(macOS 用 Apple 私有框架,Windows 必须换方案)
|
||||||
|
和**打包/分发链路**(DMG → 安装包)。窗口启动方式也需改。
|
||||||
|
|
||||||
|
总计约 70 行平台相关代码需要替换,其余 6000+ 行可原样移植。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目结构与可移植性对照表
|
||||||
|
|
||||||
|
| 文件 | 行数 | 可移植性 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `web/` (index.html / style.css / js/*) | ~2500 | ✅ **完全可移植** | 纯前端,零改动。自绘 Canvas 地图,无第三方依赖 |
|
||||||
|
| `core/<br>playback.py` | 687 | ✅ 完全可移植 | 播放引擎(弧长积分、速度剖面、seek、循环) |
|
||||||
|
| `core/route_engine.py` | 576 | ✅ 完全可移植 | 路线规划(OSRM + 直线退化、途经点) |
|
||||||
|
| `core/geoutils.py` | 234 | ✅ 完全可移植 | 坐标换算(WGS84 / GCJ-02、haversine、抽稀) |
|
||||||
|
| `core/noise.py` | 208 | ✅ 完全可移植 | 真实感模型(多尺度值噪声、漂移、抖动、粗差) |
|
||||||
|
| `core/config_store.py` | 312 | ✅ 完全可移植 | 配置持久化(路径需调整) |
|
||||||
|
| `core/ws.py` | 326 | ✅ 完全可移植 | 手写 WebSocket(RFC 6455),基于 stdlib |
|
||||||
|
| `core/tilecache.py` | 323 | ✅ 完全可移植 | 瓦片代理 + 磁盘 LRU 缓存 |
|
||||||
|
| `core/protocol.py` | 160 | ✅ 完全可移植 | WS 协议常量与错误类型 |
|
||||||
|
| `core/location_mock.py` | 229 | ✅ 完全可移植 | 无设备模拟模式 |
|
||||||
|
| `core/device.py` | 427 | ⚠️ 基本可移植 | 设备发现用 `pymobiledevice3.usbmux`,Windows 上走 Apple Mobile Device 服务(iTunes 组件) |
|
||||||
|
| `core/location_provider.py` | 701 | ⚠️ 基本可移植 | DVT 会话逻辑跨平台;**只有 1 处** macOS 特定(错误提示文案里的 `./run.sh`) |
|
||||||
|
| `core/server.py` | 1352 | ⚠️ 少量改动 | HTTP + WS 服务、命令分发;约 15 处平台相关(窗口/路径/提示) |
|
||||||
|
| `core/tunnel.py` | 434 | ❌ **需重写** | 隧道建立,见第 3 节 |
|
||||||
|
| `main.py` | 284 | ⚠️ 需改 | 窗口启动(Chrome/Edge 路径)、数据目录 |
|
||||||
|
| `tools/make_dmg.py` | — | ❌ **纯 macOS** | 用 `hdiutil`,Windows 需换安装包方案 |
|
||||||
|
| `tools/make_icon.py` | — | ✅ 可移植 | Pillow 绘制,但 `.icns` 生成用 `iconutil`(macOS)→ Windows 需改成 `.ico` |
|
||||||
|
| `tools/verify_bundle.py` | — | ⚠️ 需改 | 校验逻辑通用,但 `codesign` / `.app` 结构是 macOS 的 |
|
||||||
|
| `Location.spec` | — | ⚠️ 需改 | PyInstaller 配置,Windows 要改成 `.exe` + `BUNDLE` → 无(改用 Inno Setup / NSIS) |
|
||||||
|
| `tests/*` | ~2000 | ✅ 大部分可移植 | `selftest.py` / `drift_sim.py` 纯逻辑,跨平台;`ui_acceptance.py` 需改浏览器路径 |
|
||||||
|
| `resources/ddi/` | 16 MB | ✅ 可移植 | 开发者镜像素材,与平台无关 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Windows 环境准备
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Python 3.11+(建议 3.12,与 macOS 侧一致)
|
||||||
|
2. iTunes(或至少 Apple Mobile Device Support)—— 提供 usbmuxd 等价服务,
|
||||||
|
pymobiledevice3 依赖它才能通过 USB 发现 iOS 设备
|
||||||
|
下载:Microsoft Store 版 iTunes,或 Apple 官网的 iTunes for Windows
|
||||||
|
3. Wintun 驱动(用于 userspace 隧道,见第 3 节)
|
||||||
|
https://www.wintun.net/
|
||||||
|
4. Git
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖安装:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install pymobiledevice3 pillow pyinstaller
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:macOS 侧 `scripts/run.sh` 里写了国内 PyPI 镜像 `mirrors.aliyun.com`,
|
||||||
|
> Windows 侧可沿用(`pip.conf` 里已有配置,Windows 下需放到 `%APPDATA%\pip\pip.ini`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心难点:iOS 17+ 的 RSD 隧道(**必须先验证**)
|
||||||
|
|
||||||
|
### 3.1 为什么 macOS 的实现不能直接用
|
||||||
|
|
||||||
|
iOS 17 起,虚拟定位不再走 usbmux,必须先建立 RSD 隧道。macOS 侧走的是
|
||||||
|
`pymobiledevice3.remote.native_tunnel`,其实现**完全建立在 Apple 私有框架上**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_IS_DARWIN = platform.system() == "Darwin"
|
||||||
|
lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
|
||||||
|
# 然后调用 xpc_connection_create_mach_service、xpc_dictionary_* 等
|
||||||
|
# 以及 ObjC block:_NSConcreteGlobalBlock
|
||||||
|
```
|
||||||
|
|
||||||
|
这些 API 是 macOS 独有的(XPC + Objective-C runtime),**Windows 上不存在**。
|
||||||
|
pymobiledevice3 的 `establish_native_rsd()` 在非 macOS 平台会抛
|
||||||
|
`UserspaceTunnelUnavailableError`。
|
||||||
|
|
||||||
|
### 3.2 Windows 上的可行方案:userspace tunnel
|
||||||
|
|
||||||
|
pymobiledevice3 内置了跨平台的 userspace 隧道实现,Windows 是**明确支持的目标平台**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pymobiledevice3.remote import userspace_tunnel
|
||||||
|
from pymobiledevice3.remote import tunnel_service
|
||||||
|
|
||||||
|
# 关键开关
|
||||||
|
tunnel_service.USE_USERSPACE_TUNNEL = True
|
||||||
|
```
|
||||||
|
|
||||||
|
实现要点(读源码可知):
|
||||||
|
|
||||||
|
- 使用 `pytun_pmd3.TunTapDevice` 创建虚拟网卡(Windows 上底层是 Wintun 驱动)
|
||||||
|
- macOS/Linux 用 AF_UNIX socketpair;**Windows 没有 socketpair,改用 TUN 设备直连**
|
||||||
|
- 该包已在依赖列表中(`pytun_pmd3-3.0.3`),无需额外安装
|
||||||
|
|
||||||
|
### 3.3 验证步骤(**请先做完这一步再写别的代码**)
|
||||||
|
|
||||||
|
> **重要**:`--userspace` 不是 `remote start-tunnel` 的选项(该命令只有 `--native`)。
|
||||||
|
> 实测确认它挂在共享的 RSD 依赖上,**`lockdown start-tunnel` 与 `mounter auto-mount` 才接受它**:
|
||||||
|
>
|
||||||
|
> | 命令 | `--userspace` |
|
||||||
|
> | --- | --- |
|
||||||
|
> | `lockdown start-tunnel` | ✅ 支持 |
|
||||||
|
> | `mounter auto-mount` | ✅ 支持 |
|
||||||
|
> | `remote start-tunnel` | ❌ 不支持(只有 `--native`,且 `--native` 是 macOS 专用) |
|
||||||
|
> | `developer dvt simulate-location` | ❌ 不支持 |
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) 设备能否被发现(需要 iTunes 提供的 Apple Mobile Device 服务)
|
||||||
|
python -m pymobiledevice3 usbmux list
|
||||||
|
|
||||||
|
# 2) 建立 userspace 隧道(Windows 上的正解)
|
||||||
|
python -m pymobiledevice3 lockdown start-tunnel --userspace --script-mode
|
||||||
|
```
|
||||||
|
|
||||||
|
期望输出形如 `HOST PORT`(例如 `fd00:...:1 54321`)—— 这正是 macOS 侧
|
||||||
|
`core/tunnel.py:_read_output()` 解析的格式,**保持这个格式,上层代码就不用改**。
|
||||||
|
|
||||||
|
**底层机制(读源码确认)**:`userspace_tunnel.UserspaceTunnel.aopen()` 内部会设置
|
||||||
|
`tunnel_service.USE_USERSPACE_TUNNEL = True`,从而让 `RemotePairingTunnel.start_tunnel()`
|
||||||
|
通过 `create_tun_device()` 工厂拿到 `UserspaceTun`(底层是 `pytun_pmd3.TunTapDevice` →
|
||||||
|
Windows 上是 Wintun),而不是内核隧道。所以**不需要任何 monkeypatch**,用上面的命令即可。
|
||||||
|
|
||||||
|
**限制**:PyTCP 的协议栈是进程内全局单例,**一个进程同时只能有一条 userspace 隧道**
|
||||||
|
(代码里会抛 `"a userspace tunnel is already active in this process"`)。
|
||||||
|
这对本工具没影响——播放引擎本来就是单一长连接,但要记得别在进程里重复建隧道。
|
||||||
|
|
||||||
|
**必须确认的事项**:
|
||||||
|
|
||||||
|
| 检查项 | 为什么重要 |
|
||||||
|
| --- | --- |
|
||||||
|
| 是否需要管理员权限 | Wintun 装驱动/建网卡通常需要;这决定要不要在界面上加提权按钮 |
|
||||||
|
| Windows 防火墙是否弹窗 | 首次建立隧道可能弹窗,需要写进文档 |
|
||||||
|
| 隧道建立后 `ping6` 是否通 | 验证虚拟网卡真的工作了 |
|
||||||
|
| 断开后网卡是否残留 | 残留会导致下次建立失败,需要清理逻辑 |
|
||||||
|
|
||||||
|
### 3.4 隧道模块的重写接口
|
||||||
|
|
||||||
|
`core/tunnel.py` 的 `TunnelManager` 类已经把隧道抽象好了,**保持以下公开接口不变**,
|
||||||
|
只替换内部实现即可,上层(`server.py` / `location_provider.py`)无需改动:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TunnelManager:
|
||||||
|
def ready(self) -> bool: ... # 隧道是否就绪
|
||||||
|
def address(self) -> tuple[str, int] | None: ... # (host, port)
|
||||||
|
def ensure(self, udid: str | None, allow_sudo: bool = False) -> dict: ...
|
||||||
|
def start(self, udid: str | None = None, allow_sudo: bool = False) -> dict: ...
|
||||||
|
def use_manual(self, host: str, port: int, udid: str | None = None) -> dict: ...
|
||||||
|
def stop(self) -> None: ...
|
||||||
|
def snapshot(self) -> dict: ... # 状态快照,推给前端
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议的实现方式**:照抄现有结构,把 `_build_command()` 换成:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# 注意:不是 remote start-tunnel,那个命令没有 --userspace
|
||||||
|
base = [sys.executable, "-m", "pymobiledevice3",
|
||||||
|
"lockdown", "start-tunnel", "--userspace", "--script-mode"]
|
||||||
|
```
|
||||||
|
|
||||||
|
打包后(`sys.frozen`)同样走自调用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
base = [sys.executable, "tunnel-helper"] # exe 调用自己的子命令
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `tunnel-helper` 内部用 Python API 建立 userspace 隧道:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 注意类名是 UserspaceRsdTunnel(不是 UserspaceTunnel)
|
||||||
|
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
|
||||||
|
|
||||||
|
tunnel = UserspaceRsdTunnel(serial=udid, autopair=True, remotepairing_fallback=True)
|
||||||
|
rsd = await tunnel.aopen() # -> RemoteServiceDiscoveryService
|
||||||
|
host, port = rsd.service.address
|
||||||
|
print(f"{host} {port}", flush=True) # 保持这个格式,上层无需改动
|
||||||
|
|
||||||
|
# 进程退出前释放(否则虚拟网卡可能残留)
|
||||||
|
await tunnel.aclose() # 或 tunnel.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
> 另外提供同步接口 `open()` / `close()`,以及 `UserspaceTun`(TUN 设备本身,
|
||||||
|
> 一般不需要直接用它)。
|
||||||
|
|
||||||
|
> **重要经验**:macOS 侧的 `tunnel-helper` 是「自调用」模式(打包后的 exe 调用自己
|
||||||
|
> 的 `tunnel-helper` 子命令),这样打包后不依赖外部 Python。Windows 侧建议照做。
|
||||||
|
> 实现见 `main.py:run_tunnel_helper()` 与 `core/tunnel.py:_build_command()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 其余需要改动的部分
|
||||||
|
|
||||||
|
### 4.1 窗口启动(`main.py`)
|
||||||
|
|
||||||
|
macOS 侧用 Chrome/Edge 的 `--app=` 模式打开无地址栏窗口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# macOS 路径
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||||
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 需改为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CANDIDATES = [
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
]
|
||||||
|
# 或从注册表读:
|
||||||
|
# HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
`--app={url} --window-size=1440,900` 参数在 Windows 上同样有效。
|
||||||
|
|
||||||
|
**另一个选择**:用 `pywebview`(Windows 上走 Edge WebView2)做成真正的原生窗口,
|
||||||
|
体验更接近 macOS 版。代价是多一个依赖。
|
||||||
|
|
||||||
|
### 4.2 数据目录(`main.py:_resolve_roots()`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# macOS: ~/Library/Application Support/iOSLocationTrack
|
||||||
|
# Windows 应改为:%LOCALAPPDATA%\iOSLocationTrack
|
||||||
|
os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 打包与分发
|
||||||
|
|
||||||
|
| macOS | Windows 对应 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PyInstaller --windowed` + `.app` bundle | `PyInstaller --windowed` 生成 `.exe`(onedir 更稳) |
|
||||||
|
| `codesign --sign -`(ad-hoc 签名) | 无对应;未签名 exe 会有 SmartScreen 警告 |
|
||||||
|
| `hdiutil` 制作 DMG | **Inno Setup**(推荐,免费)或 NSIS 制作 `setup.exe` |
|
||||||
|
| `iconutil` 生成 `.icns` | Pillow 直接存 `.ico`(多尺寸:16/32/48/64/128/256) |
|
||||||
|
|
||||||
|
`tools/make_icon.py` 的绘制代码可完全复用,只需把最后的 `.icns` 输出改成 `.ico`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||||
|
img.save("app.ico", sizes=[(s, s) for s in sizes])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 需要删除或替换的文件
|
||||||
|
|
||||||
|
- `tools/make_dmg.py` —— 纯 macOS,直接删掉
|
||||||
|
- `tools/ddi_offline_fetch.sh` —— bash 脚本,Windows 无用
|
||||||
|
- `scripts/run.sh` / `scripts/build_app.sh` —— 改写为 `.ps1` 或 `.bat`
|
||||||
|
- `Location.spec` —— 改写:去掉 `BUNDLE()`,`icon` 改成 `.ico`,`console=False`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 设备侧注意事项(平台无关,但容易踩坑)
|
||||||
|
|
||||||
|
这些是 macOS 侧实测踩过的坑,Windows 上同样会遇到:
|
||||||
|
|
||||||
|
### 5.1 镜像挂载用 Python API,不要用 CLI
|
||||||
|
|
||||||
|
打包成 exe 后没有 `pymobiledevice3` 命令行入口,必须用 Python API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
from pymobiledevice3.services.mobile_image_mounter import (
|
||||||
|
MobileImageMounterService, auto_mount)
|
||||||
|
|
||||||
|
client = await create_using_usbmux(serial=udid, autopair=True,
|
||||||
|
connection_type="USB", pair_timeout=5.0)
|
||||||
|
async with MobileImageMounterService(lockdown=client) as mounter:
|
||||||
|
for image_type in ("Personalized", "Developer"):
|
||||||
|
if await mounter.is_image_mounted(image_type):
|
||||||
|
return # 已挂载,直接返回(否则会抛 AlreadyMountedError)
|
||||||
|
await auto_mount(client, version=version)
|
||||||
|
```
|
||||||
|
|
||||||
|
**踩坑记录**:
|
||||||
|
- `AlreadyMountedError` 必须当成功处理,不是失败
|
||||||
|
- 不要只检查「镜像是否挂载」就认为可用——macOS 侧实测 iOS 16.5 上镜像显示已挂载但 DVT 服务不启动
|
||||||
|
- iOS 17+ **不要**用 lockdown 服务名探测 DVT(`com.apple.instruments.dtservicehub` 走 RSD,不在 lockdown 里)
|
||||||
|
|
||||||
|
### 5.2 必须打包包元数据
|
||||||
|
|
||||||
|
PyInstaller 默认不打包 `dist-info`,而部分包(如 `apple_compress`)在运行期用
|
||||||
|
`importlib.metadata` 读自己的版本,漏掉会报
|
||||||
|
`No package metadata was found for apple_compress`,导致镜像挂载失败:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
datas = []
|
||||||
|
for pkg in ("apple_compress", "pymobiledevice3", "pyimg4", "construct",
|
||||||
|
"cryptography", "bpylist2", "opack2"):
|
||||||
|
try:
|
||||||
|
datas += copy_metadata(pkg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 不要把 `distutils` 加进 excludes
|
||||||
|
|
||||||
|
PyInstaller 的 hook 会强制导入它,排除后**构建直接失败**:
|
||||||
|
|
||||||
|
```
|
||||||
|
ValueError: Target module "distutils" already imported as "ExcludedModule('distutils',)"
|
||||||
|
```
|
||||||
|
|
||||||
|
同理 `questionary` / `prompt_toolkit` / `pygments` 被 pymobiledevice3 运行期导入,
|
||||||
|
排除后真机功能会静默失效(设备发现退化成 libimobiledevice 兜底)。
|
||||||
|
|
||||||
|
### 5.4 支持的 iOS 版本范围
|
||||||
|
|
||||||
|
**iOS 17 ~ 27.2**,一套个性化镜像全系共用(`resources/ddi/personalized/`,约 16 MB)。
|
||||||
|
挂载时由 Apple TSS 在线签发,因此**只有签名步骤需要联网**。
|
||||||
|
|
||||||
|
**iOS 16 及更早不支持**——其开发者镜像必须由 Apple 按系统构建号签发,
|
||||||
|
第三方镜像会被设备拒签(已实测:`Failed to verify the disk image signature`,
|
||||||
|
换 libimobiledevice 同样失败,说明是设备端拒绝)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 术语与设计约束(改代码前必读)
|
||||||
|
|
||||||
|
| 约束 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| **DVT 连接必须长连接保持** | 会话一断,模拟定位立即失效。所以播放引擎不能每发一个点就重连 |
|
||||||
|
| **DVT 只能改经纬度** | `simulateLocationWithLatitude:longitude:` 仅两个参数。`horizontalAccuracy`、`altitude`、`floor`、`isSimulatedBySoftware`、加速度计/陀螺仪/计步器由 iOS 合成或来自硬件,**无法注入** |
|
||||||
|
| **`last_point` 与 `last_reported` 必须分开** | 前者是轨迹上的当前点(供 status 用),后者是实际下发的点(用于推算速度)。混用会导致播放线程崩溃或速度算错 |
|
||||||
|
| **速度推算要用单调时钟** | macOS 侧曾因用 `time.time()` 且读取顺序错误,导致 dt 恒被钳到下限、速度偏大 20 倍 |
|
||||||
|
| **`update_tuning` 的参数默认值必须是哨兵** | 用 `None` 表示「不修改」会导致 `False` 也被当成「不修改」,开关永远关不掉 |
|
||||||
|
| **坐标内部一律 WGS84** | 高德瓦片是 GCJ-02,只在前端请求瓦片与落点时转换 |
|
||||||
|
| **同一时间只能有一个工具占隧道** | 设备只有一条 RSD 连接,i4Tools / Xcode / 本工具会互相挤掉 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收标准(移植完成后请逐项跑通)
|
||||||
|
|
||||||
|
macOS 侧的测试套件大部分可直接复用,作为 Windows 版的验收基线:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python tests\selftest.py # 83 项:协议、几何、播放引擎、配置、瓦片(无需设备)
|
||||||
|
python tests\drift_sim.py # 40 项:真实感模型(幅度、平滑性、开关、参数校验)
|
||||||
|
python tests\offline_tiles.py # 13 项:瓦片缓存
|
||||||
|
python main.py --mock # 界面能开、能画轨迹、能播放(无设备)
|
||||||
|
```
|
||||||
|
|
||||||
|
真机验收(**这一项是 Windows 版的核心风险点**):
|
||||||
|
|
||||||
|
1. `pymobiledevice3 usbmux list` 能看到设备
|
||||||
|
2. 点「挂载开发者镜像」成功
|
||||||
|
3. 点「连接设备」→ 隧道建立 → DVT 会话就绪
|
||||||
|
4. 播放轨迹 → 手机定位真的移动
|
||||||
|
5. 点「停止」→ 手机恢复真实定位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 建议的 Windows 侧工作顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
第 1 步:环境 + 可行性验证(最高优先级,1~2 小时)
|
||||||
|
└─ 装 iTunes / pymobiledevice3 / Wintun
|
||||||
|
└─ 跑通 `remote start-tunnel --userspace`,拿到 HOST PORT
|
||||||
|
└─ 用 pymobiledevice3 的 DVT 直接下发一次定位,确认手机真的动了
|
||||||
|
★ 这一步不通,后面全白做。务必先做。
|
||||||
|
|
||||||
|
第 2 步:移植纯逻辑层(无风险,可并行)
|
||||||
|
└─ 复制 core/ 下 10 个可移植模块 + web/ 整个目录
|
||||||
|
└─ 跑 tests/selftest.py 与 tests/drift_sim.py 确认 123 项全绿
|
||||||
|
|
||||||
|
第 3 步:重写隧道层
|
||||||
|
└─ 按第 3.4 节的接口实现 Windows 版 TunnelManager
|
||||||
|
└─ 实现 `--tunnel-helper` 自调用模式(为打包做准备)
|
||||||
|
|
||||||
|
第 4 步:窗口与路径适配
|
||||||
|
└─ main.py 的浏览器路径、数据目录
|
||||||
|
|
||||||
|
第 5 步:打包
|
||||||
|
└─ PyInstaller → .exe(注意第 5.2、5.3 节的坑)
|
||||||
|
└─ Inno Setup 制作安装包
|
||||||
|
|
||||||
|
第 6 步:完整验收(第 7 节)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 需要向 macOS 侧(或用户)确认的问题
|
||||||
|
|
||||||
|
1. **目标 Windows 版本**?(Windows 10 / 11,是否要支持 ARM64)
|
||||||
|
2. **是否需要内嵌浏览器**(pywebview + WebView2)还是沿用 Chrome/Edge `--app=` 模式?
|
||||||
|
3. **安装包形式**:Inno Setup `setup.exe` / 绿色免安装 zip?
|
||||||
|
4. **隧道方案确认**:如果 `--userspace` 在目标机器上不通,备选方案是
|
||||||
|
让用户手动跑 `tunneld`(走「手动 RSD」入口,界面已支持),需要确认可接受性
|
||||||
|
5. **是否要保留 macOS 与 Windows 双平台同一代码库**?
|
||||||
|
建议:把平台相关代码集中到 `core/platform_win.py` / `core/platform_mac.py`,
|
||||||
|
用一个 `core/platform.py` 分发,避免分叉两份代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 参考
|
||||||
|
|
||||||
|
- 仓库:https://github.com/jbjf6666/ios-location-track
|
||||||
|
- 协议契约:`docs/PROTOCOL.md`
|
||||||
|
- 设备侧取证记录:`docs/DEVICE_NOTES.md`(含 macOS 侧实测的完整排障过程)
|
||||||
|
- pymobiledevice3 文档:https://github.com/doronz88/pymobiledevice3
|
||||||
|
- userspace 隧道源码:`pymobiledevice3/remote/userspace_tunnel.py`
|
||||||
|
- 开发者镜像素材来源:https://github.com/doronz88/DeveloperDiskImage
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[global]
|
||||||
|
index-url = https://mirrors.aliyun.com/pypi/simple/
|
||||||
|
timeout = 30
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 725 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一步完成:生成图标 → 打包独立 .app → ad-hoc 签名 → 制作 DMG → 验证
|
||||||
|
# 产物:dist/虚拟定位轨迹.app(独立运行)、dist/虚拟定位轨迹-<版本>.dmg(分发用)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
PY=".venv/bin/python"
|
||||||
|
VERSION="1.0.0"
|
||||||
|
APP="dist/虚拟定位轨迹.app"
|
||||||
|
DMG="dist/虚拟定位轨迹-${VERSION}.dmg"
|
||||||
|
|
||||||
|
if [ ! -x "$PY" ]; then
|
||||||
|
echo "未找到 $PY,请先运行 ./scripts/run.sh 完成依赖安装"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 依赖:PyInstaller(打包用)
|
||||||
|
if ! "$PY" -c "import PyInstaller" >/dev/null 2>&1; then
|
||||||
|
echo "安装 PyInstaller…"
|
||||||
|
"$PY" -m pip install --quiet --disable-pip-version-check \
|
||||||
|
-i "${IOSLOC_PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}" pyinstaller
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) 图标
|
||||||
|
if [ ! -f resources/AppIcon.icns ]; then
|
||||||
|
echo "生成图标…"
|
||||||
|
"$PY" tools/make_icon.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) 开发者镜像素材(iOS 17+ 全系共用;体积大,不入 git,首次构建时下载)
|
||||||
|
if [ ! -f resources/ddi/personalized/Image.dmg ]; then
|
||||||
|
echo "下载 iOS 17+ 个性化镜像素材(约 16MB,仅需一次)…"
|
||||||
|
"$PY" tools/fetch_ddi.py || {
|
||||||
|
echo "素材下载失败。可手动放到 resources/ddi/personalized/ 后重试:"
|
||||||
|
echo " Image.dmg / BuildManifest.plist / Image.dmg.trustcache"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) 打包
|
||||||
|
echo "打包 .app(约 2-4 分钟)…"
|
||||||
|
"$PY" -m PyInstaller --clean --noconfirm --log-level WARN Location.spec
|
||||||
|
|
||||||
|
# 5) ad-hoc 签名:Apple Silicon 上未签名的 arm64 应用可能被系统直接拒绝运行
|
||||||
|
echo "ad-hoc 签名…"
|
||||||
|
codesign --force --deep --sign - "$APP"
|
||||||
|
codesign --verify --deep --strict "$APP" && echo " 签名校验通过"
|
||||||
|
|
||||||
|
# 6) 制作 DMG
|
||||||
|
echo
|
||||||
|
"$PY" tools/make_dmg.py
|
||||||
|
|
||||||
|
# 7) 验证独立运行
|
||||||
|
echo
|
||||||
|
"$PY" tools/verify_bundle.py
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "================================================"
|
||||||
|
echo "完成:"
|
||||||
|
echo " 独立应用:$APP"
|
||||||
|
echo " 分发镜像:$DMG"
|
||||||
|
echo "================================================"
|
||||||
@@ -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" "$@"
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""浏览器级离线可用验证:先联网预热当前视野的瓦片,再切断服务端回源,验证界面仍能正常出图。
|
||||||
|
|
||||||
|
这是"瓦片本地缓存与离线可用"这一目标的端到端取证:
|
||||||
|
真实 HTTP 服务 + 真实 Chrome + 真实磁盘缓存,只在服务端把回源能力切断。
|
||||||
|
|
||||||
|
运行:./.venv/bin/python tests/offline_browser.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "tests"))
|
||||||
|
|
||||||
|
from core import config_store, protocol, server as server_mod, tilecache # noqa: E402
|
||||||
|
from ui_acceptance import CDP, launch_chrome # noqa: E402
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if ok:
|
||||||
|
PASS += 1
|
||||||
|
print(f" \033[32m✓\033[0m {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
FAILURES.append(f"{name} {detail}")
|
||||||
|
print(f" \033[31m✗\033[0m {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
global PASS, FAIL
|
||||||
|
port = 8802
|
||||||
|
print("\033[1m浏览器级离线可用验证\033[0m")
|
||||||
|
|
||||||
|
# 用项目自己的缓存目录(用户真实路径),但把上限调小以免影响用户配置
|
||||||
|
app = server_mod.App(root=ROOT, mock=True, port=port)
|
||||||
|
app.devices.refresh()
|
||||||
|
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
process, ws_url = launch_chrome(1280, 800, 9588)
|
||||||
|
cdp = None
|
||||||
|
try:
|
||||||
|
cdp = CDP(ws_url)
|
||||||
|
cdp.call("Page.enable")
|
||||||
|
cdp.call("Runtime.enable")
|
||||||
|
cdp.call("Emulation.setDeviceMetricsOverride",
|
||||||
|
{"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False})
|
||||||
|
print("\n1) 联网状态加载界面(预热视野内瓦片到磁盘缓存)")
|
||||||
|
cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{port}/"})
|
||||||
|
time.sleep(10)
|
||||||
|
stats_online = dict(app.tiles.stats)
|
||||||
|
check("界面已连接服务端",
|
||||||
|
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
|
||||||
|
str(cdp.eval("document.querySelector('#connPill').textContent")))
|
||||||
|
served = stats_online.get("hit", 0) + stats_online.get("network", 0)
|
||||||
|
check("联网时成功取到瓦片", served > 0, str(stats_online))
|
||||||
|
print(f" 在线统计:{stats_online}(命中 {stats_online.get('hit')} + 回源 {stats_online.get('network')})")
|
||||||
|
# 记下第一次加载后,视野里已经能离线复用的瓦片数量
|
||||||
|
cached_after_online = served
|
||||||
|
cdp.shot("/tmp/offline-1-online.png")
|
||||||
|
|
||||||
|
print("\n2) 切断服务端回源(模拟断网),强制刷新页面")
|
||||||
|
real_fetch = tilecache.TileCache._fetch
|
||||||
|
|
||||||
|
def offline_fetch(self, url): # noqa: ANN001
|
||||||
|
raise OSError("模拟断网:Network is unreachable")
|
||||||
|
|
||||||
|
tilecache.TileCache._fetch = offline_fetch
|
||||||
|
cdp.call("Page.reload", {"ignoreCache": True})
|
||||||
|
time.sleep(10)
|
||||||
|
stats_offline = dict(app.tiles.stats)
|
||||||
|
print(f" 离线统计:{stats_offline}")
|
||||||
|
|
||||||
|
warnings = cdp.eval(
|
||||||
|
"(function(){var w=document.querySelector('.map-warn');return w?w.textContent.trim():''})()"
|
||||||
|
)
|
||||||
|
canvas = cdp.eval(
|
||||||
|
"(function(){var c=document.querySelector('#map canvas');return c?JSON.stringify({w:c.width,h:c.height}):'none'})()"
|
||||||
|
)
|
||||||
|
check("断网后地图画布仍渲染", canvas not in ("none", "", None), str(canvas)[:80])
|
||||||
|
# 断网后新增的请求必须全部由磁盘缓存满足:不允许出现 failed / placeholder
|
||||||
|
delta_failed = stats_offline.get("failed", 0) - stats_online.get("failed", 0)
|
||||||
|
delta_hit = stats_offline.get("hit", 0) - stats_online.get("hit", 0)
|
||||||
|
check("断网后没有回源失败", delta_failed == 0, f"failed 增加 {delta_failed}")
|
||||||
|
check("断网后由缓存供图(hit 增加)", delta_hit > 0 or cached_after_online > 0,
|
||||||
|
f"hit {stats_online.get('hit')} → {stats_offline.get('hit')}")
|
||||||
|
check("界面未出现白屏级错误", "错误" not in str(cdp.eval("document.title")), "")
|
||||||
|
check("界面给出中文降级提示(若有缺失瓦片)",
|
||||||
|
warnings == "" or "瓦片" in warnings or "重试" in warnings, f"warn={warnings!r}")
|
||||||
|
|
||||||
|
# 用像素级判断:截图里非纯色像素占比说明底图确实画出来了
|
||||||
|
size = cdp.eval(
|
||||||
|
"(function(){var c=document.querySelector('#map canvas');if(!c)return null;"
|
||||||
|
"var ctx=c.getContext('2d');var d=ctx.getImageData(0,0,Math.min(400,c.width),Math.min(400,c.height)).data;"
|
||||||
|
"var seen={};for(var i=0;i<d.length;i+=4){seen[(d[i]>>4)+','+(d[i+1]>>4)+','+(d[i+2]>>4)]=1}"
|
||||||
|
"return Object.keys(seen).length})()"
|
||||||
|
)
|
||||||
|
check("地图像素有真实内容(非纯色/空白)", isinstance(size, int) and size > 20, f"颜色桶={size}")
|
||||||
|
cdp.shot("/tmp/offline-2-offline.png")
|
||||||
|
|
||||||
|
print("\n3) 恢复网络后自动回到在线出图")
|
||||||
|
tilecache.TileCache._fetch = real_fetch
|
||||||
|
cdp.call("Page.reload", {"ignoreCache": True})
|
||||||
|
time.sleep(8)
|
||||||
|
stats_restored = dict(app.tiles.stats)
|
||||||
|
check("恢复后重新回源", stats_restored.get("network", 0) >= stats_offline.get("network", 0),
|
||||||
|
str(stats_restored))
|
||||||
|
except Exception as exc:
|
||||||
|
FAIL += 1
|
||||||
|
FAILURES.append(f"异常 {exc}")
|
||||||
|
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tilecache.TileCache._fetch = tilecache.TileCache.__dict__.get("_fetch", None) or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if cdp:
|
||||||
|
cdp.close()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||||
|
except Exception:
|
||||||
|
process.terminate()
|
||||||
|
app.shutdown()
|
||||||
|
try:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"\n{'=' * 56}")
|
||||||
|
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
|
||||||
|
print("截图:/tmp/offline-1-online.png、/tmp/offline-2-offline.png")
|
||||||
|
for item in FAILURES:
|
||||||
|
print(" -", item)
|
||||||
|
return 0 if FAIL == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""端到端自检:单元 + 集成(无设备,走 --mock)。
|
||||||
|
|
||||||
|
运行:./.venv/bin/python tests/selftest.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from core import config_store, geoutils, protocol, route_engine, tilecache, ws # noqa: E402
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, condition: bool, detail: str = "") -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if condition:
|
||||||
|
PASS += 1
|
||||||
|
print(f" \033[32m✓\033[0m {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
FAILURES.append(f"{name} {detail}")
|
||||||
|
print(f" \033[31m✗\033[0m {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def section(title: str) -> None:
|
||||||
|
print(f"\n\033[1m{title}\033[0m")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 单元
|
||||||
|
def test_protocol() -> None:
|
||||||
|
section("1. 协议与校验")
|
||||||
|
check("合法经纬度通过", protocol.validate_latlng(39.9, 116.4) == (39.9, 116.4))
|
||||||
|
for bad in [(91, 0), (0, 181), ("a", 1), (float("nan"), 0)]:
|
||||||
|
try:
|
||||||
|
protocol.validate_latlng(*bad)
|
||||||
|
check(f"非法经纬度被拒 {bad}", False)
|
||||||
|
except protocol.AppError:
|
||||||
|
check(f"非法经纬度被拒 {bad}", True)
|
||||||
|
check("倍速上下限", protocol.validate_speed_scale(1.5) == 1.5)
|
||||||
|
try:
|
||||||
|
protocol.validate_speed_scale(999)
|
||||||
|
check("倍速超限被拒", False)
|
||||||
|
except protocol.AppError:
|
||||||
|
check("倍速超限被拒", True)
|
||||||
|
check("模式解析", protocol.validate_mode("cycling") == "cycling")
|
||||||
|
check("JSON 中文不被转义", "定位" in protocol.dumps({"msg": "定位"}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_geo() -> None:
|
||||||
|
section("2. 地理计算")
|
||||||
|
d = geoutils.haversine(39.9, 116.4, 39.9, 116.41)
|
||||||
|
check("haversine 精度", 830 < d < 870, f"got {d:.1f}m")
|
||||||
|
b = geoutils.bearing(39.9, 116.4, 40.0, 116.4)
|
||||||
|
check("正北方位角≈0", b < 1 or b > 359, f"got {b:.2f}")
|
||||||
|
path = [(39.9, 116.4), (39.91, 116.41)]
|
||||||
|
dense = geoutils.densify(path, 20.0)
|
||||||
|
check("路径加密点变多", len(dense) > 50, f"{len(dense)} 点")
|
||||||
|
acc = geoutils.cumulative_lengths(dense)
|
||||||
|
total = acc[-1]
|
||||||
|
check("累计弧长单调递增", all(acc[i] <= acc[i + 1] for i in range(len(acc) - 1)))
|
||||||
|
mid, _ = geoutils.interpolate(dense, acc, total / 2)
|
||||||
|
check("弧长中点落在起终点之间", 39.9 <= mid[0] <= 39.91, f"{mid}")
|
||||||
|
simplified = geoutils.simplify(dense, 2.0)
|
||||||
|
check("简化后点数下降且首尾保留", len(simplified) < len(dense) and simplified[0] == dense[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_engine() -> None:
|
||||||
|
section("3. 路线引擎")
|
||||||
|
config = config_store.ConfigStore(ROOT / "config.json")
|
||||||
|
engine = route_engine.RouteEngine(config)
|
||||||
|
route = engine.build([[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False)
|
||||||
|
check("直线兜底引擎标记", route.engine == "straight")
|
||||||
|
check("路径非空且已加密", len(route.path) > 100, f"{len(route.path)} 点")
|
||||||
|
check("距离 > 1.5km", route.distance > 1500, f"{route.distance:.0f}m")
|
||||||
|
check("时长合理", 60 < route.duration < 1200, f"{route.duration:.0f}s")
|
||||||
|
check("速度剖面长度一致", len(route.speeds) == len(route.path))
|
||||||
|
check("速度均为正", all(v > 0 for v in route.speeds))
|
||||||
|
mid = route.point_at_distance(route.distance / 2)
|
||||||
|
check("中点取点有效", 39.89 <= mid[0] <= 39.91, f"{mid}")
|
||||||
|
payload = route.to_json()
|
||||||
|
check("JSON 坐标是 [lng,lat]", abs(payload["path"][0][0] - 116.40) < 0.01, str(payload["path"][0]))
|
||||||
|
restored = route_engine.sanitize_route_payload(payload)
|
||||||
|
check("route 往返一致", abs(restored.distance - route.distance) < 1.0, f"{restored.distance:.1f} vs {route.distance:.1f}")
|
||||||
|
check("高亮子路径不超过全长", len(route.highlight(route.distance / 2)) <= len(route.path))
|
||||||
|
|
||||||
|
# GPX 往返
|
||||||
|
gpx = route_engine.to_gpx(route, creator="selftest")
|
||||||
|
check("GPX 含 trkpt", "<trkpt" in gpx and gpx.startswith("<?xml"))
|
||||||
|
coords, name, _ = route_engine.from_gpx(gpx)
|
||||||
|
check("GPX 导入保形(不抽稀丢点)", len(coords) == len(route.path), f"{len(coords)} vs {len(route.path)}")
|
||||||
|
total = geoutils.cumulative_lengths(coords)[-1]
|
||||||
|
check("GPX 往返距离误差 < 2m", abs(total - route.distance) < 2.0, f"误差 {abs(total - route.distance):.3f}m")
|
||||||
|
|
||||||
|
# 单点与异常
|
||||||
|
single = engine.build([[116.40, 39.90]], mode="walking", use_osrm=False)
|
||||||
|
check("单点轨迹可用", len(single.path) == 1 and single.distance == 0)
|
||||||
|
for bad, label in [([], "空途经点"), ([[200, 39]], "非法经度")]:
|
||||||
|
try:
|
||||||
|
engine.build(bad, use_osrm=False)
|
||||||
|
check(f"{label}被拒", False)
|
||||||
|
except protocol.AppError:
|
||||||
|
check(f"{label}被拒", True)
|
||||||
|
|
||||||
|
# OSRM URL 生成(不依赖外网)
|
||||||
|
check("flight 不走 OSRM", engine._osrm([(39.9, 116.4), (39.91, 116.41)], "flight") is None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tiles() -> None:
|
||||||
|
section("4. 瓦片与缓存")
|
||||||
|
source = config_store.DEFAULT_TILE_SOURCES["amap"]
|
||||||
|
url1 = tilecache.build_url(source, 12, 3367, 1580)
|
||||||
|
check("高德 URL 生成", url1.startswith("https://webrd0") and "{x}" not in url1, url1)
|
||||||
|
url2 = tilecache.build_url(source, 12, 3368, 1580)
|
||||||
|
check("子域轮换生效", url1.split("/")[2] != url2.split("/")[2], f"{url1.split('/')[2]} vs {url2.split('/')[2]}")
|
||||||
|
arc = config_store.DEFAULT_TILE_SOURCES["arcgis_sat"]
|
||||||
|
check("Esri URL 含 z/y/x", "/tile/12/1580/3367" in tilecache.build_url(arc, 12, 3367, 1580))
|
||||||
|
check("瓦片坐标校验", tilecache.validate_tile_request(12, 3367, 1580) == (12, 3367, 1580))
|
||||||
|
try:
|
||||||
|
tilecache.validate_tile_request(12, 99999, 1)
|
||||||
|
check("越界瓦片被拒", False)
|
||||||
|
except protocol.AppError:
|
||||||
|
check("越界瓦片被拒", True)
|
||||||
|
|
||||||
|
tmp = ROOT / ".cache-test"
|
||||||
|
cache = tilecache.TileCache(tmp, max_mb=1)
|
||||||
|
result = cache.get_tile("amap", source, 12, 0, 0)
|
||||||
|
check("瓦片请求返回图片", result.body[:4] in (b"\x89PNG", b"\xff\xd8") and result.content_type.startswith("image/"),
|
||||||
|
f"{result.status} {result.source} {len(result.body)}B")
|
||||||
|
check("瓦片已落盘缓存", any(tmp.rglob("*.png")) or any(tmp.rglob("*.jpg")))
|
||||||
|
result2 = cache.get_tile("amap", source, 12, 0, 0)
|
||||||
|
check("第二次命中缓存", result2.source == "cache", result2.source)
|
||||||
|
removed = cache.clear()
|
||||||
|
check("清理缓存", removed >= 1, f"removed={removed}")
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_config() -> None:
|
||||||
|
section("5. 配置")
|
||||||
|
path = ROOT / ".config-test.json"
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
store = config_store.ConfigStore(path)
|
||||||
|
check("默认瓦片源为高德", store.get("tile_source") == "amap")
|
||||||
|
try:
|
||||||
|
store.update({"send_hz": 999})
|
||||||
|
check("非法 send_hz 被拒(不再静默夹取)", False)
|
||||||
|
except protocol.AppError as exc:
|
||||||
|
check("非法 send_hz 被拒(不再静默夹取)", exc.code == "bad_config", exc.code)
|
||||||
|
store.update({"send_hz": 8})
|
||||||
|
check("合法 send_hz 生效", store.get("send_hz") == 8.0, str(store.get("send_hz")))
|
||||||
|
store.update({"tile_source": "amap_sat"})
|
||||||
|
store2 = config_store.ConfigStore(path)
|
||||||
|
check("配置持久化", store2.get("tile_source") == "amap_sat")
|
||||||
|
try:
|
||||||
|
store.update({"不存在的项": 1})
|
||||||
|
check("未知配置被拒", False)
|
||||||
|
except protocol.AppError:
|
||||||
|
check("未知配置被拒", True)
|
||||||
|
sources = store.tile_sources()
|
||||||
|
check("瓦片源齐全", all(k in sources for k in ("amap", "amap_sat", "arcgis_sat", "osm")))
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- WS 客户端
|
||||||
|
class WSClient:
|
||||||
|
def __init__(self, host: str, port: int, timeout: float = 30.0) -> None:
|
||||||
|
self.sock = socket.create_connection((host, port), timeout=timeout)
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
request = (
|
||||||
|
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
||||||
|
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
)
|
||||||
|
self.sock.sendall(request.encode())
|
||||||
|
header = b""
|
||||||
|
while b"\r\n\r\n" not in header:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("握手失败")
|
||||||
|
header += chunk
|
||||||
|
if b"101" not in header.split(b"\r\n")[0]:
|
||||||
|
raise RuntimeError(f"握手未升级:{header[:200]!r}")
|
||||||
|
expected = base64.b64encode(hashlib.sha1((key + ws.GUID).encode()).digest()).decode()
|
||||||
|
if expected.encode() not in header:
|
||||||
|
raise RuntimeError("Sec-WebSocket-Accept 校验失败")
|
||||||
|
self.buf = header.split(b"\r\n\r\n", 1)[1]
|
||||||
|
self.messages: list[dict] = []
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self.reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
|
self.reader.start()
|
||||||
|
|
||||||
|
def _recv_exact(self, n: int) -> bytes | None:
|
||||||
|
while len(self.buf) < n:
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
self.buf += chunk
|
||||||
|
out, self.buf = self.buf[:n], self.buf[n:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _read_loop(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
head = self._recv_exact(2)
|
||||||
|
if head is None:
|
||||||
|
return
|
||||||
|
opcode = head[0] & 0x0F
|
||||||
|
length = head[1] & 0x7F
|
||||||
|
if length == 126:
|
||||||
|
ext = self._recv_exact(2)
|
||||||
|
if ext is None:
|
||||||
|
return
|
||||||
|
length = struct.unpack("!H", ext)[0]
|
||||||
|
elif length == 127:
|
||||||
|
ext = self._recv_exact(8)
|
||||||
|
if ext is None:
|
||||||
|
return
|
||||||
|
length = struct.unpack("!Q", ext)[0]
|
||||||
|
payload = self._recv_exact(length) if length else b""
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
if opcode == 0x8:
|
||||||
|
return
|
||||||
|
if opcode in (0x9, 0xA):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with self.lock:
|
||||||
|
self.messages.append(json.loads(payload.decode("utf-8")))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def send(self, obj: dict) -> None:
|
||||||
|
payload = json.dumps(obj, ensure_ascii=False).encode()
|
||||||
|
header = bytearray([0x81])
|
||||||
|
mask = os.urandom(4)
|
||||||
|
length = len(payload)
|
||||||
|
if length < 126:
|
||||||
|
header.append(0x80 | length)
|
||||||
|
elif length < 65536:
|
||||||
|
header.append(0x80 | 126)
|
||||||
|
header.extend(struct.pack("!H", length))
|
||||||
|
else:
|
||||||
|
header.append(0x80 | 127)
|
||||||
|
header.extend(struct.pack("!Q", length))
|
||||||
|
header.extend(mask)
|
||||||
|
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
|
self.sock.sendall(bytes(header) + masked)
|
||||||
|
|
||||||
|
def wait_for(self, predicate, timeout: float = 15.0) -> dict | None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
seen = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
with self.lock:
|
||||||
|
batch = self.messages[seen:]
|
||||||
|
seen = len(self.messages)
|
||||||
|
for message in batch:
|
||||||
|
if predicate(message):
|
||||||
|
return message
|
||||||
|
time.sleep(0.05)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration() -> None:
|
||||||
|
section("6. 端到端(--mock 模式,真 HTTP + 真 WebSocket)")
|
||||||
|
from core import server as server_mod
|
||||||
|
|
||||||
|
port = 8899
|
||||||
|
app = server_mod.App(root=ROOT, mock=True, port=port)
|
||||||
|
app.devices.refresh()
|
||||||
|
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
time.sleep(0.4)
|
||||||
|
try:
|
||||||
|
# --- REST ---
|
||||||
|
with urllib.request.urlopen(base + "/api/health", timeout=5) as response:
|
||||||
|
health = json.loads(response.read())
|
||||||
|
check("GET /api/health", health.get("ok") and health.get("version") == protocol.APP_VERSION, str(health)[:120])
|
||||||
|
check("mock 模式标记", health.get("mock") is True)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/api/devices", timeout=5) as response:
|
||||||
|
devices = json.loads(response.read())
|
||||||
|
check("GET /api/devices 返回模拟设备", len(devices["devices"]) == 1 and devices["devices"][0]["ios_major"] == 26,
|
||||||
|
str(devices)[:150])
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=5) as response:
|
||||||
|
tiles = json.loads(response.read())
|
||||||
|
keys = {item["key"] for item in tiles["sources"]}
|
||||||
|
check("瓦片源接口含 key/datum", "amap" in keys and tiles["sources"][0]["datum"] in ("gcj02", "wgs84"), str(tiles)[:150])
|
||||||
|
check("瓦片源三种字段名都在", tiles["current"] == tiles["tile_source"] == tiles["selected"])
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/", timeout=5) as response:
|
||||||
|
html = response.read().decode("utf-8", "replace")
|
||||||
|
check("GET / 返回前端页面", "<html" in html.lower(), html[:80])
|
||||||
|
|
||||||
|
# 路线规划(REST)
|
||||||
|
body = json.dumps({"waypoints": [[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], "mode": "driving"}).encode()
|
||||||
|
request = urllib.request.Request(base + "/api/route/build", data=body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
route_result = json.loads(response.read())
|
||||||
|
check("POST /api/route/build", route_result.get("ok") and route_result["route"]["distance"] > 500,
|
||||||
|
str(route_result)[:120])
|
||||||
|
|
||||||
|
# GPX 往返(REST)
|
||||||
|
export_body = json.dumps({"route": route_result["route"], "name": "自检轨迹"}).encode()
|
||||||
|
request = urllib.request.Request(base + "/api/gpx/export", data=export_body, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
gpx_text = response.read().decode()
|
||||||
|
check("POST /api/gpx/export", "<trkpt" in gpx_text and "自检轨迹" in gpx_text)
|
||||||
|
request = urllib.request.Request(base + "/api/gpx/import", data=gpx_text.encode(), headers={"Content-Type": "application/gpx+xml"})
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
imported = json.loads(response.read())
|
||||||
|
check("POST /api/gpx/import", imported["ok"] and imported["count"] >= 2, str(imported)[:120])
|
||||||
|
check("GPX 导入不丢几何(返回全量点)", len(imported["waypoints"]) == imported["count"] >= 100,
|
||||||
|
f"count={imported['count']}")
|
||||||
|
check("GPX 导入同时提供 path/points 全量数组",
|
||||||
|
imported.get("path") == imported["waypoints"] and imported.get("points") == imported["waypoints"])
|
||||||
|
imported_coords = [(p[1], p[0]) for p in imported["waypoints"]]
|
||||||
|
imported_total = geoutils.cumulative_lengths(imported_coords)[-1]
|
||||||
|
check("GPX 导入距离保真(误差 < 2m)", abs(imported_total - route_result["route"]["distance"]) < 2.0,
|
||||||
|
f"误差 {abs(imported_total - route_result['route']['distance']):.3f}m")
|
||||||
|
|
||||||
|
# 瓦片代理
|
||||||
|
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as response:
|
||||||
|
tile_bytes = response.read()
|
||||||
|
check("GET /api/tiles 返回图片", tile_bytes[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile_bytes)}B")
|
||||||
|
|
||||||
|
# --- WebSocket ---
|
||||||
|
client = WSClient("127.0.0.1", port)
|
||||||
|
client.send({"type": "hello", "id": "h1", "client": "selftest"})
|
||||||
|
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1")
|
||||||
|
check("WS hello ack 携带 welcome 数据", ack is not None and ack.get("mock") is True and "config" in ack,
|
||||||
|
str(ack)[:140])
|
||||||
|
check("welcome 里带设备与隧道快照", ack is not None and "tunnel" in ack and ack.get("version") == protocol.APP_VERSION,
|
||||||
|
str(ack)[:140])
|
||||||
|
|
||||||
|
client.send({"type": "ping", "id": "p1"})
|
||||||
|
pong = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "p1")
|
||||||
|
check("WS ping→ack(ts)", pong is not None and "ts" in pong, str(pong)[:120])
|
||||||
|
|
||||||
|
client.send({"type": "device.connect", "id": "c1"})
|
||||||
|
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=20)
|
||||||
|
check("WS device.connect", connected is not None and connected.get("ok", True), str(connected)[:160])
|
||||||
|
|
||||||
|
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
|
||||||
|
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=15)
|
||||||
|
check("WS location.set", ack is not None and ack.get("ok", True), str(ack)[:160])
|
||||||
|
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=10)
|
||||||
|
check("收到 location 事件(单点)", location is not None and abs(location["lng"] - 116.3975) < 1e-6, str(location)[:160])
|
||||||
|
|
||||||
|
# 多设备/非法命令
|
||||||
|
client.send({"type": "play.start", "id": "e1", "route": {"path": [], "waypoints": []}})
|
||||||
|
bad = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "e1", timeout=10)
|
||||||
|
check("空轨迹被拒并回中文错误", bad is not None and bad.get("ok") is False and "轨迹" in bad.get("error", ""), str(bad)[:160])
|
||||||
|
|
||||||
|
# 播放流程
|
||||||
|
route_payload = route_result["route"]
|
||||||
|
client.send({"type": "play.start", "id": "s1", "route": route_payload, "speed_scale": 8.0, "loop": False})
|
||||||
|
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=20)
|
||||||
|
check("WS play.start", started is not None and started.get("ok", True), str(started)[:160])
|
||||||
|
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=10)
|
||||||
|
check("播放下发首个定位", first is not None and first.get("progress", 1) < 0.2, str(first)[:160])
|
||||||
|
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.05, timeout=20)
|
||||||
|
check("播放推进(进度增长)", progressed is not None, str(progressed)[:160])
|
||||||
|
status = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is True, timeout=10)
|
||||||
|
check("status 事件含剩余时间", status is not None and "remaining" in status, str(status)[:160])
|
||||||
|
|
||||||
|
client.send({"type": "play.pause", "id": "p2"})
|
||||||
|
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=10)
|
||||||
|
check("暂停生效", paused is not None, str(paused)[:120])
|
||||||
|
time.sleep(0.6)
|
||||||
|
with client.lock:
|
||||||
|
after_pause = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
|
||||||
|
time.sleep(0.8)
|
||||||
|
with client.lock:
|
||||||
|
still = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
|
||||||
|
check("暂停后不再下发", still - after_pause <= 1, f"{after_pause} → {still}")
|
||||||
|
|
||||||
|
client.send({"type": "play.resume", "id": "r1"})
|
||||||
|
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=10)
|
||||||
|
check("继续生效", resumed is not None, str(resumed)[:120])
|
||||||
|
|
||||||
|
client.send({"type": "play.speed", "id": "sp1", "speed_scale": 20.0})
|
||||||
|
fast = client.wait_for(lambda m: m.get("type") == "status" and m.get("speed_scale") == 20.0, timeout=10)
|
||||||
|
check("倍速调整生效", fast is not None, str(fast)[:120])
|
||||||
|
|
||||||
|
client.send({"type": "play.seek", "id": "sk1", "progress": 0.5})
|
||||||
|
seeked = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sk1", timeout=10)
|
||||||
|
check("seek 生效", seeked is not None and seeked.get("ok", True), str(seeked)[:120])
|
||||||
|
at_half = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.45, timeout=10)
|
||||||
|
check("seek 后位置过半", at_half is not None, str(at_half)[:140])
|
||||||
|
|
||||||
|
# 播放到结束(循环 + 高倍速)
|
||||||
|
client.send({"type": "play.speed", "id": "sp2", "speed_scale": 50.0})
|
||||||
|
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sp2", timeout=10)
|
||||||
|
done = client.wait_for(
|
||||||
|
lambda m: m.get("type") == "status" and m.get("playing") is False and m.get("progress", 0) >= 0.999,
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
check("轨迹播放到终点", done is not None, str(done)[:160])
|
||||||
|
|
||||||
|
# 循环模式
|
||||||
|
client.send({"type": "play.start", "id": "s2", "route": route_payload, "speed_scale": 50.0, "loop": True})
|
||||||
|
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s2", timeout=15)
|
||||||
|
wrapped = client.wait_for(
|
||||||
|
lambda m: m.get("type") == "status" and m.get("loop") is True and m.get("progress", 1) < 0.5 and m.get("playing"),
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
check("loop 模式回到起点继续", wrapped is not None, str(wrapped)[:160])
|
||||||
|
|
||||||
|
client.send({"type": "play.stop", "id": "st1"})
|
||||||
|
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=15)
|
||||||
|
check("停止播放", stopped is not None, str(stopped)[:140])
|
||||||
|
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=10)
|
||||||
|
check("停止后通知恢复真实定位", cleared is not None, str(cleared)[:120])
|
||||||
|
|
||||||
|
client.send({"type": "log.list", "id": "lg1", "limit": 50})
|
||||||
|
logs = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "lg1", timeout=10)
|
||||||
|
check("日志回填", logs is not None, str(logs)[:120])
|
||||||
|
|
||||||
|
client.send({"type": "不存在的命令", "id": "x1"})
|
||||||
|
unknown = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "x1", timeout=10)
|
||||||
|
check("未知命令回中文错误", unknown is not None and unknown.get("ok") is False, str(unknown)[:140])
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
time.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
app.shutdown()
|
||||||
|
try:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(f"\033[1m{protocol.APP_NAME} 自检\033[0m (Python {sys.version.split()[0]})")
|
||||||
|
started = time.time()
|
||||||
|
test_protocol()
|
||||||
|
test_geo()
|
||||||
|
test_route_engine()
|
||||||
|
test_tiles()
|
||||||
|
test_config()
|
||||||
|
try:
|
||||||
|
test_integration()
|
||||||
|
except Exception as exc:
|
||||||
|
global FAIL
|
||||||
|
FAIL += 1
|
||||||
|
FAILURES.append(f"集成测试异常:{exc}")
|
||||||
|
print(f" \033[31m✗\033[0m 集成测试异常:{exc.__class__.__name__}: {exc}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"\n{'=' * 56}")
|
||||||
|
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项,用时 {time.time() - started:.1f}s")
|
||||||
|
if FAILURES:
|
||||||
|
print("\n失败明细:")
|
||||||
|
for item in FAILURES:
|
||||||
|
print(f" - {item}")
|
||||||
|
return 1
|
||||||
|
print("\033[32m全部通过\033[0m")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 离线抓取 DeveloperDiskImage 素材(绕过 raw.githubusercontent.com 的 SNI 阻断)
|
||||||
|
#
|
||||||
|
# 背景(2026-10-14 实测,本机 jbjf-Mac-mini):
|
||||||
|
# raw.githubusercontent.com 解析到 4 个 GitHub Pages IP,其中 3 个在 TLS ClientHello 后被 RST / 黑洞:
|
||||||
|
# 185.199.108.133 -> 连接超时(curl exit 28)
|
||||||
|
# 185.199.109.133 -> RST(curl exit 35)
|
||||||
|
# 185.199.110.133 -> RST(curl exit 35)
|
||||||
|
# 185.199.111.133 -> HTTP 200 正常
|
||||||
|
# DNS 轮询会随机选中坏 IP,而 developer_disk_image/repo.py 的 requests.get 没有重试也没有超时,
|
||||||
|
# 所以单次 RST 就会让 mounter auto-mount 整体失败(ConnectionResetError(54))。
|
||||||
|
#
|
||||||
|
# 本脚本:固定走可用 IP + 重试 + 校验字节数,把素材放到 pymobiledevice3 期望的位置。
|
||||||
|
# 用法:tools/ddi_offline_fetch.sh [输出目录] 默认输出 /tmp/ddi
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-/tmp/ddi}"
|
||||||
|
GOOD_IPS=("185.199.111.133" "185.199.108.133" "185.199.109.133" "185.199.110.133")
|
||||||
|
BASE="https://raw.githubusercontent.com/doronz88/DeveloperDiskImage/main"
|
||||||
|
CACHE="$HOME/.pymobiledevice3/Xcode_iOS_DDI_Personalized"
|
||||||
|
PY="$(cd "$(dirname "$0")/.." && pwd)/.venv/bin/python"
|
||||||
|
|
||||||
|
mkdir -p "$OUT/16.5" "$CACHE"
|
||||||
|
|
||||||
|
# fetch <仓库相对路径> <期望字节数> <输出文件>
|
||||||
|
fetch() {
|
||||||
|
local path="$1" want="$2" dest="$3"
|
||||||
|
for ip in "${GOOD_IPS[@]}"; do
|
||||||
|
echo "--- $path (期望 $want 字节, 尝试 $ip)"
|
||||||
|
if curl -sS -m 300 --retry 3 --retry-all-errors --resolve "raw.githubusercontent.com:443:$ip" \
|
||||||
|
-o "$dest.part" -w " http=%{http_code} size=%{size_download} t=%{time_total}s\n" "$BASE/$path"; then
|
||||||
|
local got; got=$(stat -f%z "$dest.part" 2>/dev/null || echo 0)
|
||||||
|
if [ "$got" = "$want" ]; then mv "$dest.part" "$dest"; echo " OK ($ip)"; return 0; fi
|
||||||
|
echo " 字节数不符 ($got != $want),换 IP"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "!!! $path 全部 IP 失败"; return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# iOS 17+(personalized)素材 —— 例如 iPhone 11 / iOS 26.6.1
|
||||||
|
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" 15733248 "$OUT/Image.dmg"
|
||||||
|
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" 801505 "$OUT/BuildManifest.plist"
|
||||||
|
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" 1895 "$OUT/Image.dmg.trustcache"
|
||||||
|
|
||||||
|
# iOS <17(经典 developer image)素材 —— 例如 iOS 16.5
|
||||||
|
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg" 7427565 "$OUT/16.5/DeveloperDiskImage.dmg"
|
||||||
|
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg.signature" 128 "$OUT/16.5/DeveloperDiskImage.dmg.signature"
|
||||||
|
|
||||||
|
# 写进 pymobiledevice3 缓存:fetch_personalized_ddi() 会直接命中,彻底不再联网
|
||||||
|
# 注意缓存里的 trustcache 文件名必须是 Image.trustcache(不是 Image.dmg.trustcache)
|
||||||
|
cp "$OUT/BuildManifest.plist" "$CACHE/BuildManifest.plist"
|
||||||
|
cp "$OUT/Image.dmg" "$CACHE/Image.dmg"
|
||||||
|
cp "$OUT/Image.dmg.trustcache" "$CACHE/Image.trustcache"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 校验缓存(ProductBuildVersion 必须等于 pymobiledevice3 的 LATEST_DDI_BUILD_ID)==="
|
||||||
|
"$PY" - <<'PY'
|
||||||
|
import plistlib, pathlib
|
||||||
|
from pymobiledevice3.services.mobile_image_mounter import LATEST_DDI_BUILD_ID, fetch_personalized_ddi
|
||||||
|
c = pathlib.Path.home()/".pymobiledevice3"/"Xcode_iOS_DDI_Personalized"
|
||||||
|
bm = plistlib.loads((c/"BuildManifest.plist").read_bytes())
|
||||||
|
print("缓存 BuildVersion :", bm["ProductBuildVersion"])
|
||||||
|
print("期望 LATEST_DDI :", LATEST_DDI_BUILD_ID)
|
||||||
|
print("缓存有效(不会重下):", bm["ProductBuildVersion"] == LATEST_DDI_BUILD_ID)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 接下来可以直接离线挂载(不再触碰 GitHub)==="
|
||||||
|
echo " # iOS 17+ 设备(personalized,走缓存)"
|
||||||
|
echo " ./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <UDID>"
|
||||||
|
echo " # iOS <17 设备(经典镜像,显式指定本地文件)"
|
||||||
|
echo " ./.venv/bin/python -m pymobiledevice3 mounter mount-developer \\"
|
||||||
|
echo " $OUT/16.5/DeveloperDiskImage.dmg $OUT/16.5/DeveloperDiskImage.dmg.signature --udid <UDID>"
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""验证打包后的 .app 能否**独立运行**(不依赖项目目录、不依赖 venv、不依赖系统 Python)。
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
1. .app 结构完整(可执行文件、Info.plist、图标、前端资源)
|
||||||
|
2. 用干净环境启动(清空 PYTHON* 环境变量、不加载 .pth),端口能起来
|
||||||
|
3. 关键 API 可用:/api/health、/api/devices、/api/settings/tiles、/ 返回前端
|
||||||
|
4. 前端资源确实打包进去了(index.html / js / css 都能取到)
|
||||||
|
5. 瓦片代理可用(说明网络与缓存路径都对)
|
||||||
|
6. tunnel-helper 子命令可执行(真机连接依赖它;打包后不能再依赖 python -m pymobiledevice3)
|
||||||
|
7. 数据目录写在 ~/Library/Application Support/iOSLocationTrack(只读包外可写)
|
||||||
|
|
||||||
|
用法:./.venv/bin/python tools/verify_bundle.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
APP = ROOT / "dist" / "虚拟定位轨迹.app"
|
||||||
|
BIN = APP / "Contents" / "MacOS" / "iOSLocationTrack"
|
||||||
|
PORT = 8830
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if ok:
|
||||||
|
PASS += 1
|
||||||
|
print(f" \033[32m✓\033[0m {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
FAILURES.append(f"{name} {detail}")
|
||||||
|
print(f" \033[31m✗\033[0m {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("\033[1m打包产物独立运行验证\033[0m")
|
||||||
|
if not APP.exists():
|
||||||
|
print(f" \033[31m✗ 未找到 {APP},请先运行 PyInstaller\033[0m")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n1) 包结构")
|
||||||
|
check("可执行文件存在", BIN.exists(), str(BIN))
|
||||||
|
check("Info.plist 存在", (APP / "Contents" / "Info.plist").exists())
|
||||||
|
check("图标已嵌入", (APP / "Contents" / "Resources" / "AppIcon.icns").exists())
|
||||||
|
web = APP / "Contents" / "Frameworks" / "web"
|
||||||
|
check("前端资源已打包", (web / "index.html").exists(), str(web))
|
||||||
|
check("前端 JS 已打包", (web / "js" / "app.js").exists())
|
||||||
|
check("前端样式已打包", (web / "style.css").exists())
|
||||||
|
size_mb = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
|
||||||
|
print(f" 包体积:{size_mb:.1f} MB")
|
||||||
|
|
||||||
|
print("\n2) Info.plist 关键字段")
|
||||||
|
try:
|
||||||
|
plist = subprocess.run(
|
||||||
|
["/usr/libexec/PlistBuddy", "-c", "Print", str(APP / "Contents" / "Info.plist")],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
).stdout
|
||||||
|
check("包名正确", "虚拟定位轨迹" in plist)
|
||||||
|
check("Bundle ID 正确", "com.local.ios-location-track" in plist)
|
||||||
|
check("最低系统版本已声明", "LSMinimumSystemVersion" in plist)
|
||||||
|
except Exception as exc:
|
||||||
|
check("读取 Info.plist", False, str(exc)[:80])
|
||||||
|
|
||||||
|
print("\n3) 干净环境启动(不依赖 venv / 项目目录)")
|
||||||
|
env = {
|
||||||
|
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
|
||||||
|
"HOME": os.path.expanduser("~"),
|
||||||
|
"LANG": "zh_CN.UTF-8",
|
||||||
|
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
|
||||||
|
# 明确清掉 Python 相关变量,证明不依赖外部环境
|
||||||
|
}
|
||||||
|
log = Path("/tmp/bundle-run.log")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[str(BIN), "--mock", "--no-browser", "--window", "none", "--port", str(PORT)],
|
||||||
|
env=env, stdout=log.open("wb"), stderr=subprocess.STDOUT,
|
||||||
|
cwd="/tmp", # 故意用 /tmp 作为工作目录,验证不依赖项目目录
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
base = f"http://127.0.0.1:{PORT}"
|
||||||
|
ready = False
|
||||||
|
deadline = time.time() + 60
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base + "/api/health", timeout=2) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.5)
|
||||||
|
check("独立启动成功(不依赖 venv)", ready, f"见 {log}")
|
||||||
|
if not ready:
|
||||||
|
try:
|
||||||
|
print(" 启动日志:")
|
||||||
|
print(" ", log.read_text(encoding="utf-8", errors="replace")[-800:])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
proc.terminate()
|
||||||
|
print(f"\n通过 {PASS} 项,失败 {FAIL} 项")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("\n4) 关键接口")
|
||||||
|
with urllib.request.urlopen(base + "/api/health", timeout=5) as resp:
|
||||||
|
health = json.loads(resp.read())
|
||||||
|
check("GET /api/health", health.get("ok") is True, str(health)[:100])
|
||||||
|
check("运行在打包模式(无 venv)", health.get("pymobiledevice3") is True, str(health)[:120])
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/", timeout=5) as resp:
|
||||||
|
html = resp.read().decode("utf-8", "replace")
|
||||||
|
check("GET / 返回前端页面", "<html" in html.lower() and "虚拟定位" in html, "")
|
||||||
|
|
||||||
|
for path, label in [("/js/app.js", "app.js"), ("/js/map.js", "map.js"), ("/style.css", "style.css")]:
|
||||||
|
with urllib.request.urlopen(base + path, timeout=5) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
check(f"静态资源 {label} 可取", resp.status == 200 and len(body) > 1000,
|
||||||
|
f"{resp.status} {len(body)}B")
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/api/devices", timeout=8) as resp:
|
||||||
|
devices = json.loads(resp.read())
|
||||||
|
check("GET /api/devices", "devices" in devices, str(devices)[:100])
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=8) as resp:
|
||||||
|
tiles = json.loads(resp.read())
|
||||||
|
check("GET /api/settings/tiles", bool(tiles.get("sources")), "")
|
||||||
|
|
||||||
|
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as resp:
|
||||||
|
tile = resp.read()
|
||||||
|
check("瓦片代理可用", tile[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile)}B")
|
||||||
|
|
||||||
|
print("\n5) 数据目录(包外可写)")
|
||||||
|
data_dir = Path.home() / "Library" / "Application Support" / "iOSLocationTrack"
|
||||||
|
check("数据目录已创建", data_dir.is_dir(), str(data_dir))
|
||||||
|
check("config.json 已生成", (data_dir / "config.json").exists(), str(data_dir))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception:
|
||||||
|
proc.terminate()
|
||||||
|
time.sleep(1)
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\n6) tunnel-helper 子命令(真机连接依赖它)")
|
||||||
|
helper = subprocess.run(
|
||||||
|
[str(BIN), "tunnel-helper", "--udid", "NONEXISTENT-UDID"],
|
||||||
|
env=env, capture_output=True, text=True, timeout=90,
|
||||||
|
)
|
||||||
|
output = (helper.stdout or "") + (helper.stderr or "")
|
||||||
|
# 不存在的设备必然失败,但必须是"能跑起来、给出中文/英文错误",而不是崩溃或无此命令
|
||||||
|
check("tunnel-helper 可执行(非崩溃)",
|
||||||
|
helper.returncode in (0, 1, 2) and "Traceback" not in output,
|
||||||
|
f"rc={helper.returncode} {output.strip()[:120]}")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 56}")
|
||||||
|
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
|
||||||
|
for item in FAILURES:
|
||||||
|
print(" -", item)
|
||||||
|
return 0 if FAIL == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>iOS 虚拟定位 · 轨迹工具</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232f81f7'/%3E%3Cpath d='M16 6a6.5 6.5 0 0 0-6.5 6.5C9.5 17.4 16 26 16 26s6.5-8.6 6.5-13.5A6.5 6.5 0 0 0 16 6Zm0 8.8a2.4 2.4 0 1 1 0-4.8 2.4 2.4 0 0 1 0 4.8Z' fill='%23fff'/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ===================== 顶部:品牌 + 设备卡片 + 操作 ===================== -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-logo" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7Zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>iOS 虚拟定位 · 轨迹工具</h1>
|
||||||
|
<p id="appSubtitle">本地服务未连接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="device-card" id="deviceCard">
|
||||||
|
<div class="device-empty">正在读取设备信息…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="top-actions">
|
||||||
|
<span class="pill pill-mute" id="serverInfo">服务端 --</span>
|
||||||
|
<span class="pill pill-err" id="connPill">未连接</span>
|
||||||
|
<button type="button" class="btn" id="btnDevicesRefresh" title="重新扫描 USB / 网络设备">刷新设备</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btnDeviceConnect" title="建立隧道并连接 DVT 定位服务">连接设备</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="btnDeviceDisconnect" title="释放会话并恢复真实定位">断开</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===================== 全局提示条 ===================== -->
|
||||||
|
<div class="banner hidden" id="alertBanner">
|
||||||
|
<span class="banner-icon" aria-hidden="true">!</span>
|
||||||
|
<span class="banner-text" id="bannerText"></span>
|
||||||
|
<button type="button" class="btn btn-small" id="bannerAction">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== 主体 ===================== -->
|
||||||
|
<main class="workspace" id="workspace">
|
||||||
|
|
||||||
|
<!-- ---------- 左侧:轨迹设置 ---------- -->
|
||||||
|
<aside class="panel panel-left" id="leftPanel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>轨迹设置</h2>
|
||||||
|
<span class="panel-sub" id="routeMeta">未命名轨迹</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<label class="block-label">出行模式</label>
|
||||||
|
<div class="seg" id="modeGroup">
|
||||||
|
<button type="button" class="seg-btn is-active" data-mode="driving">驾车</button>
|
||||||
|
<button type="button" class="seg-btn" data-mode="cycling">骑行</button>
|
||||||
|
<button type="button" class="seg-btn" data-mode="walking">步行</button>
|
||||||
|
<button type="button" class="seg-btn" data-mode="flight">飞行</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:6px">
|
||||||
|
<button type="button" class="btn btn-small" id="btnCampusPreset" title="步行 1.4 m/s、下发 10Hz、漂移 4m、浮动 ±8%、不循环">校园跑预设</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnMarathonPreset" title="骑行 5.5 m/s、下发 10Hz、漂移 6m、浮动 ±10%">长跑预设</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="block-label" for="jumpCoord">快速定位到坐标 <span class="hint-mini">校园跑:先填学校坐标</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="jumpCoord" class="input" placeholder="例如 30.2741, 120.1551" title="纬度, 经度(WGS84,逗号或空格分隔)">
|
||||||
|
<button type="button" class="btn btn-small" id="btnJumpCoord">跳转</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:4px">
|
||||||
|
<button type="button" class="btn btn-small" id="btnSavePlace">记住此地</button>
|
||||||
|
<select id="placeSelect" class="input select" title="已记住的地点">
|
||||||
|
<option value="">(暂无记住的地点)</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-small" id="btnGoPlace">前往</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint-mini" style="margin:4px 0 0">跳转只是把地图移到该点,方便你在学校范围内画轨迹;不会改变手机定位。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="block-label" for="speedRange">播放倍速 <span class="hint-mini">0.1x – 50x</span></label>
|
||||||
|
<span class="value-tag" id="speedValue">1.0x</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="speedRange" class="range" min="0" max="1000" step="1" value="500" title="0.1x - 50x(指数刻度)">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="loopToggle">
|
||||||
|
<span>循环播放(到达终点后回到起点)</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="block-label">真实感模拟 <span class="hint-mini">校园跑等打卡类测试</span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="driftToggle" checked>
|
||||||
|
<span>定位漂移(模拟 GPS 误差缓慢游走)</span>
|
||||||
|
</label>
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="hint-mini" for="driftRange">漂移半径</label>
|
||||||
|
<span class="value-tag" id="driftValue">4 米</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="driftRange" class="range" min="0" max="50" step="1" value="4" title="0 - 50 米">
|
||||||
|
|
||||||
|
<label class="check" style="margin-top:6px">
|
||||||
|
<input type="checkbox" id="jitterToggle" checked>
|
||||||
|
<span>速度浮动(瞬时速度在设定值上下波动)</span>
|
||||||
|
</label>
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="hint-mini" for="jitterRange">浮动幅度</label>
|
||||||
|
<span class="value-tag" id="jitterValue">±8%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="jitterRange" class="range" min="0" max="60" step="1" value="8" title="0 - 60%">
|
||||||
|
|
||||||
|
<label class="check" style="margin-top:6px">
|
||||||
|
<input type="checkbox" id="timingJitterToggle" checked>
|
||||||
|
<span>发送节奏抖动(真实 GPS 不是精确每秒一次)</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="deriveSpeedToggle" checked>
|
||||||
|
<span>速度由位移推算(与 iOS 的算法一致)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check" style="margin-top:6px">
|
||||||
|
<input type="checkbox" id="grossErrorToggle">
|
||||||
|
<span>注入粗差跳点(测自己 App 的过滤逻辑)</span>
|
||||||
|
</label>
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="hint-mini" for="grossErrorRange">粗差概率</label>
|
||||||
|
<span class="value-tag" id="grossErrorValue">2%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="grossErrorRange" class="range" min="0" max="20" step="1" value="2" title="0 - 20%" disabled>
|
||||||
|
|
||||||
|
<p class="hint-mini" style="margin:6px 0 0">
|
||||||
|
漂移用多尺度噪声(慢漂+中尺度+快抖),沿轨迹平滑游走;发送间隔带 ±15% 抖动与偶发丢采样;
|
||||||
|
上报速度由实际位移÷时间推算,速度/位移/时间三者自洽。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<label class="block-label">路线概览</label>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><span class="stat-k">总距离</span><span class="stat-v" id="statDistance">--</span></div>
|
||||||
|
<div class="stat"><span class="stat-k">按速度预计</span><span class="stat-v" id="statDuration">--</span></div>
|
||||||
|
<div class="stat"><span class="stat-k">按倍速播放</span><span class="stat-v" id="statPlayDuration">--</span></div>
|
||||||
|
<div class="stat"><span class="stat-k">路径点数</span><span class="stat-v" id="statPoints">0</span></div>
|
||||||
|
<div class="stat"><span class="stat-k">途经点</span><span class="stat-v" id="statWaypoints">0</span></div>
|
||||||
|
<div class="stat"><span class="stat-k">路线引擎</span><span class="stat-v" id="statEngine">--</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="row">
|
||||||
|
<label class="block-label inline" for="routeName">轨迹名称</label>
|
||||||
|
<input type="text" id="routeName" class="input" maxlength="60" placeholder="未命名轨迹">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<div class="row row-between">
|
||||||
|
<label class="block-label">途经点列表</label>
|
||||||
|
<span class="hint-mini">拖拽排序 · 双击改坐标</span>
|
||||||
|
</div>
|
||||||
|
<div class="wp-list" id="wpList"></div>
|
||||||
|
<div class="wp-empty" id="wpEmpty">还没有途经点:点地图工具条上的「添加途经点」在地图上点选,或导入 GPX。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<label class="block-label" for="savedRoutes">本地保存的路线</label>
|
||||||
|
<div class="row">
|
||||||
|
<select class="input select" id="savedRoutes"></select>
|
||||||
|
<button type="button" class="btn" id="btnSaveRoute" title="保存到浏览器 localStorage">保存</button>
|
||||||
|
<button type="button" class="btn" id="btnLoadRoute" title="载入所选路线">载入</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="btnDeleteSaved" title="删除所选路线">删除</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<label class="block-label" for="tileSourceSelect">底图</label>
|
||||||
|
<select class="input select" id="tileSourceSelect"></select>
|
||||||
|
<div class="row row-wrap gap-top">
|
||||||
|
<button type="button" class="btn btn-small" id="btnTunnelEnsure" title="iOS 17+ 建立 RSD 隧道(必要时需要管理员权限)">建立隧道</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnTunnelManual" title="手动填写 RSD 主机与端口">手动 RSD</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnMountImage" title="给 iOS 设备挂载开发者镜像(DVT 虚拟定位的前提,内置镜像素材)">挂载开发者镜像</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnTilesClear" title="清理服务端瓦片磁盘缓存并重新拉取">清理瓦片缓存</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint-mini" style="margin:6px 0 0">
|
||||||
|
「挂载开发者镜像」是虚拟定位的前提(iOS 17+ 一般由 Xcode 自动完成)。
|
||||||
|
应用内置了 iOS 17–27.x 全系共用的镜像素材,无需联网下载;签名由 Apple 在线签发。
|
||||||
|
点击后过程实时输出到右侧运行日志。<b>仅支持 iOS 17 及以上</b>。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block block-muted">
|
||||||
|
<label class="block-label">快捷键</label>
|
||||||
|
<div class="keys">
|
||||||
|
<span><kbd>空格</kbd> 播放 / 暂停</span>
|
||||||
|
<span><kbd>Esc</kbd> 结束折线绘制</span>
|
||||||
|
<span><kbd>Del</kbd> 删除选中点</span>
|
||||||
|
<span><kbd>双击</kbd> 放大 / 结束绘制</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 固定操作条:始终可见的主操作 -->
|
||||||
|
<section class="actions-sticky">
|
||||||
|
<button type="button" class="btn btn-primary btn-block" id="btnBuildRoute">加载 OSRM 路线</button>
|
||||||
|
<div class="btn-grid">
|
||||||
|
<button type="button" class="btn" id="btnClear">清空</button>
|
||||||
|
<button type="button" class="btn" id="btnExportGpx">导出 GPX</button>
|
||||||
|
<button type="button" class="btn" id="btnImportGpx">导入 GPX</button>
|
||||||
|
<button type="button" class="btn" id="btnClearLocation">恢复真实定位</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ---------- 中间:地图 ---------- -->
|
||||||
|
<section class="map-area" id="mapArea">
|
||||||
|
<div class="map-toolbar" id="drawToolbar">
|
||||||
|
<button type="button" class="tool-btn is-active" id="toolSelect" data-tool="select" title="选择 / 拖拽途经点">选择</button>
|
||||||
|
<button type="button" class="tool-btn" id="toolAdd" data-tool="add" title="在地图上点击添加途经点">添加途经点</button>
|
||||||
|
<button type="button" class="tool-btn" id="toolPolyline" data-tool="polyline" title="连续点击加点,双击或 Esc 结束">折线绘制</button>
|
||||||
|
<span class="tool-sep"></span>
|
||||||
|
<button type="button" class="tool-btn" id="toolUndo" title="撤销上一步">撤销</button>
|
||||||
|
<button type="button" class="tool-btn" id="toolDelete" title="删除选中的途经点">删除选中点</button>
|
||||||
|
<button type="button" class="tool-btn" id="toolFit" title="缩放至完整轨迹">全览</button>
|
||||||
|
<span class="tool-hint" id="toolHint">拖拽平移 · 滚轮缩放</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-chips" id="mapChips">
|
||||||
|
<span class="chip" id="chipPlayState">未播放</span>
|
||||||
|
<span class="chip" id="chipProgress">进度 0%</span>
|
||||||
|
<span class="chip" id="chipSpeed">速度 --</span>
|
||||||
|
<span class="chip" id="chipCoord">--</span>
|
||||||
|
<span class="chip chip-mute" id="chipTrack">轨迹点 0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="map" class="map-host"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------- 右侧:日志 ---------- -->
|
||||||
|
<aside class="panel panel-right" id="logPanel">
|
||||||
|
<div class="panel-head log-head">
|
||||||
|
<h2 class="log-title">运行日志</h2>
|
||||||
|
<span class="panel-sub" id="logCount">0 / 300</span>
|
||||||
|
<div class="log-actions">
|
||||||
|
<button type="button" class="btn btn-small" id="btnFetchLogs" title="向服务端拉取最近 200 条日志">拉取</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnClearLog">清空</button>
|
||||||
|
<button type="button" class="btn btn-small" id="btnToggleLog" title="折叠 / 展开日志面板">折叠</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-body" id="logBody"></div>
|
||||||
|
<div class="log-collapsed-label" id="logCollapsedLabel">运 行 日 志</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===================== 底部播放条 ===================== -->
|
||||||
|
<footer class="playbar">
|
||||||
|
<div class="transport">
|
||||||
|
<button type="button" class="tbtn tbtn-play" id="btnPlay" title="开始播放">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
|
||||||
|
<span>播放</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tbtn" id="btnPause" title="暂停" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M7 5h3.5v14H7V5Zm6.5 0H17v14h-3.5V5Z"/></svg>
|
||||||
|
<span>暂停</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tbtn" id="btnResume" title="继续" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 5.5v13l10-6.5-10-6.5Zm12 0h2v13h-2v-13Z"/></svg>
|
||||||
|
<span>继续</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tbtn tbtn-stop" id="btnStop" title="停止播放并恢复真实定位" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 6h12v12H6z"/></svg>
|
||||||
|
<span>停止</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-top">
|
||||||
|
<span class="play-state" id="playStateText">未播放</span>
|
||||||
|
<span class="progress-num" id="liveProgress">0.0%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="progressRange" class="range range-progress" min="0" max="1000" step="1" value="0">
|
||||||
|
<div class="progress-times">
|
||||||
|
<span>已用 <b id="timeElapsed">00:00</b></span>
|
||||||
|
<span>剩余 <b id="timeRemaining">00:00</b></span>
|
||||||
|
<span>序号 <b id="liveIndex">0 / 0</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-stats">
|
||||||
|
<div class="live"><span class="live-k">实时速度</span><span class="live-v" id="liveSpeed">--</span></div>
|
||||||
|
<div class="live"><span class="live-k">当前坐标</span><span class="live-v live-v-mono" id="liveCoord">--</span></div>
|
||||||
|
<div class="live"><span class="live-k">倍速</span><span class="live-v" id="liveScale">1.0x</span></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== 弹窗 / 提示 ===================== -->
|
||||||
|
<div class="modal-mask hidden" id="modalMask">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<h3 id="modalTitle">提示</h3>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn" id="modalCancel">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="modalOk">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-wrap" id="toastWrap"></div>
|
||||||
|
|
||||||
|
<input type="file" id="gpxFile" accept=".gpx,.xml,application/gpx+xml,text/xml" hidden>
|
||||||
|
|
||||||
|
<script src="js/coord.js"></script>
|
||||||
|
<script src="js/ws.js"></script>
|
||||||
|
<script src="js/map.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/*!
|
||||||
|
* ws.js — WebSocket 客户端(协议见 docs/PROTOCOL.md §3)
|
||||||
|
*
|
||||||
|
* 能力:
|
||||||
|
* - 连接 ws://127.0.0.1:8787/ws,连接后立即发 hello
|
||||||
|
* - 自动重连:指数退避 400ms → 10s(带抖动),连接成功后重置
|
||||||
|
* - 心跳:每 10s 发 ping;连续 3 次没有 pong 主动断开重连
|
||||||
|
* - 命令请求-应答:request(cmd, fields, timeout) → Promise(ack ok:false 会 reject)
|
||||||
|
* - 事件订阅:on(type, fn) / on('*', fn) / onState(fn)
|
||||||
|
*
|
||||||
|
* 导出:window.WSClient
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var CMD_TIMEOUT = 8000;
|
||||||
|
var TICK_TIMEOUT = 6000;
|
||||||
|
var HEARTBEAT_MS = 10000;
|
||||||
|
var MAX_BACKOFF = 10000;
|
||||||
|
|
||||||
|
function nowMs() {
|
||||||
|
return (global.performance && global.performance.now) ? global.performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function WSClient(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
this.url = opts.url || defaultUrl();
|
||||||
|
// 部分服务端实现会在 101 响应里回带未请求的 Sec-WebSocket-Protocol,
|
||||||
|
// 浏览器按 RFC 6455 会直接判定握手失败。这里在首次连接失败后自动带上 v1 再试一次。
|
||||||
|
this.subprotocols = opts.subprotocols || null;
|
||||||
|
this.everOpened = false;
|
||||||
|
this.protocolFallback = false;
|
||||||
|
this.handlers = Object.create(null); // type -> [fn]
|
||||||
|
this.stateHandlers = [];
|
||||||
|
this.state = 'closed'; // closed | connecting | open
|
||||||
|
this.sock = null;
|
||||||
|
this.seq = 0;
|
||||||
|
this.pending = Object.create(null); // id -> {resolve, reject, timer, cmd}
|
||||||
|
this.attempt = 0;
|
||||||
|
this.closedByUser = false;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
this.lastPongAt = 0;
|
||||||
|
// 最近一次收到任何入站消息的时间(不只是 pong):用于判断连接是否真的静默
|
||||||
|
this.lastInboundAt = 0;
|
||||||
|
this.missedPings = 0;
|
||||||
|
this.manualClose = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultUrl() {
|
||||||
|
var loc = global.location;
|
||||||
|
var host = (loc && loc.hostname) ? loc.hostname : '127.0.0.1';
|
||||||
|
var port = (loc && loc.port) ? loc.port : '8787';
|
||||||
|
var proto = (loc && loc.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||||
|
return proto + '//' + host + ':' + port + '/ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
WSClient.prototype.on = function (type, fn) {
|
||||||
|
(this.handlers[type] || (this.handlers[type] = [])).push(fn);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.off = function (type, fn) {
|
||||||
|
var list = this.handlers[type];
|
||||||
|
if (!list) return this;
|
||||||
|
var i = list.indexOf(fn);
|
||||||
|
if (i >= 0) list.splice(i, 1);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.onState = function (fn) {
|
||||||
|
this.stateHandlers.push(fn);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._setState = function (s, detail) {
|
||||||
|
if (this.state === s && !detail) return;
|
||||||
|
this.state = s;
|
||||||
|
for (var i = 0; i < this.stateHandlers.length; i++) {
|
||||||
|
try { this.stateHandlers[i](s, detail); } catch (e) { /* 忽略订阅者异常 */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._emit = function (type, payload, raw) {
|
||||||
|
var list = this.handlers[type];
|
||||||
|
if (list) {
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
try { list[i](payload, raw); } catch (e) {
|
||||||
|
if (global.console) global.console.error('[ws] 事件处理异常 ' + type, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var all = this.handlers['*'];
|
||||||
|
if (all) {
|
||||||
|
for (var j = 0; j < all.length; j++) {
|
||||||
|
try { all[j](payload, raw, type); } catch (e2) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.connect = function () {
|
||||||
|
var self = this;
|
||||||
|
if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return this;
|
||||||
|
this.closedByUser = false;
|
||||||
|
this.manualClose = false;
|
||||||
|
this._setState('connecting');
|
||||||
|
var sock;
|
||||||
|
try {
|
||||||
|
sock = this.subprotocols ? new WebSocket(this.url, this.subprotocols) : new WebSocket(this.url);
|
||||||
|
} catch (e) {
|
||||||
|
this._scheduleReconnect('WebSocket 构造失败:' + e.message);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
this.sock = sock;
|
||||||
|
|
||||||
|
sock.onopen = function () {
|
||||||
|
self.attempt = 0;
|
||||||
|
self.everOpened = true;
|
||||||
|
self.lastPongAt = nowMs();
|
||||||
|
self.lastInboundAt = nowMs();
|
||||||
|
self.missedPings = 0;
|
||||||
|
self._setState('open');
|
||||||
|
self._emit('open', { url: self.url });
|
||||||
|
// hello 用请求-应答发送:服务端把 welcome 数据并回在 hello 的 ack 里
|
||||||
|
// (契约里 hello → welcome;两种形态都兼容:ack 命中后归一化成 welcome 事件)
|
||||||
|
self.request('hello', { client: 'web' }, 8000).then(function (msg) {
|
||||||
|
self._emit('welcome', msg);
|
||||||
|
}).catch(function (err) {
|
||||||
|
self._emit('hello_failed', { error: err && err.message });
|
||||||
|
});
|
||||||
|
self._startHeartbeat();
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onmessage = function (ev) {
|
||||||
|
var msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data);
|
||||||
|
} catch (e) {
|
||||||
|
self._emit('error', { error: '服务端消息不是合法 JSON', raw: String(ev.data).slice(0, 200) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
self._dispatch(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onerror = function () {
|
||||||
|
self._emit('sockerror', { url: self.url });
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onclose = function (ev) {
|
||||||
|
self._stopHeartbeat();
|
||||||
|
self.sock = null;
|
||||||
|
if (self.manualClose || self.closedByUser) {
|
||||||
|
self._setState('closed', ev && ev.reason);
|
||||||
|
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self._setState('closed', ev && ev.reason);
|
||||||
|
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: false });
|
||||||
|
self._scheduleReconnect(ev && ev.reason ? ('连接关闭:' + ev.reason) : '连接已关闭');
|
||||||
|
};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._dispatch = function (msg) {
|
||||||
|
// 任何入站消息都证明服务端还活着。不能只认 pong:
|
||||||
|
// 服务端执行 device.connect(iOS 17+ 建 RSD 隧道,最长约 1 分钟)时会持续推 log 帧,
|
||||||
|
// 但由于该命令是同步阻塞的,ping 的 pong 可能迟迟回不来——只认 pong 会误判断线。
|
||||||
|
this.lastInboundAt = nowMs();
|
||||||
|
this.missedPings = 0;
|
||||||
|
if (msg.type === 'pong') {
|
||||||
|
this.lastPongAt = nowMs();
|
||||||
|
}
|
||||||
|
// 请求-应答:只要带 id 且命中 pending,就作为该请求的应答
|
||||||
|
if (msg.id && this.pending[msg.id]) {
|
||||||
|
var p = this.pending[msg.id];
|
||||||
|
delete this.pending[msg.id];
|
||||||
|
if (p.timer) clearTimeout(p.timer);
|
||||||
|
if (msg.type === 'ack' && msg.ok === false) {
|
||||||
|
var err = new Error(msg.error || '服务端拒绝了该命令');
|
||||||
|
err.code = msg.code || 'ack_failed';
|
||||||
|
err.cmd = msg.cmd || p.cmd;
|
||||||
|
p.reject(err);
|
||||||
|
} else {
|
||||||
|
p.resolve(msg);
|
||||||
|
}
|
||||||
|
// 应答同时也是事件(例如 ack 之外带状态),继续广播
|
||||||
|
}
|
||||||
|
this._emit(msg.type || 'unknown', msg, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 发送原始对象(无应答等待) */
|
||||||
|
WSClient.prototype.send = function (obj) {
|
||||||
|
if (!this.sock || this.sock.readyState !== 1) return false;
|
||||||
|
try {
|
||||||
|
this.sock.send(JSON.stringify(obj));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this._emit('error', { error: '发送失败:' + e.message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.nextId = function (prefix) {
|
||||||
|
this.seq += 1;
|
||||||
|
return (prefix || 'c') + this.seq + '-' + Math.floor(Math.random() * 1e6).toString(36);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令请求-应答。
|
||||||
|
* @param {string} cmd 命令 type,例如 'play.start'
|
||||||
|
* @param {object} [fields] 附加字段
|
||||||
|
* @param {number} [timeout] 毫秒,默认 8000
|
||||||
|
* @returns {Promise<object>} 应答消息;ok:false 或超时 → reject(Error)
|
||||||
|
*/
|
||||||
|
WSClient.prototype.request = function (cmd, fields, timeout) {
|
||||||
|
var self = this;
|
||||||
|
var id = this.nextId();
|
||||||
|
var payload = Object.assign({ type: cmd, id: id }, fields || {});
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (!self.sock || self.sock.readyState !== 1) {
|
||||||
|
var e = new Error('尚未连接到本地服务,请确认服务已启动');
|
||||||
|
e.code = 'ws_closed';
|
||||||
|
reject(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var timer = setTimeout(function () {
|
||||||
|
if (self.pending[id]) {
|
||||||
|
delete self.pending[id];
|
||||||
|
var te = new Error('命令超时(' + cmd + ')');
|
||||||
|
te.code = 'timeout';
|
||||||
|
te.cmd = cmd;
|
||||||
|
reject(te);
|
||||||
|
}
|
||||||
|
}, timeout || CMD_TIMEOUT);
|
||||||
|
self.pending[id] = { resolve: resolve, reject: reject, timer: timer, cmd: cmd };
|
||||||
|
var ok = self.send(payload);
|
||||||
|
if (!ok) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
delete self.pending[id];
|
||||||
|
var se = new Error('发送失败:连接不可用');
|
||||||
|
se.code = 'ws_send_failed';
|
||||||
|
reject(se);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.isOpen = function () {
|
||||||
|
return !!this.sock && this.sock.readyState === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._startHeartbeat = function () {
|
||||||
|
var self = this;
|
||||||
|
this._stopHeartbeat();
|
||||||
|
this.heartbeatTimer = setInterval(function () {
|
||||||
|
if (!self.isOpen()) return;
|
||||||
|
var lastSeen = Math.max(self.lastInboundAt || 0, self.lastPongAt || 0);
|
||||||
|
var idle = nowMs() - lastSeen;
|
||||||
|
// 只要还在收到任何消息(log/status/location/ack)就说明连接健康,不累计丢包
|
||||||
|
if (idle > HEARTBEAT_MS * 3) {
|
||||||
|
self.missedPings += 1;
|
||||||
|
} else {
|
||||||
|
self.missedPings = 0;
|
||||||
|
}
|
||||||
|
if (self.missedPings >= 3) {
|
||||||
|
self._emit('error', {
|
||||||
|
error: '连接静默超过 ' + Math.round(HEARTBEAT_MS * 9 / 1000) + ' 秒,正在重建连接'
|
||||||
|
});
|
||||||
|
try { self.sock.close(); } catch (e) { /* 忽略 */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.request('ping', {}, TICK_TIMEOUT).catch(function () { /* ping 超时由 missedPings 兜底 */ });
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._stopHeartbeat = function () {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype._scheduleReconnect = function (reason) {
|
||||||
|
var self = this;
|
||||||
|
if (this.closedByUser || this.reconnectTimer) return;
|
||||||
|
if (!this.everOpened && !this.protocolFallback && !this.subprotocols) {
|
||||||
|
this.protocolFallback = true;
|
||||||
|
this.subprotocols = ['v1'];
|
||||||
|
this.attempt = 0;
|
||||||
|
this._emit('protocol_fallback', { reason: reason || '' });
|
||||||
|
}
|
||||||
|
var delay = Math.min(MAX_BACKOFF, 400 * Math.pow(2, this.attempt)) + Math.floor(Math.random() * 250);
|
||||||
|
this.attempt += 1;
|
||||||
|
this._emit('reconnecting', { delay: delay, attempt: this.attempt, reason: reason });
|
||||||
|
this.reconnectTimer = setTimeout(function () {
|
||||||
|
self.reconnectTimer = null;
|
||||||
|
self.connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
WSClient.prototype.close = function () {
|
||||||
|
this.closedByUser = true;
|
||||||
|
this.manualClose = true;
|
||||||
|
this._stopHeartbeat();
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
var ids = Object.keys(this.pending);
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
var p = this.pending[ids[i]];
|
||||||
|
if (p && p.timer) clearTimeout(p.timer);
|
||||||
|
if (p) p.reject(new Error('连接已关闭'));
|
||||||
|
delete this.pending[ids[i]];
|
||||||
|
}
|
||||||
|
if (this.sock) {
|
||||||
|
try { this.sock.close(); } catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
this.sock = null;
|
||||||
|
this._setState('closed', 'manual');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 立即重连(用户点「重试」时用) */
|
||||||
|
WSClient.prototype.reconnectNow = function () {
|
||||||
|
this.attempt = 0;
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.sock) {
|
||||||
|
try { this.sock.close(); } catch (e) { /* 忽略 */ }
|
||||||
|
this.sock = null;
|
||||||
|
}
|
||||||
|
this.connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
global.WSClient = WSClient;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||