Skip to content

ios vnc - #3

Merged
jkelleyrtp merged 19 commits into
mainfrom
jk/ios-vnc
Jul 27, 2026
Merged

ios vnc#3
jkelleyrtp merged 19 commits into
mainfrom
jk/ios-vnc

Conversation

@jkelleyrtp

@jkelleyrtp jkelleyrtp commented Jul 27, 2026

Copy link
Copy Markdown
Member
  • Add live iOS Simulator framebuffer capture
  • Add VideoToolbox H.264 encoding for simulator capture
  • Add serve-sim: stream and inspect the iOS Simulator in a browser
  • Document simulator workflow and private framework pitfalls
  • Rotate the simulator via GSEvent; measure and reject a GPU frame copy
  • Add scroll, orientation, device settings, and a side-ribbon UI

Devin Review

Status Commit
⚪ Not started

Run Devin Review

Open in Devin Review (Staging)

Jonathan Kelley and others added 19 commits July 26, 2026 20:25
Replaces the need to shell out to `xcrun simctl io screenshot` for frame
acquisition by driving SimulatorKit's display pipeline directly.

Three things made this non-obvious:

- CoreSimulator vends its IO ports and display descriptors as ROCKRemoteProxy
  objects that implement their interface via forwarding, so objc2's msg_send!
  rejects them in debug builds. Messages now go through raw objc_msgSend
  helpers guarded by respondsToSelector:.

- ROCKit marshals block arguments across the proxy boundary by reading the
  block's ObjC type encoding, which requires BLOCK_HAS_SIGNATURE. block2 does
  not emit that flag yet, so the three screen callbacks are created by a small
  C shim where clang emits a conforming block.

- SimulatorKit recycles its framebuffer IOSurface in place, so frames are
  deep-copied into a pooled buffer before going downstream.

Also carries over the seed-based dirty check, the 200ms forced re-emit floor
(needed so idle screens still paint for late joiners), and the re-wire retry
that covers lazily created descriptors.

Verified against a booted iPhone 17: 1206x2622, ~5fps idle, ~68fps animating.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Encodes on the capture queue, since CVPixelBuffer and CMSampleBuffer are not
Send and only the compressed bytes need to travel. Emits either Annex-B (for
WebRTC) or AVCC plus a separate avcC record (for browser VideoDecoder) from
the same session.

Configured for interactive latency: low-latency rate control, no frame
reordering, MaxFrameDelayCount 0, Baseline profile for broad WebRTC codec
negotiation. SPS/PPS are prepended only to IDRs rather than every frame.

Verified against a booted iPhone 17: 211 access units over 5s at ~60fps,
all Annex-B framed, ~4 Mbps, keyframes on the configured 2s interval.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Adds `accessibility-cli serve-sim`, which serves a live, interactive view of a
booted simulator with accessibility element inspection.

accessibility-core gains a platform-agnostic `video` module: a VideoCapture
trait plus encoded-frame types. It is encoded-frame oriented rather than
surface oriented because every platform that can do this has a hardware
encoder next to its capture API, and passing raw surfaces across the boundary
would force a copy. Only the iOS Simulator implements it; other platforms
report it unsupported, matching how capture_screen already stubs.

accessibility-serve is a new crate holding the server so that webrtc, axum and
their transitive dependencies stay out of the library crates.

Notes on the design:

- Video is offered over WebRTC by default and as length-prefixed H.264 over a
  WebSocket for WebCodecs clients. One Annex-B encoder feeds both; the
  WebSocket path converts framing on the way out.
- Input and accessibility each get a dedicated thread. The simulator's
  Objective-C objects are not Sync and their calls block, and an accessibility
  tree fetch can take hundreds of milliseconds, which pointer events must not
  queue behind.
- Element picking uses the cached tree for hover feedback and confirms with a
  real objectAtPoint: hit test once the pointer settles. The cached tree
  mis-picks overlapping views; the simulator does not.
- Accessibility frames arrive in macOS screen points wherever the Simulator
  window happens to sit, so rects are normalized against the app's own bounds
  before leaving the server. That also makes them scale-independent.

The framebuffer wiring state moved into CaptureState so the idle timer can
actually rebuild the pipeline; previously it only counted attempts. This also
let the unsafe Send/Sync impls on SimFramebuffer go away, leaving the unsafety
localized to the raw pointer wrapper.

Verified against a booted iPhone 17 over both transports: video renders, taps
and the home button drive the device, and the inspector highlights elements
and reports a copyable CLI selector.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Records the things that cost real debugging time: CoreSimulator's proxied
objects breaking msg_send!, blocks needing BLOCK_HAS_SIGNATURE, the load-
bearing screen-callback registration, and the WebCodecs hardwareAcceleration
trap. Also notes that the cli_macos tests need a TCC-permitted GUI session,
so their failures are environmental rather than regressions.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Adds SimulatorHID::set_orientation. Orientation does not travel over Indigo
like touches do: it is a GSEvent delivered by mach message to the simulator's
PurpleWorkspacePort, the same path Simulator.app uses for Device > Rotate.
Requires Simulator.app to be running, since it publishes the port.

Also investigated replacing the per-frame framebuffer memcpy with a Metal
blit, since both surfaces are already IOSurface-backed. Benchmarked against a
real 1206x2622 surface with cache-cold sources:

    CPU memcpy   0.377 ms/copy   33.5 GB/s
    Metal blit   0.517 ms/copy   24.5 GB/s

The GPU path is 37% slower. Command buffer submission and the
waitUntilCompleted round trip cost more than the copy saves, because unified
memory already makes the CPU path fast. At 60fps the memcpy is ~23 ms/s, about
2% of one core, so it was never the bottleneck it looked like. Reverted the
Metal path and recorded the numbers so this is not retried blindly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Scroll. iOS has no scroll wheel, so wheel deltas are synthesized into a touch
drag. The awkward part is that a real finger runs out of screen, so the
virtual contact point is lifted and re-planted in the middle whenever it nears
an edge, and lifted entirely once the wheel goes quiet. The input worker moved
to a channel with a timeout so it can notice that quiet period.

Orientation. Rotation goes out as a GSEvent to PurpleWorkspacePort. The
framebuffer never rotates — the simulator draws rotated content into a
fixed portrait surface — so the browser rotates for display and un-rotates
pointer coordinates. Orientation is tracked server-side and seeded at startup
from the accessibility bounds aspect, so attaching to an already-rotated
device no longer renders sideways.

Device settings. Appearance, increase contrast and content size over
simctl ui. Those are the only three simctl implements; the rest of Xcode's
Devices window needs an in-simulator helper and is deliberately out of scope.

System edge gestures. Swipe-up-to-home never worked: the edge argument to
IndigoHIDMessageForMouseNSEvent was declared as a bool in the register the
ABI uses for the edge, so every gesture was flagged as edgeless. Fixed the
signature and plumbed the edge through, rotating it with the device.

Fixed an accessibility coordinate bug this surfaced. AX frames are in logical
space — iOS reports landscape bounds as 874x402 — so they were being rotated a
second time for display and hit tests were being sent in the wrong space.
Correct in portrait, wrong everywhere else. The two spaces are now documented
where they are used.

The UI is now a vertical ribbon beside the device rather than a toolbar
underneath, with the inspector and settings as drawers.

Also investigated replacing the per-frame memcpy with a Metal blit. It is 37%
slower (0.517 ms vs 0.377 ms on a cache-cold 12.1 MB surface) because the
command buffer round trip costs more than the copy saves on unified memory.
Reverted, with the numbers recorded.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
…solution

The stream was blocky and stuttery even on loopback. Both symptoms had the
same cause.

Bits per pixel was 0.0297 — roughly a quarter of what screen content needs.
The important part is that starving the encoder does not just soften the
picture: with low-latency rate control VideoToolbox drops frames to stay
inside its per-frame budget, which is AverageBitRate / ExpectedFrameRate
regardless of the rate actually being achieved. Measured during scrolling:

    6 Mbps    12.2 KB/frame   0.0315 bpp   33.9 fps
    24 Mbps   32.2 KB/frame   0.0835 bpp   60.3 fps

So the blockiness and the jank were one problem, not two.

Raising the bitrate is the wrong fix. A phone framebuffer is about fifteen
times the pixels the browser ever displays — 3.16 MP encoded for a 0.21 MP
preview — and every one of those pixels costs bits. The long edge is now
capped at 1280 by default and the bitrate derived from the encode resolution
at ~0.15 bpp instead of being a fixed number, which also makes it correct for
a watch or an iPad rather than tuned for one phone. Same stimulus, same
bandwidth:

    native 3.16 MP @ 6 Mbps   4.99 Mbps   0.0297 bpp   53.2 fps
    588x1280 @ derived        4.95 Mbps   0.1338 bpp   49.2 fps

Also fixes the keyframe interval, which counted frames only and so stretched a
"2 second" interval to twenty whenever the device idled at 5fps, and stops the
WebCodecs client painting a downscaled frame onto a full-resolution canvas.

Adds GET /api/stats, since none of the above was diagnosable without it, and
ruled out two suspects along the way: no keyframe storms and no subscribers
falling behind.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Most of the screen was unselectable. Three separate causes.

objectAtPoint: returns a platform element carrying its own translation, which
is not necessarily the one tokenized on the way in. Without the bridge
delegate token on that object, attribute reads do not fail — they quietly
return an empty label and a zero-size frame. The picker then drew a zero-size
highlight, which reads as "this element cannot be selected". get_tree already
did this and had a comment marking it IMPORTANT; the hit-test path did not.
Sampling 240 points across Settings: 62 returned degenerate elements before,
0 after, and status bar items became selectable for the first time.

Hit testing empty space resolves to a full-screen backdrop — every app has an
Application node and usually a full-bleed container group — and highlighting
one painted a box over the entire device. Backdrops are now filtered from both
the tree and the hit test, and empty space reports nothing rather than
everything.

The client's containment search also broke ties towards the outermost element,
so for the very common Button-wrapping-Label-wrapping-text case it selected the
wrapper instead of the control.

Not fixed, because it is not fixable here: Safari web content lives in a
separate WebContent process and is genuinely absent from the tree, which is
what made the original Safari test page look so broken. Noted in the backlog.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Measured rather than assumed, and the earlier note in the backlog was wrong.
Hit testing does reach web content: objectAtPoint: resolves links, buttons and
text inside a WKWebView. It is get_tree that is blind, returning only the host
app's own chrome. There is also no hierarchy to traverse — a hit-test result
has no children at any depth, and its parent is a web-area group that reports
zero children — so enumeration has to be sampled or come from elsewhere.

Hit tests cost 3.2 ms median, which is what makes a bounds-guided sweep a
realistic option at 0.2-0.5 s per page.

The same applies to any embedded web view, since Safari's content area is a
WKWebView like any other, so hybrid apps are drivable by coordinate and
invisible to every tree-based tool.

No implementation yet; the plan deliberately starts with a 30-minute
experiment to find out whether SimulatorBridge sees web content at all, since
that decides between two very differently sized pieces of work.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
…SimulatorBridge

Read idb rather than benchmarking it, which turned out to matter.

SimulatorBridge is dead. accessibilityElementsWithDisplayId: survives only in
idb's private headers and is called from nowhere; git log -S shows it added and
then removed by "Delete FBSimulatorBridge". All that remains is the output
format, kept for downstream compatibility. Building it would have
reimplemented something its own authors deleted.

idb's current path is AXPTranslator over
sendAccessibilityRequestAsync:completionQueue:completionHandler:, with exactly
the two request kinds we already use, so it would inherit the same blindness.

They solve web content by grid hit testing, and say so outright:
"Remote elements are in separate processes and require grid-based
hit-testing." That is first-party confirmation that no better API exists.
Their implementation is more careful than a naive sweep in ways worth copying:
a coverage grid filled during the ordinary traversal so probes skip explained
regions, pid-based identification of remote content, frame dedup, and
provenance recorded per element as recursive vs point_grid.

Worth noting that the experiment I proposed last time would have produced a
false negative: remoteContentOptions defaults to nil and is not plumbed through
the proto or the Python CLI, so idb ui describe-all shows no web content even
though idb can fetch it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Reading idb turned up a bug in our own code. IndigoHIDMessageForKeyboardArbitrary
takes USB HID usage codes, not HIToolbox virtual keycodes. Sending 0, 11, 8 —
our values for abc — into Safari's address bar types "he", which is exactly
what those mean as USB HID usages. Keyboard input has never worked; it has
been silently typing different letters. idb's own comment in
FBSimulatorIndigoHID.swift claims HIToolbox and is wrong, while their Python
layer uses USB HID correctly.

Also worth having: modifiers are held key events rather than a flag, which is
the fix for capitals and shifted symbols; Xcode 27 silently disables legacy
Indigo keyboard entirely when dtuhidd is active, and idb has built a whole XPC
transport in response; the main display is identified by displayClass 0 rather
than by picking the largest surface; VTPixelTransferSession can replace our
memcpy, the BGRA to NV12 conversion and the downscale in a single GPU pass;
and rate control can target quality instead of a bitrate, which avoids the
per-frame budget that was making the encoder drop frames.

One place we are ahead: idb has no screen-edge parameter, so it cannot flag a
touch as a system edge gesture.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Keyboard input had never worked. IndigoHIDMessageForKeyboardArbitrary takes
USB HID usage codes, but the client sent HIToolbox virtual keycodes, so every
keystroke produced a different letter — sending our codes for "abc" typed
"he". There was also no modifier support, so capitals and shifted symbols
were impossible and were dropped or silently lowercased.

Text now goes to the server as text and is expanded there, so the US-ASCII
table lives in one tested place instead of being duplicated in JavaScript.
Shift is a held key event around the base usage, because the Indigo message
has no shift flag. Unmappable characters fail the whole string rather than
typing a subtly wrong one, and paste is wired up as the practical way to enter
anything long.

Verified on device: "Test@Example.com?" now types verbatim. It previously
became "testexample.com".

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
The keymap in accessibility-serve owns the character-to-key policy and its own
usage constants; a second unused copy in the sys crate was just another thing
to keep in sync. The important fact — that these are USB HID usages and not
HIToolbox keycodes — now lives on send_key_with_modifiers where it is needed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Web content lives in a separate process and has no traversable hierarchy, so a
page shows up as nothing but Safari's own chrome. It is reachable by point
though, which is the opening: mark every element the tree walk found on a
coverage grid, then hit test the cells left over.

Following idb's design, which solves the same problem the same way: skip cells
already explained, dedup by frame because a coarse grid lands on a large
element repeatedly, cap the probe count, and record how each element was found
so a point sample is never mistaken for a node with real parentage and order.

Coverage is reported whether or not a scan runs, and is a useful signal on its
own — a full web page reporting 4% is a much better description of the problem
than an empty element list.

On a Safari page: 5 elements at 4.1% coverage becomes 15 at 49.5%, using 114
probes in 0.47s, and every element of the page is found. Opt-in via
?scan=true, and used by the inspector so hover works over web content.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
…by class

Two changes to the capture path, plus one thing tried and rejected.

VTPixelTransferSession now does the copy off the recycled live surface, the
BGRA to NV12 conversion, and the downscale in a single hardware pass into a
pooled buffer. That removes the row-wise memcpy and also removes the implicit
conversion VTCompressionSession was doing internally. Measured on the same
stimulus: 47-55 fps against 49 before, at an unchanged 0.13 bits per pixel and
about 6% of a core.

This does not contradict the earlier result that a Metal blit was slower than
memcpy — that was a bare copy doing one job, against a transfer doing three.

Main display selection now reads displayClass from the descriptor's state and
takes class 0, falling back to largest live area. A booted iPhone exposes two
framebuffer descriptors, classes 0 and 1, so the old heuristic was relying on
the main display also happening to be the biggest.

Constant-quality rate control was implemented and then removed: with
EnableLowLatencyRateControl the Quality property is ignored outright, measured
at 0.0221 bpp for quality 1.0 versus 0.0223 for quality 0.4. Shipping a knob
that silently does nothing is the same mistake as --fps. Low latency matters
more here than constant quality, so bitrate stays.

Also fixes a self-deadlock the stats change introduced: two lock() calls in
one struct expression, where the first guard outlives the second acquisition.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Constant quality was removed last commit because the knob did nothing. It did
nothing because EnableLowLatencyRateControl makes VideoToolbox ignore the
Quality property — not because quality control is unavailable. Turning the
low-latency constraint off makes it work:

    low-latency on    quality 0.4 -> 0.0223 bpp,  quality 1.0 -> 0.0221 bpp
    low-latency off   quality 0.3 -> 0.70 Mbps,   quality 0.9 -> 11.82 Mbps

So the two settings are one decision, and Tuning models it as one:

  Interactive { bitrate } — low-latency rate control, no frame delay, a
  bitrate derived from the encode resolution. Anything with a live viewer.

  Recording { quality } — no low-latency constraint, so the quality target is
  honoured and bits go where the picture needs them.

Pairing them this way makes the combination that silently does nothing
impossible to ask for, which was the actual hazard. Frames stay in decode
order in both tunings: B-frames would help recording quality, but WebRTC's
payloader and the raw stream framing both assume output order matches input
order, so that is left for when there is a real recording path to feed.

Interactive remains the default and is unchanged at 0.136 bpp and ~56fps.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
Recording runs a second, independent encode of the same frames rather than
retuning the streaming encoder. That separation is what makes B-frames usable:
the live path cannot reorder frames because WebRTC's payloader and the raw
stream framing both assume the encoder emits them in submission order, but a
file has no such constraint and reordering is where most of the quality per
bit comes from. It also lets a recording keep its own resolution and quality
regardless of what the viewer happens to be watching.

AVAssetWriter does the encoding and the muxing, fed pixel buffers through an
adaptor rather than sample buffers we encoded ourselves. That avoids ordering
decode and presentation timestamps by hand, which is exactly the part B-frames
complicate. Timestamps are wall-clock elapsed, so the simulator's very
variable paint rate plays back at the speed it actually happened.

Verified: 521 frames at 882x1920, High profile, with a genuine mix of frame
types — IBBBPBBBPBBBPBBBPBBBPBPBPPPP... All I and P would have meant frame
reordering silently failed to take. The live stream is unaffected while
recording, 54.3fps against 51.7 idle, for about 10.5% of a core in total.
Stopping twice is a clean 409 rather than a panic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
AGENTS.md had grown to 299 lines, most of it private-framework detail that
only matters when working on one particular area. It was read in full for
every task regardless, and the parts that mattered were buried among the parts
that did not.

The specifics now live in .devin/skills, loaded when relevant:

  ios-simulator-internals      proxies, block signatures, framebuffer port
                               discovery, IOSurface lifetime
  ios-simulator-input          coordinate spaces, USB HID keycodes, edge
                               gestures, orientation, device settings
  ios-simulator-video          bits per pixel, rate control, pixel transfer,
                               recording, browser decode
  ios-simulator-accessibility  bridge tokens, backdrops, scope differences,
                               reaching web content

Each groups notes that share a failure mode, which is the useful axis here:
the internals ones fail silently at the proxy boundary, the input ones deliver
an event that does the wrong thing, the accessibility ones return emptiness
that reads as absence. Stating that theme up front is most of the value.

AGENTS.md keeps what every task needs — how to build, test and run — plus a
table pointing at the skills. Verified no content was lost by diffing the
distinctive tokens of the old file against the new set.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <166158716+staging-devin-ai-integration[bot]@users.noreply.github.com>
@jkelleyrtp
jkelleyrtp merged commit 476b8bf into main Jul 27, 2026
12 of 14 checks passed
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