docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
commit
85e9a53750
65 files changed
+18603
No files matched your search
+427
@@ -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()
|
||||
Reference in new issue
Block a user