Skip to content

feat(remote): ios remote — minimal browser remote-control (WDA-free live screen) - #834

Open
danielpaulus wants to merge 11 commits into
mainfrom
feat/ios-remote
Open

feat(remote): ios remote — minimal browser remote-control (WDA-free live screen)#834
danielpaulus wants to merge 11 commits into
mainfrom
feat/ios-remote

Conversation

@danielpaulus

@danielpaulus danielpaulus commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What it does

Adds ios remote [--port=<port>] [--driver=<driver>] [--devicekit-url=<url>] [--wda-url=<url>] [options] — a tiny, self-contained browser remote-control for a device. It serves an inline HTML/CSS/JS page (no external/CDN assets) that mirrors the screen and drives basic input. It binds 0.0.0.0:<port> (default 8080) so it is reachable over Tailscale.

Usage:

ios remote --udid=<udid> --port=8080
# then open http://<host>:8080/ in a browser

The UI is an <img src="/screen"> filling the page, with a click→tap / drag→swipe handler, a text box for typing, and Home / Lock / Vol+ / Vol- buttons.

The split: WDA-free screen + WDA-free (DeviceKit) input

Two independent halves, and both are now WDA-free:

  • Live screen is WDA-FREE. GET /screen reuses the instruments screenshot service (ios/instruments) in a ~12 fps loop and streams JPEG frames as MJPEG (multipart/x-mixed-replace). This needs the tunnel + a mounted developer disk image, but no WebDriverAgent. A small in-package broadcaster fans the latest frame out to all clients and drops frames for slow consumers so a stalled browser can't back-pressure capture (no global state, unlike the existing StartMJPEGStreamingServer).
  • Input uses DeviceKit by default (WDA is broken on iOS 26; DeviceKit works). /tap, /swipe, /type, /button shell out to the same ios binary (os.Executable) and reuse the proven ios ui … commands, targeting the device udid and the selected driver: --driver=devicekit (default, --devicekit-url, default http://127.0.0.1:12004 or GO_IOS_DEVICEKIT_URL) or --driver=wda (--wda-url, default http://127.0.0.1:8100 or GO_IOS_WDA_URL). So the screen works with no driver running; input needs a reachable driver. Buttons (home/lock/volume) route through DeviceKit, which supports them.

Coordinate mapping is in one place (fractionToPoints): the browser sends each click as a 0..1 fraction of the displayed image, and the server maps it linearly to the driver's logical points using the size fetched once via ios ui size on the same driver (cached), never a hardcoded/WDA size. parseSize understands DeviceKit's device.info JSON-RPC envelope ({"result":{"screenSize":{width,height},"scale"}}) as well as the WDA window/size and bare shapes. On an iPhone SE 3rd gen DeviceKit reports 375×667 logical points (MJPEG frames are points × scale = 750×1334 px). Keeping the browser in fraction-space means it never has to know the device's pixel/point sizes or CSS letterboxing.

Runner supervision — ios remote self-heals input (NEW)

Input intermittently broke and stayed broken until a human restarted the runner. Root cause: the input path uses a separate long-running ios ui run devicekit process (JSON-RPC at http://127.0.0.1:12004). That runner's on-device XCTest/testmanagerd automation channel intermittently drops — DTX Connection with EOFconn2 closed unexpectedly error=EOF → the process exits rc=1 (ui run: runner failed: lost connection to testmanagerd). It is not memory/jetsam and not a deterministic load threshold; it's an iOS-side disconnect we can't prevent. Previously ios remote shelled out per action and did not own the runner, so once it died every action returned connection refused forever.

ios remote now supervises the DeviceKit runner (default, DeviceKit driver):

  • Spawns ios ui run devicekit --udid=<udid> as a child of the same binary (os.Executable), detecting readiness by polling the DeviceKit health endpoint with a ~60s startup timeout.
  • Monitors + auto-respawns: when the child dies it logs the exit (reason/rc) at WARN and respawns with capped exponential backoff (1s→10s), resetting the backoff after a runner stays up >60s — never spin-looping hot.
  • Lifecycle state (starting|ready|restarting|down) is exposed at GET /status (and /healthz) and surfaced in the UI ("input runner restarting…").
  • 503 during recovery: /tap /swipe /type /button return 503 {"error":"input runner starting, retry shortly","runnerState":"…"} while not ready, instead of failing with connection-refused.
  • Clean shutdown: SIGINT/SIGTERM terminates the child (own process group) so no orphaned ios ui run devicekit is left behind.

--no-manage-runner keeps the old behavior (connect to an externally-run runner at --devicekit-url); --runner-driver selects which driver's runner to supervise (only devicekit is spawnable). The screen stream's tunnel self-heal is unchanged. Rich golog lifecycle logging (module go-ios/remote, udid attr): spawned (pid), ready (elapsed), exited (reason/rc), restarting (attempt/backoff). The spawn/health seam is a small interface, so the supervisor state machine is unit-tested without a device (fake death → respawn, backoff reset, shutdown stops child, input 503 when not ready).

Tunnel-info self-refresh on reconnect (hardening)

The screen service used to cache the tunnel address for the process lifetime, so when the device's tunnel changed (CI run ends, replug, tunnel daemon restart) the stream went blank forever with dial … i/o timeout / "reconnect failed". On each screen-service reconnect we now re-fetch fresh tunnel/RSD info via a non-fatal resolver and dial that, mirroring how a fresh ios screenshot re-resolves each call — so the mirror self-recovers instead of dying.

Native iOS-26 touch — follow-up

DeviceKit already gives WDA-free input on iOS 26. A further follow-up is native in-process touch injection (e.g. via CoreDevice / a HID path) so ios remote needs no external UI driver at all.

Options considered

  • Screen via WDA MJPEG (ios ui stream mjpeg) — rejected: it needs a driver just to see the screen, defeating the "screen works standalone" goal. The instruments screenshot service is driver-free and already present.
  • Re-implement a driver HTTP client in the new package for input — rejected: shelling out to ios ui reuses battle-tested code paths (driver selection, session handling, env/URL resolution) instead of duplicating them; the cost is one subprocess per input event, which is negligible for a human-driven remote.
  • Reuse the global-state StartMJPEGStreamingServer/mjpegHandler — rejected: its sync.Map/global conversion-queue design can't be instanced cleanly; the small local broadcaster is instanceable and testable and passes JPEG through untouched.
  • Browser sends pixel coords + image dims — rejected in favor of pure fractions: fractions keep all device-size knowledge server-side in a single mapping function.

Tests

go build ./..., go vet ./..., gofmt -l, go test ./... are clean. Unit tests cover driver defaulting (empty → DeviceKit), the --devicekit-url/--wda-url flag selection, coordinate mapping against the driver's logical size (including DeviceKit's 375×667), and ui size parsing for the DeviceKit JSON-RPC envelope + DeviceKit screenSize + WDA envelope + bare + noisy output, plus that / serves the HTML UI with 200 and /button rejects unknown buttons.

🤖 Generated with Claude Code

Deployment status (office01, iOS 26.5, udid 00008110-001C58C00A88401E)

Deployed and live on the office01 CI Mac over Tailscale, driving input via DeviceKit (WDA-free), reachable at http://office01:8090/.

  • Screen mirror: WORKS. GET /screen streams live JPEG frames (~28 frames / 5 s captured, ~2.7 MB, valid JPEG SOI markers). Fully WDA-free.
  • Input via DeviceKit: WORKS. POST /tap at multiple coordinates, POST /type, and POST /button (lock) all return {"result":{"success":true}} and actually act on the iOS 26.5 device. The server logs driver: devicekit, driverURL: http://127.0.0.1:12004.
  • Swipe: blocked by a pre-existing DeviceKit ui swipe bug, not by ios remote. POST /swipe (and a plain ios ui swipe --driver=devicekit) returns Invalid parameters … data missing from DeviceKit's device.io.swipe RPC. Tap/type/button are unaffected; fixing the DeviceKit swipe param schema is separate from this PR.

This supersedes the earlier WDA-only status: WDA v13.1.3 on iOS 26.5 returned Unhandled endpoint for tap/homescreen/keys, so ios remote now defaults to DeviceKit.

Earlier robustness fixes remain in this PR: resolve tunnel/RSD info for ios remote (instruments needs it on iOS 17+); read --port through docopt's repeatable-list shape; send integer tap coordinates (ios ui tap rejects decimals); self-heal the screen stream on reconnect.

Usage:

ios remote --udid=<udid> --port=8090            # DeviceKit input (default)
ios remote --udid=<udid> --driver=wda           # opt back into WDA
# open http://<tailscale-host>:8090/ in a browser

danielpaulus and others added 11 commits August 12, 2026 09:41
…ive screen)

Add `ios remote [--port=<port>] [--wda-url=<url>]`, a small self-contained
HTTP server (inline HTML/CSS/JS, no CDN) that mirrors a device to the browser
and drives basic input.

Two independent halves:
- Live screen is WDA-FREE: it reuses the instruments screenshot service and
  streams JPEG frames as MJPEG (multipart/x-mixed-replace) at GET /screen.
  Needs the tunnel + a mounted developer disk image, no WebDriverAgent.
- Input is WDA-backed (go-ios has no native touch injection on main): the
  /tap, /swipe, /type and /button handlers shell out to the same `ios` binary
  (os.Executable) reusing the proven `ios ui …` commands against the device
  udid and --wda-url.

Coordinate mapping lives in one place: the browser sends a 0..1 fraction of the
displayed image; fractionToPoints maps it linearly to WDA logical points using
the window size fetched once via `ios ui size`.

Binds 0.0.0.0:<port> (default 8080) so it is reachable over Tailscale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ios remote` opens the instruments screenshot service, which on iOS 17+ needs
the device resolved with an RSD provider from the active tunnel. Add "remote"
to needsAutomaticTunnelInfo's allowlist so resolveDevice attaches tunnel info,
matching `screenshot`/`instruments`. Without it the screenshot service fails
with "InvalidService / needs an active tunnel".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The global usage declares `ios forward … [--port=<mapping>]…` as repeatable, so
docopt surfaces --port as a []string for every command and args.String("--port")
returns "". Add portArg to read either shape so `ios remote --port=<n>` binds the
requested port instead of silently falling back to the default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ui commands auto-detect and prefer DeviceKit; ios remote always drives WDA
(and passes --wda-url), so pin the driver explicitly to avoid a DeviceKit RPC
attempt on 127.0.0.1:12004.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ios ui tap/swipe` parse --x/--y as integers and reject decimals with
"--x is required". Round mapped points to the nearest integer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The instruments screenshot service can wedge (e.g. a takeScreenshot timeout
while a WDA XCUITest session contends for the DVT channel), and the loop used
to return permanently, leaving connected /screen clients with a dead stream.
Reconnect the screenshot service and keep streaming so the unattended server
recovers on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on reconnect

WDA is broken on iOS 26 ("Unhandled endpoint"); DeviceKit works. Make `ios
remote` drive input through DeviceKit by default, keeping WDA available behind
--driver=wda.

- Add --driver=<wda|devicekit> (default devicekit) and --devicekit-url (default
  http://127.0.0.1:12004 / GO_IOS_DEVICEKIT_URL). --wda-url still applies for the
  wda case. The tap/swipe/type/button handlers shell out to
  `ios ui <cmd> --driver=<driver> --{devicekit,wda}-url=<url> --udid=<udid>` with
  integer coordinates.
- Coordinate mapping uses the SAME driver's reported logical size: parseSize now
  understands DeviceKit's device.info JSON-RPC envelope
  ({"result":{"screenSize":{width,height},"scale"}}) alongside the WDA
  window/size and bare shapes, so browser click fractions map to the driver's
  logical points (e.g. 375x667 on iPhone SE) instead of a hardcoded/WDA size.
- Buttons (home/lock/volume) route through DeviceKit, which supports them.
- Self-heal the screen stream across tunnel changes: the screen service cached
  the tunnel address for the process lifetime, so a changed tunnel (CI run ends,
  replug, tunnel daemon restart) left the stream blank forever with dial i/o
  timeouts. On each reconnect we now RE-FETCH fresh tunnel/RSD info via a
  non-fatal resolver (mirroring how a fresh `ios screenshot` re-resolves each
  call) and dial that, so it recovers on its own.

Verified end-to-end on an iOS 26.5 device via DeviceKit: live MJPEG screen plus
tap/type/button all return {"result":{"success":true}}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DeviceKit's device.io.swipe RPC expects x1/y1/x2/y2, but ios ui swipe sent
fromX/fromY/toX/toY, so every DeviceKit swipe was rejected with "Invalid
parameters" ("data missing") — including swipes issued by `ios remote`.
Verified against the live DeviceKit runner on an iOS 26.5 device: x1/y1/x2/y2
returns {"result":{"success":true}}; all other key namings fail. The WDA
dragfromtoforduration path keeps its own fromX/toX schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ios remote` now owns the input runner instead of shelling out to a
separately-started `ios ui run devicekit`. Root cause of the intermittent
input breakage: the on-device XCTest/testmanagerd automation channel drops
(`DTX Connection with EOF` -> `conn2 closed unexpectedly EOF`), the runner
process exits rc=1, and every input action then returns connection-refused
until someone manually restarts it. It is not memory/jetsam and not a
deterministic load threshold, so input must self-heal via supervision.

With the DeviceKit driver (the default), `ios remote` now:
- spawns `ios ui run devicekit --udid=<udid>` as a child (same binary via
  os.Executable), detecting readiness by polling the DeviceKit health
  endpoint with a 60s startup timeout;
- monitors the child and, when it dies, logs the exit (reason/rc) at WARN
  and respawns it with capped exponential backoff (1s -> 10s), resetting the
  backoff once a runner stays up >60s, so it never spin-loops;
- tracks a lifecycle state (starting|ready|restarting|down) exposed at
  `/status` (and `/healthz`) and surfaced in the UI as "input runner
  restarting…";
- returns HTTP 503 `{"error":"input runner starting, retry shortly",
  "runnerState":"..."}` from /tap /swipe /type /button while not ready,
  instead of letting the shelled `ios ui` command fail with
  connection-refused;
- terminates the child cleanly on SIGINT/SIGTERM (own process group) so no
  orphaned `ios ui run devicekit` is left behind.

`--no-manage-runner` keeps the previous behavior (connect to an
externally-run runner at --devicekit-url); `--runner-driver` selects which
driver's runner to supervise (only devicekit is spawnable). The screen
stream's existing tunnel self-heal is unchanged.

The spawn/health seam is a small interface so the supervisor state machine
is unit-tested without a device: fake runner death triggers a respawn,
backoff resets after stability, shutdown stops the child, and input returns
503 when not ready.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `ios remote` screen came from the instruments ScreenshotService polled in
a loop (MJPEG of full JPEGs) — a redundant, heavy DTX channel, since the remote
already supervises a DeviceKit runner for input. That runner exposes efficient
hardware video, so use it for BOTH video and input and drop the instruments
screenshot path entirely.

Server:
- GET /video.h264 passthrough-proxies the runner's /h264 (H.264 Annex-B
  elementary stream, hardware-encoded, delta-compressed, ~KB/s), forcing
  Content-Type video/h264 and flushing bytes as they arrive.
- GET /screen now proxies the runner's /mjpeg (browser-native fallback),
  passing the runner's multipart content-type through.
- The proxy reconnects with small backoff and only dials while the supervised
  runner is ready, so the screen self-heals in lockstep with input.
- Removed the instruments ScreenshotService, its poll/transcode loop, and the
  tunnel-info-refresh-for-screenshots wiring (resolver). The supervisor that
  backs input is unchanged; it now backs the screen too.

Browser:
- Primary player decodes /video.h264 via WebCodecs VideoDecoder into a <canvas>:
  parses Annex-B NALs, caches SPS(7)/PPS(8), derives the avc1.PPCCLL codec
  string from the SPS, starts at the first IDR(5) key chunk, and reconnects the
  stream. Falls back automatically to <img src="/screen"> (MJPEG) when
  WebCodecs is unavailable or the decoder errors. Tap/swipe/type/buttons are
  unchanged (DeviceKit driver), mapped off whichever screen element is active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The browser player decoded /video.h264 but wasn't smooth: it fed one NAL
per EncodedVideoChunk and painted inside the decode-output callback, and the
runner served its low ~27fps default. Rework the client player and boost the
source.

Player (ios/remote/ui.go):
- Assemble ACCESS UNITS: group NALs into one chunk per picture, starting a new
  AU at each VCL slice (type 1/5); leading non-VCL NALs (SEI/AUD) ride with the
  following slice. SPS(7)/PPS(8) are cached, (re)inserted in-band into key AUs,
  and drive the decoder config (avc1.PPCCLL from the SPS, optimizeForLatency).
- Decouple decode from paint: keep only the freshest decoded VideoFrame in a
  1-slot holder and draw it in a requestAnimationFrame loop, closing any older
  undrawn frame. This is the core smoothness fix.
- Backpressure: drop delta AUs while decodeQueueSize > 2 until the next
  keyframe (low latency over completeness for a live mirror). Require a fresh
  keyframe after a stream reconnect.

Server (ios/remote/remote.go, cmd_remote.go, main.go):
- /video.h264 now proxies the runner's /h264?fps=<fps>&bitrate=<bitrate>.
  New `ios remote --fps` (default 60) / `--bitrate` (default 8000000) flags,
  with DefaultFPS/DefaultBitrate constants. Measured on-device: the runner's
  /h264 goes from ~27fps default to materially higher when asked.

HUD (toggle 'd'): shows MODE (h264/canvas vs mjpeg/img fallback), rendered fps
(rAF paints/sec), decoder state + decodeQueueSize, dropped-frame counts (late +
backpressure), and the last decoder error, so it's obvious at a glance whether
H.264 is live or it fell back and where any stall is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant