Skip to content

Q4 — pymobiledevice3 server-side utilities: what exists, what's reusable

Status: verified — 2026-04-18

Straight source read of pymobiledevice3 upstream v9.9.1 (local clone at /home/op/dev/repos/pymobiledevice3). No live runs, no inference — every claim below cites a specific file and line.

TL;DR — no ready-made RemoteXPC server, but the client-side codec is fully reusable

  • pymobiledevice3 ships no "pretend to be iPhone" server fixture.
  • Its test suite contains fake_* helpers (tests/cli/developer/dvt/sysmon/test_process_unit.py fake_print_json, fake_create, etc.), but these are UI-callback stubs on the client side — they do NOT speak RemoteXPC over a socket.
  • pymobiledevice3/remote/remotexpc.py is the client. It has no server counterpart in the codebase.
  • pymobiledevice3/cli/remote.py has a tunneld subcommand (TunneldRunner at line 37 of that file), but it is a tunnel coordinator HTTP server, not a RemoteXPC endpoint.

The good news: every one of the XpcWrapper.build / _build_xpc_object helpers in pymobiledevice3/remote/xpc_message.py is direction-agnostic — they produce the bytes for a whole XPC-wrapped payload regardless of whether you intend to send as client or as server. For Q3's incremental dialog bisection, this means we can mirror CDS's request shapes back as responses without writing a codec from scratch.

Findings by grep

server / listen / bind / fixture / mock / fake in remote/ and tests/

grep -rnE -i "class.*Server|listen\(|accept\(|bind\(|def.*fixture|def.*mock|def.*fake" \
    pymobiledevice3/remote/ tests/

Only hits: tests/cli/developer/dvt/sysmon/test_process_unit.py with fake_print_json, fake_create, fake_prompt_selection. All of them are pytest monkey-patches that replace UI-callback functions. None listen on a socket.

pymobiledevice3/remote/ contains no Server class and no asyncio.start_server call. Verified separately:

grep -rnE "start_server|asyncio\.start_server|AsyncServer" pymobiledevice3/
# (no output)

XpcWrapper.build — the reusable construct

pymobiledevice3/remote/remotexpc.py uses XpcWrapper.build in three places inside _do_handshake and _open_channel:

  • Line 165 — empty header-only sync frame on ROOT_CHANNEL (flags 0x0201, payload=None)
  • Line 181 — generic _open_channel helper that builds header-only frames with arbitrary flags
  • (Plus an implicit build call inside send_request for dict-bearing frames)

The matching _build_xpc_* primitives in xpc_message.py cover every XPC type we are likely to need:

Helper Handles Line
_build_xpc_dictionary dict 245
_build_xpc_array list 237
_build_xpc_string str 266
_build_xpc_bool bool 259
_build_xpc_data bytes 273
_build_xpc_double float 280
_build_xpc_uuid uuid.UUID 287
_build_xpc_null None 294
_build_xpc_uint64 XpcUInt64Type 301
_build_xpc_int64 XpcInt64Type 308
_build_xpc_object dispatch above by type 315

These compose into whatever payload shape Q3 needs to replay or synthesize on the server side.

pymobiledevice3 remote CLI subcommands

ls pymobiledevice3/cli/remote*
# pymobiledevice3/cli/remote.py

Just one file. Its subcommands are client-oriented:

  • tunneld — the TunneldRunner HTTP server (pymobiledevice3/tunneld/server.py). This is what we already run on havoc via scripts/iosmux-restore.sh.
  • rsd-info, developer, etc — client commands against a live iPhone.

No serve, mock-server, or replay subcommand exists.

Tests that open sockets

grep -rnE "socket\(|start_server" tests/
# tests/test_service_connection.py:31:    sock = socket.socket()
# tests/test_usbmux.py:7:    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Both of these are test-helper sockets that connect out, not bind. Not usable as server-side fixtures.

Implications for Q3

Verdict: Q3's dispatch table stays hand-crafted, but the hand-crafting is not deep work — each response we need to synthesize gets built via XpcWrapper.build({"size": N, "flags": F, "payload": _build_xpc_object(PY_DICT)}), where the PY_DICT is whatever Python shape we want to serialize. The hard work is deciding what to reply with, not how to encode it.

For the generic-handshake phase (the three frames captured in Q1), a minimal mirror looks like:

from pymobiledevice3.remote.xpc_message import XpcWrapper, XpcFlags

# Reply to ROOT_CHANNEL empty-dict request (mirror shape):
reply_root_empty = XpcWrapper.build({
    "size": 0, "flags": XpcFlags.ALWAYS_SET, "payload": None
})

# Reply to REPLY_CHANNEL INIT_HANDSHAKE (mirror flag):
reply_reply_init = XpcWrapper.build({
    "size": 0,
    "flags": XpcFlags.ALWAYS_SET | XpcFlags.INIT_HANDSHAKE,
    "payload": None,
})

These bytes can be fed into our spike listener's send path as the response-side dispatch for the already-captured handshake frames. CDS should then proceed to its first pair-specific request, which Q3 will characterize.

What is NOT here — the honest gap

There is no tested-on-real-iPhone reference for what the pair-flow specific responses look like. Beyond the generic handshake phase, Q3 must source each reply from either:

  • a live server capture (Q2 — plaintext on utun4 ideally), or
  • a post-TLS decryption (Q5 — if Q2 shows TLS), or
  • halt on a named gap if neither is feasible.

pymobiledevice3's client code does NOT encode what the server sends after the handshake (only what the server sends it, processed via parse, not the reverse). So while the codec is reusable, the protocol knowledge past the handshake still has to come from live observation.