Files

1450 lines
64 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP + WebSocket 服务端:实现 docs/PROTOCOL.md 的全部接口。
架构:
- 一个后台线程跑 asyncio 事件循环(AsyncRunner),设备/DVT 的所有协程都在那里执行;
- ThreadingHTTPServer 处理 HTTP 与 WebSocket 升级,每条连接一个线程;
- 播放引擎线程通过 AsyncRunner 串行下发定位。
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import mimetypes
import os
import posixpath
import re
import shutil
import subprocess
import threading
import time
import urllib.parse
from collections import deque
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from . import config_store, device as device_mod, protocol, route_engine, tilecache, tunnel as tunnel_mod, ws
def _web_root() -> Path:
"""前端资源目录。
- 源码运行:项目根目录下的 web/
- 打包成 .app 独立运行:PyInstaller 解包目录(sys._MEIPASS)/web
"""
import sys
base = getattr(sys, "_MEIPASS", None)
if base:
candidate = Path(base) / "web"
if candidate.is_dir():
return candidate
return Path(__file__).resolve().parent.parent / "web"
WEB_ROOT = _web_root()
class AsyncRunner:
"""把一个 asyncio 事件循环放到后台线程,供同步代码提交协程。"""
def __init__(self) -> None:
self._loop = asyncio.new_event_loop()
self._ready = threading.Event()
self._thread = threading.Thread(target=self._run, name="async-runner", daemon=True)
self._thread.start()
self._ready.wait(timeout=5.0)
def _run(self) -> None:
asyncio.set_event_loop(self._loop)
self._ready.set()
self._loop.run_forever()
@property
def loop(self) -> asyncio.AbstractEventLoop:
return self._loop
def run(self, coro, timeout: float | None = 30.0, wait: bool = True):
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
if not wait:
return future
try:
return future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
raise protocol.AppError("设备响应超时,请检查连接后重试", "device_timeout", status=504)
def shutdown(self) -> None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=3.0)
except Exception:
pass
class App:
"""应用装配:配置、设备、隧道、定位会话、路由、播放、瓦片、日志。"""
def __init__(self, root: Path, mock: bool = False, port: int = protocol.DEFAULT_PORT,
resources: Path | str | None = None) -> None:
# root = 可写数据目录(打包后是 %LOCALAPPDATA%\\iOSLocationTrack)
# resources = 只读资源目录(打包后是 PyInstaller 解包目录),默认与 root 相同
self.root = Path(root)
self.resources = Path(resources) if resources is not None else self.root
self.mock = mock
self.port = port
self.host = protocol.DEFAULT_HOST
self.config = config_store.ConfigStore(self.root / "config.json")
# 首次运行就把配置落盘:否则界面上改的设置(漂移/下发频率等)重启后会丢,
# 打包成 .app 时数据目录是新建的,这一点尤其明显。
if not self.config.path.exists():
try:
self.config.save()
except Exception:
pass
self.runner = AsyncRunner()
self.hub = ws.Hub()
# Windows 的 userspace 隧道必须建在应用自己的事件循环上(地址仅本进程可达),
# 因此把 runner 注入 TunnelManager;macOS 只用它做子进程管理,传了也无害。
self.tunnel = tunnel_mod.TunnelManager(
log=self.log, on_change=self._on_tunnel_change, runner=self.runner
)
self.devices = device_mod.DeviceManager(mock=mock, on_change=self._on_devices_change)
self.routes = route_engine.RouteEngine(self.config)
self.tiles = tilecache.TileCache(
self.config.cache_dir(), int(self.config.get("tile_cache_max_mb") or 1024)
)
self.logs: deque[dict[str, Any]] = deque(maxlen=500)
self._log_file = self._open_log_file()
self._lock = threading.RLock()
self.provider: Any = None
self.provider_udid: str | None = None
self.playback = None # 延后创建(依赖 provider 注入)
from .playback import PlaybackEngine
self.playback = PlaybackEngine(
runner=self.runner,
config=self.config,
on_location=self._broadcast_location,
on_status=self._broadcast_status,
on_log=self.log,
)
self.started_at = time.time()
self.shutdown_event = threading.Event()
# 开发者镜像挂载是否正在进行(避免重复触发)
self._mount_running = False
# ---------------- 日志 ----------------
def _open_log_file(self):
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。
带简单滚动:超过 5 MB 就把当前日志改名为 ``app.log.1``(只保留一份旧日志),
避免长期使用后日志无限增长。
"""
try:
directory = self.root / "logs"
directory.mkdir(parents=True, exist_ok=True)
log_path = directory / "app.log"
try:
if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024:
backup = directory / "app.log.1"
try:
backup.unlink()
except OSError:
pass
log_path.replace(backup)
except OSError:
pass
handle = open(log_path, "a", encoding="utf-8")
return handle
except Exception:
return None
def log(self, level: str, message: str) -> None:
entry = {"ts": protocol.now_ts(), "level": level, "msg": str(message)}
self.logs.append(entry)
self.hub.broadcast({"type": "log", **entry})
handle = getattr(self, "_log_file", None)
if handle is not None:
try:
handle.write(f"{entry['ts']}\t{level}\t{message}\n")
handle.flush()
except Exception:
pass
def recent_logs(self, limit: int = 200) -> list[dict[str, Any]]:
try:
count = int(limit)
except (TypeError, ValueError) as exc:
raise protocol.AppError("limit 必须是整数", "bad_request") from exc
items = list(self.logs)[-max(1, min(count, 500)):]
return items
# ---------------- 广播 ----------------
def _broadcast_location(self, payload: dict[str, Any]) -> None:
self.hub.broadcast({"type": "location", **payload})
def _broadcast_status(self, payload: dict[str, Any]) -> None:
self.hub.broadcast({"type": "status", **payload})
def _on_devices_change(self) -> None:
self.hub.broadcast({"type": "devices", **self.devices.devices_json()})
def _on_tunnel_change(self) -> None:
self.hub.broadcast({"type": "tunnel", **self.tunnel.snapshot()})
def welcome_payload(self) -> dict[str, Any]:
return {
"type": "welcome",
"version": protocol.APP_VERSION,
"name": protocol.APP_NAME,
"mock": self.mock,
"config": self.config.snapshot(),
"devices": self.devices.list_devices() and [d.to_json() for d in self.devices.list_devices()],
"selected": self.devices.selected_udid(),
# 与 tunnel.ensure 用同一套语义:mock 模式/IOS<17 不该显示"空闲/建立中"
"tunnel": self._tunnel_semantics(),
"backend": self.devices.backend,
"pymobiledevice3": device_mod.pymobiledevice3_importable(),
}
def _tunnel_semantics(self) -> dict[str, Any]:
"""统一的隧道语义(welcome / REST / WS 三处共用,避免行为不一致)。"""
device = self.devices.get_selected()
if self.mock:
return {
"state": "connected",
"message": "模拟模式:无需建立 RSD 隧道",
"rsd": "mock",
"mode": "mock",
"udid": device.udid if device else None,
"ios_major": device.ios_major if device else None,
"need_tunnel": False,
}
if device is not None and (device.ios_major or 0) < 17:
return {
"state": "unsupported",
"message": f"iOS {device.ios_version} 使用 usbmux + 开发者镜像的经典通道,不需要 RSD 隧道",
"rsd": None,
"mode": None,
"udid": device.udid,
"ios_major": device.ios_major,
"need_tunnel": False,
}
snapshot = self.tunnel.snapshot()
snapshot["ios_major"] = device.ios_major if device else None
snapshot["need_tunnel"] = True
return snapshot
# ---------------- 会话管理 ----------------
def provider_for(self, device: device_mod.Device, connect: bool = True) -> Any:
"""按需创建并连接定位 provider(mock 或真机)。"""
with self._lock:
if self.provider is not None and self.provider_udid == device.udid:
if not connect or getattr(self.provider, "connected", False):
return self.provider
if self.mock:
from .location_mock import MockLocationProvider
provider = MockLocationProvider(log=self.log)
provider.connect_sync(self.runner)
self.provider = provider
self.provider_udid = device.udid
self.playback.set_provider(provider)
return provider
from .location_provider import RealLocationProvider
# 连之前先体检:iOS<17 必须有开发者镜像,否则连了也是白连,
# 而且如果同时插着另一台可用的设备,要直接告诉用户换哪台。
self._preflight(device)
# RSD 有两种形态,必须区分(这是 Windows 与 macOS 最大的架构差异):
# - macOS native 隧道:地址内核可路由 → 传 (host, port) 让 provider 自己连
# - Windows userspace 隧道:地址只在**本进程的用户态协议栈**里可达,
# 直接 TCP 连接会得到 OSError [WinError 1231] 网络位置不可达 →
# 必须把隧道返回的那个已连接 RSD 对象本身注入给 provider
rsd = None
rsd_obj = None
if (device.ios_major or 0) >= 17:
# 必须**无条件**调用 ensure():它内部会判断已建立的隧道是不是绑在当前
# 选中的这台设备上,UDID 不一致会自动重建。
# 之前这里先用 `if not self.tunnel.ready` 短路,导致切换设备时复用了
# 上一台设备的 RSD 对象 —— 表现为"给错的手机下发定位"。
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
if snapshot["state"] != "connected":
raise protocol.AppError(
snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED,
"tunnel_required",
status=409,
)
rsd_obj = self.tunnel.rsd_object()
if rsd_obj is None:
rsd = self.tunnel.address()
provider = RealLocationProvider(
rsd=rsd_obj,
rsd_host=rsd[0] if rsd else None,
rsd_port=rsd[1] if rsd else None,
udid=device.udid,
log=self.log,
)
try:
self.runner.run(provider.connect(device), timeout=40.0)
except Exception:
# 失败时把新建的 provider 关掉,避免 DVT/lockdown 泄漏
try:
self.runner.run(provider.close(), timeout=15.0)
except Exception:
pass
# 并把隧道标记为失效,下一次连接会重建,
# 避免拿着一个已经死掉的 RSD 反复重试。
try:
self.tunnel.invalidate("DVT 会话建立失败,隧道可能已失效")
except Exception:
pass
raise
self.provider = provider
self.provider_udid = device.udid
self.playback.set_provider(provider)
device_mod.DeviceManager.mark(self.devices, device.udid, simulating=True)
return provider
def _preflight(self, device: device_mod.Device) -> None:
"""连接前的可用性体检,尽量在动手之前给出可操作的中文提示。
典型场景:同时插着两台设备,用户选中的那台(例如 Wi-Fi 连接的旧设备)没挂开发者镜像,
而另一台 USB 设备是可用的——这时必须明确告诉用户该换哪台,而不是抛底层英文错误。
"""
if (device.ios_major or 0) >= 17:
return # iOS 17+ 走 RSD 隧道,不需要开发者镜像
mounted = self._developer_image_mounted(device.udid)
if mounted is None or mounted:
return
others = [
d for d in self.devices.list_devices()
if d.udid != device.udid and (d.ios_major or 0) >= 17 and d.state == "connected"
]
hint = ""
if others:
best = others[0]
hint = f";建议改用另一台设备:{best.name}(iOS {best.ios_version},{best.connection.upper()})"
raise protocol.AppError(
f"{device.name}(iOS {device.ios_version})尚未挂载开发者镜像,无法做虚拟定位。"
f"请执行:{tunnel_mod.mounter_hint(device.udid)}{hint}",
"developer_image_missing",
status=409,
)
def _developer_session_ready(self, udid: str, major: int) -> bool:
"""判断"现在能不能真的做虚拟定位"。
- **iOS 17 及以上**:DVT 走 RSD 隧道,lockdown 里根本没有那个服务名,
因此不能拿服务名去试。这里以"镜像是否已挂载"为准 —— 后续连接设备时
会自动建隧道并打开 DVT,真连不上会在那一步给出明确报错。
- **iOS 16 及更早**:镜像挂载 ≠ DVT 可用(实测 16.5 上个性化镜像挂上了
但服务不存在),必须真的开一次服务才算就绪。
"""
import asyncio
if not major or major >= 17:
return self._developer_image_mounted(udid) is True
async def probe() -> bool:
from pymobiledevice3.lockdown import create_using_usbmux
try:
client = await create_using_usbmux(
serial=udid, autopair=False, connection_type="USB", pair_timeout=3.0
)
except Exception:
return False
try:
conn = await client.start_lockdown_developer_service(
"com.apple.instruments.remoteserver.DVTSecureSocketProxy"
)
try:
conn.close()
except Exception:
pass
return True
except Exception:
return False
finally:
try:
await client.close()
except Exception:
pass
try:
return bool(self.runner.run(probe(), timeout=25.0))
except Exception:
return False
def _developer_image_mounted(self, udid: str) -> bool | None:
"""查询设备是否已挂载开发者镜像(查询失败返回 None)。
用 Python API 查询(不再依赖 pymobiledevice3 的 CLI),
因此打包成 .app 后同样可用。
"""
async def probe() -> bool:
from pymobiledevice3.lockdown import create_using_usbmux
from pymobiledevice3.services.mobile_image_mounter import (
MobileImageMounterService,
)
client = await create_using_usbmux(
serial=udid, autopair=False, connection_type="USB", pair_timeout=3.0
)
try:
async with MobileImageMounterService(lockdown=client) as mounter:
for image_type in ("Personalized", "Developer"):
try:
if await mounter.is_image_mounted(image_type):
return True
except Exception:
continue
return False
finally:
try:
await client.close()
except Exception:
pass
try:
return bool(self.runner.run(probe(), timeout=25.0))
except Exception:
return None
def close_session(self, stop_playback: bool = True) -> None:
with self._lock:
provider = self.provider
self.provider = None
self.provider_udid = None
if stop_playback and self.playback is not None:
self.playback.stop_locked(notify=False)
if provider is not None:
try:
self.runner.run(provider.close(), timeout=12.0, wait=True)
except Exception as exc:
self.log("warn", f"释放设备会话时出错:{exc}")
self.playback.set_provider(None)
for device in self.devices.list_devices():
device_mod.DeviceManager.mark(self.devices, device.udid, simulating=False)
# ---------------- 命令分发 ----------------
def dispatch(self, cmd: str, message: dict[str, Any]) -> dict[str, Any]:
"""同步执行一条命令,异常统一转成 AppError(供 REST 复用)。"""
try:
handler = self._COMMANDS.get(cmd)
if handler is None:
raise protocol.AppError(f"未知命令:{cmd}", "unknown_command", status=404)
return handler(self, message) or {}
except protocol.AppError:
raise
except Exception as exc:
detail = f"{exc.__class__.__name__}: {exc}"
self.log("error", f"{cmd} 内部错误:{detail}")
raise protocol.AppError(f"内部错误:{detail[:160]}", "internal", status=500) from exc
def handle_command(self, cmd: str, message: dict[str, Any]) -> dict[str, Any]:
return self.dispatch(cmd, message)
def handle_ws(self, conn: ws.WSConnection, message: dict[str, Any]) -> None:
cmd = str(message.get("type") or "").strip()
request_id = message.get("id")
try:
handler = self._COMMANDS.get(cmd)
if handler is None:
raise protocol.AppError(f"未知命令:{cmd or '(空)'}", "unknown_command")
result = handler(self, message)
if request_id is not None:
# 注意:payload 里可能自带 type(如 welcome),必须先剔除,否则会把 ack 的类型覆盖掉
payload = {k: v for k, v in (result or {}).items() if k not in ("type", "id")}
conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, **payload})
except protocol.AppError as exc:
self.log("error", f"{cmd}: {exc.message}")
if request_id is not None:
conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, "ok": False,
"error": exc.message, "code": exc.code})
else:
conn.send_json({"type": "error", "error": exc.message, "code": exc.code})
except Exception as exc: # 兜底,绝不让 WS 线程崩掉
detail = f"{exc.__class__.__name__}: {exc}"
self.log("error", f"{cmd} 内部错误:{detail}")
if request_id is not None:
conn.send_json({"type": "ack", "id": request_id, "cmd": cmd, "ok": False,
"error": f"内部错误:{detail[:160]}", "code": "internal"})
else:
conn.send_json({"type": "error", "error": f"内部错误:{detail[:160]}", "code": "internal"})
def _cmd_hello(self, message: dict[str, Any]) -> dict[str, Any]:
self.devices.refresh()
return self.welcome_payload()
def _cmd_ping(self, message: dict[str, Any]) -> dict[str, Any]:
return {"ts": protocol.now_ts()}
def _cmd_devices_refresh(self, message: dict[str, Any]) -> dict[str, Any]:
self.devices.refresh()
return self.devices.devices_json()
def _cmd_device_select(self, message: dict[str, Any]) -> dict[str, Any]:
udid = str(message.get("udid") or "").strip()
if not udid:
raise protocol.AppError("缺少 udid", "bad_request")
self.close_session()
device = self.devices.select(udid)
self.config.update({"last_device": udid})
return {"device": device.to_json()}
def _cmd_device_connect(self, message: dict[str, Any]) -> dict[str, Any]:
device = self.devices.get_selected()
if device is None:
self.devices.refresh()
device = self.devices.get_selected()
if device is None:
raise protocol.AppError(
"未检测到 iOS 设备:请用数据线连接、解锁设备并在弹窗中点「信任」", "no_device", status=404
)
self.log("info", f"正在连接 {device.name}(iOS {device.ios_version or '未知'})…")
provider = self.provider_for(device)
detail = {}
probe = getattr(provider, "probe", None)
if callable(probe):
try:
detail = self.runner.run(probe(), timeout=25.0) or {}
except Exception as exc:
self.log("warn", f"连接自检未通过:{exc}")
self.log("success", f"设备已连接:{device.name}")
return {
"device": (self.devices.get(device.udid) or device).to_json(),
"tunnel": self.tunnel.snapshot(),
"probe": detail,
}
def _cmd_device_disconnect(self, message: dict[str, Any]) -> dict[str, Any]:
self.close_session()
self.tunnel.stop()
self.log("info", "已断开设备并释放隧道")
return {"device": (self.devices.get_selected().to_json() if self.devices.get_selected() else None),
"tunnel": self.tunnel.snapshot()}
def _cmd_location_set(self, message: dict[str, Any]) -> dict[str, Any]:
lat, lng = protocol.validate_latlng(message.get("lat"), message.get("lng"))
device = self._require_device()
provider = self.provider_for(device)
if self.playback.active:
self.playback.stop(notify=False, clear_provider=False)
self.playback.set_provider(provider)
self.playback.set_point(lat, lng, provider=provider)
self.log("success", f"已把设备定位到 {lat:.6f}, {lng:.6f}")
return {}
def _cmd_location_clear(self, message: dict[str, Any]) -> dict[str, Any]:
device = self.devices.get_selected()
if device is not None and self.provider is not None:
self.provider_for(device)
self.playback.clear_point(provider=self.provider)
return {}
def _cmd_play_start(self, message: dict[str, Any]) -> dict[str, Any]:
device = self._require_device()
route = route_engine.sanitize_route_payload(message.get("route"))
speed_scale = message.get("speed_scale")
loop = message.get("loop")
try:
start_index = int(message.get("start_index") or 0)
except (TypeError, ValueError):
start_index = 0
# 前端可以把"真实感"参数一并带上(漂移 / 速度浮动)
tuning = message.get("tuning")
if isinstance(tuning, dict):
patch = {k: tuning[k] for k in self._TUNING_KEYS if k in tuning}
if patch:
self.playback.update_tuning(**patch)
provider = self.provider_for(device)
self.playback.start(route=route, speed_scale=speed_scale, loop=loop, start_index=start_index, provider=provider)
self.log(
"success",
f"开始沿轨迹播放:{route.name},{route.distance / 1000:.2f} 公里 / "
f"{_fmt_duration(route.duration)},倍速 {self.playback.speed_scale}x,下发 {self.config.get('send_hz')}Hz",
)
self.playback.log_tuning()
return {"status": self.playback.status(force=True), "route": route.to_json(),
"tuning": self.playback.tuning()}
def _cmd_play_pause(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.pause()
return {"status": self.playback.status(force=True)}
def _cmd_play_resume(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.resume()
return {"status": self.playback.status(force=True)}
def _cmd_play_stop(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.stop(notify=True, clear_provider=True)
return {"status": self.playback.status(force=True)}
def _cmd_play_seek(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.seek(message.get("progress"))
return {"status": self.playback.status(force=True)}
def _cmd_play_speed(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.set_speed(message.get("speed_scale"))
return {"status": self.playback.status(force=True)}
_TUNING_KEYS = (
"drift_enabled", "drift_m", "speed_jitter_enabled", "speed_jitter",
"timing_jitter_enabled", "derive_speed",
"gross_error_enabled", "gross_error_probability", "gross_error_m",
)
def _cmd_play_tuning(self, message: dict[str, Any]) -> dict[str, Any]:
"""实时调整"真实感"参数(只改参数,不打断播放)。
只传消息里**实际出现**的字段:缺失的字段保持原值,
显式传 false/0 也能生效(服务端 update_tuning 用哨兵区分"未提供")。
"""
patch = {k: message[k] for k in self._TUNING_KEYS if k in message}
tuning = self.playback.update_tuning(**patch)
if "log" in message and message.get("log"):
self.playback.log_tuning()
return {"tuning": tuning, "status": self.playback.status(force=True)}
def _cmd_play_loop(self, message: dict[str, Any]) -> dict[str, Any]:
self.playback.set_loop(message.get("loop"))
return {"status": self.playback.status(force=True)}
# ---------------- 开发者镜像挂载(iOS 17 以下必需) ----------------
def _cmd_mount_developer_image(self, message: dict[str, Any]) -> dict[str, Any]:
"""挂载开发者镜像。iOS<17 的 DVT 定位必须先挂镜像,iOS 17+ 一般由 Xcode 自动挂。
在后台线程执行(下载 + 挂载可能几十秒),过程日志实时推给界面。
"""
device = self.devices.get_selected()
if device is None:
self.devices.refresh()
device = self.devices.get_selected()
if device is None:
raise protocol.AppError("未检测到 iOS 设备", "no_device", status=404)
if self.mock:
self.log("info", "模拟模式:跳过镜像挂载")
return {"mounted": True, "message": "模拟模式,无需挂载"}
if self._mount_running:
raise protocol.AppError("正在挂载中,请稍候…", "busy", status=409)
self._mount_running = True
threading.Thread(
target=self._mount_worker, args=(device.udid, device.name, device.ios_version, device.ios_major),
name="mounter", daemon=True,
).start()
return {"started": True}
def _mount_worker(self, udid: str, name: str, version: str, major: int) -> None:
try:
self.log("info", f"开始为 {name}(iOS {version})挂载开发者镜像…")
# 关键:不能只看"镜像是否挂载"就判定可用 —— 实测 iOS 16.5 上个性化镜像
# 显示 IsMounted=true,但 DVT 服务并不会启动(挂载成功 ≠ 能虚拟定位)。
# 因此这里以"开发者服务是否真的能打开"为准。
if self._developer_session_ready(udid, major):
self.log("success", "开发者服务已就绪,无需重复挂载")
self.hub.broadcast({"type": "mounted", "mounted": True, "message": "开发者服务已就绪"})
return
ok, detail = self._mount_with_fallback(udid, version, major)
# 用"服务是否可用"做最终判定,而不是仅看挂载标志
ready = self._developer_session_ready(udid, major)
if ok and ready:
self.log("success", f"开发者镜像挂载完成且服务已就绪({detail})")
self.hub.broadcast({"type": "mounted", "mounted": True, "message": detail})
elif ok and not ready:
note = (
"镜像已挂到设备上,但开发者服务仍未启动,虚拟定位还不可用。"
"请用 Xcode(Window → Devices and Simulators)连接一次设备完成 "
"Preparing device for development,再回来重试。"
)
self.log("error", note)
self.hub.broadcast({"type": "mounted", "mounted": False, "message": note})
else:
self.log("error", f"挂载失败:{detail}")
self.hub.broadcast({"type": "mounted", "mounted": False, "message": detail})
except Exception as exc:
self.log("error", f"挂载异常:{exc.__class__.__name__}: {exc}")
self.hub.broadcast({"type": "mounted", "mounted": False, "message": str(exc)[:150]})
finally:
self._mount_running = False
def _mount_with_fallback(self, udid: str, version: str, major: int) -> tuple[bool, str]:
"""挂载开发者镜像(面向 iOS 17 及以上)。
本工具支持的虚拟定位路径是 **iOS 17+ 的 RSD + DVT**:
应用内置一套个性化镜像(全系共用),挂载时由 Apple TSS 按设备在线签发,
因此不需要逐版本素材,iOS 17 到 27.x 都走同一条路。
iOS 16 及更早不在支持范围:那套经典镜像由 Apple 按系统构建号签发,
社区镜像在多数新构建上会被设备拒签(实测 16.5/20F66 失败,libimobiledevice 同样失败)。
这里直接给出明确提示,不做徒劳重试。
"""
if major and major < 17:
return False, (
"本工具只支持 iOS 17 及以上。你的设备是 iOS " + version + ","
"它的开发者镜像必须由 Apple 按系统构建号签发,第三方镜像会被设备拒签,"
"所以无法通过本工具挂载。若确实要调试该设备,请先用 Xcode 连接一次"
"(Window → Devices and Simulators)让它自动完成镜像准备。"
)
if not self._stage_personalized_ddi():
return False, (
"应用内没有个性化镜像素材,且自动下载失败。"
"请在网络可用时重试,或用 Xcode 连接设备自动挂载一次。"
)
self.log("info", f"用内置个性化镜像挂载(iOS {version})…")
ok, detail = self._mount_personalized(udid, version)
if ok:
return True, detail
return False, self._clean_mounter_error(detail)
@staticmethod
def _clean_mounter_error(raw: str) -> str:
"""把底层异常整理成一句人话(原始字符串里塞满了嵌套的 Error Domain)。"""
text = str(raw)
low = text.lower()
if "alreadymounted" in low.replace(" ", "") or "already mounted" in low:
return "镜像已经在设备上(无需重复挂载)"
if "failed to verify the disk image signature" in low:
return "设备拒绝了这个镜像的签名(该系统的镜像需由 Apple 按构建号签发)"
if "reset by peer" in low or "connection aborted" in low:
return "与 Apple 服务器通信被中断(TSS 签名需要联网,请检查网络后重试)"
if "access denied" in low or "requires root" in low:
return "需要管理员权限"
if "no device" in low:
return "未找到设备"
# 去掉嵌套的 UserInfo 噪声,只留第一句
first = text.replace("\n", " ").split("UserInfo=")[0].strip()
return first[:120] if first else "未知错误"
def _mount_personalized(self, udid: str, version: str) -> tuple[bool, str]:
"""用内置个性化镜像挂载(需联网向 Apple 申请 TSS 签名)。
优先走 Python API(打包版可用),失败再退回 CLI(源码版)。
"""
ok, detail = self._run_auto_mount(udid, version)
if ok:
return True, detail
if self._mounter_cli() is not None:
self.log("info", "Python API 挂载失败,改用命令行重试…")
return self._run_mounter(["auto-mount", "--udid", udid])
return False, detail
def _run_auto_mount(self, udid: str, version: str) -> tuple[bool, str]:
"""用 Python API 直接挂载(不依赖 pymobiledevice3 的 CLI)。
`auto_mount` 会按 iOS 版本自动分流:
- iOS < 17 → auto_mount_developer(经典镜像,需要能写入 Xcode 的 DeviceSupport 目录)
- iOS >= 17 → auto_mount_personalized(用内置个性化镜像 + Apple TSS 在线签名)
打包成 .app 后没有 CLI 可用,因此这条路径是打包版唯一可行的挂载方式。
"""
async def worker() -> None:
from pymobiledevice3.lockdown import create_using_usbmux
from pymobiledevice3.services.mobile_image_mounter import (
MobileImageMounterService,
auto_mount,
)
client = await create_using_usbmux(
serial=udid, autopair=True, connection_type="USB", pair_timeout=5.0
)
try:
# 先查设备上是否已有镜像:已有就直接返回,避免 AlreadyMountedError。
# 两种类型都查(经典 Developer / 个性化 Personalized)。
try:
async with MobileImageMounterService(lockdown=client) as mounter:
for image_type in ("Personalized", "Developer"):
try:
if await mounter.is_image_mounted(image_type):
return
except Exception:
continue
except Exception:
pass
await auto_mount(client, version=version)
finally:
try:
await client.close()
except Exception:
pass
try:
self.runner.run(worker(), timeout=420.0)
return True, "挂载完成"
except Exception as exc:
name = exc.__class__.__name__
text = str(exc)
# 设备上已经有镜像时 pymobiledevice3 会抛 AlreadyMountedError ——
# 这不是失败(我们要的就是"已挂载"状态),直接当成功处理。
if "AlreadyMounted" in name or "already mounted" in text.lower():
return True, "镜像已在设备上(无需重复挂载)"
return False, f"{name}: {text}"
def _run_mounter(self, args: list[str]) -> tuple[bool, str]:
"""执行 pymobiledevice3 mounter 子命令(源码版兜底路径),输出实时写入日志。"""
import subprocess as _sp
cli = self._mounter_cli()
if cli is None:
return False, "当前环境无法调用 pymobiledevice3 命令行"
command = [*cli, "mounter", *args]
self.log("info", "执行:" + " ".join(str(part) for part in command[1:]))
try:
process = _sp.Popen(
command, stdout=_sp.PIPE, stderr=_sp.STDOUT, text=True, bufsize=1,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
except Exception as exc:
return False, f"无法启动挂载进程:{exc}"
tail: list[str] = []
assert process.stdout is not None
for line in process.stdout:
text = line.strip()
if not text:
continue
tail.append(text)
if len(tail) > 40:
tail.pop(0)
self.log("info", "[挂载] " + text[:200])
code = process.wait(timeout=300)
if code == 0:
return True, "挂载命令执行成功"
summary = tail[-1] if tail else f"退出码 {code}"
if "AccessDenied" in summary or "root" in summary.lower():
summary += "(可能需要管理员权限)"
return False, summary
def _mounter_cli(self) -> list[str] | None:
"""返回可用的 pymobiledevice3 CLI 前缀(打包版不支持,返回 None)。"""
import sys
if getattr(sys, "frozen", False):
return None
return self.tunnel._cli_command()
def _stage_personalized_ddi(self) -> bool:
"""把应用内置的个性化镜像装到 pymobiledevice3 期望的缓存目录。
期望位置:~/.pymobiledevice3/Xcode_iOS_DDI_Personalized/
文件名必须是 Image.dmg / BuildManifest.plist / **Image.trustcache**
(注意源文件名是 Image.dmg.trustcache)。
这样 iOS 17+ 全系(17 ~ 27.x)都能离线挂载;只有 TSS 签名需要联网,
而那是 Apple 服务器按设备实时签发的,无法预置。
"""
import sys
from . import apple_driver
sources: list[Path] = []
base = getattr(sys, "_MEIPASS", None)
if base:
sources.append(Path(base) / "resources" / "ddi" / "personalized")
# 打包后 root 是可写数据目录,素材在 resources(只读包内目录)
sources.append(self.resources / "resources" / "ddi" / "personalized")
sources.append(self.root / "resources" / "ddi" / "personalized")
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
# 必须**三件齐全**的目录才算可用。以前只判断"有没有 Image.dmg",
# 可能挑到一个残缺目录(比如缺 Image.dmg.trustcache),拷进去照样挂不上 ——
# 与 apple_driver.ddi_status() 的判定标准保持一致。
required = tuple(apple_driver.DDI_FILES)
src = next((d for d in sources
if all((d / name).exists() for name in required)), None)
if src is None:
self.log("warn", "未找到完整的个性化镜像素材(需要 Image.dmg / "
"BuildManifest.plist / Image.dmg.trustcache 三件齐全)")
return False
dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized"
try:
dest.mkdir(parents=True, exist_ok=True)
mapping = {
"Image.dmg": "Image.dmg",
"BuildManifest.plist": "BuildManifest.plist",
"Image.dmg.trustcache": "Image.trustcache",
}
copied = []
for source_name, dest_name in mapping.items():
source_file = src / source_name
target = dest / dest_name
# 体积一致就跳过,避免每次挂载都拷 15MB
if target.exists() and target.stat().st_size == source_file.stat().st_size:
continue
shutil.copy2(source_file, target)
copied.append(dest_name)
if copied:
self.log("info", "已从应用内置素材安装个性化镜像:" + "、".join(copied))
# 落地后再校验一次,避免"拷了但没拷全"
missing = [d for d in mapping.values() if not (dest / d).exists()]
if missing:
self.log("warn", "镜像素材不完整,缺少:" + "、".join(missing))
return False
return True
except Exception as exc:
self.log("warn", f"安装内置个性化镜像失败:{exc}")
return False
def _cmd_mount_status(self, message: dict[str, Any]) -> dict[str, Any]:
"""查询当前设备的开发者镜像/会话状态。"""
device = self.devices.get_selected()
if device is None:
return {"mounted": None, "ready": False, "running": self._mount_running}
major = device.ios_major or 0
return {
"mounted": self._developer_image_mounted(device.udid),
"ready": self._developer_session_ready(device.udid, major),
"running": self._mount_running,
"ios_major": major,
"supported": major >= 17,
}
def _cmd_log_list(self, message: dict[str, Any]) -> dict[str, Any]:
return {"lines": self.recent_logs(_as_int(message.get("limit"), 200, "limit"))}
def _cmd_route_build(self, message: dict[str, Any]) -> dict[str, Any]:
route = self.routes.build(
message.get("waypoints") or [],
mode=message.get("mode") or "driving",
name=message.get("name"),
use_osrm=bool(message.get("use_osrm", True)),
loop=bool(message.get("loop")),
speed_scale=message.get("speed_scale") or 1.0,
)
engine = route.engine
self.log(
"info",
f"路线规划完成({'OSRM 真实路网' if engine == 'osrm' else '直线'}):"
f"{route.distance / 1000:.2f} 公里,预计 {_fmt_duration(route.duration)}",
)
return {"route": route.to_json(), "engine": engine}
def _cmd_tunnel_manual(self, message: dict[str, Any]) -> dict[str, Any]:
snapshot = self.tunnel.use_manual(
str(message.get("host") or ""), message.get("port"), self.devices.selected_udid()
)
self.log("info", f"已切换为手动 RSD:{snapshot.get('rsd')}")
return {"tunnel": snapshot}
def _cmd_tunnel_ensure(self, message: dict[str, Any]) -> dict[str, Any]:
if self.mock or (self.devices.get_selected() is not None
and (self.devices.get_selected().ios_major or 0) < 17):
semantics = self._tunnel_semantics()
return {"tunnel": semantics, "need_tunnel": False}
device = self.devices.get_selected()
snapshot = self.tunnel.ensure(device.udid if device else None, allow_sudo=bool(message.get("sudo")))
snapshot["ios_major"] = device.ios_major if device else None
snapshot["need_tunnel"] = True
return {"tunnel": snapshot, "need_tunnel": True}
def _cmd_tiles_clear(self, message: dict[str, Any]) -> dict[str, Any]:
removed = self.tiles.clear()
self.log("info", f"已清理瓦片缓存:{removed} 个文件")
return {"removed": removed}
def _cmd_config_update(self, message: dict[str, Any]) -> dict[str, Any]:
patch = message.get("config") or {}
updated = self.config.update(patch)
self.tiles.max_bytes = max(64, int(updated.get("tile_cache_max_mb") or 1024)) * 1024 * 1024
return {"config": updated}
def _require_device(self) -> device_mod.Device:
device = self.devices.get_selected()
if device is None:
self.devices.refresh()
device = self.devices.get_selected()
if device is None:
raise protocol.AppError(
"未检测到 iOS 设备:请用数据线连接、解锁设备并在弹窗中点「信任」", "no_device", status=404
)
return device
_COMMANDS: dict[str, Callable[["App", dict[str, Any]], dict[str, Any]]] = {
"hello": _cmd_hello,
"ping": _cmd_ping,
"devices.refresh": _cmd_devices_refresh,
"device.select": _cmd_device_select,
"device.connect": _cmd_device_connect,
"device.disconnect": _cmd_device_disconnect,
"location.set": _cmd_location_set,
"location.clear": _cmd_location_clear,
"play.start": _cmd_play_start,
"play.pause": _cmd_play_pause,
"play.resume": _cmd_play_resume,
"play.stop": _cmd_play_stop,
"play.seek": _cmd_play_seek,
"play.speed": _cmd_play_speed,
"play.tuning": _cmd_play_tuning,
"play.loop": _cmd_play_loop,
"log.list": _cmd_log_list,
"route.build": _cmd_route_build,
"tunnel.manual": _cmd_tunnel_manual,
"tunnel.ensure": _cmd_tunnel_ensure,
"tiles.clear": _cmd_tiles_clear,
"device.mount_image": _cmd_mount_developer_image,
"device.mount_status": _cmd_mount_status,
"config.update": _cmd_config_update,
}
# ---------------- 关停 ----------------
def shutdown(self) -> None:
try:
self.playback.stop(notify=False, clear_provider=True)
except Exception:
pass
try:
self.close_session(stop_playback=False)
except Exception:
pass
try:
self.tunnel.stop()
except Exception:
pass
try:
self.devices.stop_monitor()
except Exception:
pass
try:
self.hub.close_all()
except Exception:
pass
try:
self.runner.shutdown()
except Exception:
pass
# 关闭日志文件句柄(打包成窗口程序时这是唯一的排查线索,必须 flush 落盘)
try:
handle = getattr(self, "_log_file", None)
if handle is not None:
handle.close()
self._log_file = None
except Exception:
pass
# ---------------- HTTP ----------------
class Handler(BaseHTTPRequestHandler):
server_version = f"iOSLocationTool/{protocol.APP_VERSION}"
protocol_version = "HTTP/1.1"
app: App # 由 serve() 注入
# 静音默认访问日志,改用 app.log
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
return
def log_error(self, fmt: str, *args: Any) -> None: # noqa: A003
return
def handle_one_request(self) -> None:
"""浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。"""
# 同一个 handler 实例会处理 keep-alive 连接上的多个请求,
# 请求体缓存必须按请求重置,否则第二个请求会读到上一个请求的 body。
self._body_cache = None
try:
super().handle_one_request()
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
self.close_connection = True
# ---------- 安全校验 ----------
# 本工具监听在本机端口上,默认只允许「本机页面 / 无 Origin 的本机客户端」访问。
# 否则任意一个网页都能通过跨站简单请求(text/plain 不需要预检)改配置、
# 下发虚拟定位、甚至调用 /api/shutdown 关掉程序。
_LOCAL_HOST_RE = re.compile(r"^(?:localhost|127\.0\.0\.1|\[::1\]|::1)(?::\d+)?$", re.I)
def _check_origin(self) -> None:
origin = self.headers.get("Origin")
if not origin:
return # 非浏览器客户端(curl / 本工具自身)不带 Origin
if origin == "null":
raise protocol.AppError("已拒绝来自沙箱页面(Origin: null)的请求", "forbidden", status=403)
netloc = urllib.parse.urlparse(origin).netloc.strip()
request_host = (self.headers.get("Host") or "").strip()
# 同源请求永远放行(这样 --host 0.0.0.0 时用局域网地址访问也正常)
if netloc and request_host and netloc.lower() == request_host.lower():
return
if self._LOCAL_HOST_RE.match(netloc):
return
raise protocol.AppError(
f"已拒绝来自 {origin} 的跨站请求(本工具仅允许本机/同源页面访问)", "forbidden", status=403
)
def _check_host(self) -> None:
"""防 DNS rebinding:攻击者把域名解析到 127.0.0.1 时 Host 会是其域名。"""
host = (self.headers.get("Host") or "").strip()
if not host:
return
if self._LOCAL_HOST_RE.match(host):
return
# 允许用户显式用 --host 0.0.0.0 指定其它地址访问的场景
if self.app.host not in ("127.0.0.1", "localhost"):
return
raise protocol.AppError(
f"已拒绝 Host: {host} 的请求(防 DNS rebinding;请用 127.0.0.1 或 localhost 访问)",
"forbidden",
status=403,
)
# ---------- 工具 ----------
def _send_json(self, payload: dict[str, Any], status: int = 200) -> None:
body = protocol.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def _send_bytes(self, body: bytes, content_type: str, status: int = 200, cache: str = "no-store") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", cache)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def _read_body(self) -> bytes:
# do_POST 会预先读完并缓存,这里直接复用,避免重复读取或漏读
cached = getattr(self, "_body_cache", None)
if cached is not None:
return cached
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length <= 0:
self._body_cache = b""
return b""
if length > 64 * 1024 * 1024:
raise protocol.AppError("请求体过大", "too_large", status=413)
data = self.rfile.read(length)
self._body_cache = data
return data
def _json_body(self) -> dict[str, Any]:
raw = self._read_body()
if not raw:
return {}
try:
data = protocol.loads(raw)
except Exception as exc:
raise protocol.AppError("请求体不是合法 JSON", "bad_json") from exc
if not isinstance(data, dict):
raise protocol.AppError("请求体必须是 JSON 对象", "bad_json")
return data
def _query(self) -> dict[str, list[str]]:
parsed = urllib.parse.urlparse(self.path)
return urllib.parse.parse_qs(parsed.query)
# ---------- 路由 ----------
def do_GET(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path
try:
self._check_origin()
self._check_host()
if path == "/ws":
self._handle_ws()
return
if path.startswith("/api/"):
self._handle_api_get(path)
return
self._serve_static(path)
except protocol.AppError as exc:
self._send_json(exc.to_dict(), exc.status)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception as exc:
self.app.log("error", f"GET {path} 失败:{exc.__class__.__name__}: {exc}")
self._send_json({"ok": False, "error": f"服务端错误:{exc}"[:200], "code": "internal"}, 500)
def do_POST(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path
try:
# **先把请求体读完并缓存**。有些 handler(如 /api/devices/connect)
# 压根不读 body;若不在这里排空,残留的字节会留在 keep-alive 连接里,
# 被当成下一个请求的请求行 —— 表现为
# 501 Unsupported method ('{}POST')
# 而且是间歇性的(取决于客户端是否复用连接),很难查。
self._body_cache = self._read_body()
self._check_origin()
self._check_host()
if path.startswith("/api/"):
self._handle_api_post(path)
return
self._send_json({"ok": False, "error": "接口不存在", "code": "not_found"}, 404)
except protocol.AppError as exc:
self._send_json(exc.to_dict(), exc.status)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception as exc:
self.app.log("error", f"POST {path} 失败:{exc.__class__.__name__}: {exc}")
self._send_json({"ok": False, "error": f"服务端错误:{exc}"[:200], "code": "internal"}, 500)
# ---------- WebSocket ----------
def _handle_ws(self) -> None:
if not ws.is_websocket_upgrade(self.headers):
self._send_json({"ok": False, "error": "该端点仅支持 WebSocket", "code": "bad_upgrade"}, 400)
return
connection = ws.handshake(self.connection, self.headers)
if connection is None:
self.close_connection = True
return
self.close_connection = True # 连接已被劫持,HTTP 循环不再读取
self.app.hub.add(connection)
self.app.log("info", f"界面已连接({connection.addr[0]})")
def on_message(conn: ws.WSConnection, message: dict[str, Any]) -> None:
self.app.handle_ws(conn, message)
def on_close(conn: ws.WSConnection) -> None:
self.app.hub.remove(conn)
connection.run(on_message, on_close)
# ---------- API ----------
def _handle_api_get(self, path: str) -> None:
app = self.app
if path == "/api/health":
self._send_json({
"ok": True,
"version": protocol.APP_VERSION,
"name": protocol.APP_NAME,
"mock": app.mock,
"device_count": len(app.devices.list_devices()),
"backend": app.devices.backend,
"pymobiledevice3": device_mod.pymobiledevice3_importable(),
"uptime": round(time.time() - app.started_at, 2),
"ws_clients": app.hub.count,
})
return
if path == "/api/devices":
self._send_json({"ok": True, **app.devices.devices_json()})
return
if path == "/api/config":
self._send_json({"ok": True, "config": app.config.snapshot()})
return
if path == "/api/settings/tiles":
sources = app.config.tile_sources()
current = app.config.get("tile_source") or "amap"
items = [
{
"key": key,
"id": key,
"name": value.get("name") or key,
"url": value.get("url") or "",
"max_zoom": int(value.get("max_zoom") or 18),
"attribution": value.get("attribution") or "",
"datum": str(value.get("coord") or ("gcj02" if key.startswith(("amap", "tencent")) else "wgs84")),
"subdomains": value.get("subdomains") or [],
}
for key, value in sources.items()
]
self._send_json({
"ok": True,
"sources": items,
"tile_sources": sources,
"current": current,
"tile_source": current,
"selected": current,
"cache": {
"dir": str(app.config.cache_dir()),
"size_mb": round(app.tiles.size_bytes() / 1048576, 2),
"max_mb": int(app.config.get("tile_cache_max_mb") or 1024),
"stats": dict(app.tiles.stats),
},
})
return
if path == "/api/tunnel":
self._send_json({"ok": True, "tunnel": app._tunnel_semantics()})
return
if path == "/api/logs":
raw_limit = (self._query().get("limit") or ["200"])[0]
self._send_json({"ok": True, "lines": app.recent_logs(_as_int(raw_limit, 200, "limit"))})
return
if path.startswith("/api/tiles/"):
self._serve_tile(path)
return
raise protocol.AppError("接口不存在", "not_found", status=404)
def _handle_api_post(self, path: str) -> None:
app = self.app
if path == "/api/devices/select":
body = self._json_body()
udid = str(body.get("udid") or "").strip()
if not udid:
raise protocol.AppError("缺少 udid", "bad_request")
app.close_session()
device = app.devices.select(udid)
app.config.update({"last_device": udid})
self._send_json({"ok": True, "device": device.to_json()})
return
if path == "/api/devices/connect":
result = app.handle_command("device.connect", {})
tunnel = app.handle_command("tunnel.ensure", {}).get("tunnel") or app.tunnel.snapshot()
self._send_json({"ok": True, **result, "tunnel": tunnel})
return
if path == "/api/devices/disconnect":
app.close_session()
app.tunnel.stop()
device = app.devices.get_selected()
self._send_json({
"ok": True,
"device": device.to_json() if device else None,
"tunnel": app.tunnel.snapshot(),
})
return
if path == "/api/config":
patch = self._json_body()
updated = app.config.update(patch.get("config") if "config" in patch else patch)
self._send_json({"ok": True, "config": updated})
return
if path == "/api/route/build":
body = self._json_body()
result = app.handle_command("route.build", body)
self._send_json({"ok": True, **result})
return
if path == "/api/gpx/export":
body = self._json_body()
route = route_engine.sanitize_route_payload(body.get("route") or body)
if body.get("name"):
route.name = str(body["name"])[:80]
text = route_engine.to_gpx(route)
body_bytes = text.encode("utf-8")
filename = urllib.parse.quote(f"{route.name or 'track'}.gpx")
self.send_response(200)
self.send_header("Content-Type", "application/gpx+xml; charset=utf-8")
self.send_header("Content-Length", str(len(body_bytes)))
self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{filename}")
self.end_headers()
try:
self.wfile.write(body_bytes)
except (BrokenPipeError, ConnectionResetError):
pass
return
if path == "/api/gpx/import":
raw = self._read_body()
text = raw.decode("utf-8", "replace")
coords, name, _kind = route_engine.from_gpx(text)
payload = [[protocol.round6(lng), protocol.round6(lat)] for lat, lng in coords]
self._send_json({
"ok": True,
# waypoints 与 path 都给全量点:前端可用 path 保形重放,也可只取端点做途经点
"waypoints": payload,
"path": payload,
"points": payload,
"name": name,
"count": len(payload),
})
return
if path == "/api/location":
body = self._json_body()
result = app.handle_command("location.set", body)
self._send_json({"ok": True, **result})
return
if path == "/api/tunnel":
body = self._json_body()
# 统一走命令分发:mock 模式 / iOS<17 的"不需要隧道"语义只在一处实现,
# 避免 REST 与 WS 两条路径行为不一致(曾导致 mock 下显示"隧道空闲/建立中")。
cmd = "tunnel.manual" if body.get("host") else "tunnel.ensure"
result = app.handle_command(cmd, body)
self._send_json({"ok": True, **result})
return
if path == "/api/tunnel/manual":
body = self._json_body()
result = app.handle_command("tunnel.manual", body)
self._send_json({"ok": True, **result})
return
if path == "/api/shutdown":
self._send_json({"ok": True})
app.log("info", "收到退出请求,正在关闭…")
app.shutdown_event.set()
return
raise protocol.AppError("接口不存在", "not_found", status=404)
def _serve_tile(self, path: str) -> None:
parts = path[len("/api/tiles/"):].split("/")
if len(parts) < 4:
raise protocol.AppError("瓦片地址格式应为 /api/tiles/{source}/{z}/{x}/{y}.png", "bad_tile", status=404)
source_key = urllib.parse.unquote(parts[0])
z, x = parts[1], parts[2]
y = re.sub(r"\.(png|jpg|jpeg|webp)$", "", parts[3], flags=re.I)
iz, ix, iy = tilecache.validate_tile_request(z, x, y)
key, source = self.app.config.tile_source(source_key)
result = self.app.tiles.get_tile(key, source, iz, ix, iy)
if result.status != 200:
# 契约要求失败回 404(前端才会走重试/告警逻辑),而不是用 200 + 占位图掩盖
self._send_bytes(result.body, "image/png", 404, cache="no-store")
return
self._send_bytes(result.body, result.content_type, 200, cache="public, max-age=604800")
# ---------- 静态文件 ----------
def _serve_static(self, path: str) -> None:
if path in ("/", ""):
path = "/index.html"
rel = posixpath.normpath(urllib.parse.unquote(path)).lstrip("/")
if rel.startswith(".."):
raise protocol.AppError("非法路径", "bad_path", status=400)
target = (WEB_ROOT / rel).resolve()
if not str(target).startswith(str(WEB_ROOT.resolve())):
raise protocol.AppError("非法路径", "bad_path", status=400)
if target.is_dir():
target = target / "index.html"
if not target.exists() or not target.is_file():
# 前端是单页应用:未知路径回落到 index.html
fallback = WEB_ROOT / "index.html"
if fallback.exists() and "." not in rel.rsplit("/", 1)[-1]:
target = fallback
else:
self._send_json({"ok": False, "error": f"文件不存在:{rel}", "code": "not_found"}, 404)
return
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in ("application/javascript", "application/json"):
content_type += "; charset=utf-8"
body = target.read_bytes()
self._send_bytes(body, content_type, 200, cache="no-cache")
def _as_int(value: Any, default: int, field: str) -> int:
"""把外部输入安全地转成整数;非法值回中文 400,而不是 500 内部错误。"""
if value is None:
return default
if isinstance(value, bool):
raise protocol.AppError(f"{field} 必须是整数", "bad_request")
number = protocol.finite(value, field)
if number != int(number):
raise protocol.AppError(f"{field} 必须是整数", "bad_request")
return int(number)
def _fmt_duration(seconds: float) -> str:
total = int(max(0, round(seconds)))
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours} 小时 {minutes} 分"
if minutes:
return f"{minutes} 分 {secs} 秒"
return f"{secs} 秒"
def serve(app: App, host: str = protocol.DEFAULT_HOST, port: int = protocol.DEFAULT_PORT) -> ThreadingHTTPServer:
app.host = host
handler = type("BoundHandler", (Handler,), {"app": app})
httpd = ThreadingHTTPServer((host, port), handler)
httpd.daemon_threads = True
return httpd