Skip to content

feat: add iOS device and iOS Simulator drivers - #1148

Draft
kirkbrauer wants to merge 5 commits into
mainfrom
feat/ios-devices
Draft

kirkbrauer wants to merge 5 commits into
mainfrom
feat/ios-devices

Conversation

@kirkbrauer

@kirkbrauer kirkbrauer commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds two drivers that let lease holders use standard iOS tools (pymobiledevice3, go-ios, libimobiledevice, idb, Appium) against a leased iOS target:

  • jumpstarter-driver-iosdevice (physical iPhone/iPad): a device-scoped usbmux endpoint (j ios serve, j ios -- COMMAND, usbfluxd via j ios connect), configured raw port forwards, and verified HTTPS to an on-device HTTP service such as WebDriverAgent (WDA).
  • jumpstarter-driver-iossimulator (macOS exporter): one simulator per lease in a private device set, power control, an idb transport through an exporter-owned idb_companion, and an optional exporter-configured HTTPS provider. The included AppiumProvider exposes a policy-limited native WebDriver API.

Both share one client (IosDeviceClient): info, serve, connect, forward, https, run.

Why

Jumpstarter has Android coverage (ADB, emulator) but no way to lease iOS devices or simulators. These drivers expose the transports existing iOS tooling already speaks, rather than wrapping tool-specific actions.

Reviewer notes

  • Trust model (trust_mode: exporter, the default): the device pairing record never leaves the exporter. Clients get a disposable, lease-scoped pairing identity, and the exporter terminates lockdown and service TLS on both sides (trust.py). This protects the exporter's pairing identity, not device capabilities: a lease grants full device access, including the iOS 17.4+ CoreDevice tunnel, by design. forward_ports are conveniences, not an access boundary. passthrough mode shares the real pairing record and is opt-in.
  • usbmuxd lists a device once per link. USB transport matches only the USB entry, so Wi-Fi sync on a Mac exporter doesn't break discovery or connections.
  • Simulator lifecycle: power off keeps the lease's data. A failed first boot removes the partial simulator, and a failed restart only stops it. Each lease directory holds a flock for its owner's lifetime, so leases left by a crashed exporter are cleaned up at the next lease or session reset, and live leases are never touched.
  • AppiumProvider:
    • It needs Node.js 22: Appium's HTTPS server (spdy/http-deceiver) fails on Node.js 24 and 26 with No such module: http_parser, even though Appium declares support for them. The provider reports the detected Node.js version when startup fails.
    • It keeps standard W3C error codes, so client exception mapping and WebDriverWait work, while messages and stack traces stay exporter-side.
    • A refused session returns session not created and leaves the service usable; uncertain session outcomes require a power cycle.
  • The docs pages are symlinks to the package READMEs, like other drivers. Cross-page links use {doc} roles because MyST resolves relative .md links through the symlink's real path.

Stack

This PR is the top of a stack: #1144 → #1146 → #1148. Only the three iOS commits are new here.

Testing

  • Unit and integration tests: jumpstarter-driver-iosdevice 287 passed, jumpstarter-driver-iossimulator 203 passed. Both also pass on Linux (Python 3.14). Ruff is clean, and ty reports no findings in source files. The strict docs build (make docs, -W -n) passes.
  • Each commit keeps uv.lock consistent (uv lock --check).
  • End to end through a kind controller/router, from a Linux client container to a macOS exporter:
    • Physical iPad (iPadOS 26.2): pymobiledevice3 (usbmux, lockdown, apps, install/uninstall), usbfluxd with j ios connect, and Appium with Appium-Python-Client over ios.https() to WDA launched through the lease.
    • iOS 27 simulator: lifecycle, idb, and the AppiumProvider with Appium-Python-Client (20/20 common operations, a refused session, and the Node.js version error).
  • Crash recovery: an exporter killed with SIGKILL left a booted simulator and companion, and the next session removed both.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kirkbrauer
kirkbrauer changed the base branch from main to fix/forward-stream-transport-failure September 27, 2026 17:21
@kirkbrauer
kirkbrauer added this pull request to stack #1149 September 27, 2026 17:21
@kirkbrauer
kirkbrauer force-pushed the feat/ios-devices branch 2 times, most recently from b9ce161 to 0916f39 Compare September 27, 2026 18:17
@mangelajo
mangelajo force-pushed the feat/ios-devices branch 2 times, most recently from a446b9a to ab5cac9 Compare September 28, 2026 19:05
Base automatically changed from fix/forward-stream-transport-failure to main September 28, 2026 20:09
github-merge-queue Bot pushed a commit that referenced this pull request Sep 28, 2026
…1146)

Part of a stack: #1144 → **#1146** → #1148. This change does not depend
on #1144; it is ordered after it so the iOS drivers in #1148 build on
both.

## Issue

`forward_stream` pumps each direction independently. When the transport
under one direction fails, for example a lost router stream or a broken
write, only that pump stops. The other keeps waiting on a peer that will
never send, so the forwarded connection stays half-open and the client
hangs instead of seeing a disconnect it can recover from. This showed up
with long-lived tool connections (usbmux, lockdown) forwarded through `j
… serve` when the router stream dropped.

## Change

`copy_stream` now returns whether it ended at EOF or on a transport
failure. On failure, `forward_stream` cancels both pumps, closing both
peers. A clean EOF still half-closes only its own direction, so a
response can still follow a request's EOF. A failed half-close
(`send_eof()` raising `OSError`) also counts as a transport failure,
except `ENOTCONN`, which Darwin reports for a normal half-close (#444).

## Impact

- Applies to every forwarded stream: exporter session streams, the
port-forward and noVNC adapters, `connect_router_stream`, and resource
transfers.
- On a transport failure, code running inside `async with
forward_stream(...)` is now cancelled too, and the block then exits
normally because its task group absorbs the cancellation. In-tree
callers are unaffected: the exporter session only waits for the RPC to
end, the adapters' bodies are empty, and the resource helpers run the
caller's work in a separate thread through the blocking portal. Async
code that does real work inside the block, for example with
`resource_async`, should not treat reaching the end of the block as
success.

## Tests

- New `streams/common_test.py` (9 tests): router loss through a real
gRPC router closes the nested socket and a new stream still works, with
and without metrics and with a waiting body; a failed write closes the
other direction while its reader is idle; a response after a request's
half-close is preserved; parent cancellation closes both idle peers; a
failed EOF write closes the other direction, while `ENOTCONN` still lets
a response through.
- `jumpstarter` package suite: 1013 passed, 20 xpassed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
Add jumpstarter-driver-iosdevice, which exposes one leased iPhone or iPad
to standard iOS tools:

- A device-scoped usbmux endpoint for tools such as pymobiledevice3,
  go-ios and libimobiledevice (`j ios serve`, `j ios -- COMMAND`), and
  usbfluxd registration with `j ios connect`.
- Exporter-owned trust: the pairing record stays on the exporter. Clients
  get a disposable lease identity, and the exporter terminates lockdown
  and service TLS on both sides. Lease holders otherwise have full device
  access, including the iOS 17.4+ CoreDevice tunnel.
- Configured raw port forwards, and verified HTTPS to an on-device HTTP
  service such as WebDriverAgent for Appium.

USB transport matches only a device's USB entry, so Wi-Fi sync, which
makes usbmuxd list the device once per link, does not break it.

Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
…ider

Add jumpstarter-driver-iossimulator, which runs one simulator per lease
in a private device set on a macOS exporter:

- Power control: power off keeps the lease's data. A failed first boot
  removes the partial simulator, and a failed restart only stops it.
- An idb transport through an exporter-owned idb_companion.
- An optional HTTPS provider selected by exporter configuration. The
  included AppiumProvider exposes a policy-limited native WebDriver API
  that works with Appium-Python-Client. It keeps standard W3C error codes
  and needs Node.js 22, because Appium's HTTPS server fails on Node.js 24
  and later.
- Leases left behind by a crashed exporter are cleaned up by the next
  lease or session reset. Each lease directory holds a lock for its
  owner's lifetime, so live leases are never touched.

Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
Link the iOS device and iOS simulator pages to their package READMEs, as
for the other drivers, and list them in the driver index.

Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
The iOS drivers use UDID, Apple's Unique Device Identifier, and BUID,
usbmuxd's system identifier, throughout. typos flags them as misspellings
of UUID and BUILD.

Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
The detach-warning and Appium startup tests read log output through
caplog, which stayed empty on the Linux CI runner even though the
warnings were emitted. They now patch the logger that emits them and
assert the call directly.

Also clear ty's findings in the iOS tests: narrow driver children and
optional values instead of reaching through the base types, patch
methods instead of assigning to them, and raise AssertionError instead
of calling pytest.fail, whose type ty cannot resolve.

Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Kirk Brauer <kirkebrauer@gmail.com>
@raballew raballew added this to the 0.10.0 milestone Sep 29, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants