Skip to content

Releases: doronz88/pymobiledevice3

v11.3.1

Choose a tag to compare

@doronz88 doronz88 released this 02 Sep 05:36
e0dc322

Highlights

🐛 Fixed DeveloperDiskImage mounting on legacy devices

Mounting the classic Developer Disk Image on older devices (observed on iOS 12.5.7) failed with ConnectionResetError: Connection lost since v10.10.0: the mounted-image check had started consulting CopyDevices, which legacy devices answer and then hang up on, killing the connection the upload needed. The mounter now drops the hung-up connection and transparently reconnects, so mounter mount-developer and auto-mount work on these devices again (#1897).

pymobiledevice3 mounter auto-mount

What's Changed

Full Changelog: v11.3.0...v11.3.1

v11.3.0

Choose a tag to compare

@doronz88 doronz88 released this 31 Aug 08:18
ec4ac06

Highlights

✨ Reach a tunneld you have no route to

tunneld only ever reported tunnel addresses: GET / hands back something like
fdba:ae68:c15f::1:63727, and the client was expected to dial it. That works only when the tunnel
interface lives on the client's own host, so the moment tunneld runs somewhere else — in a docker
container while the client is on the host, or on a device-lab machine while the client is a CI
worker — the reported address is unreachable and the API is useless to that client.

The new WS /connect endpoint (#1893, @cyclomati) bridges into the tunnel through the HTTP API
instead, and pymobiledevice3 now speaks it itself, so every command works against a remote
tunneld with no extra tooling:

# on the machine with the device attached
sudo pymobiledevice3 remote tunneld --host 0.0.0.0

# anywhere that can reach it — IPv6 hosts go in brackets
pymobiledevice3 developer dvt ls / --tunnel 'UDID@lab-mac:49151'

Every connection the RSD opens — the RemoteXPC handshake and each service — is carried over its own
websocket. Library consumers get the same from get_tunneld_devices(('lab-mac', 49151)) and
get_tunneld_device_by_udid(...): bridging turns on automatically whenever the tunneld host is
non-loopback, and bridge=True forces it for addresses that only look local, such as an SSH
port-forward (ssh -L 49151:127.0.0.1:49151 lab-mac, reached on the CLI as --tunnel UDID@127.0.0.1).

✨ Front an entire device lab behind one tunneld

A tunneld could already federate others' listings, but the addresses in those listings are ULAs on
the upstream host's utun, reachable only from a client that already had L3 connectivity there — so
federation quietly presupposed a VPN or hand-rolled routes, and a device discovered through an
aggregator was a dead end.

Now a /connect for a device an instance does not serve itself is relayed to the upstream that owns
it, so clients need a route to one tunneld and nothing else:

# on every host with devices attached
sudo pymobiledevice3 remote tunneld --host 0.0.0.0

# on the one host clients can reach; monitors off so it federates rather than
# competing for devices attached here
sudo pymobiledevice3 remote tunneld --host 0.0.0.0 \
    --no-usb --no-wifi --no-usbmux --no-mobdev2 \
    --upstream http://lab-1:49151 --upstream http://lab-2:49151

# from anywhere: any device in the lab, addressed through the front tunneld
pymobiledevice3 developer dvt ls / --tunnel 'UDID@front:49151'

--upstream is repeatable and seeds the same set POST /upstream manages at runtime. Listing
entries now carry an originnull for devices the queried instance serves directly, otherwise
the URL of the hop that reported them — so a client that can reach that upstream may skip the
relay. An upstream's close codes are passed through unchanged, so a 4404/4502 names the
tunneld that actually refused rather than collapsing into a generic failure.

As with the rest of the tunneld HTTP API, /connect is unauthenticated, and an aggregator relays
into other hosts' devices — bind non-loopback addresses on trusted networks only.

💥 tunneld --uds has been removed

--uds only ever moved the HTTP control channel onto a unix socket, while the data plane — the RSD
dials to the reported tunnel-address:tunnel-port, and now the /connect bridge — always relied on
TCP, so it never delivered a pure-unix path. The option is gone, TunneldAddress narrows to
(host, port), and a --tunnel value with a non-numeric :suffix now fails with a message naming
the removal instead of being silently treated as a socket path. Use --host/--port and the
matching UDID:PORT form.

🐛 Federation no longer drops the listings it was meant to merge

Tunnelds that register each other (A → B → A) recursed on every GET /: the nesting spawned a
growing tree of in-flight requests against the bounded to_thread executor, and the innermost
fetches lost the race against their parents' 2s timeout. With two mutually registered instances,
each one's listing was missing the other's device — the exact thing federation exists to provide.

Federated requests now carry x-tunneld-hops-remaining (4 by default), decremented per hop and not
forwarded at zero, and a tunnel reachable through several paths is listed once instead of once per
path.

🐛 /connect addresses a tunnel, not just a device

A device can have more than one tunnel — a front tunneld that also monitors the same host holds
its own alongside the federated one. Keying on the UDID alone paired whichever tunnel was found
locally with the port the caller asked for, dialing an endpoint that exists nowhere:

4502 failed to connect to [fdc9:ec7b:4fdf::1]:63727: [Errno 61] Connect call failed

/connect now takes an optional ?address= naming the tunnel the caller means; a local tunnel is
used only when it matches, and otherwise the request falls through to the upstream serving that
exact tunnel. Older tunnelds ignore the parameter and keep their UDID-only behaviour.

🐛 Upstream URLs that could never work are rejected at registration

Federation reads an upstream address twice — requests fetches the listing from the URL, while the
relay dials the host and port parsed out of it — and the two only agreed on the exact spelling
http://HOST:PORT. A schemeless lab-1:49151 parses as scheme lab-1 with no host, so the listing
fetch threw InvalidSchema into a debug log and the relay refused: registering one did nothing at
all, silently. An https:// upstream fetched its listing over TLS but was relayed to in plaintext,
on port 80 when no port was given.

HOST, HOST:PORT and http://HOST[:PORT] (IPv6 in brackets) are now all accepted and stored
canonically with tunneld's default port filled in, and anything neither path could act on fails
loudly — a usage error from --upstream, a 400 from POST /upstream. https:// is rejected
outright rather than half-supported, since the relay speaks plaintext.

✨ XDG Base Directory Specification on Linux

Fresh Linux installations now place pymobiledevice3's home folder at $XDG_DATA_HOME/pymobiledevice3,
defaulting to ~/.local/share/pymobiledevice3 when the variable is unset or relative, as the spec
requires. An existing ~/.pymobiledevice3 is preferred and keeps being used, so current setups are
unaffected; macOS and Windows are unchanged, and WSL inherits the Linux behavior. Intermediate
directories created under sudo are chowned back to the invoking user. Fixes #1601.

What's Changed

New Contributors

Full Changelog: v11.2.4...v11.3.0

v11.2.4

Choose a tag to compare

@doronz88 doronz88 released this 30 Aug 18:41
a568e33

Highlights

🐛 A page with iframes no longer kills the DevTools session on iOS 26

iOS 26 announces a Target.targetCreated per subframe, with type "frame", and the CDP bridge adopted every announced target as the session's current one — so the session walked off its page target onto the last iframe and stayed there. Nothing moved it back: a frame target is neither committed nor destroyed while its page is fine.

What the frame target answered from then on depends on site isolation. An in-process frame replies Page domain already enabled and Missing node for given nodeId; a site-isolated frame runs FrameInspectorController, which has no Page/Network/Audit at all — and with site isolation off registers no agents whatsoever, so every request came back as -32601 'Runtime' domain was not found, for Runtime, Network, Page, CSS, Debugger and Audit alike, and the session was dead for the rest of its life. This is a distinct failure from a target that went away, which answers -32000 Missing target for given targetId and was already handled.

The session is now routed only to targets announced as pages — in create() as well, since a frame target can be the very first targetInfo a session sees at attach time. Adopted targets are tracked, so a subframe going away no longer fires a load event and a document update that reset panels the frontend had filled from the page.

Reproduced and verified on iOS 26.6.1 with Chrome's DevTools frontend attached to a Safari tab, on a page with cross-origin iframes.

pymobiledevice3 webinspector cdp
# then open http://127.0.0.1:9222/, and attach to a page that embeds cross-origin iframes

What's Changed

Full Changelog: v11.2.3...v11.2.4

v11.2.3

Choose a tag to compare

@doronz88 doronz88 released this 30 Aug 15:23
cac4d5f

Highlights

🐛 A navigation that swaps process no longer leaves DevTools with a black screen

A navigation that commits in a new process destroys the target the CDP bridge is talking to, and WebKit never answers what was in flight to it. That is exactly the moment Chrome's frontend asks for the resource tree and starts a screencast, so both were left without an answer: Page.getResourceTree raised a KeyError on the missing result and Page.startScreencast a TypeError out of its screen-size probe, and the frontend was handed a request it cannot act on. The screencast was the damaging one — unlike the resource tree the frontend never asks again, so the screen stayed black for the rest of the session — and the bridge additionally kept a screencast that never started, so closing the session then failed on it and left the target's queue-consumer tasks draining the events of every later session on that page.

Both requests are now re-asked of the target that took over. The events that commit the swap are applied by the receive loop, which is paused for the whole of every request wait, so the retry yields to let them through. wait_for_event_id also records the destroyed target as it abandons the wait, so a target that was merely swapped out is no longer misreported as "stopped responding" and backed off.

pymobiledevice3 webinspector cdp
# then open http://127.0.0.1:9222/, pick a page, and navigate it across a process swap

🐛 A JSContext target answers Page requests the way a Node target does

A JSContext debuggable implements no Page domain, and its global object carries no window — so neither the resource tree nor the screencast's size probe can ever succeed there. Both are now refused with a protocol error, which Chrome's frontends absorb exactly as they do from a real Node target, instead of raising out of the translation and leaving the frontend with no answer at all.

What's Changed

Full Changelog: v11.2.2...v11.2.3

v11.2.2

Choose a tag to compare

@doronz88 doronz88 released this 30 Aug 06:10
6ba6dfc

Highlights

🐛 The DevTools frontend is no longer fetched through an HTTP proxy

When the CDP bridge serves its DevTools frontend from a locally launched Chrome, it reaches that Chrome over loopback — and the fetch was going through whatever proxy happened to be configured. urllib applies proxy configuration to loopback as readily as to anything else: proxy_bypass_environment consults only no_proxy, and macOS' ExceptionsList/"exclude simple hostnames" cannot match 127.0.0.1, which is exactly the form the fallback hands back. With a proxy set, the bridge dialled it to reach a Chrome running on the same machine, every asset came back empty, and DevTools opened blank — while the landing page and the bridge's own WebSocket kept working, since browsers bypass loopback implicitly.

Anything addressed to this machine is now fetched through an opener with an empty ProxyHandler. The hosted build stays on the default opener, so a proxy genuinely needed to reach it is still used.

A failed frontend lookup is also no longer remembered: it used to be cached as an empty base and short-circuited on, so a single unlucky attempt served 404s — a permanently blank window — for the rest of the session.

pymobiledevice3 webinspector cdp
# then open http://127.0.0.1:9222/ and pick a page

🐛 Reattaching to Web Inspector no longer fails as "Web inspector is not enabled"

webinspectord admits a new session only about ten seconds after the previous one started — measured from the start, so closing early does not help. Until the gate lifts it accepts the connection but never completes the TLS handshake, and that is a hair longer than the handshake timeout. Restarting the CDP bridge, opening an automation session once inspection was done, or simply running the Web Inspector tests in sequence therefore failed, and because a disabled device also terminates the connection, the abort was reported as Web inspector is not enabled — pointing at a Settings toggle that was never off. The attempt that timed out has itself waited the gate out, so the connection is retried once.

🐛 A second run of the CDP bridge in one process no longer wedges

The bridge's per-page state lived for the whole process, but its asyncio primitives belong to the event loop that created them, and a page handler killed without unwinding leaves its lock held. Carried into a later run in the same process, every connection to that page waited out the full handover timeout for a lock nobody was left to release, then failed outright because the lock belonged to a loop that was gone. That state is now reset at startup, so each run begins from primitives of its own loop.

🐛 A page held by another debugger says so

A page already being debugged over a different Web Inspector connection — a second pymobiledevice3, Safari's own Web Inspector, or a client that exited without detaching — never reports an inspection target, and both attach paths blamed the device for it. The page listing has carried the holder's connection identifier all along; it is kept for web pages now, the way automation targets already keep it, and named in the error instead.

What's Changed

Full Changelog: v11.2.1...v11.2.2

v11.2.1

Choose a tag to compare

@doronz88 doronz88 released this 29 Aug 06:02
38fbd22

Highlights

🐛 webinspector cdp no longer comes up blank on a page that is already being inspected

Opening a debuggable that another DevTools window was still attached to produced an empty frontend: no console output, and every expression typed into it silently swallowed. WebKit serves a single inspector session per debuggable, so the bridge serializes sessions per page — but the page endpoint accepted the websocket before taking that lock and then waited on it forever. The frontend saw a perfectly good connection and simply never got an answer.

A DevTools tab left open in a background tab or another window holds its page for as long as it lives, so this was not a narrow race. JSContexts took the worst of it: the debuggable outlives every frontend that ever inspected it, and js_app.html is nothing but the console, so a blocked session showed up as a completely empty window.

A new connection now takes the page over, the way Safari's own Web Inspector does. The superseded frontend gets its websocket closed and shows a disconnect rather than going quietly dead, and a burst of connections arriving together is still served one after another instead of evicting each other.

pymobiledevice3 webinspector cdp
# then open http://127.0.0.1:9222/ and pick the same JSContext from a second window

What's Changed

Full Changelog: v11.2.0...v11.2.1

v11.2.0

Choose a tag to compare

@doronz88 doronz88 released this 28 Aug 14:20
7805eb9

Highlights

webinspector cdp can drive JSContext debuggables

Any process that made a bare JSContext inspectable — jsContext.isInspectable = true on iOS >= 16.4, or a development-signed build — is now listed alongside Safari's tabs and can be debugged from Chrome DevTools, the same way webinspector js-shell and Safari's own Develop menu have always offered it:

pymobiledevice3 webinspector cdp
# then open http://127.0.0.1:9222/ and pick "myapp (1234): JSContext"

Listing it was the easy half. Such a debuggable is JavaScriptCore's inspector, not WebKit's, and it differs in four ways that each had to be bridged:

  • It implements no Target domain. Nothing is announced on attach, and messages are exchanged un-multiplexed — replies carry a top-level id and arrive on the inspector's result table rather than wrapped in Target.dispatchMessageFromTarget.
  • Page identifiers are numbered per application, so every JSContext is page 1 and they all collided under one id. Targets are now addressed as <application>:<page>.
  • It announces no execution context, and Chrome's frontend refuses to evaluate anything until it knows one — the context picker read "Not selected" and the console prompt silently swallowed every line. One is synthesized on Runtime.enable.
  • Its console output is gated behind Console.enable, which Chrome's JavaScript-only frontend never sends (it expects V8's behaviour, where Runtime.enable is enough). The bridge sends it on the frontend's behalf.

JSContexts are advertised as Chrome's node target type and opened with js_app.html, the JavaScript-only DevTools frontend — Console, Sources and Memory, with no Elements or Network panel for a target that has no document behind it. They are titled after the process hosting them, since they all share the name JSContext and have no URL to tell them apart.

WebinspectorService.wir_events changed type as part of this — see Breaking below.

🐛 Concurrent debugger sessions no longer consume each other's events

webinspectord events carry no id, and every receive loop popped them from one shared list. With two sessions open on the same bridge — two JSContexts, or two Safari tabs — whichever loop woke first took the other's events: one client saw everything twice and the other went silent. Every forwarded message is tagged with the session it belongs to (WIRDestinationKey), so they are now queued per session.

🐛 The CDP target listing no longer accumulates dead pages, and refreshes itself

Three fixes to the landing page at http://127.0.0.1:9222/:

  • An application's listing is its complete set of pages, not a delta, but it was merged into the cached one without ever dropping what it no longer mentions. Every tab ever opened stayed listed for the rest of the session, as a dead target that could not be attached to.
  • The page was rendered once, so a tab opened or closed on the device needed a manual reload. It now keeps itself current, and pauses while it is not the visible tab.
  • Serving /json blocked for a fixed half second waiting for listings it had just requested — 503 ms of a 506 ms response. webinspectord pushes a fresh listing on its own whenever a page opens, closes or navigates, so that wait bought nothing; answering from the already-live state takes ~2 ms.

Titles and URLs reported by the device are also escaped now, instead of being interpolated into the landing page verbatim.

🐛 A refused backup reports the free space the device actually requires

DLMessagePurgeDiskSpace's CACHE_DELETE_AMOUNT is not the backup's space requirement — it overstates it by a flat 2 GiB, leaving users to reverse-engineer the real threshold from two runs (#1879). -[MBDriveBackupEngine _prepareFreeSpace] makes the figure recoverable, so it is derived and reported directly, and attached to the MBErrorDomain/105 refusal that follows — which now raises NotEnoughDiskSpaceError instead of surfacing as a raw dict. When the figures don't fit the model, the raw request is reported instead, so an untested iOS version degrades to the old message rather than printing a wrong number.

The error also explains why the requirement can exceed the device's own used space: a 256 GB iPhone asking for ~967 GB reads as a bug until you know the device counts hardlinked and cloned files at full size, and that upload_files really does land a device-side clone family as N full copies.

⚠️ Breaking

WebinspectorService.wir_events is now dict[str, list[Any]], keyed by session id, instead of a single list[Any]. Use WebinspectorService.session_events(session_id) to reach one session's queue. find_page_id() now takes an application-qualified identifier (make_target_id() builds one); a bare page identifier still resolves to the first application reporting it, exactly as before.

For clients of webinspector cdp, /json target ids changed shape from 1 to PID:440:1. CDP clients treat these as opaque, so Chrome DevTools, VS Code's js-debug and Puppeteer are unaffected.

What's Changed

Full Changelog: v11.1.6...v11.2.0

v11.1.6

Choose a tag to compare

@doronz88 doronz88 released this 27 Aug 23:29
a6bd794

Highlights

🐛 lockdown wifi-connections on and lockdown assistive-touch on take their argument again

Both commands have taken the state as a positional argument since they were added, and every place that documents them — including docs/guides/ios17-tunnels.md — shows it that way. The Typer migration in v7.0.0 translated

@click.argument("state", type=click.Choice(["on", "off"]))

into a plain function parameter, and Typer renders those as options, not arguments. So the documented invocation has been failing ever since:

$ pymobiledevice3 lockdown wifi-connections on
╭─ Error ──────────────────────────────────────────────────────────────────────╮
│ Got unexpected extra argument(s) (on)                                        │
╰──────────────────────────────────────────────────────────────────────────────╯

The argument had silently become --state on, which was never documented anywhere. Both commands are now annotated with typer.Argument(), restoring the original interface:

pymobiledevice3 lockdown wifi-connections on
pymobiledevice3 lockdown wifi-connections off
pymobiledevice3 lockdown wifi-connections        # no argument: query current state
pymobiledevice3 lockdown assistive-touch on
Usage: pymobiledevice3 lockdown wifi-connections [OPTIONS] [state]:<on|off>

Nothing is taken away — --state was an artifact of the migration, not a released interface. A parametrized test now asserts each command's --help renders state in the Arguments panel and exposes no --state option, so the panel a parameter lands in stays checked.

What's Changed

Full Changelog: v11.1.5...v11.1.6

v11.1.5

Choose a tag to compare

@doronz88 doronz88 released this 27 Aug 19:23
eb33d56

Highlights

🐛 A backup no longer dies on DLMessagePurgeDiskSpace

When iOS decides the backup destination is too small it asks the host to make room, via DLMessagePurgeDiskSpace. That is a request, not a failure — Apple's own host (_DLPurgeDiskSpaceOnComputer in DeviceLink.framework) forwards CACHE_DELETE_VOLUME / CACHE_DELETE_AMOUNT / CACHE_DELETE_URGENCY_LIMIT to CacheDeletePurgeSpaceWithInfo(), replies with a DLMessageStatusResponse, and keeps serving the connection whether the purge worked or not.

pymobiledevice3 raised NotEnoughDiskSpaceError without answering the device at all. That tore the link down and replaced the device's own diagnosis with a guess, discarding the one useful number in the message: how many bytes it wants freed.

The handler now mirrors Apple's purge-failed path (status -1, "DLPurgeDiskSpace failed to purge", zero bytes reclaimed), logs what was asked for, and lets the loop continue — so the device gets to say what is actually wrong:

['DLMessageProcessMessage', {'ErrorCode': 105,
  'ErrorDescription': 'Insufficient free disk space on drive to back up (MBErrorDomain/105)'}]

Measured on an iPhone18,4 running iOS 26.6.1 by reporting a controlled figure for DLMessageGetFreeDiskSpace, CACHE_DELETE_AMOUNT is required - reported against a per-device constant:

reported asked for sum
1,048,576 2,334,848,147 2,335,896,723
67,108,864 2,268,787,859 2,335,896,723
134,217,728 2,201,678,995 2,335,896,723

To be clear about the scope: this does not make a refused backup succeed. When the device wants more room than the destination has, that is the device's call. What changes is that you see its reason and the exact byte figure it is demanding, instead of Not enough disk space from the host.

The figure reported for DLMessageGetFreeDiskSpace is now logged at debug level too — it is the number the device compares against, and it was missing from every bug report on this so far:

pymobiledevice3 -vv backup2 backup --full /path/to/backups

What's Changed

Full Changelog: v11.1.4...v11.1.5

v11.1.4

Choose a tag to compare

@doronz88 doronz88 released this 27 Aug 17:14
5111a02

Highlights

🐛 A device the native tunnel can't find now raises DeviceNotFoundError

Asking the macOS native transport for a UDID no device answers to used to fail with an internal _RemotePairingError — a subclass of UserspaceTunnelUnavailableError, not of DeviceNotFoundError. Every other transport raises DeviceNotFoundError for the same situation, so code that caught it (yours, or the CLI's own --reconnect retry loop) simply missed the native case.

It is now both: DeviceNotFoundError for callers, and still a native-path failure internally, so the automatic native → userspace → tunneld routing is unchanged. The practical consequence for the CLI is that a device that vanished mid-session is treated as reconnectable on the native transport too, exactly as it already was over usbmux and tunneld.

from pymobiledevice3.exceptions import DeviceNotFoundError
from pymobiledevice3.remote.native_tunnel import NativeRemotedTunnel

try:
    async with NativeRemotedTunnel(serial=udid) as rsd:
        ...
except DeviceNotFoundError as e:
    print(e.udid, e)   # the target, and why the lookup failed

DeviceNotFoundError says which lookup failed

The exception carried only the UDID — str(exc) was empty, and every consumer had to re-render it. It now takes a message as well, while udid stays a dedicated member, and each raise site names the transport that came up empty:

Device not found: usbmux has no device matching udid <UDID> over USB
Device not found: tunneld (127.0.0.1:49151) serves no tunnel for udid <UDID>
Device not found: remotepairingd reported no device matching udid <UDID>

A raise site that passes only the UDID still yields the familiar Device not found: <UDID>, so existing callers are unaffected.

📚 Every environment variable in one place

PYMOBILEDEVICE3_DEFAULT_FALLBACK and its siblings were only mentioned in passing inside the tunnel guides, with nowhere to look them up. Environment variables now lists all of them — device selection, transport selection, the advanced/debug ones — plus the variables your shell or OS sets that change behaviour anyway (SUDO_USER, ALLUSERSPROFILE, ...).

# opt back out of the macOS native default for this shell
export PYMOBILEDEVICE3_DEFAULT_FALLBACK=userspace

What's Changed

Full Changelog: v11.1.3...v11.1.4