Files
jbjf6666 85e9a53750 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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

53 KiB
Raw Permalink Blame History

设备侧取证笔记(pymobiledevice3 / DVT 虚拟定位 / iOS 17+ 隧道)

本文由 device-dev(task-2)维护。所有 API 事实均取自本机真实源码,不是凭记忆。 冻结契约见 PROTOCOL.md,本文件不修改契约,只记录实现依据。

取证来源:先从阿里云镜像下载官方 wheel 并解包到 /tmp/pmd3src/extracted/, 再与安装进 .venv 的 site-packages 版本逐条比对(见第 1 节)。 引用格式:文件路径:行号,相对 pymobiledevice3/ 包根目录。


1. 版本与 Python 3.12 依赖

项目 结果
包版本 11.29.1(wheel:pymobiledevice3-11.29.1-py3-none-any.whl)
Requires-Python >=3.9(wheel METADATA 第 24 行)
官方 classifier 明确列出 Programming Language :: Python :: 3.12(METADATA 第 21 行)
本机解释器 .venv → Python 3.12.14(Homebrew /opt/homebrew/opt/python@3.12)
安装源 阿里云镜像 https://mirrors.aliyun.com/pypi/simple/(pypi.org 在本机 12s 才响应,官方源不可用)
安装结果 ✅ 成功(Successfully installed … pymobiledevice3-11.29.1 …,退出码 0,日志 /tmp/pmd3_install.log)
安装位置 /Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages
纯 Python py3-none-any,无本地编译(个别依赖带 arm64 wheel,如 qh3、cffi)

依赖里与隧道/DTX 相关的关键项(METADATA 第 25-68 行):

  • cryptography>=43.0.0、construct>=2.9.29、bpylist2>=4.0.1 —— lockdown / DTX 编解码
  • qh3<2,>=1.0.0 —— QUIC(tunneld 与 RemotePairing 隧道)
  • pytun-pmd3>=3.0.3 —— 内核 utun 隧道(需要 root)
  • pmd-pytcp>=0.3.7; python_version >= "3.9" —— 免 root 的纯 Python 用户态网络栈(userspace 隧道)
  • sslpsk-pmd3>=1.0.3; python_version < "3.13" —— 本机是 3.12,所以会装它

注意:Python 3.12 下 TunnelProtocol.DEFAULT = QUIC(cli/remote.py:108-116: "default: TCP on Python >=3.13, otherwise QUIC"),这是后面隧道方案取舍的依据之一。


2. iOS<17 路径:usbmux / lockdown 直连 DVT

2.1 拿 lockdown 客户端

lockdown.py:1370-1382:

async def create_using_usbmux(
    serial: Optional[str] = None,
    identifier: Optional[str] = None,
    label: str = DEFAULT_LABEL,
    autopair: bool = True,
    connection_type: Optional[str] = None,
    pair_timeout: Optional[float] = None,
    local_hostname: Optional[str] = None,
    pair_record: Optional[dict[str, Any]] = None,
    pairing_records_cache_folder: Optional[Path] = None,
    port: int = SERVICE_PORT,
    usbmux_address: Optional[str] = None,
) -> UsbmuxLockdownClient:
  • 返回值是 UsbmuxLockdownClient(lockdown.py:1149),继承 LockdownClient(lockdown.py:102)。
  • 它是 LockdownServiceProvider(lockdown_service_provider.py:15)的子类,因此可以直接丢给 DvtProvider。
  • close() 是 async:lockdown.py:899-904 → await self.service.close()。
  • 开发者模式查询:lockdown.py:534-541 async def get_developer_mode_status(self) -> bool (读 com.apple.security.mac.amfi 域的 DeveloperModeStatus)。

2.2 usbmux 地址怎么拿

usbmux.py:

行号 API 说明
129-132 @dataclass class MuxDevice(devid, serial, connection_type) usbmux 设备条目
452 async def create_mux(usbmux_address=None) 打开 usbmuxd 连接(async context manager)
456 async def list_devices(usbmux_address=None) -> list[MuxDevice] 枚举设备
485 async def select_device(udid=..., connection_type=...) 选设备
500-507 async def select_devices_by_connection_type(connection_type, usbmux_address=None) 按 USB/Network 过滤

usbmux_address 参数适用于远程/自定义 usbmuxd(USBMUXD_SOCKET_ADDRESS 环境变量);本机默认走 /var/run/usbmuxd。我们不需要自己连 usbmux 拿地址:create_using_usbmux(serial=...) 内部已完成 ServiceConnection.create_using_usbmux(...)(lockdown.py:1404-1406)。

2.3 DVT provider 怎么建

services/dvt/instruments/dvt_provider.py:8-53:

class DvtProvider(DtxServiceProvider):
    SERVICE_NAME = "com.apple.instruments.remoteserver.DVTSecureSocketProxy"   # iOS 14-16
    RSD_SERVICE_NAME = "com.apple.instruments.dtservicehub"                    # iOS 17+
    OLD_SERVICE_NAME = "com.apple.instruments.remoteserver"                    # iOS < 14

    def __init__(self, lockdown: LockdownServiceProvider,
                 strip_ssl: Optional[bool] = None,
                 dtx: Optional[DTXConnection] = None) -> None: ...

关键结论:iOS<17 与 iOS≥17 用的是同一个 DvtProvider 类,服务名由 DtxServiceProvider.service_name_for() 自动选择(dtx_service_provider.py:80-97):

@classmethod
def service_name_for(cls, service_provider: LockdownServiceProvider) -> str:
    if isinstance(service_provider, RemoteServiceDiscoveryService):
        return cls.RSD_SERVICE_NAME if cls.RSD_SERVICE_NAME is not None else cls.SERVICE_NAME
    if cls.OLD_SERVICE_NAME is not None and Version(service_provider.product_version).major < 14:
        return cls.OLD_SERVICE_NAME
    return cls.SERVICE_NAME
  • 传 RemoteServiceDiscoveryService → com.apple.instruments.dtservicehub(隧道,iOS17+)
  • 传 LockdownClient → com.apple.instruments.remoteserver.DVTSecureSocketProxy(iOS14-16)
  • 传 LockdownClient 且 product_version < 14 → com.apple.instruments.remoteserver

SSL 剥离(老服务需要)也自动判断:dtx_service_provider.py:99-111 _default_strip_ssl(), RSD 永远返回 False,lockdown 且 iOS<14 返回 True。

2.4 iOS<17 的另一条路(我们用作兜底)

services/simulate_location.py:8-40 DtSimulateLocation,走老开发者服务 com.apple.dt.simulatelocation,自己手写 4 字节大端协议头:

SERVICE_NAME = "com.apple.dt.simulatelocation"          # :17
async def clear(self):                                  # :23-26
    service = await self.lockdown.start_lockdown_developer_service(self.SERVICE_NAME)
    await service.sendall(struct.pack(">I", 1))
async def set(self, latitude, longitude):               # :28-40
    service = await self.lockdown.start_lockdown_developer_service(self.SERVICE_NAME)
    await service.sendall(struct.pack(">I", 0))
    await service.sendall(struct.pack(">I", len(lat)) + lat.encode())
    await service.sendall(struct.pack(">I", len(lon)) + lon.encode())

pymobiledevice3 的 CLI 把这条路标为 iOS<17 专用:cli/developer/simulate_location.py:16 (help="Simulate GPS location on iOS versions below 17 ..."),对应命令 pymobiledevice3 developer simulate-location set -- LAT LON。 注意它每次 set 都新开一个服务连接(源码注释 services/simulate_location.py:13-14: "A fresh lockdown developer service connection is opened for each command."),所以它天然不需要长连接; 但需要设备已挂载开发者镜像(start_lockdown_developer_service,lockdown_service_provider.py:162-170 失败时会提示 pymobiledevice3 mounter mount)。


3. iOS≥17 路径:RSD 隧道 + DVT

3.1 RSD 客户端构造参数

remote/remote_service_discovery.py:81-152:

class RemoteServiceDiscoveryService(LockdownServiceProvider):
    def __init__(
        self,
        address: tuple[str, int],                                  # (host, port)
        name: Optional[str] = None,
        open_connection: Optional[Callable[..., Any]] = None,      # 自定义拨号器(userspace 隧道注入)
        auxiliary_metadata: Optional[dict[str, dict[str, Any]]] = None,
        handshake_uuid: Optional[uuid.UUID] = None,
    ) -> None:
  • 注意是位置参数 address 一个元组,不是 host= / port= 两个关键字(self.service = RemoteXPCConnection(address, ...),:141)。
  • host/port 从隧道来:内核 utun 隧道(有 root)、macOS 原生隧道、或 tunneld 发布的地址。
  • RSD_PORT = 58783(remote_service_discovery.py:38),Bonjour 广播的 RSD 端口。
  • 是 async context manager:__aenter__ → connect()(:558-563),__aexit__ → close()(:552-556)。
  • connect()(:303-342):握手后填充 peer_info、udid、product_type,并尽力建立远程 lockdown (com.apple.mobile.lockdown.remote.trusted → .untrusted)。
  • product_version(:178-185)从 peer_info["Properties"]["OSVersion"] 取,connect 之后才可用。

3.2 服务发现:没有 browse,直接查 peer_info

RSD 不用 lockdownd 的 StartService RPC。真正的 API:

行号 API 返回
545-550 _get_service_entry(name) peer_info["Services"][name],缺失抛 InvalidServiceError
503-511 get_service_port(name) -> int 该服务的设备侧 TCP 端口
513-525 get_service_features(name) 服务声明的 features
472-483 start_remote_service(name) 未连接的 RemoteXPCConnection
409-451 start_lockdown_service(name) 完成 RSD check-in 的 ServiceConnection
485-501 start_service(name) 按 UsesRemoteXPC 自动选上面两种

peer_info["Services"] 的每一项形如 {"Port": <int>, "Properties": {...}}。 没有 browse() 方法;Bonjour 浏览是 remote/utils.py get_rsds() 与 cli/remote.py browse 的事。

3.3 DVT provider 怎么建(iOS≥17)

不需要 DvtProvider 之外的类,也没有 RemoteServiceDiscoveryService.get_dvt_provider() 这个方法 (我在 11.29.1 源码里 grep 过 get_dvt_provider,不存在)。正确做法是把 RSD 对象直接传给 DvtProvider:

async with RemoteServiceDiscoveryService((host, port)) as rsd:
    async with DvtProvider(rsd) as dvt:          # 自动用 com.apple.instruments.dtservicehub
        ...

依据:dtx_service_provider.py:93-94 的 isinstance(service_provider, RemoteServiceDiscoveryService) 分支

  • dvt_provider.py:34 RSD_SERVICE_NAME。

4. DVT 虚拟定位的精确调用方式(最重要)

4.1 类与方法签名

services/dvt/instruments/location_simulation.py:7-47:

class LocationSimulationService(DTXService):                       # :7
    IDENTIFIER = "com.apple.instruments.server.services.LocationSimulation"   # :8

    @dtx_method("simulateLocationWithLatitude:longitude:")          # :10
    async def simulate_location_with_latitude_longitude_(self, latitude: float, longitude: float) -> None: ...

    @dtx_method("stopLocationSimulation", expects_reply=False)      # :13
    async def stop_location_simulation(self) -> None: ...


class LocationSimulation(DtxService[LocationSimulationService], LocationSimulationBase):   # :17
    def __init__(self, dvt: DtxServiceProvider):                    # :26
        super().__init__(dvt)
        LocationSimulationBase.__init__(self)

    async def set(self, latitude: float, longitude: float) -> None:  # :30
        await self.service.simulate_location_with_latitude_longitude_(latitude, longitude)

    async def clear(self) -> None:                                   # :41
        await self.service.stop_location_simulation()
  • 包路径:pymobiledevice3.services.dvt.instruments.location_simulation.LocationSimulation
  • 类名不是 DvtLocationSimulator(11.29.1 里没有这个名字),是 LocationSimulation。
  • 方法名是 set(latitude, longitude) / clear(),不是 simulate_location(lat, lon) / clear_location()。
  • 构造参数是 DvtProvider(DtxServiceProvider),不是 lockdown。

4.2 上下文管理器语义(决定我们怎么保持长连接)

dtx_service.py:21-170:

class DtxService(Generic[_SVC_T]):
    def __init__(self, provider: DtxServiceProvider, service: Optional[_SVC_T] = None) -> None:  # :52
        self._provider = provider
        self._service = service
    async def connect(self) -> None:          # :140-151  打开 DTX channel(幂等)
    async def close(self) -> None:            # :153-158  **no-op**(通道生命周期归 provider 管)
    async def __aenter__(self) -> Self:       # :160-162  await self.connect()
    async def __aexit__(...):                 # :164-170  await self.close()

dtx_service_provider.py:117-178:

def __init__(self, lockdown, strip_ssl=None, dtx=None)   # :117  (dtx 传入时 provider 不负责关它)
async def connect(self) -> None:                          # :149  打开传输 + DTX 握手(幂等)
async def close(self) -> None:                            # :167  await self._dtx.aclose()
async def __aenter__/__aexit__                            # :173-178

结论(三条,全部有源码依据):

  1. LocationSimulation 与 DvtProvider 都是 async context manager,但也可以手动 await provider.connect() → await location.connect(),然后一直复用(connect() 幂等,:154/:149)。
  2. LocationSimulation.close() 是 no-op(dtx_service.py:153-158),真正的释放靠 DvtProvider.close() 关闭 DTX 连接(dtx_service_provider.py:167-171)。
  3. 必须显式调用 clear(),否则设备会一直停留在模拟位置;__aexit__ 不会帮你 clear。

4.3 「连接断掉定位就失效」——CLI 取证

cli/developer/dvt/simulate_location.py:36-38:

async with DvtProvider(service_provider) as dvt, LocationSimulation(dvt) as location_simulation:
    await location_simulation.set(latitude, longitude)
    OSUTILS.wait_return()

OSUTILS.wait_return() 在 POSIX 上是阻塞等待信号(osu/posix_util.py:129-131):

def wait_return(self):
    print("Press Ctrl+C to send a SIGINT or use 'kill' command to send a SIGTERM")
    signal.sigwait([signal.SIGINT, signal.SIGTERM])

即官方 CLI set 之后一直挂在连接上直到 Ctrl-C。这直接证明:DVT 虚拟定位依赖长连接, 连接断开后模拟位置即失效。→ 我们的 RealLocationProvider 必须长连接(见第 6 节实现)。

4.4 可直接复制的 10 行示例(iOS≥17)

import asyncio
from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation

async def main(host: str, port: int, lat: float, lon: float) -> None:
    async with RemoteServiceDiscoveryService((host, port)) as rsd:      # 隧道给的 RSD 地址
        async with DvtProvider(rsd) as dvt:                              # -> dtservicehub
            async with LocationSimulation(dvt) as sim:                   # 打开 DTX channel
                await sim.set(lat, lon)                                  # 单点定位
                await asyncio.sleep(30)                                  # 期间保持连接!
                await sim.clear()                                        # 恢复真实定位

asyncio.run(main("10.7.0.1", 51234, 39.908, 116.397))

iOS<17(无隧道)只需把第一行换成 lockdown:

from pymobiledevice3.lockdown import create_using_usbmux
lockdown = await create_using_usbmux(serial=None)      # 第一台 USB 设备
# 之后 DvtProvider(lockdown) -> LocationSimulation 完全一样

4.5 CLI 等价命令

命令 适用 源码
pymobiledevice3 developer dvt simulate-location set -- LAT LON iOS 17+(跑在隧道上) cli/developer/dvt/simulate_location.py:26-38
pymobiledevice3 developer dvt simulate-location clear iOS 17+ 同上 :18-23
pymobiledevice3 developer simulate-location set -- LAT LON iOS<17(老服务) cli/developer/simulate_location.py:28-35
pymobiledevice3 developer simulate-location clear iOS<17 同上 :21-25

两者都在 developer 子命令树里(cli/developer/__init__.py:64-71:cli.add_typer(dvt.cli)、 cli.add_typer(simulate_location.cli))。注意 dvt 是父命令名, developer simulate-location 与 developer dvt simulate-location 是两条不同的命令。


5. iOS 17+ 隧道方案(逐条 + 是否需要 root)

5.1 结论速查表

方案 命令 需要 root? 跨进程可用? 源码依据
经典内核隧道 sudo pymobiledevice3 remote start-tunnel --script-mode 是 是(打印 HOST PORT) cli/remote.py:326-429,is_admin 检查在 :417-418
lockdown 别名 sudo pymobiledevice3 lockdown start-tunnel --script-mode 是 是 cli/lockdown.py:211-222(@sudo_required)
macOS 原生隧道 pymobiledevice3 remote start-tunnel --native --script-mode 否 是 cli/remote.py:297-323、:367-376、:405-416
进程内 userspace 隧道 --userspace / UserspaceRsdTunnel 否 否(仅同进程) userspace_tunnel.py:793-834、cli/cli_common.py:564-573
tunneld 服务 sudo pymobiledevice3 remote tunneld 是(@sudo_required) 是(但客户端要按 tunneld 协议/dialer 接) cli/remote.py:93-125、tunneld/api.py:16

5.2 逐条取证

(a) lockdown start-tunnel —— 需要 root

cli/lockdown.py:211-222:

@cli.command("start-tunnel")
@sudo_required
@async_command
async def cli_start_tunnel(service_provider: ServiceProviderDep, script_mode: ... = False) -> None:
    """start tunnel"""
    await async_cli_start_tunnel(service_provider, script_mode)

@sudo_required 的实现(cli/cli_common.py:165-173):

def sudo_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not OSUTILS.is_admin:
            raise AccessDeniedError()
        else:
            func(*args, **kwargs)
    return wrapper

即非 root 直接抛 AccessDeniedError(exceptions.py:546-549 "Need extra permissions to execute this command")。 它内部用 TunnelProtocol.TCP(cli/lockdown.py:207)。

(b) remote start-tunnel —— 经典路径需要管理员,--native 免 root

cli/remote.py:326-429,关键几段:

@cli.command("start-tunnel")
@async_command
async def cli_start_tunnel(...):
    """start tunnel (classic root tunnel by default; --native piggybacks Apple's tunnel on macOS, no root)"""
    ...
    if native:
        ...
        await native_tunnel_task(udid, script_mode=script_mode)     # :415  免 root 分支
        return
    if not OSUTILS.is_admin:                                        # :417-418
        raise AccessDeniedError()

native_tunnel_task(cli/remote.py:297-323)会打印:

Identifier: <UDID>
RSD Address: <address>
RSD Port: <port>
Use the follow connection option:
--rsd <address> <port>

--native 的帮助文本(:367-376)明确写了 "no root, macOS only",并提示它和 remoted 会互相抢占 RSD 连接 ("Its RSD connection and remoted's evict each other, so it is opt-in")。实现见 remote/native_tunnel.py:950 async def establish_native_rsd(serial=None)。

(c) remote tunneld —— 需要 root,且默认 QUIC

cli/remote.py:93-125:

@cli.command("tunneld")
@sudo_required                                    # :94  ← 需要 root
def cli_tunneld(
    host: ... = TUNNELD_DEFAULT_ADDRESS[0],       # :96
    port: ... = TUNNELD_DEFAULT_ADDRESS[1],       # :97
    protocol: ... = TunnelProtocol.DEFAULT,       # :116  Python 3.12 → QUIC
    ...
  • 默认监听地址:tunneld/api.py:16 TUNNELD_DEFAULT_ADDRESS = ("127.0.0.1", 49151)。
  • remote.py:108-116 的 help:"default: TCP on Python >=3.13, otherwise QUIC" → 本机 3.12 默认 QUIC。
  • tunneld 的价值是「一次 root 起服务,之后多个非特权客户端复用」;但客户端要按 tunneld 的 传输协议接入(QUIC + 自定义 API),对我们的单进程应用来说复杂度明显高于 (a)/(b)。

(d) 进程内 userspace 隧道 —— 完全免 root,但只在同进程可用

remote/userspace_tunnel.py:793-834 的类文档:

class UserspaceRsdTunnel:
    """A no-root, in-process iOS 17+ RSD tunnel and its connected RSD, as one closeable handle.
    Replaces the kernel utun (which needs root/admin) with a pure-Python PyTCP stack ...
    Constraints:
    * One tunnel per process. ...
    * The device address is in-process only, reachable only from this process's userspace
      stack — never by an external tool. The RSD reports this via
      RemoteServiceDiscoveryService.is_in_process_tunnel ...

remote_service_discovery.py:146-152:

@property
def is_in_process_tunnel(self) -> bool:
    """True when this RSD reaches the device through an in-process dialer (the userspace
    tunnel) rather than a kernel-routable interface. The device address (self.service.address)
    is then only reachable from THIS process ..."""
    return self.open_connection is not None

便利函数:userspace_tunnel.py:998-1000 async def establish_userspace_rsd(serial=None, autopair=True, remotepairing_fallback=True) -> RemoteServiceDiscoveryService。

(e) 上游的自动升级逻辑(可作为我们的语义参照)

__main__.py:408-452:当 iOS 17+ 设备上的 developer 命令因 InvalidServiceError / RSDRequiredError 失败时,CLI 会自动重试一次并建立默认隧道,日志为:

Trying again over {the native tunnel|a no-root userspace tunnel|tunneld} since
it is a developer command on an iOS 17+ device
(override with PYMOBILEDEVICE3_DEFAULT_FALLBACK, or --tunnel/--userspace/--native)

默认优先级由 cli/cli_common.py:433-454 default_transport_preference() 决定:内置默认 "userspace"(免 root),可用环境变量 PYMOBILEDEVICE3_DEFAULT_FALLBACK 覆盖为 native/tunneld。

5.3 无 root 情况下最可行的方案(明确推荐)

推荐 ①(跨进程,macOS,免 root):

./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode
# 输出:<RSD_HOST> <RSD_PORT>   → 交给 RealLocationProvider(rsd_host=..., rsd_port=...)

理由:无需 sudo;打印的地址是内核可路由的,任何进程都能用;正好匹配 「core/tunnel.py 提供 rsd_host/rsd_port」的架构。 代价:与系统的 remoted 抢占同一台设备的 RSD 连接,可能被顶掉(重新建隧道即可)。

推荐 ②(有管理员权限时最稳):

sudo ./.venv/bin/python -m pymobiledevice3 remote start-tunnel --script-mode
# 输出:<RSD_HOST> <RSD_PORT>

这是官方经典路径,不依赖 macOS 的 remoted,长时间运行最稳定;run.sh 提示用户输入密码即可。

推荐 ③(单进程应用,完全免 root):进程内 userspace 隧道 (UserspaceRsdTunnel / establish_userspace_rsd),但 RSD 地址不能跨进程传递; 如果要走这条路,必须把已连接的 RSD 对象直接注入 RealLocationProvider(rsd=...) (我为这个场景预留了可选关键字参数,见第 6.3 节)。

不推荐:remote tunneld(要 root + Python 3.12 下默认 QUIC,客户端集成成本高); lockdown start-tunnel(要 root,且只是经典隧道的别名)。


6. 我们的实现(core/location_provider.py / core/location_mock.py)

6.1 与 lead 的接口约定

  • 只使用 lead 提供的 DeviceManager.list_devices()(Device dict 字段见 PROTOCOL.md 2.1)与 DeviceManager.get_selected();connect(device) 用容错读取(dict 的 .get()),字段缺失不会崩。
  • 我不改 core/device.py、core/tunnel.py;需要的额外能力通过构造参数注入。
  • RSD 地址由 lead 的 core/tunnel.py 提供。

6.2 冻结签名与返回结构

class RealLocationProvider:
    def __init__(self, rsd_host=None, rsd_port=None, udid=None, *, rsd=None, lockdown=None,
                 connect_timeout=15.0, on_log=None, log=None)   # log= 是 on_log 的别名
    async def connect(self, device) -> dict     # 建立长连接会话
    def connect_sync(self, runner, device=None) -> dict   # 同步版(提交给调用方 runner)
    async def set_location(self, lat, lon) -> None
    async def clear_location(self) -> None
    async def close(self) -> None
    @property connected -> bool
    async def probe(self, device=None) -> dict  # {"ok","detail","ios_major",...};device 可选

MockLocationProvider 同构:MockLocationProvider(device=None, on_log=None, latency=None, log=None)

  • connect_sync(runner, device=None)。

log= 与 connect_sync() 是按 lead 的 core/server.py 真实调用点加的兼容别名 (core/server.py:147 MockLocationProvider(log=self.log)、:148 provider.connect_sync(self.runner)、 :167-172 RealLocationProvider(..., log=self.log))。冻结的五个方法签名一个都没改,只是新增。 connect_sync 只调用传入 runner 的 run(coro),不新建事件循环/线程(协程仍在 runner 自己的循环里跑)。

connect() 返回(mock 与真机同构,方便 lead 组装 /api/devices/connect):

{"ok": true, "mock": false, "udid": "...", "ios_version": "26.0", "ios_major": 26,
 "connection": "usb", "tunnel": "connected", "strategy": "dvt-rsd",
 "rsd": "10.7.0.1:51234", "pymobiledevice3": "11.29.1", "detail": "中文说明"}

6.3 三条策略与降级顺序

strategy 触发条件 实现
dvt-rsd 注入了 rsd= 对象,或给了 rsd_host+rsd_port RemoteServiceDiscoveryService((host,port)) → DvtProvider(rsd) → LocationSimulation
dvt-lockdown 无 RSD 且 iOS<17 create_using_usbmux(serial=udid) → DvtProvider(lockdown) → LocationSimulation
legacy dvt-lockdown 失败 DtSimulateLocation(com.apple.dt.simulatelocation,会真开一次服务校验可用性)
  • 长连接:connect() 只建一次;set_location() 复用同一 DTX channel(await self._location.set(...)), 不重连。依据见 4.3(连接断开定位即失效)。
  • 同一事件循环:全部原生 async/await,无线程、无 asyncio.run。
  • 中文异常:所有失败包成 LocationProviderError,文案含修复建议;iOS≥17 无隧道时直接给出 「请以管理员权限运行 run.sh」+「--native 免 root 隧道」两条路。
  • 不崩的降级:未安装 pymobiledevice3 时 import core.location_provider 仍然成功, probe() 返回 ok=False + 中文安装指引,而不是 ModuleNotFoundError。
  • 不假成功:legacy 策略在 connect() 阶段就真开一次 start_lockdown_developer_service() 校验(源码依据:services/simulate_location.py:19-22 的构造不做设备 I/O),真机实测踩到过这个坑,详见 8.1。

6.4 probe() 返回

{"ok": false, "detail": "iOS 17 及以上必须先用 RSD 隧道…", "ios_major": 26, "strategy": null}
  • probe() 永不抛异常,供前端「测试连接」按钮直接展示 detail。
  • 可选参数 device(与 connect() 同一个 Device dict):传入后先做静态判断 (未配对 / 未开开发者模式 / iOS 版本),能省掉一次设备往返并回填 ios_major。
  • 无隧道 + iOS<17 时会真的去连一次(点「测试连接」就应该真的测); 无隧道 + iOS17+ 时只报隧道状态,不做无意义重连。

6.5 与 lead 的 core/server.py 真实联调(--mock 模式端到端)

我用 lead 的入口在 8791 端口起了一次 mock 服务(不干扰他 8799 上的实例),实测:

$ ./.venv/bin/python main.py --mock --port 8791 --no-browser --window none

$ curl -s http://127.0.0.1:8791/api/health
{"ok":true,"version":"1.0.0","name":"iOS 虚拟定位轨迹工具","mock":true,"device_count":1,
 "backend":"mock","pymobiledevice3":true,"uptime":2.31,"ws_clients":0}

$ curl -s -X POST http://127.0.0.1:8791/api/devices/connect -d '{}'
{"ok":true,"device":{...},"tunnel":{...},
 "probe":{"ok":true,"detail":"模拟模式:DVT 虚拟定位可用(iOS 26,无需隧道)","ios_major":26}}

$ curl -s -X POST http://127.0.0.1:8791/api/location -d '{"lat":39.908,"lng":116.397}'
{"ok":true}

$ curl -s "http://127.0.0.1:8791/api/logs?limit=20"
… {"level":"success","msg":"[模拟模式] 已连接模拟设备 (iPhone 17 Pro)(udid=MOCK-0001,iOS 26.0)"}
  {"level":"info",   "msg":"[模拟模式] 模拟模式:定位下发仅记录日志,不会真正修改设备位置"}
  {"level":"info",   "msg":"[模拟模式] 下发定位 lat=39.908000, lon=116.397000(第 1 次,模拟)"}
  {"level":"success","msg":"已把设备定位到 39.908000, 116.397000"}
  {"level":"info",   "msg":"[模拟模式] 下发定位 lat=31.230400, lon=121.473700(第 2 次,模拟)"}

验证到:

  1. MockLocationProvider(log=self.log) + connect_sync(self.runner) 在 lead 的真实装配下工作正常;
  2. probe 的中文 detail 直接出现在 /api/devices/connect 响应里(前端「测试连接」可直接用);
  3. set_location(lat, lon) 的参数顺序正确(日志里 lat=39.908 / lon=116.397 没有互换);
  4. provider 的日志经 log= 回调进入 lead 的日志面板(/api/logs 可见)。

测试用的 8791 服务已关闭(lsof 确认端口释放),lead 的 8799 实例未受影响。


7. 自测命令与真实输出

以下命令均为 device-dev 在本机实际执行过、并复制真实输出的结果。

7.1 安装结果

$ ./.venv/bin/python -m pip install --index-url https://mirrors.aliyun.com/pypi/simple/ \
      --timeout 30 --retries 2 pymobiledevice3
...
Successfully installed ASGIMiddlewareStaticFile-0.7.0 IPython-9.17.1 Pillow-12.3.0 aiofiles-25.1.0
... pymobiledevice3-11.29.1 pmd-pytcp-0.3.7 qh3-1.9.4 pytun-pmd3-3.0.3 cryptography-50.0.2
... sslpsk-pmd3-1.0.3 typer-0.27.3 typer-injector-0.3.0 ...
[exit 0]

$ ./.venv/bin/python -m pip show pymobiledevice3
Name: pymobiledevice3
Version: 11.29.1
Location: /Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages

site-packages 里的关键源码与用于取证的 wheel 逐字节一致(diff -q 全部 SAME):

SAME  services/dvt/instruments/location_simulation.py
SAME  services/dvt/instruments/dvt_provider.py
SAME  dtx_service_provider.py
SAME  dtx_service.py
SAME  remote/remote_service_discovery.py
SAME  services/simulate_location.py

7.2 语法 / import 自检

$ ./.venv/bin/python -m compileall core/location_provider.py core/location_mock.py tools/*.py
COMPILEALL OK

$ ./.venv/bin/python -c "import core.location_provider, core.location_mock; print('IMPORT OK')"
IMPORT OK

这两个模块在 pymobiledevice3 尚未安装时也照样 import 成功(懒加载 + 中文错误), 已在安装前实测过一次。

7.3 离线自测(无设备也全绿)

$ ./.venv/bin/python tools/selftest_location.py
====================================================================
虚拟定位后端离线自测(无需 iOS 设备)
Python 3.12.14 | cwd=/Users/jbjf/Desktop/location
====================================================================

== 1. MockLocationProvider ==
  [通过] 初始 connected=False
  [通过] connect 返回 dict 且 ok=True —— 模拟设备已连接(iOS 26.0,无需隧道)
  [通过] connect 后 connected=True
  [通过] connect 返回中文说明
  [通过] set_location 延时 5~10ms —— 实测 9.90ms
  [通过] send_count 递增
  [通过] last_location 记录正确
  [通过] clear_location 后 last_location=None
  [通过] close 后 connected=False
  [通过] close 幂等
  [通过] mock probe ok=True —— {'ok': True, 'detail': '模拟模式:DVT 虚拟定位可用(iOS 26,无需隧道)', 'ios_major': 26}

== 2. RealLocationProvider 降级逻辑 ==
  pymobiledevice3 已安装:True(版本 11.29.1)
  [通过] 初始 connected=False
  [通过] 未连接下发抛 LocationProviderError —— 尚未连接设备,无法下发定位:请先调用 connect()(或点击界面上的「连接设备」)
  [通过] 未连接 clear_location 为 no-op
  [通过] 未配对设备报中文错误 —— 设备未与此电脑配对:请在 iPhone 上点击「信任此电脑」后重试…
  [通过] 未开开发者模式报中文错误 —— 设备未开启「开发者模式」:请在 iPhone 上打开 设置 → 隐私与安全性 → 开发者模式…
  [通过] iOS17+ 无隧道提示建隧道 —— 设备为 iOS 26.0,需要先建立 RSD 隧道才能虚拟定位。iOS 17 及以上必须先用 RSD 隧道…
  [通过] 越界纬度报中文错误 —— 经纬度超出合法范围:lat=123.0, lon=116.0
  [通过] probe 返回 ok/detail/ios_major
  [通过] probe detail 为中文
  [通过] probe(隧道不可达) 不抛异常且 ok=False —— 建立 RSD/DVT 会话失败:无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立
  [通过] 文案为中文
  [通过] probe(device) 未开开发者模式 → ok=False 且中文
  [通过] probe(device) 回填 ios_major=26
  [通过] probe(device) iOS17+ 无隧道 → 提示建隧道
  [通过] close 幂等且 connected=False
  [通过] 不可达隧道报中文错误 —— 建立 RSD/DVT 会话失败:无法连接 RSD 隧道(连接被拒绝),隧道可能已断开或尚未建立

== 3. 与 lead 的 core/server.py 集成 ==
  [通过] MockLocationProvider(log=...) 构造成功
  [通过] connect_sync(runner) 建立会话
  [通过] log= 回调收到日志 —— 2 条
  [通过] set_location(lat, lon) 经 runner 调用成功
  [通过] close 后 connected=False
  [通过] RealLocationProvider(log=...) 构造成功
  [通过] probe(device) 经 runner 调用不抛异常
  [通过] connect_sync 无隧道时抛中文异常 —— 设备为 iOS 26,需要先建立 RSD 隧道才能虚拟定位。iOS 17 及以上必须先用 RSD 隧道才能使用 DVT 虚拟定位…

====================================================================
通过 36 项,失败 0 项
全部通过 ✅

说明:上表是最终一轮的输出(36 项,含第 3 节照抄 core/server.py 用法的 8 条集成断言); 早期未加这些断言时是 25 项,同样全绿。 第 3 节用的是 core/server.py 里 AsyncRunner 的同构替身(后台线程 + 独立事件循环), 因此也验证了「跨线程提交协程」这一真实运行模型下我的 provider 不会自己新建事件循环。

(tools/selftest_location.py 里断言了:错误信息必须含中文、必须是 LocationProviderError 而不是 AttributeError/ModuleNotFoundError。)


8. 真机事实(本机实际接了两台 iPhone)

任务书写的是「本机没有连接 iOS 设备」,但实测有。这条对验收影响很大,特此记录。

$ ./.venv/bin/python -m pymobiledevice3 usbmux list
[
    {"BuildVersion": "20F66", "ConnectionType": "Network", "DeviceName": "iPhone 17 Pro Max",
     "Identifier": "00008120-0011713A1444C01E", "ProductType": "iPhone15,3", "ProductVersion": "16.5"},
    {"BuildVersion": "23G83", "ConnectionType": "USB", "DeviceName": "iPhone",
     "Identifier": "00008030-001551840A02402E", "ProductType": "iPhone12,1", "ProductVersion": "26.6.1"}
]
UDID 型号 iOS 连接 开发者模式 已挂载开发者镜像
00008120-0011713A1444C01E iPhone 17 Pro Max (iPhone15,3) 16.5 Network ✅ 已开启 ❌ mounter list → []
00008030-001551840A02402E iPhone (iPhone12,1) 26.6.1 USB ❌ 未开启 ❌ mounter list → []

pymobiledevice3 doctor 的真实结论(本机能力):

Host — this works:
  Device plugged in    iPhone (00008030-001551840A02402E), and usbmux lists it
  usbmux daemon        reachable, 2 device(s): …C01E over Network, …402E over USB
  Service access       opened a lockdown connection to 00008030-001551840A02402E
  Tunnel (native)      remotepairingd answered, 1 device(s)      ← 免 root 原生隧道在本机可用
  Tunnel (userspace)   pmd-pytcp 0.3.7                           ← 免 root 进程内隧道也可用

Host — not available here:
  Tunnel (tunneld)     not running on 127.0.0.1:49151

Device — worth knowing:
  Developer mode       disabled
  Developer image      no Personalized image is mounted
      so:  `developer` commands will fail even with a working tunnel
      fix: pymobiledevice3 mounter auto-mount

结论:真机端到端现在无法跑通,原因不在代码——两台设备都没有挂载开发者镜像,且 iOS 26.6.1 那台 没开开发者模式。要让真机路径生效,需要用户手动做两步(都会改动设备,device-dev 未擅自执行):

  1. iOS 26 那台开启「开发者模式」(pymobiledevice3 amfi enable-developer-mode,设备会重启并需解锁);
  2. pymobiledevice3 mounter auto-mount 挂载开发者镜像。

8.1 真机实测:iOS 16.5 设备(开发者模式开、无镜像、Network)

$ ./.venv/bin/python tools/probe_device.py --udid 00008120-0011713A1444C01E --probe
pymobiledevice3 版本:11.29.1
Python:3.12.14

[真机] 建立 DVT 会话(iOS<17 直连)失败:设备未提供所需的开发者服务。iOS<17 通常是尚未挂载开发者镜像,
       请执行:./.venv/bin/python -m pymobiledevice3 mounter auto-mount;iOS 17+ 则需要 RSD 隧道…
       改用老版 simulatelocation 服务重试
=== 设备 1 ===
  UDID        : 00008120-0011713A1444C01E
  名称        : iPhone 17 Pro Max
  型号        : iPhone15,3
  iOS 版本    : 16.5 (major=16)
  连接方式    : network
  配对状态    : connected
  开发者模式  : 已开启
  DVT 可用性  : 不可用 —— iOS 16 < 17:可经 usbmux/lockdown 直连 DVT(或老版 simulatelocation 服务)
  实测连接    : 失败 —— 建立老版虚拟定位会话(com.apple.dt.simulatelocation)失败:设备未提供所需的开发者服务…

真机验证到的事实:

  1. create_using_usbmux(serial=...) 对 Network(Wi-Fi) 连接的设备同样有效;
  2. 无开发者镜像时,DvtProvider(lockdown).connect() 真的抛 InvalidServiceError(str(e) 就是 "InvalidService",不带 "Error" 后缀);
  3. 实测发现并修掉一个真 bug:DtSimulateLocation.__init__ 不做任何设备 I/O (services/simulate_location.py:19-22),所以「构造成功」不等于「服务可用」。 最初版本因此出现「connect 报成功 → 第一次 set 才失败」的假成功;现在 _connect_lockdown_path() 会真的 await lockdown.start_lockdown_developer_service(...) 校验一次 (见 core/location_provider.py 的 legacy 分支),上面的输出就是修复后的诚实失败。

8.2 iOS 26.6.1 设备(USB、开发者模式未开)

⚠️ 本节是 20:47 的中间状态,已被后续进展取代(保留作为取证过程)。 21:00 之后实测的最终事实:

  • 用户在手机上开启了开发者模式并重启,amfi developer-mode-status 返回 true;
  • 开发者镜像已挂载:mounter list 显示 Personalized DeveloperDiskImage 642.16、 MountPath=/System/Developer、ProductBuildVersion=27A266a (由 Xcode 的 CoreDeviceService 用本机素材自动挂载);
  • 虚拟定位链路已端到端跑通:免 root 的 remote start-tunnel --native --script-mode 建立 RSD 隧道 → 打开 DVT dtservicehub → LocationSimulation 长连接 → 单点定位与轨迹播放均成功(tests/real_gui_acceptance.py 11/11、 tests/real_device_e2e.py 通过),停止后恢复真实定位;
  • 素材下载会被 SNI 级阻断(raw.githubusercontent.com 的 4 个 IP 只有 185.199.111.133 可用),离线抓取方案见 tools/ddi_offline_fetch.sh。
$ ./.venv/bin/python tools/probe_device.py --udid 00008030-001551840A02402E --probe
=== 设备 1 ===
  UDID        : 00008030-001551840A02402E
  名称        : iPhone
  型号        : iPhone12,1
  iOS 版本    : 26.6.1 (major=26)
  连接方式    : usb
  配对状态    : connected
  开发者模式  : 未开启
  DVT 可用性  : 不可用 —— 未开启开发者模式:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启)

--probe 直接在静态判断阶段就拦下(开发者模式未开),不会再浪费一次设备往返。


9. CLI --help 实测输出(真实终端输出,逐字粘贴)

9.1 pymobiledevice3 --help(命令树)

 Usage: python -m pymobiledevice3 [OPTIONS] COMMAND [ARGS]...

 Swiss-army CLI for pairing, inspecting, backing up, and automating iOS devices.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ developer        Developer tooling for iOS devices (requires Developer Mode  │
│                  + mounted DeveloperDiskImage).                              │
│ lockdown         Pair/Unpair device or access other lockdown services        │
│ remote           Create and browse RemoteXPC tunnels (RSD/tunneld) for       │
│                  developer services.                                         │
│ usbmux           Inspect usbmuxd-connected devices and forward TCP ports to  │
│                  them.                                                       │
│ mounter          Mount/Umount DeveloperDiskImage or query related info       │
│ version          Query pymobiledevice3 version                               │
╰──────────────────────────────────────────────────────────────────────────────╯

9.2 developer dvt simulate-location --help(iOS 17+,DVT 路径)

$ ./.venv/bin/python -m pymobiledevice3 developer dvt simulate-location --help
 Usage: python -m pymobiledevice3 developer dvt simulate-location [OPTIONS] COMMAND [ARGS]...

 Simulate device location through DVT on iOS 17 and newer.

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ clear  Clear the currently simulated location on iOS 17 and newer.           │
│ set    Set a simulated location through DVT on iOS 17 and newer.             │
│ play   Replay a GPX route through DVT on iOS 17 and newer.                   │
╰──────────────────────────────────────────────────────────────────────────────╯

9.3 developer simulate-location --help(iOS<17,老服务路径)

$ ./.venv/bin/python -m pymobiledevice3 developer simulate-location --help
 Usage: python -m pymobiledevice3 developer simulate-location [OPTIONS] COMMAND [ARGS]...

 Simulate GPS location on iOS versions below 17 (set, clear, or replay GPX routes).

╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ clear  Stop location simulation and resume real GPS on iOS versions below 17.│
│ set    Set a fixed simulated location on iOS versions below 17 (latitude,    │
│        longitude).                                                           │
│        Example: `set 40.690008 -74.045843` (Liberty Island).                 │
│ play   Replay a GPX route on iOS versions below 17; optionally disable       │
│        sleeps and add timing jitter.                                         │
╰──────────────────────────────────────────────────────────────────────────────╯

两条命令的 help 文本("iOS 17 and newer" / "iOS versions below 17")与源码里的 cli/developer/dvt/simulate_location.py:13 与 cli/developer/simulate_location.py:16 完全对应, 这也从 CLI 侧证明了「iOS<17 与 iOS≥17 是两条不同的服务」。

9.4 remote start-tunnel --help(注意 --native = 免 root)

$ ./.venv/bin/python -m pymobiledevice3 remote start-tunnel --help
 Usage: python -m pymobiledevice3 remote start-tunnel [OPTIONS]

 start tunnel (classic root tunnel by default; --native piggybacks Apple's
 tunnel on macOS, no root)

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --script-mode  --no-script-mode   Print only HOST and port for scripts       │
│                                   instead of formatted output.               │
│                                   [default: no-script-mode]                  │
│ --protocol     -p  <tcp|quic>     Transport protocol for the tunnel.         │
│                                   [default: (TCP on Python >=3.13,          │
│                                   otherwise QUIC)]                           │
│ --native       --no-native        Piggyback Apple's remoted tunnel via       │
│                                   remotepairingd and publish its RSD         │
│                                   address instead of creating a new tunnel;  │
│                                   no root, macOS only. Its RSD connection    │
│                                   and remoted's evict each other, so it is   │
│                                   opt-in …                                   │
╰──────────────────────────────────────────────────────────────────────────────╯

9.5 remote tunneld --help(默认 QUIC,且需要 root)

$ ./.venv/bin/python -m pymobiledevice3 remote tunneld --help
 Usage: python -m pymobiledevice3 remote tunneld [OPTIONS]

 Start Tunneld service for remote tunneling

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --host                     <str>       Address to bind the tunneld server to.│
│                                        [default: 127.0.0.1]                  │
│ --port                     <int>       Port to bind the tunneld server to.   │
│                                        [default: 49151]                      │
│ --protocol     -p  <tcp|quic>          Transport protocol for tunneld        │
│                                        (default: TCP on Python >=3.13,       │
│                                        otherwise QUIC).                      │
│                                        [default: quic]        ← 3.12 下默认  │
│ --usb/--no-usb, --wifi/--no-wifi, --usbmux/--no-usbmux, --mobdev2/…          │
╰──────────────────────────────────────────────────────────────────────────────╯

9.6 lockdown start-tunnel --help

$ ./.venv/bin/python -m pymobiledevice3 lockdown start-tunnel --help
 Usage: python -m pymobiledevice3 lockdown start-tunnel [OPTIONS]

 start tunnel

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --script-mode  --no-script-mode  Show only HOST and port number to allow     │
│                                  easy parsing from external shell scripts    │
│ --help  -h                       Show this message and exit.                 │
╰──────────────────────────────────────────────────────────────────────────────╯

(它只有 --script-mode——因为 RSD 主机/端口由底层内核隧道直接决定;实现见 cli/lockdown.py:193-222,内部以 TunnelProtocol.TCP 调 start_tunnel_task。)

9.7 remote browse --help

$ ./.venv/bin/python -m pymobiledevice3 remote browse --help
 Usage: python -m pymobiledevice3 remote browse [OPTIONS]

 browse RemoteXPC devices (remotepairingd on macOS by default, bonjour elsewhere)

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --timeout   <float>          Browse timeout (in seconds)  [default: 3]       │
│ --native    --no-native      Browse via Apple's remotepairingd (the remoted  │
│                              tunnel daemon) instead of bonjour; no root,     │
│                              macOS only …                                    │
╰──────────────────────────────────────────────────────────────────────────────╯

9.8 无 root 实测:三条命令一律拒绝

$ ./.venv/bin/python -m pymobiledevice3 remote tunneld
2026-10-14 20:33:29 … __main__[83672] ERROR This command requires root privileges. Consider retrying with "sudo".
[exit 1]

$ ./.venv/bin/python -m pymobiledevice3 remote start-tunnel
2026-10-14 20:33:29 … __main__[83689] ERROR This command requires root privileges. Consider retrying with "sudo".
[exit 1]

$ ./.venv/bin/python -m pymobiledevice3 lockdown start-tunnel
2026-10-14 20:33:33 … __main__[83717] ERROR This command requires root privileges. Consider retrying with "sudo".
[exit 1]

→ 与 5.3 的结论互相印证:免 root 的跨进程隧道只有 remote start-tunnel --native --script-mode (本机 doctor 也确认 Tunnel (native) remotepairingd answered, 1 device(s))。


10. 交付物清单与给 lead 的接线说明

10.1 我交付的文件(写作用域内,未碰任何别人的文件)

文件 内容
core/location_provider.py RealLocationProvider(dvt-rsd / dvt-lockdown / legacy 三策略,长连接,中文异常,probe())
core/location_mock.py MockLocationProvider(--mock 模式,接口与真机同构,5~10ms 模拟往返)
tools/probe_device.py 列出设备 + iOS 版本 + 开发者模式 + DVT 可用性;--probe --rsd HOST PORT 实测建会话
tools/probe_location.py 命令行单点定位:--lat --lon [--clear] [--rsd HOST PORT] [--duration N]
tools/selftest_location.py 离线自测(无设备,36 项断言,含与 core/server.py 的集成断言)
docs/DEVICE_NOTES.md 本文件(全部取证)

10.2 lead 侧的接线方式

from core.location_provider import RealLocationProvider, LocationProviderError
from core.location_mock import MockLocationProvider

# 真机(RSD 地址来自 core/tunnel.py)
provider = RealLocationProvider(rsd_host=host, rsd_port=port, udid=device["udid"], on_log=push_log)
info = await provider.connect(device)        # {"ok":..., "strategy":"dvt-rsd", "detail": 中文}
await provider.set_location(lat, lon)        # 注意签名顺序是 (lat, lon)
await provider.clear_location()              # play.stop / location.clear 必须调用
await provider.close()

# mock 模式
provider = MockLocationProvider(on_log=push_log)
  • connect() 返回的 dict 里 tunnel 字段可直接映射到 Device.tunnel; info["detail"] 适合直接作为 /api/devices/connect 的提示文案。
  • 失败一律是 LocationProviderError,str(e) 就是给用户看的中文(可直接转成 {"ok":false,"error":str(e)})。probe() 则永不抛异常。
  • iOS 17+ 且没有隧道时抛:「设备为 iOS X,需要先建立 RSD 隧道才能虚拟定位。…sudo ./run.sh…」, 可直接用于 PROTOCOL.md 里的 tunnel_hint。
  • 隧道首选:./.venv/bin/python -m pymobiledevice3 remote start-tunnel --native --script-mode (macOS 免 root,输出 HOST PORT;本机 doctor 已验证 remotepairingd 可用)。 有管理员权限时用 sudo … remote start-tunnel --script-mode 更稳(不与系统 remoted 抢连接)。
  • 可选扩展(不破坏冻结签名):RealLocationProvider(…, rsd=<已连接的 RemoteServiceDiscoveryService>) 用于把同进程的免 root userspace 隧道(UserspaceRsdTunnel)直接注入; 注意它的地址跨进程不可达,不能靠子进程打印 HOST/PORT 传递。