feat(remote): ios remote — minimal browser remote-control (WDA-free live screen) - #834
Open
danielpaulus wants to merge 11 commits into
Open
feat(remote): ios remote — minimal browser remote-control (WDA-free live screen)#834danielpaulus wants to merge 11 commits into
danielpaulus wants to merge 11 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 binds0.0.0.0:<port>(default8080) so it is reachable over Tailscale.Usage:
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:
GET /screenreuses 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 existingStartMJPEGStreamingServer)./tap,/swipe,/type,/buttonshell out to the sameiosbinary (os.Executable) and reuse the provenios ui …commands, targeting the device udid and the selected driver:--driver=devicekit(default,--devicekit-url, defaulthttp://127.0.0.1:12004orGO_IOS_DEVICEKIT_URL) or--driver=wda(--wda-url, defaulthttp://127.0.0.1:8100orGO_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 a0..1fraction of the displayed image, and the server maps it linearly to the driver's logical points using the size fetched once viaios ui sizeon the same driver (cached), never a hardcoded/WDA size.parseSizeunderstands DeviceKit'sdevice.infoJSON-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 remoteself-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 devicekitprocess (JSON-RPC athttp://127.0.0.1:12004). That runner's on-device XCTest/testmanagerd automation channel intermittently drops —DTX Connection with EOF→conn2 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. Previouslyios remoteshelled out per action and did not own the runner, so once it died every action returnedconnection refusedforever.ios remotenow supervises the DeviceKit runner (default, DeviceKit driver):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.starting|ready|restarting|down) is exposed atGET /status(and/healthz) and surfaced in the UI ("input runner restarting…")./tap /swipe /type /buttonreturn503 {"error":"input runner starting, retry shortly","runnerState":"…"}while not ready, instead of failing with connection-refused.ios ui run devicekitis left behind.--no-manage-runnerkeeps the old behavior (connect to an externally-run runner at--devicekit-url);--runner-driverselects which driver's runner to supervise (only devicekit is spawnable). The screen stream's tunnel self-heal is unchanged. Richgologlifecycle logging (modulego-ios/remote,udidattr): 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 freshios screenshotre-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 remoteneeds no external UI driver at all.Options considered
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.ios uireuses 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.StartMJPEGStreamingServer/mjpegHandler— rejected: itssync.Map/global conversion-queue design can't be instanced cleanly; the small local broadcaster is instanceable and testable and passes JPEG through untouched.Tests
go build ./...,go vet ./...,gofmt -l,go test ./...are clean. Unit tests cover driver defaulting (empty → DeviceKit), the--devicekit-url/--wda-urlflag selection, coordinate mapping against the driver's logical size (including DeviceKit's 375×667), andui sizeparsing for the DeviceKit JSON-RPC envelope + DeviceKitscreenSize+ WDA envelope + bare + noisy output, plus that/serves the HTML UI with 200 and/buttonrejects 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/.GET /screenstreams live JPEG frames (~28 frames / 5 s captured, ~2.7 MB, valid JPEG SOI markers). Fully WDA-free.POST /tapat multiple coordinates,POST /type, andPOST /button(lock) all return{"result":{"success":true}}and actually act on the iOS 26.5 device. The server logsdriver: devicekit,driverURL: http://127.0.0.1:12004.ui swipebug, not byios remote.POST /swipe(and a plainios ui swipe --driver=devicekit) returnsInvalid parameters … data missingfrom DeviceKit'sdevice.io.swipeRPC. 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 endpointfor tap/homescreen/keys, soios remotenow defaults to DeviceKit.Earlier robustness fixes remain in this PR: resolve tunnel/RSD info for
ios remote(instruments needs it on iOS 17+); read--portthrough docopt's repeatable-list shape; send integer tap coordinates (ios ui taprejects decimals); self-heal the screen stream on reconnect.Usage: