063: Device boxes: one iOS Simulator or Android emulator per box, in a frame in the panel, shared with the agent
Status: accepted, 26 October 2026, with the owner’s answers; revised the same day after two independent reviews, answered finding by finding under Review answers. Unit 2.4’s measurements of each Android model and encoder (E6, E7, E17), and unit 3.1’s of the Android template (E2, E13, E19, E24), and unit 3.2’s of the iOS image (E1, E13, E20, E24, and its slimming), and unit 4.2’s of the iOS driver (E4, E5), and units 5.1’s shared parts and 5.2’s of input on iOS (E8, E9, E14, E21, and how iOS pastes), and unit 6.3’s of the iOS controls (E11, and each read-back), and unit 6.1’s of the frame (turns, folds drawn from the table, and the island), and unit 6.4’s of the toolbar, and unit 4.1’s of the Android driver (the stream of each model, recovery, E3 and E23), and unit 7.1’s of computer use on iOS, and unit 7.4’s of recording a device (E15), and units 5.1’s and 6.2’s of input and controls on Android (E8, E10, E11, E14, E21, E22, each read-back, and each device’s own Android ID), and units 7.2’s and 7.3’s of computer use on Android (E12, and how the tree server gives way to instrumentation tests), and unit 7.5’s of spares and copying from a device (E16, E24, and a suspend that did not restore), are in 063 results. Built on the research in iOS Simulator boxes and Android emulator boxes. Extends 060: a device’s screen is one more video display in the one viewer. 059: box_record records a device. 027: a kind can say it has a device. 038 and 045: control covers the device, through a restart of the plugin. 032: a spare holds a booted device. 049 and 054: device images are built, checked and refreshed as other images are.
Context
The owner wants a thread’s phone or tablet in the panel, as Xcode’s Device Hub and Android Studio’s running devices show one: the device in its frame, with a narrow bar of controls beside it. A person and the agent share it as they share a desktop. The agent builds, installs and tests on it, reads its screen as elements, and records what it did.
Two research notes measured how. What decides this note:
| iOS Simulator | Android emulator | |
|---|---|---|
| Host | Tart macOS VM, paravirtual GPU, hardware H.264 | Proxmox QEMU VM with nested KVM, drawn by the CPU (SwiftShader) |
| Screen source | the framebuffer, through idb_companion 1.7.7 | the emulator’s gRPC streamScreenshot |
| Tap to encoded frame, in the box | 64 ms median, 78 ms worst (Tart) | 58 ms median, 67 ms worst, with x264 in the box |
| Frames a second, busy | 58 (Tart), 60 (pool Mac) | 32 encoded; drawing peaks at about 35 on 7 of 8 cores |
| Cost of the stream | 1% of a core idle, 10% busy, 57 MiB | x264: 20 ms a frame, about 0.65 of a core at 32 fps |
| Input | HID through idb, held open | gRPC touches and keys, held open |
| Text with no US key | paste: simctl pbcopy, then Cmd+V |
paste: gRPC setClipboard, then Paste |
| Tree for the agent | idb describe-all, 18 ms |
uiautomator dump, 1.9 s |
| Screenshot | idb, 22 ms | gRPC, 16 ms raw, 80 ms PNG |
| Boot | 1.3 to 1.9 s once booted in that VM; 14 to 68 s first boot | 2.2 to 3.9 s from its snapshot; 16 to 18 s cold |
| Memory per booted device | about 1 to 1.5 GiB | 3.4 GB after boot, about 5 GB after use |
| Foldables | none in Xcode 27.0 | Pixel Fold: fold, unfold and posture by command |
Every Android number above is the Pixel 8, at 1080x2400. The Pixel Fold open and the Pixel Tablet have about 1.6 times its pixels, and drawing and encoding both grow with pixels, so their frame rate and cost are not known yet. E17 measures them before the Android viewer is built.
Through BB’s server, both should show a tap in about 80 to 90 ms at the median: the box’s own 54 to 64 ms, and BB’s 25 ms (060). That is an estimate, the sum of medians from different tests, not a measured distribution. The iOS figure also took a frame’s size as the sign of a change, not its decoded pixels. E14 measures it in the browser, on decoded pixels, with its tail.
The lead made nine decisions before this note: two kinds, ios and
android; one device per box, switched in place; idb_companion for iOS and
a server of bb-box’s own for Android, both into 060’s viewer; Android on
the CPU with one AVD per profile, each with a snapshot, and Google APIs
images only; text other than ASCII as a paste; the agent’s device input
held back while a person has control, and its commands not; frames of
bb-box’s own drawing; spares that keep a booted device; and Tart nested
virtualisation, Namespace and pool Macs later. After the reviews the lead
also set defaults for the note’s open questions; they are written in, and
listed under Defaults set by the lead. Where
this note changes the brief, it says so under
Changes to the brief.
Decisions at a glance
Each decision rests on a measurement, or names the experiment (E1 to E24, under Experiments) that settles it in a named unit.
| Decision | Settled by | |
|---|---|---|
| 1 | Two kinds, ios and android: a host VM with one booted device. An ios box keeps the host’s macOS desktop, for the person only; an android box has no host desktop in v1 |
iOS: the simulator runs headless in a Tart VM. Android: it runs headless in a QEMU VM with KVM, and not in LXC, Docker or Tart Linux |
| 2 | One data model for both platforms, in which a state the device cannot be in cannot be written: posture only on foldables, no fold on iOS, a runtime on every offered device, relative and absolute rotation apart | the two research sketches, merged, and both reviews |
| 3 | One device server per box, bb-box-device, in TypeScript on the box’s Node, with a driver per platform |
Node is in both images already; idb and the emulator both speak gRPC, which Node’s http2 reaches. Unit 2.2 proves the gRPC codec against both |
| 4 | It speaks 060’s Selkies subset for video and input, and bb-box’s own BB_DEVICE messages for the rest, over a unix socket reached by connect over SSH, as bb-box-stream |
bb-box-stream does so on Macs and Windows today (060) |
| 5 | Every request has an ID, every switch a written intent, and every restart a rule: the operations and recovery contract | both reviews |
| 6 | The server’s code is delivered by version, only forward, only by the plugin instance that holds the box’s control connection, and never while a person has control or a recording runs | 045’s reload of two instances |
| 7 | A viewer picks a display by its ID, not its protocol; a device display has no VNC and falls back to stills where video cannot play | today’s routing picks the first Selkies display |
| 8 | Android frames are encoded by a small C helper, on x264 where the operator’s image built it and on OpenH264 otherwise, which makes a key frame on demand; frames reach it by shared memory if E6 shows it pays | x264 took 20 ms a frame at 1080x2400; raw frames are 7.8 MB |
| 9 | Nothing of the device listens off the box: idb on a unix socket; the emulator’s gRPC with a token, and a firewall of bb-box’s own that the emulator cannot start without | the emulator’s gRPC listens on every interface with no token by default |
| 10 | The panel draws the device in a frame of bb-box’s own, from numbers in a model table, with a narrow toolbar and a More menu | Device Hub and Android Studio; what each platform can do, measured |
| 11 | The computer tools read and drive the device only: its tree, its framebuffer, its touches | Cua saw one element on Android and nothing of the device on iOS |
| 12 | A new tool, box_device, has one action per control; screenshots and URLs stay with the computer tools |
the control tables in both notes |
| 13 | While a person holds control, the agent’s device input is refused at once, and input already sent is stopped; reading, new installs and commands go on | 038 for desktops, and the notes’ finding that adb input cannot be seen |
| 14 | Text other than ASCII is pasted in batches, with the device’s clipboard saved and put back, and iOS’s Allow Paste answered by the server | both reviews; the notes’ paste measurements |
| 15 | box_record records the device server’s own stream, framed, on a monotonic timeline, with captions in a band beside the device |
both servers already encode the device’s pixels; adb screenrecord stops at 3 minutes |
| 16 | A spare waits as a suspended or hibernated VM with its device booted. Spares are off by default: an operator sets a count (the owner, 28 October 2026) | E3 and E4 check the device survives; E24 measures the disk |
The data shape
The types are in one file, src/core/device.ts, read by the plugin, the
panel and the device server alike, and the model table is beside it in
src/core/device-models.ts. Names below are the ones the code will use.
Two rules hold throughout:
- A state the device cannot be in has no way to be written. Posture is
a field only of a foldable’s state; a foldable is only an Android model;
a flat model has exactly one screen, a foldable exactly two. Code that
reads a model or a state never checks for a combination the types allow
but the device cannot have. A file of compile-time cases that must fail
to type-check (
test/device-types.ts, with@ts-expect-error) keeps it so. - Data from outside is checked where it comes in. The box record, the image record, the in-box state and intent files, and every wire message are parsed by one validator each, which gives a typed value or a refusal in words. A model name is resolved against the table there, so nothing past the boundary holds a name that is not a model.
Device models
A model is one device a box can boot. bb-box’s own table holds every model it can offer, with the numbers its frame is drawn from.
type DevicePlatform = "ios" | "android";type Orientation = "upright" | "turned-left" | "upside-down" | "turned-right";type Posture = "closed" | "half-open" | "open";
interface Screen { readonly pixels: { readonly width: number; readonly height: number }; // upright readonly scale: number; // pixels to points (iOS) or to dp (Android: density / 160) readonly cornerRadius: number; // pixels readonly cutout: Cutout | null; // drawn by the frame on iOS; null where the device draws its own}
interface Cutout { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly shape: "pill" | "circle" }
interface Body { readonly bezel: number; // pixels of black glass round the screen readonly radius: number; readonly buttons: readonly { readonly edge: "left" | "right" | "top"; readonly from: number; readonly to: number }[]; // fractions of the edge}
interface FlatModel { readonly name: string; // "iPhone 18 Pro": what box_acquire's device and box_device's switch take readonly form: "phone" | "tablet"; readonly screen: Screen; readonly body: Body;}
interface FoldableModel { readonly name: string; readonly form: "foldable"; readonly outer: Screen; // shows when closed readonly inner: Screen; // shows half open and open readonly hinge: "vertical" | "horizontal"; readonly postures: readonly [Posture, ...Posture[]]; readonly body: Body;}
type IosModel = FlatModel & { readonly platform: "ios" };type AndroidModel = (FlatModel | FoldableModel) & { readonly platform: "android" };type DeviceModel = IosModel | AndroidModel;A model in the table has no runtime: the same Pixel 8 can run Android 15 or 16. A model as an image offers it does. The image record lists each with the versions it was built and checked with, so an image with another runtime is told apart, and box_status can say what the agent tests on:
interface OfferedDevice { readonly model: string; // a DeviceModel's name, resolved against the table when read readonly native: string; // a simulator device type ID, or an AVD's name readonly runtime: Runtime;}
type Runtime = | { readonly platform: "ios"; readonly os: string; readonly build: string; readonly xcode: string; readonly companion: string } // "27.0", "24A434", "27A266a", "1.7.7" | { readonly platform: "android"; readonly api: number; readonly image: "google_apis"; readonly revision: number; readonly abi: "x86_64"; readonly emulator: string; readonly snapshot: SnapshotOrigin | null }; // 35, 9, "37.2.12.0"
interface SnapshotOrigin { readonly emulator: string; readonly cpu: string; readonly made: string } // what a snapshot is tied toThe first catalogue is what the notes measured, with what each adds to its image:
| Model | Platform | Form | Screen, upright | Measured | Adds to the image |
|---|---|---|---|---|---|
| iPhone 18 Pro (default) | ios | phone | 1206x2622 at 3x | stream, input, controls, boots | about 0.15 GB: its data after a first boot (133 MiB measured) |
| iPhone 17 | ios | phone | from its device type | first boot, 27.0 s | about 0.15 GB, to measure (E24) |
| iPad Pro 13-inch (M5) | ios | tablet | 2064x2752 | first boot, 67.7 s; hardware encoder at its size | about 0.15 GB, to measure (E24) |
| Pixel 8 (default) | android | phone | 1080x2400 at 420 dpi | stream, input, controls, boots | about 4.5 GB: 2.0 GB data, 2.5 GB snapshot memory |
| Pixel Fold | android | foldable | outer 1080x2092; inner 2208x1840 | fold, posture, boot 17.1 s; stream not yet (E17) | about 4.5 GB, to measure (E24) |
| Pixel Tablet | android | tablet | 2560x1600 | boot 17.4 s; stream not yet (E17) | about 4.5 GB, to measure (E24) |
Each image’s own cost is under Images. Each further model is a row in the table and a line in the image’s build. The image’s check fails when a model’s pixels differ from the table’s, so a frame never fits the wrong screen.
A device box’s state
interface Geometry { readonly generation: number; // up by one at each change of screen, orientation or video size, and at each switch readonly physical: Orientation; // how the device is held: what rotate sets readonly content: Orientation; // how the app in front draws, read from the device readonly video: { readonly width: number; readonly height: number }; // the stream's pixels now}
type FoldGeometry = Geometry & { readonly screen: "outer" | "inner" };
type FailureCode = "boot-failed" | "device-exited" | "companion-failed" | "emulator-failed" | "firewall-missing" | "incompatible";
type Power = | { readonly is: "booting"; readonly since: number } | { readonly is: "booted"; readonly awake: boolean } | { readonly is: "switching"; readonly to: string; readonly step: SwitchStep; readonly since: number } | { readonly is: "stopped" } | { readonly is: "failed"; readonly error: { readonly code: FailureCode; readonly words: string }; readonly since: number };
type Observed<T> = | { readonly known: true; readonly value: T; readonly at: number; readonly by: "bb-box" | "outside" } | { readonly known: false; readonly why: string };
interface Override<T> { readonly set: T | null; readonly at: number | null } // what bb-box last set; null: the device's own
type DeviceState = | { readonly platform: "ios"; readonly model: string; readonly power: Power; readonly geometry: Geometry | null; readonly settings: IosSettings; readonly at: number } | { readonly platform: "android"; readonly form: "phone" | "tablet"; readonly model: string; readonly power: Power; readonly geometry: Geometry | null; readonly settings: AndroidSettings; readonly at: number } | { readonly platform: "android"; readonly form: "foldable"; readonly model: string; readonly power: Power; readonly geometry: FoldGeometry | null; readonly posture: Observed<Posture>; readonly settings: AndroidSettings; readonly at: number };
interface IosSettings { readonly appearance: Observed<"light" | "dark">; readonly textSize: Observed<IosContentSize>; // "large" is iOS's default readonly location: Override<LatLon>; // simctl cannot read it back readonly statusBar: Override<IosStatusBar>;}
type IosStatusBar = { readonly clean: boolean; readonly battery: { readonly level: number; readonly charging: boolean } | null };
interface AndroidSettings { readonly appearance: Observed<"light" | "dark">; readonly fontScale: Observed<AndroidFontScale>; // 1 is Android's default readonly location: Observed<LatLon | null>; // gRPC getGps reads it back readonly battery: Observed<{ readonly level: number; readonly charging: boolean }>; // the device's battery API sees it readonly network: Observed<NetworkPreset>; readonly statusBar: Override<"clean">;}
type NetworkPreset = "online" | "offline" | "slow";interface LatLon { readonly latitude: number; readonly longitude: number }type IosContentSize = "extra-small" | "small" | "medium" | "large" | "extra-large" | "extra-extra-large" | "extra-extra-extra-large" | "accessibility-medium" | "accessibility-large" | "accessibility-extra-large" | "accessibility-extra-extra-large" | "accessibility-extra-extra-extra-large";type AndroidFontScale = 0.85 | 1 | 1.15 | 1.3 | 1.5 | 1.8 | 2;What each shape rules out:
- Posture is a field of the foldable arm only, and the foldable arm is Android only. A phone cannot carry one, and iOS cannot fold.
- The screen a foldable shows is in its geometry: the outer when closed, the inner half open and open. A flat device’s geometry has no screen to name.
- An error exists only on a failed device, and a failed device always has one. A booted device says whether it is awake, so a locked Android phone, which sends no frames, is shown as asleep, not frozen.
- Network is an Android setting only. iOS has no network control in v1, and its state has no field that would claim one.
- On iOS, battery is part of the status bar override, as
simctl status_bar overridemakes it. Setting the status bar live clears the battery too, and the state shows both cleared. The answer says so: “The status bar is live again, so the battery shows the Mac’s.” - What bb-box set and what the device shows are apart. A setting the
device can report is
Observed, with who changed it last:outsidewhen it differs from what bb-box last set, as afteradb shell settingsinbox_run. A setting the device cannot report is anOverride: what bb-box set, and nothing more is claimed. - Text size keeps each platform’s own steps. iOS’s content size categories and Android’s font scale do not scale text alike, so the state and the answers name the platform’s own step, such as “accessibility-large” or 1.3. The toolbar’s larger, smaller and default move between them on either.
Rotation has two meanings, kept apart. physical is how the device is
held, which bb-box sets. content is how the app in front draws, which the
device decides: on a phone turned sideways, Android’s launcher stays
upright. The frame turns the picture by content. The emulator’s picture
turns with the device as it is held, whatever the app draws, so the Android
driver reports content as physical, and what the app draws is in the
picture (unit 4.1). Both are counted from the screen’s own upright, not named
portrait or landscape: the Pixel Tablet’s upright is landscape, and a
foldable’s inner screen is wider than tall. People and agents see words:
“upright”, “turned left”, “upside down”, “turned right”.
Where each part lives, with one writer each:
| Data | Lives in | Written by | Read by |
|---|---|---|---|
| The model table | code, src/core/device-models.ts |
bb-box | everything |
The devices an image offers, as OfferedDevices |
the image record’s devices: /usr/share/bb-box/image.json on Linux, /usr/local/share/bb-box/image.json on macOS; and the backend’s own copy of the record |
the image build | the backend’s kinds(), box_list, acquire, the doctor |
| What the box should run, and any switch under way | the server’s intent file, ~/.bb-box/device/intent.json in the box |
the device server, written whole and renamed into place | the device server at start |
DeviceState |
the server’s state file, ~/.bb-box/device/state.json; the plugin keeps the last it heard in memory |
the device server | the plugin, every viewer |
| A box’s platform, model and runtime, for lists | the box record’s new device field: a copy |
the plugin, from each BB_DEVICE_HELLO and BB_DEVICE_STATE |
box_list, box_status, spares, the panel’s lists |
| How the tab shows it: fit, frame on or off | the tab’s local storage, per box, as the stream choice is | the viewer | the viewer |
The device server is the one writer of what a box runs. The box record’s
device is a copy, never a second truth: when the two differ, the server’s
wins, and the plugin updates the record. An acquire or a switch asks the
server, and the record changes only once the server answers.
The server reads back what can change behind it: geometry at once, from each frame’s size and the device’s reported orientation; the rest when a client connects, after each control, and every 10 s while a viewer watches. How long a read takes is measured in units 6.2 and 6.3; the notes timed setting each value, not reading it.
Controls: one union
type DeviceControl = | { readonly action: "home" } | { readonly action: "back" } | { readonly action: "recents" } | { readonly action: "lock" } | { readonly action: "wake" } | { readonly action: "rotate"; readonly turn: "left" | "right" } // a quarter turn from where it is | { readonly action: "rotate"; readonly to: Orientation } // to one orientation | { readonly action: "posture"; readonly to: Posture } | { readonly action: "appearance"; readonly to: "light" | "dark" } | { readonly action: "text_size"; readonly to: "larger" | "smaller" | "default" | IosContentSize | AndroidFontScale } | { readonly action: "location"; readonly to: LatLon | null } | { readonly action: "status_bar"; readonly to: "clean" | "live" } | { readonly action: "battery"; readonly to: { readonly level: number; readonly charging: boolean } | null } | { readonly action: "network"; readonly to: NetworkPreset } | { readonly action: "install"; readonly paths: readonly [string, ...string[]] } | { readonly action: "switch"; readonly device: string } | { readonly action: "reboot" };A control is checked against the box’s model before it is sent, by one
function, supports(model, control), which reads the model’s arms: posture
on a flat model, back on iOS and a font scale on iOS are refused there,
with one set of words. The toolbar, box_device and the server all call
it. What each platform does for each, from the notes’ control tables:
| Action | iOS | Android |
|---|---|---|
| home | idb HOME button: 315 ms by its CLI, seen | gRPC GoHome: 2 ms, seen |
| back | refused: iOS has no back button | gRPC GoBack: 1 ms, seen |
| recents | refused: no command found, only Device Hub’s menu | gRPC AppSwitch, seen |
| lock | idb LOCK: 359 ms, seen | KEYCODE_SLEEP, until Android reads asleep: 0.2 s |
| wake | idb HOME, twice if the lock screen shows | KEYCODE_WAKEUP, until Android reads awake: 0.2 s; the image has no lock screen |
| rotate | idb ui rotate: 198 to 364 ms; the stream turns to 2622x1206 |
gRPC setPhysicalModel ROTATION in one step, to the new picture: 61 to 125 ms |
| posture | refused: Xcode 27.0 has no foldable | gRPC setPosture, until cmd device_state reads it: 0.2 to 0.6 s; closed, half open, open. Tent and flipped are refused on the Pixel Fold, so not offered |
| appearance | simctl ui appearance: 140 ms, seen |
cmd uimode night: 25 ms, seen |
| text_size | simctl ui content_size: 239 ms, seen |
settings put system font_scale: 10 ms, seen |
| location | simctl location set, clear: 121 to 139 ms, accepted |
gRPC setGps, read back by getGps: 0.2 s. An emulator always has a location, so clearing sets the emulator’s own |
| status_bar | simctl status_bar override, clear: 178 ms, seen |
SystemUI’s demo mode (E10): 9:41, full signal, the battery as bb-box set it; 0.2 s, seen, and set again when the device starts |
| battery | the status bar’s picture only, through status_bar override; the answer says the device’s battery API is unchanged |
gRPC setBattery, read back by getBattery: 0.1 s, seen in Settings |
| network | refused in v1 | the presets below, checked by an app’s own requests (E22) |
| install | simctl install: not timed |
adb install -r, or install-multiple for split APKs, then sync so a crash keeps the app (E23): 0.2 to 0.3 s for a 2 MB APK |
| switch | see Switching | see Switching |
| reboot | simctl reboot: 5.3 s |
stop and boot cold: 16 to 18 s |
Android’s network presets, each complete, so one call always lands in the same state:
| Preset | Airplane mode | Wi-Fi, data | Speed | Delay |
|---|---|---|---|---|
| online | off | on | full | none |
| offline | on | off | full | none |
| slow | off | on | 473.6 kbit/s each way, as edge |
100 ms more on the way out |
Setting a preset sets every column, so slow then online leaves no
delay behind. E22 checks that an app’s own request fails offline and is
slow on slow, not only that the settings read back. It found that the
emulator console’s network speed and network delay do not reach an
app’s requests over Wi-Fi, and the emulator’s mobile data never connects,
so slow shapes Wi-Fi inside the device, with netem on wlan0 and,
for what comes in, on ifb0.
Volume, Siri, the Action button, shake, erase, snapshots and audio are left
out of v1. idb sends no volume or Action button, shake was not looked at,
the device’s sound is not streamed, and the rest are rare enough for
box_run.
Input and coordinates
One coordinate space for everything: the video’s pixels as the viewer
shows them now. The viewer’s pointer, the agent’s taps, a tree’s frames and
a screenshot’s pixels all use it, and each carries the geometry
generation it was made in.
- The server owns the transform. From
physical,content, the active screen and the video’s size, it turns a point into the device’s own: points at the device’s scale for idb, pixels in the emulator’s own rotation for gRPC. Which space each takes after a rotation is E8: on Android, the AVD’s own panel, upright, whatever way the device is held and whatever the app draws, and on the Pixel Fold the inner screen’s panel, stretched onto the outer screen while it is closed. - A point from another generation is refused, with
stale-geometry, never mapped. An agent’s screenshot, element references and coordinates from before a rotation, a fold or a switch are void after it. The tool says so: “The device turned since that screenshot. Take another.” - Input waits for the new picture. From a control that changes
geometry until the first frame of the new generation, the server holds
the person’s touches and refuses the agent’s with
stale-geometry. A gesture under way when the geometry changes is ended where it is.
type DeviceInput = | { readonly type: "tap"; readonly at: Point } | { readonly type: "long_press"; readonly at: Point; readonly ms: number } | { readonly type: "swipe"; readonly from: Point; readonly to: Point; readonly ms: number } | { readonly type: "pinch"; readonly centre: Point; readonly from: number; readonly to: number; readonly ms: number } | { readonly type: "key"; readonly key: string; readonly modifiers: readonly ("shift" | "control" | "alt" | "meta")[] } // W3C key names | { readonly type: "text"; readonly text: string }; // ASCII as keys; the rest pasted in batches
interface Point { readonly x: number; readonly y: number; readonly generation: number }
interface DeviceElement { readonly role: string; // button, text field, cell, image, static text... readonly identifier: string | null; // Android's resource ID; iOS's accessibility identifier readonly label: string; readonly value: string | null; readonly frame: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }; readonly enabled: boolean; readonly focused: boolean; readonly can: { readonly tap: boolean; readonly edit: boolean; readonly scroll: boolean } | null; // null where the platform does not say readonly children: readonly DeviceElement[];}Agents name keys one way, by W3C key name. The drivers map them: to HID usages on iOS and to the emulator’s key names on Android. No tool’s description names HID.
Kinds and offers
ios |
android |
|
|---|---|---|
| Label | iOS Simulator | Android emulator |
| Host | macOS on arm64, a Tart VM | Linux on x86_64, a Proxmox QEMU VM where the host gives KVM (057) |
| Image | bb-box-ios, built on the Tart macOS image |
a Proxmox QEMU template of its own, built on the Linux VM template (057), with no desktop |
| Commands | SSH, sh, as the box user with sudo | SSH, sh, as box with sudo |
| Displays | device, the device’s video; desktop, the host’s, for the person |
device only |
| Computer use | the device, through the device server | the device, through the device server |
| Enrols | yes | yes, with Docker as other Linux boxes (041) |
| Sizes | the macOS presets, medium by default, 6 cores and 16 GiB, the size every iOS measurement was made at, with room for Xcode beside the device; a device takes 1 to 1.5 GiB and half a core idle | 8 cores and 16 GiB by default, as measured for the phone; refused below 4 cores and 10 GiB. E17 may raise both for the tablet and the foldable |
KindCapabilitiesgainsdevice: the platform and theOfferedDevices of an offer’s image, or null.KindInfogains the platform, which the panel names the kind by.computerUsestays Cua’s and is null for both.DisplayCapabilitygainsshows: "desktop" | "device". A device display hasresizable: falseand no RFB partner.- An offer has a model when its image’s record lists it. An acquire with a
devicetries only offers that have it, asfeaturesnarrow offers (041). An acquire withon(044) orimage(049) is checked against that offer’s or that image’s record, not the standard offer’s. - A project image (049) may build on
iosorandroid. It adds tools, such as a JDK for Gradle; it does not change the devices, which come from the standard image beneath it. Its check runs the device check too. - An ios box takes one of a Mac’s two macOS VM slots while it runs, as any macOS box does. A Mac that offers both kinds shares its slots between them.
- Android on a Mac (arm64 images under Tart’s nested virtualisation) will be
a kind of its own,
android-arm64, as 040 gives arm64 Linux kinds of their own, since offers of a kind agree on their architecture.
The host’s desktop
- iOS. The person may open the host’s desktop from the top bar, for
Xcode. The agent’s computer tools never drive it; its work on the host
is commands. The Desktop view carries a line: “Do not open Device Hub
here. It takes the device’s clipboard, so pasting stops working. The
device is in the Device view.” The server also watches for Device Hub
(
com.apple.dt.Devices) every 5 s. While it runs, the device’s state has a warning the panel shows, with Quit Device Hub. E20 checks whether Xcode’s Run opens Device Hub, and whether a preference in the image stops it. - Android. No host desktop in v1. The box is the Linux VM template’s, with no X server, Selkies or Cua, so the emulator has the cores. A host desktop could come later as a feature, once measured beside a busy emulator.
The device server
One program, a driver per platform
bb-box-device is TypeScript run by the box’s own Node: Homebrew’s Node 24
in the Tart image, and the pinned Node in Linux images. It has no npm
dependencies. It speaks gRPC with Node’s http2 and a small protobuf codec
for the messages it uses, tested against the field numbers of the pinned
.proto files: idb’s, and the emulator’s emulator_controller.proto.
Unit 2.2 proves the codec against both before any driver is built on it.
Why not Swift or Go: Node is in both images now; the server shares
src/core/device.ts with the plugin, so the protocol cannot drift between
them; and the plugin can deliver it, as below, with no image rebuild for a
change.
Inside it, one interface, one implementation per platform, and one for tests:
interface DeviceDriver { attach(target: OfferedDevice, signal: AbortSignal): Promise<"reattached" | "booted">; // reattach to a running device, or boot it shutdown(signal: AbortSignal): Promise<void>; video(): VideoSource; touch(contacts: readonly Contact[]): Promise<void>; // every contact now down, in the device's own space; empty lifts all key(key: string, down: boolean): Promise<void>; setClipboard(text: string): Promise<void>; clipboard(): Promise<string>; control(control: DeviceControl, signal: AbortSignal): Promise<void>; screenshot(): Promise<Uint8Array>; // PNG at the stream's pixels tree(): Promise<readonly DeviceElement[]>; readBack(): Promise<Partial<DeviceState>>;}
interface VideoSource { frames(): AsyncIterable<EncodedFrame>; // whole H.264 access units, only when the screen changes keyframe(): void; // the next frame is an IDR with SPS and PPS, even on a still screen}
interface EncodedFrame { readonly data: Uint8Array; readonly key: boolean; readonly generation: number; readonly monotonicMs: number }| iOS driver | Android driver | Test driver | |
|---|---|---|---|
| Device | simctl boot, shutdown, bootstatus; reattaches to a booted device of the target’s UDID |
the emulator in its own user service, bb-box-emulator@<avd>, -no-window -gpu swiftshader_indirect -grpc 8554 -grpc-use-token; adb emu kill saves the snapshot; reattaches to a running emulator found by a discovery file whose process is alive; boots cold after an end that saved no snapshot (E23) |
none |
| Frames | idb_companion’s video_stream, H.264, encoded by VideoToolbox in the box |
gRPC streamScreenshot RGB888 at the device’s size, given width and height both; encoded by bb-box-h264 |
a test card, drawn and encoded in the server’s own process |
| Key frame on demand | idb’s, if E5 shows it gives one on a still screen; otherwise the stream is restarted, which gave its first frame in 61 ms on a warm companion | the helper encodes the last raw frame again as an IDR | encodes the card again |
| Touch, keys | idb’s HID stream, opened when a person takes control or the agent sends its first input, as the first tap after the companion starts took 578 ms | gRPC sendTouch, sendKey, held open |
recorded, for tests |
| Clipboard | simctl pbcopy with LANG=en_US.UTF-8, simctl pbpaste |
gRPC setClipboard, getClipboard |
recorded |
| Tree | idb describe-all, 18 ms |
bb-box’s tree server in the device, run by app_process with UiAutomation, 2 to 4 ms a read; uiautomator dump, 1.9 s, when the server cannot run (units 7.2, 7.3) |
a fixed tree |
| Screenshot | idb, 22 ms | gRPC getScreenshot PNG, 80 ms |
the test card |
The test driver lets the shared path, the plugin, the relay and the viewer
be built and checked on any Linux box or Mac with no emulator or simulator.
It is offered as a model, “Test device”, on linux, linux-desktop-cpu and
macos boxes when the advanced setting test-device is on, which is off by
default. The conformance suite (009) turns it on, so every backend’s
device path is checked; the suite’s device cases run on real device kinds
too wherever an offer has a device, as below.
bb-box-h264 is about 200 lines of C, built in the Android image against
Ubuntu’s libx264 where the operator’s build has it, and against OpenH264,
with Cisco’s binary as Windows uses it, where it has not. It reads frames, and a one-byte command to make the next
frame a key frame, and writes each access unit with its length first. An
H.264 byte stream from the ffmpeg command line has no frame boundaries,
which held each frame until the next began: 100 to 300 ms on a still screen
in the Android note’s measurement. x264 runs as the note measured it:
ultrafast, zerolatency, 8 Mbit/s, 20 ms a frame at 1080x2400 on one
core. Every key frame carries SPS and PPS. Frames reach it by the
emulator’s shared-memory transport (MMAP), which skips copying 7.8 MB a
frame through Node, if E6 shows that it pays; otherwise through a pipe.
Its licence is settled in the owner’s answers.
The stream
- Bounded at every step. Between the emulator and the encoder there is one slot: a raw frame not yet taken is replaced by a newer one, so at most two raw frames are held, about 16 MB for the phone and 23 MB for the tablet, whatever the encoder’s speed. Raw traffic at 32 fps is about 250 MB/s, so nothing queues it.
- Each viewer is paced by the relay, by acknowledged frames, as desktops are (056). A viewer that falls behind skips to the next key frame, which the server makes on request; other viewers are not held back.
- The recorder takes every encoded frame through a queue of at most 2 s. If it overflows, its segment ends and a new one starts at the next key frame, and the clip notes the gap.
- A joining viewer, a returning hidden tab and a decoder reset each ask
for a key frame (
REQUEST_KEYFRAME), and get one within a frame’s time, still screen or not. A change of size starts a new generation, whose first frame is a key frame at the new size. - The encoder or idb’s stream dies: the server starts it again and sends a key frame. Viewers see one stall, under 1 s, checked by killing each in units 4.1 and 4.2.
Where it listens and how bb-box reaches it
As bb-box-stream does (060):
- It listens on a unix socket,
~/.bb-box/device/device.sock, in a folder only the box user can open. It has no TCP port. One server runs per box: it holds a lock (flockon~/.bb-box/device/lock), and removes a socket file left behind only once it holds the lock. - bb-box reaches it with
bb-box-device connectover the box user’s SSH, thedevicedisplay’s command route.connectjoins its stdin and stdout to the socket, starting the service first if it is not running. If the service manager has given up on it,connectsays so in words, which the panel shows. - A connection carries an HTTP upgrade, then WebSocket frames. The path
says the client’s role, as Selkies’ does:
/device?role=viewerfor a viewer’s video,role=inputfor the box’s one input connection, which asks for no video, androle=controlfor the plugin’s own requests. - So the Selkies relay (056) carries a device’s video and a person’s input as it carries a desktop’s: pacing by acknowledged frames, key frames on request, and input only from the viewer with control.
- One control connection at a time, which holds the box’s lease. A
plugin instance opens one per box on first use, and closes it after 10
idle minutes, when the box stops, and when it is released. When a second
instance connects, as during a reload (045), the later-started instance
takes the lease, and the server tells the other
supersededand closes its connection. Only the holder of the lease may deliver code, set control’s owner, or start a recording.
Messages
The Selkies subset, as bb-box-stream speaks it:
| From | Message | Meaning |
|---|---|---|
| viewer | SETTINGS, START_VIDEO, STOP_VIDEO, REQUEST_KEYFRAME, CLIENT_FRAME_ACK,<frame> |
video, as for desktops |
| input | m,<x>,<y>,<buttons>,0 |
a person’s mouse: button 1 down is a touch at that point, moves are its moves, up ends it. Button 3 is a long press. With Alt held, a drag is a pinch: two touches mirrored about the screen’s centre, and Alt itself never reaches the device |
| input | m2,... |
the wheel and a trackpad’s scroll: a swipe from the pointer of a twelfth of the screen per notch, notches within 100 ms joined into one swipe |
| input | kd,<keysym>, ku,<keysym>, kr |
physical keys down and up, and all keys up. A keysym with no US key joins the text batch |
| input | cw,<base64> and the chunked forms |
taken by no device: since version 29 the server drops them, as a viewer’s clipboard goes only with a paste (F-15) |
| server | MODE websockets |
as Selkies |
| server | binary type 4 | an H.264 access unit, with its frame number, and since version 9 the low 16 bits of its geometry generation in the header’s last two bytes, so the viewer holds a turned picture until the first frame of the new generation |
| server | clipboard,<base64> |
the device’s clipboard, to the input connection only, which passes it to the viewer with control |
bb-box’s own, each a verb, then one JSON value, as BB_CURSOR is a verb of
bb-box-stream’s:
| From | Message | Meaning |
|---|---|---|
| server | BB_DEVICE_HELLO {protocol, version, platform, devices, state, lease} |
first, on every connection: the protocol and the server’s version, the box’s devices and the state |
| server | BB_DEVICE_STATE {state} |
each change of DeviceState, to every connection |
| server | BB_DEVICE_TOUCH {contacts, by} |
each touch, by the agent or a person, to viewers: those not in control draw it |
| server | BB_DEVICE_STILL {png, generation} |
a still of the screen, to a viewer that asked for stills instead of video |
| input | BB_DEVICE {id, type: "touch", contacts} |
a person’s touches with more than one finger, from a touch screen |
| input | BB_DEVICE {id, type: "text", text} |
committed text from the person: an input method’s composition, a phone’s keyboard, or Type text |
| input | BB_DEVICE {id, type: "paste", text} |
the person’s paste, since version 29: Cmd+V or Ctrl+V, or Paste from this computer, pasted whole with the device’s clipboard put back (F-15) |
| control, input | BB_DEVICE {id, type: "control", control} |
a DeviceControl |
| control | BB_DEVICE {id, type: "input", input, owner} |
a DeviceInput from the agent, with the owner generation it was sent under |
| control | BB_DEVICE {id, type: "owner", generation, holder} |
control’s owner changed: agent or person |
| control | BB_DEVICE {id, type: "screenshot"}, {type: "tree"} |
reads |
| control | BB_DEVICE {id, type: "record", to: "start" or "stop"} |
box_record |
| control | BB_DEVICE {id, type: "result"} |
the answer to an earlier request, asked for again after a lost connection |
| server | BB_DEVICE_ANSWER {id, ok: true, state, ms, ...} or {id, ok: false, error: {code, words}} |
the answer to one request; a screenshot comes as base64 PNG, a tree as DeviceElements |
Error codes: unsupported (this platform or model has no such control),
unknown-device, bad-value, not-booted, busy (another control or a
switch is under way), stale-geometry, ambiguous (two elements match a
reference), outcome-unknown (the server cannot tell whether a control
took effect; the answer gives the state read back), superseded, and
failed (the command’s own words). The plugin adds person-has-control
before a request reaches the server. The words are the ones the agent and
the panel show.
The relay drops BB_DEVICE from a viewer without control, as it drops
input today. Controls from the person’s toolbar come over the input
connection, so the same rule holds them.
Operations and recovery
Requests
- Every request has an ID, made by the client. The server keeps the answers to the last 200 requests. A request sent again with the same ID gets the first answer, or “in progress”; it is never done twice. So a lost reply never turns one rotate into two.
- One control at a time. Controls and switches run one after another;
a control sent during a switch is refused with
busy, in words. Reads run beside them. Input has its own queue. - Each control has a deadline: 10 s for most, 120 s for a switch or a
reboot, 300 s for an install. Past it, the answer is
outcome-unknownwith the state read back, so the agent reads where the device is rather than trying again. - A lost connection: the client connects again and asks for the result
by ID. If the server restarted meanwhile, it answers from its intent file
(below), or with
outcome-unknownand the state read back.
Switching
A switch stops one model and starts another. The server writes its intent before each step, whole, by renaming a new file into place:
type SwitchStep = "stopping" | "booting" | "waiting" | "streaming";
interface Intent { readonly target: string; // the model the box should run readonly switching: { readonly id: string; readonly from: string; readonly step: SwitchStep; readonly since: number } | null;}- Stopping the old model: on Android,
adb emu kill, which saves its snapshot; on iOS,simctl shutdown. - Booting the new one: from its snapshot on Android;
simctl booton iOS. - Waiting for it to be ready:
sys.boot_completedon Android,bootstatuson iOS, with a deadline of 120 s. - Streaming its first frame. Then
targetis the new model, andswitchingis cleared.
When the server starts with a switch in its intent, it looks at what is running, never starting a second device: a booted simulator of either model, or an emulator whose discovery file names a live process. Discovery files of dead processes are deleted. It then carries on from that step.
If the new model does not come up by the deadline, the server boots the old
one again and answers: “Could not start the Pixel Fold: failed.
Each model keeps its own apps and data. A switch shows the other model as it was left, and the picker says so: “Apps are per device. Install your app again on the Pixel Fold.” A reboot keeps apps and data; a release loses them with the box.
Switch times, end to end, to the first frame of the new model:
| iOS | Android | |
|---|---|---|
| A model booted before in this box | shutdown (not timed) and 1.3 to 1.9 s to boot | 2.8 s to stop and save, and 2.2 to 3.9 s to boot from its snapshot: 5.0 to 6.7 s |
| A model booted once in the image, not in this box | shutdown and the boot E1 measures | as above, if the image’s snapshot loads (E2); 2.8 s and 16 to 18 s cold if not |
| A model never booted | 14 to 68 s | not offered: every model is booted once in the image |
E16 times each, through to a frame the viewer has drawn.
When things stop and start
| What happens | The device | The device server | Input held | A recording |
|---|---|---|---|---|
| The server crashes | lives on: simulators under CoreSimulatorService, the emulator in its own service | back in about 1 s: the LaunchAgent sets ThrottleInterval to 1, as launchd’s default waits 10 s; the systemd unit sets RestartSec=1, StartLimitIntervalSec=60, StartLimitBurst=10. After 10 crashes in a minute it stays down, and connect says why |
lifted as the server starts: every touch up, every key up | its segment ends; a new one starts at the next key frame; the clip notes the gap |
| The device crashes | the server boots it once again; a second crash within 5 minutes leaves it failed, with Reboot device |
stays up | lifted | as for a server crash |
| A connection ends: input, control, relay or SSH | lives on | stays up | every touch and key that connection held is lifted, and its queued input dropped | goes on |
| bb-box reloads, two instances at once (045) | lives on | stays up; the later instance takes the lease; the earlier is superseded |
the relay’s input connection ends, so its touches and keys are lifted; a person keeps control (045) | goes on |
| The server’s code changes | lives on | updated only when no person has control and no recording runs, by the lease holder (below) | lifted, as for a crash | not touched: the update waits for it |
| The box stops and starts: a Tart suspend, a Proxmox hibernation | suspended with the VM | on resume it sees its monotonic and wall clocks part by more than 5 s, then sets the device’s clock if it lags (E3, E4), opens idb’s HID stream or the emulator’s gRPC again, and sends a key frame. A resumed Linux VM’s clock stays behind until its time service next asks, up to about half an hour, so bb-box sets an android box’s clock as it resumes it | lifted on resume | ends at the stop; box_record says so |
| A spare is claimed after hours suspended | as above | as above, and E3 and E4 check it after 1 h and after 24 h | none | none |
The VM restarts cold: box_restart, a resize by restart (061), a crash |
before a restart it asks for, bb-box stops the device with its snapshot saved; after any restart the device boots again | starts with the VM, reads its intent, and boots target |
none | ends; box_record says so |
| The emulator is killed with no snapshot saved | its next boot is cold, in about 15 s: the last snapshot saved would lose everything written since, apps installed included (E23). A cold boot keeps what reached the device’s disk | as for a device crash | lifted | as for a device crash |
Delivering the server’s code
- By version, not hash.
src/core/device.tsholdsDEVICE_SERVER_VERSION, a whole number, andDEVICE_PROTOCOLS, the range of protocol versions the plugin speaks. A test fails when the server’s files change and the version does not. - Only forward. The holder of the lease delivers its server when the box’s is older, or when the box’s speaks no protocol the plugin can. Otherwise it uses the server it finds. So during a reload, the earlier instance never sends its older code back, as it holds no lease once the later one connects; and a bb-box rolled back to an older version replaces a server only if it cannot speak to it.
- Never under a person or a recording. A delivery waits while a person holds control or a recording runs, and the plugin log says so, as 045’s does: “Box b-12’s device server waits for version 7 until the person hands back control.” It is tried again at each hand-back and each recording’s end.
- How. The files go to
~/.bb-box/device/server/<version>/through the command transport, about 100 KB.currentis pointed at them, and the service restarted. Viewers see the box end their connection, and come back within 043’s 5 s. The device stays booted. - Rollback. The last version’s folder stays. If the new one does not
answer
BB_DEVICE_HELLOwithin 10 s, twice,currentpoints back, the version is marked bad for that box, and the plugin delivers it no more until a newer one. The doctor shows a box running a version marked bad.
Fencing input
- Control’s owner has a generation. The plugin tells the server each change of owner, with a number that only rises. The agent’s input carries the generation it was sent under, and the server refuses input from an older generation, so input queued before a person took control never reaches the device.
- Taking control stops the agent’s input at once. Queued input is dropped; a gesture under way is lifted where it is; a paste whose clipboard was set but whose paste key was not sent is abandoned, and the clipboard put back. A paste key already sent finishes, as it takes about 0.1 s.
- Hand-back, a switch and a server start lift every touch and key.
- Control belongs to the viewer, not to a display. A person’s tab that shows the device, then the host’s desktop, then the device again keeps control throughout. Hiding one display’s session to show the other is not the viewer going away, and starts none of 020’s timers.
Keeping the device’s own ports closed
The rule: nothing outside the box reaches the device but through bb-box, and if that cannot be ensured, the device does not start.
| What | How it is kept |
|---|---|
| The device server | the unix socket in a 0700 folder; reached only through the box user’s SSH |
| idb_companion | --grpc-domain-sock in the same folder; no TCP port |
| The emulator’s gRPC, 8554 | bound to loopback if the pinned emulator can be told to (E19). Otherwise on every interface, as now, and so behind the firewall below and a token. The token is made new at each start, and read by the server from that start’s discovery file under ~/.android/avd/running/, readable by the box user alone. It never leaves the box, and is never logged |
| The emulator’s console and adb, 5554, 5555, and adb’s server, 5037 | loopback, as each binds; the console’s auth token file is kept, made per box |
| All of these | the firewall below |
The firewall:
- A table of bb-box’s own,
inet bb_box_device, so IPv4 and IPv6 alike, with an input hook at priority -300, ahead of anything Docker or Tailscale writes. It drops 8554, 5554, 5555 and 5037 from every interface butlo. Docker and Tailscale manage their own tables and chains, never this one, so their rule changes leave it be. A container ondocker0or a tailnet peer ontailscale0is notlo, and is dropped. - Fail closed. A unit,
bb-box-device-firewall.service, loads the table and checks it is there. The emulator’s serviceRequiresit and, before each start, checks the table again; without it the emulator does not start, and the device’s state says why. The unit runs at every boot and resume, so there is no time after a boot when the port is open. - Checked live in unit 3.1, and by the image’s check: each port from another box, from a container in the box, and from a tailnet peer where the box has joined one (050), over IPv4 and IPv6, during boot and after an emulator restart; and gRPC calls with no token and a wrong token refused, from inside the box.
A container started with the host’s network shares lo, and so reaches
the ports, with the token still needed. It is the agent’s own, in the box,
as adb is. One trusted user still: the thread’s agent and the person
share the box, and the agent may run adb or simctl, which can do anything
the server can. This is not a security boundary between them.
The viewer
The owner’s picture: the device, framed, in the middle of the panel, a narrow bar of controls beside it.
+------------------------------------------------------------------+| [iPhone 18 Pro v] [Device | Desktop] Take control Type text ... ||------------------------------------------------------------------|| .-----------. [Home ] || | .-------. | [Back ] Android || | | video | | [Recnt] Android || | | | | ------- || | | | | [Rot L] || | | | | [Rot R] || | | | | [Fold ] foldables || | '-------' | ------- || '-----------' [Light] || [Text ] || ------- || [Shot ] || [More ] |+------------------------------------------------------------------+Device and Desktop shows only on an ios box.
Choosing a display
Today the plugin takes a box’s first display as its RFB desktop, and finds its video by looking for the first Selkies display. The panel keys its kept sessions by box, viewer and protocol. Two Selkies displays on one box, the host’s and the device’s, cannot be told apart that way.
- A display is chosen by its ID (
desktop,device), in the route, in the panel’s kept-session key, and in the relay’s maps. The protocol is a property of the display, not how it is found. - Input and the clipboard go to one display: the one the person’s tab shows. Control stays one per box, covering both.
- The viewer tells a device from a desktop by
DisplayCapability.shows. A device display draws the frame and toolbar; a desktop display draws as now. - A device display ignores the
videosetting, the viewer’s Show as VNC, and resizing to the panel: it has only video, at the device’s own size.
Where video cannot play
060 has not checked Safari, and the panel on a phone is Safari. A device has no VNC to fall back to, and showing the host’s desktop instead would put a person’s clicks on the wrong screen.
- Where the browser has no H.264 decoding, or the first frame does not decode within 3 s, the device display asks for stills: a PNG of the screen over the same connection when it changes, at most 5 a second. Above the frame: “This browser cannot play the device’s video, so it shows a picture a few times a second.” Touches and controls work as with video.
- Never a black frame with no words. While waiting for the first frame the screen shows the model’s name and “Connecting”.
- E18 checks Safari on macOS and on iOS in unit 2.3.
The frame
- Drawn by bb-box, as SVG around the video’s canvas, from the model’s
BodyandScreen: a body with rounded corners, black glass ofbezelpixels round the screen, the side buttons, and the screen’s corners clipped tocornerRadius. No Apple or Google artwork. - Cutouts. On iOS the frame draws the Dynamic Island over the screen,
as Device Hub’s chrome does. idb’s stream and screenshot draw it as a
black pill, which the frame’s covers in place;
simctl io screenshotleaves it out. The island’s numbers were measured from the stream in unit 6.1. On Android the device draws its own cutout, so the frame draws none. - Per form and posture:
| Form | Posture | Drawn |
|---|---|---|
| phone | one body, turned with the device | |
| tablet | one body, thinner bezel, turned with the device | |
| foldable | closed | the outer screen in a phone body |
| foldable | open | the inner screen in one wide body, with a faint crease down the hinge |
| foldable | half open | as open, with the crease shaded, both halves flat and readable, and “Half open” under the frame |
- Rotation. The frame turns with
physical. While the device turns, the last frame of video turns with the frame, as a real phone held sideways shows its old picture for a moment, and stays until the first frame of the new generation comes, however long the device takes: up to 1.5 s on Android. The animation is 300 ms; if the new frame is later, the turned picture waits under a faint veil. Wherecontentdiffers fromphysical, as the launcher upright on a phone held sideways, the video is drawn as the device draws it, turned within the frame, by the same transform the server uses for touches, so a tap lands where it is drawn. - Fold and unfold. The inner screen’s right half folds over the left in 350 ms, then the outer screen fades in; unfolding is the reverse. The stream changes size mid-stream (1080x2092 closed, 2208x1840 open, as measured), and the old frame holds until the first new one comes.
- Reduced motion: with the browser’s
prefers-reduced-motion, both change at once. - Size. The frame and toolbar fit the panel, the device’s shape kept, and the screen is never drawn larger than its own pixels at the browser’s pixel ratio. The More menu has Pixel for pixel, which scrolls, and Show frame, on by default.
- Light and dark. The device’s body is graphite in both of BB’s themes. In BB’s dark theme the body has a 1 px light edge so it stands off the background; in the light theme, a soft shadow. The device’s own appearance is separate, and is what the Appearance control sets.
- Booting, asleep and failed. While the device boots the screen is
black, with the model’s name and “Booting”. An asleep device shows its
last picture dimmed, with “Asleep” and Wake. A failed device shows its
errorand Reboot device.
The toolbar
At most nine controls beside the frame, and a More menu. Each earns its place by being frequent in testing an app, and either awkward with a mouse or two or more taps deep in the device’s own Settings. A control the platform or model lacks is not drawn, so there are no disabled ghosts.
| Order | Control | Platforms | Why it is on the bar |
|---|---|---|---|
| 1 | Home | both | the commonest navigation; the home gesture is unreliable with a mouse. Device Hub and Android Studio both have it |
| 2 | Back | Android | Android’s commonest navigation; an edge swipe with a mouse is unreliable |
| 3 | Recents | Android | switching apps; a swipe up and hold with a mouse is unreliable. iOS has no command for it |
| 4 | Rotate left | both | a layout check every app gets; a quarter turn |
| 5 | Rotate right | both | so turning back is one click, as both IDEs have. A design call, not measured |
| 6 | Fold | foldables | a popover: Closed, Half open, Open; the posture is what a foldable is tested for |
| 7 | Appearance | both | one click between light and dark; its icon shows which is on |
| 8 | Text size | both | a popover: smaller, larger, default, and the platform’s step now |
| 9 | Screenshot | both | copies the device’s own pixels as a PNG to this computer’s clipboard; where the browser refuses the clipboard, downloads it |
| More | both | Lock or Wake; Open link; Location (a few cities, or latitude and longitude, checked in range); Status bar: clean or live; Battery (level, charging); Network (Android); Show frame; Pixel for pixel; Reboot device, which says that it takes 5 to 18 s and keeps apps |
Left off: install (it needs a file in the box, which is the agent’s work);
volume, Siri and the Action button (rare, and idb sends no volume); shake
(not checked); recording (the agent’s box_record); snapshots (a later
decision).
- Narrow panels. With the panel under 480 px wide, or the device’s screen under 280 px wide beside the bar where the row leaves it wider, as unit 6.4 found the row only shrinks a device the panel’s height limits, the bar goes below the frame as one row: Home, Back and Recents on Android, Rotate, Fold on foldables, and More, which takes the rest. Targets stay 44 px. On a phone’s BB the row is the same.
- Accessible. Each control has a name, Appearance and Fold say which is on, popovers close with Escape and a click outside, and a change of the device’s state is announced to a screen reader.
- Open link and Screenshot are not box_device actions, as box_device
leaves URLs and screenshots to the computer tools. Open link is an
openrequest on the person’s input connection, which the agent’scomputer_launchcan share in unit 7.1, refused for the agent while a person has control. Screenshot asks the plugin for the device server’s own screenshot, so it needs no control. - The device picker, “iPhone 18 Pro” in the top bar, lists the kind’s
models for this box, and switches. A switch to a model not yet booted in
this box warns that it may take a minute (iOS first boots, 14 to 68 s).
The server says which models it booted in this box in its hello’s
bootedHere, kept in~/.bb-box/device/booted-here.json. - Device and Desktop in the top bar, on an ios box only, shows the device or the host’s desktop, each a kept session (037), so switching is instant.
Control, hand-back and Type text
- One control per box, covering the device and the host’s desktop, as one owner of input at a time (008). Take control and Hand back are the desktop viewer’s own, in the same place.
- A toolbar click takes control when the viewer does not have it, as Take control does, and says so in the top bar: “You have control. Hand back when done.” So a person changes the appearance in one click, not three. Screenshot does not take control: it sends the device nothing. On a touch screen, where there is no hover, the toolbar shows “Using a control takes control from the agent” once, under the bar, the first time.
- Watchers see touches. A viewer without control draws each
BB_DEVICE_TOUCHas a 36 px translucent circle for 300 ms, so a person watching sees where the agent tapped. The viewer with control draws only its own pointer. - Typed keys go as keys where a US keyboard has one. Committed text, from
an input method’s composition or a phone’s keyboard, goes as one
textmessage, in order with the keys around it. So Japanese typed through the browser’s input method arrives whole. - Type text (#1094) opens the same field as on desktops, and is the way the viewer suggests for long or non-English text. Its text goes as one paste.
- Cmd+V or Ctrl+V in the viewer pastes this computer’s clipboard into
the device, as on desktops. The viewer reads the clipboard at the paste,
never on taking control or the focus, and sends it as one
pasterequest, text only; the server pastes it as below and puts the device’s clipboard back (F-15, the owner’s decision of 28 October 2026). What the device copies comes back to the viewer with control: gRPC’s clipboard on Android, andsimctl pbpasteevery 2 s while the tab has the focus on iOS, as Tart’s desktops do (#464). The server’s own pastes are not echoed back. - Two fingers. With a mouse, Alt and drag pinches. On a touch screen, two fingers on the device are two touches.
Text other than ASCII
Text with no US key, from the agent or the person, is pasted:
- Batched. Characters are gathered until 150 ms pass with none, or a key that is not text comes, or 2,000 characters. Then they go as one paste, in order: a key typed after them waits for the paste.
- The clipboard is saved and put back. The server reads the device’s clipboard, sets its own text, sends the paste key, waits until the paste has landed, and puts the saved clipboard back. How long to wait is measured in E21, and on iOS watched (step 5). While it does, the clipboard sync to the viewer is paused, so the person’s computer never receives the server’s text.
- iOS’s Allow Paste is answered by the server. The first paste into an app shows the alert, with Allow Paste as a button in the tree. After its own paste, the server looks for that alert for 1 s, and taps Allow Paste once if it is there. It never answers an alert it did not cause.
- Android’s notice (“pasted from your clipboard”) shows once a batch, not once a character. Android adds no space to pasted text and takes keys while its notice shows, so the server holds none; it puts the clipboard back 300 ms after the paste key, as Chrome on the device read the clipboard up to 150 ms after it (E21).
- iOS’s paste waits on the device (F-21). A set of the clipboard
returns only once
simctl pbpastereads the new text back. While a just-booted device’s pasteboard does not answer, simctl’s exit 60, each call is tried again every second for up to 30 s. From just before the paste key the server watches the device’s log: the clipboard goes back once the app’s read is logged, or 5 s after the key if none is. The next keys wait until iOS’s paste notice gives the keyboard back, at most 6 s; where the log cannot be watched, 1.5 s and 2.6 s instead. A device is said to be booted only once its accessibility and pasteboard answer, for at most 45 s more.
Agent tools
box_acquire, box_list, box_status and box_run
box_acquire({kind: "ios", device: "iPhone 18 Pro"}).deviceis new: “For an ios or android box, the device it boots, from box_list, such as iPhone 18 Pro or Pixel Fold. Defaults to the kind’s first.” With another kind it is refused. A name no offer has is refused, with the kind’s models listed. Withwait, the answer comes once the device has booted, or after 120 s with “still booting”, and says how long it took. The CLI’sbb box acquiregains--device, and the New box form a device step for these kinds.box_list’s line for each device kind ends with its models: “Devices: iPhone 18 Pro (default), iPhone 17, iPad Pro 13-inch (M5).” A box’s line names its device.box_statusadds the device’s line: model, runtime (“iOS 27.0, Xcode 27.0” or “Android 15, API 35, Google APIs”), power, orientation, posture, appearance, text size, any warning, and the IDs for commands.box_runruns on the host, as on any box. On an ios boxsimctlcommands may name the device asbooted; on an android boxANDROID_SERIALis set, soadbneeds no-s.BB_DEVICE_IDholds the UDID or the serial on both.
box_device
One action per control, so the toolbar and the agent do the same things in the same words.
| Parameter | Type | For |
|---|---|---|
box |
string | the box |
action |
home, back, recents, lock, wake, rotate, posture, appearance, text_size, location, status_bar, battery, network, install, switch, reboot |
what to do |
turn |
left, right |
rotate: a quarter turn from where the device is |
to |
string or number | rotate: upright, turned-left, upside-down, turned-right. posture: closed, half-open, open. appearance: light, dark. text_size: larger, smaller, default, or a step of this platform: an iOS content size such as accessibility-large, or an Android font scale such as 1.3. status_bar: clean, live. network: online, offline, slow |
latitude, longitude |
number | location: both, in range, or neither to clear it |
level, charging |
0 to 100, boolean | battery: both, or neither for the device’s own |
paths |
string array | install: on iOS one .app folder built for the simulator; on Android one .apk, or the split APKs of one app. In the box (box_copy puts them there) |
device |
string | switch: a model from box_list |
Each action takes exactly its own parameters. A parameter that belongs to
another action, half a pair, or a value out of range is refused with
bad-value, saying what the action takes. Rotate takes turn or to, not
both.
Answers say what happened, how long it took, and the state after it: “Turned the iPhone 18 Pro left in 0.3 s. Now: turned left, 2622x1206, dark, text size large, booted.” Refusals:
| Code | Words, for example |
|---|---|
unsupported |
“iOS has no back button. Its actions are: home, lock, wake, rotate, …” or “The Pixel 8 does not fold. The Pixel Fold does.” |
unknown-device |
“This box has no Pixel 9. Its devices are: Pixel 8, Pixel Fold, Pixel Tablet.” |
bad-value |
“text_size on Android takes larger, smaller, default or a font scale: 0.85, 1, 1.15, 1.3, 1.5, 1.8 or 2.” |
not-booted |
“The Pixel Fold is still booting. Try again in a few seconds, or call box_status.” |
busy |
“The box is switching to the Pixel Fold. Try again when box_status says it has booted.” |
outcome-unknown |
“The connection to the box ended while turning the device, so bb-box cannot tell whether it turned. It is now upright. Read box_status before trying again.” |
person-has-control |
“A person has control of this box’s device. Wait for them to hand it back, or take it back with computer_take_control when they ask.” |
failed |
the command’s own words |
Install, in more detail:
- iOS takes simulator builds only. A
.appbuilt for the iOS Simulator SDK installs. An.ipa, or a.appbuilt for devices, is refused: “This is a build for a physical iPhone. Build for the simulator, for example xcodebuild -sdk iphonesimulator, and install the .app.” - Android takes APKs. One
.apk, or the split APKs of one app throughadb install-multiple. An.aabis refused: “Android App Bundles do not install directly. Make APKs with bundletool build-apks –mode=universal.” - The toolchain is the project’s. The ios image has Xcode. The android image has the emulator and platform-tools, not a JDK, Gradle or the SDK’s build tools: a project adds them with its own image (049). The setup guide shows how.
- During a person’s control, an install of an app the device does not
have runs. One that would replace an installed app, which stops it if
it is open, is refused with
person-has-control.
The lead’s sketch had screenshot and open_url too. They are left to the
computer tools, which already have both, so there is one way to each.
Computer use on a device
On a device box the computer tools drive the device only, never the host. The agent’s work on the host is commands.
| Tool | On a device box |
|---|---|
computer_windows |
one window, ID 1, named after the model: “iPhone 18 Pro (device)”. A system alert, such as Allow Paste, is inside it |
computer_observe |
the device’s tree, as today’s text: one element a line, references such as e12, with the identifier where the platform gives one |
computer_screenshot |
the framebuffer, at most 1568 px on its long edge, with its scale and geometry generation |
computer_act click |
a tap at the element’s centre, or at x and y in the latest image |
computer_act double_click |
two taps 120 ms apart |
computer_act right_click |
a long press of 1.2 s, which opened the context menu on both platforms; the description says so |
computer_act scroll |
a swipe within the nearest scrollable element holding the target, or the screen’s middle 80% where the platform does not say which scrolls: a fifth of that height per step, points every 16 ms, stopped when the picture stops moving |
computer_act set_value |
on an editable field only: a tap, select all, and the value typed as computer_type types it, keys where a US keyboard has them and the rest pasted. Refused on anything else, a slider included, with the words “Drag it with computer_act’s drag instead” |
computer_act drag |
a swipe from ref, or x and y, to to_x and to_y in the latest image, held still before it lifts; device boxes only. Added in unit 7.1, as the refusal above names it |
computer_act key, computer_type keys |
the key or chord, by W3C key name |
computer_type text |
ASCII as keys, with autocorrect off in the image; the rest pasted, as above |
computer_launch app |
an app by its bundle ID or package, or its name: simctl launch, am start; the answer waits until the app is in the tree, as a new app’s elements take 3 to 6 s on iOS |
computer_launch url |
simctl openurl, am start -a android.intent.action.VIEW |
computer_take_control |
as today |
- References. A reference is found again by its identifier first, then
by role, label, and its place among its siblings, as 022 to 024 do for
desktops. When two elements match, the action is refused with
ambiguous, naming both. iOS gives a flat list; the server nests it by frames, which is only an approximation, so on iOS the identifier and the frame count for more than the nesting. Unit 7.1 found idb’s nested form gives the nesting itself. Before a tap by reference, bb-box asks the device what is at the point, and refuses, naming it, when something else covers the element, as a title bar, the keyboard or a hidden page can. - After an action the tool waits until the screen has not changed for 300 ms, at most 2 s, and answers with what changed, as on desktops.
- Web pages in Safari are not in iOS’s nested tree, which stops at
Safari’s own controls, but each point of the page names its element. So
where the app’s own elements leave the screen’s middle to another
process, bb-box reads the page point by point and lists it in a
web view(F-20 in 063 results). Chrome on Android shows a page’s contents only some of the time, touiautomator dumpand the tree server alike; otherwise the tree has aweb viewwhose value says so. - Android’s tree comes from bb-box’s tree server in the device, in 2 to
4 ms a read, and from
uiautomator dump, 1.9 s, when the server cannot run (units 7.2 and 7.3). Android lets one UiAutomation client in at a time, and the dump is one too, so while an instrumentation test runs the tree is refused in words and screenshots go on: unit 7.3 measured a dump during a test’s start crashing it. This changes 7.3’s row, which used the dump while tests ran; see 063 results.
What waits while a person has control
| The agent’s call | While a person holds control |
|---|---|
computer_act, computer_type, computer_launch |
refused at once, in the device’s words; input already queued is dropped |
box_device, every action but install |
refused at once, the same way |
box_device install |
runs for a new app; refused for one already installed |
computer_windows, computer_observe, computer_screenshot |
answer: reading the device sends it nothing |
box_record |
runs |
box_run, including simctl, idb and adb |
runs. bb-box cannot see input sent this way (adb shell input), and does not try |
Refused, not held: a call that waited for a hand-back would hold the agent’s turn for as long as the person tests, which may be minutes. This is today’s rule for desktops (038). The one difference from desktops is that reads go on: on a desktop, taking control closes Cua’s session, and a device has no such session to close.
Recording
box_record works on device boxes with the same actions, captions, clip
and 5-minute limit as on desktops (059):
- The source is the device server’s own stream, not the host’s screen:
the device’s own pixels, with no window, at the device’s frame rate.
simctl io recordVideo(54 fps busy) andadb screenrecord(12 fps busy, 3 minutes at most) are not needed. - A monotonic timeline. Frames are stamped by the server’s monotonic clock, so a wall clock set after a resume cannot move them. The events log gives the start and the stop in the same clock.
- Segments. The server copies its H.264 into
capture-<n>.mkvunder 059’s folder. Each segment starts with SPS, PPS and a key frame, which the server makes as the segment starts. A new segment starts at each change of the stream’s size: a rotation, a fold. The log has each segment’s size, orientation and posture, and every touch. - Still screens. The server sends frames only when the screen changes, so a still stretch is a gap between frames. The clip holds each frame until the next, and the last frame until the stop. So a recording of a screen that never changed is a clip of its first frame for its whole length, and a long still end is kept, then cut as 059 cuts stillness.
- No viewer needed. Frames come while a recording runs, viewer or not.
- Failures are honest. A server restart, a device crash or a stop of the box ends a segment. The clip shows the gap with a caption, “The device restarted here”, or ends early, and box_record’s answer says what was lost. Release removes the folder, as 059 does.
- Making the clip. Each segment is decoded at 059’s 15 fps. Each is
placed, framed, on one canvas that fits the largest shape in the
recording, at most 1600 px on its long edge. The frame is drawn by ffmpeg
from the same
BodyandScreennumbers, with filters checked present in both the macOS and the Linux images’ ffmpeg (059 records where they differ). Touches show as the viewer’s circles. - Captions sit in a band of the canvas above the device, or below it
with
position: "bottom", so they never cover the app.
Spares and ready times
- A spare holds its device booted, and costs no memory while it waits. An android spare is a hibernated Proxmox VM, as Linux and Windows spares are. An ios spare is a suspended Tart VM. That is new: today a Tart spare is a running box, and only Tart’s warm VMs, which are not enrolled, wait suspended (032). Unit 7.5 changes the Tart backend so an enrolled spare can wait suspended, taking no VM slot.
- Off by default, an operator’s choice. The owner decided on 28
October 2026: “it should be an opt-in configuration and off default”.
The kind’s
sparessetting (032), per kind, not per host, is 0 for ios and android as for every kind, so nothing is made until an operator sets a count. A spare is then made where an acquire of its kind would go, by the backend’s usual room check; its saved memory, up to the box’s memory, is not counted ahead (unit 7.5’s results). The doctor’s Spares section says, for each device kind, that spares are off, or how many are kept and ready, what each costs while it waits, and why a kind has too few. - A claim resumes the VM, checks the server’s version, sets the device’s clock, and, when the thread asked for another model, switches.
- A spare from an older image build is retired at claim and a new one made, as 049 retires stale builds. A Tart macOS box records its image’s build at its clone, and the backend compares it with the image’s record on its Mac.
- Before it waits, a spare boots each of its kind’s models once and goes back to its default, so a claim of any model switches as one booted before in the box (unit 7.5’s results).
- An ios spare waits suspended no longer than Tart’s
warm-max-hours, 6 by default, as a saved state that old may not restore (E4); an older one is replaced. A spare whose saved state does not restore boots afresh, and box_acquire says so. - The default model is the one a spare holds.
Expected time to a booted device:
| Path | iOS | Android |
|---|---|---|
| A spare, its model | Tart’s resume, then nothing (E4) | 4.1 to 5.0 s, as a hibernated Windows spare resumed (057), if the emulator survives it (E3) |
| A spare, another model booted before in this box | that, then a switch: the shutdown, and 1.3 to 1.9 s | that, then 5.0 to 6.7 s |
| A spare, another model booted once in the image | that, then a switch: the shutdown, and E1’s boot | that, then 5.0 to 6.7 s if the image’s snapshot loads (E2); 19 to 21 s if it boots cold |
| A new box | a clone and macOS’s boot (13.6 s in 061), then the device: 1.9 to 13.1 s by E1, 32.2 s were it never booted | a clone and the VM’s boot (8.3 s in 061), then 2.2 to 3.9 s from its snapshot if it loads there (E2), 16 to 18 s cold |
| Becoming a BB machine | 8 to 11 s more, unless a spare, which is enrolled ahead (032) | about 5 s more, unless a spare |
If E3 or E4 shows the device does not survive its VM’s suspend, a spare waits with its device shut down instead, and a claim boots it: the second row’s times.
Images
Both are built, checked, refreshed and promoted as other images are (049): a build in a box, a check, a trial, then promotion, keeping the last working build.
iOS: bb-box-ios |
Android: a Proxmox QEMU template | |
|---|---|---|
| Base | the Tart macOS image with Xcode 27, its iOS runtime (7.5 GiB, a cryptex) and the other runtimes it already has | the Linux VM template (057), with no desktop |
| Adds | idb_companion 1.7.7, 115 MB, from Meta’s Homebrew tap, its bottle pinned by version and SHA-256, with its licence notice; each offered model created, booted once and shut down; the image record’s devices |
the SDK’s command-line tools, emulator 37.2.12.0, platform-tools 37.0.1 and the Android 15 (API 35) Google APIs x86_64 system image revision 9, each pinned by URL and SHA-256 from Google’s repository manifest in setup/android/pins.json; one AVD per model, each booted once and saved to its snapshot; bb-box-h264; the firewall unit; the image record’s devices |
| Size added | about 0.6 GB: idb’s 115 MB and about 0.15 GB a model, to measure (E24). Clones share the base on APFS, so the other runtimes cost nothing more | about 18 GB: 4.6 GB of SDK and about 4.5 GB a model (2.0 GB data, 2.5 GB snapshot memory), to measure (E24). A used device grows to 5 to 7 GB in its box |
| Build time added | each model’s first boot: 27 to 68 s | 48 s to install, about 17 s to boot each model, 2.8 s to save it |
| Built by | setup/tart/prepare-ios-image.sh, on the macOS image |
setup/proxmox-qemu/build.sh android, in a box of the Linux VM template |
| Refreshed | with the macOS image, after 30 days, by bb box images refresh (049) |
monthly, in a box of the current build, as 054 refreshes Windows: the pins move with bb-box’s releases |
| Licences | idb: MIT. Xcode’s, accepted at build as now | the SDK’s terms, accepted by sdkmanager in the build. Java is needed for sdkmanager and avdmanager at build only, and removed after. See open questions |
The ios image is its own, not the macOS image itself, so macOS boxes do not carry booted devices; it is a clone of the macOS image, so the iOS runtime and Xcode are not stored twice.
What a build keeps and what it clears, so no two boxes share what should be their own (049’s sixth rule):
| Kept | Cleared before capture | |
|---|---|---|
| iOS | each model’s device, booted once and shut down; its keyboard preferences | the simulators’ pasteboards; CoreSimulator’s logs; Device Hub’s preferences and any record that it ran; idb’s state |
| Android | each AVD and its snapshot, booted with no account; hw.keyboard = yes; the settings below |
~/.android/avd/running/ and every discovery file; ~/.emulator_console_auth_token; adb’s keys (~/.android/adbkey*); the device’s clipboard, cleared before each snapshot is saved; logcat |
A snapshot keeps what the device holds in its memory and on its disk, its
Android ID among them, so every box from one template began with the same
one. The device server gives each device its own the first time it boots
in a box: a new android_id, and a new key for the IDs apps read, which
Android makes only as its framework starts, so the server reboots the
device once. Restarting only the framework, with stop and start, lost
the device’s Wi-Fi network and left the phone service failing (F-3 in
063 results).
Before the reboot the server waits until Android has written the
emulator’s Wi-Fi network to its disk. That adds about 25 s to the
device’s first boot in a box, and nothing to its later boots or switches,
which keep it. A spare (unit
7.5) pays it before it is claimed.
Defaults in the image, so typing and pasting are predictable:
| Default | iOS | Android | Why |
|---|---|---|---|
| Autocorrect, predictions and smart punctuation off | the device’s keyboard preferences, written before its first boot ends (E13) | Gboard’s, set at build (E13) | on Tart, “Hello idb 123” typed as keys became “Hello IMDb 123” |
| A hardware keyboard | HID keys need none | hw.keyboard = yes in each AVD’s config.ini, before its snapshot |
with no, as avdmanager writes, keys never reached the device; changing it later voids the snapshot |
| Stylus handwriting off | settings put secure stylus_handwriting_enabled 0 |
Gboard’s “Try out your stylus” sheet took the typing | |
| Auto-rotate on | settings put system accelerometer_rotation 1 |
rotation by setPhysicalModel needs it |
|
| Device Hub never started | not opened at build; a preference that stops Xcode opening it, if E20 finds one | it takes the device’s pasteboard, and asks for the microphone at first launch | |
| No window | no Simulator app | -no-window |
the nested-virtualisation dialog sat over the screen; with the window open, the gRPC stream fell to 26 fps |
| Awake, no lock screen | the simulator does not lock | svc power stayon true, the longest screen timeout, no lock screen |
a sleeping device sends no frames |
| Animations | on | on | apps are tested as people see them |
The image’s check grows with the units:
- From units 3.1 and 3.2: each model boots from the image’s state, its pixels match the model table, and the defaults above hold; on Android, the ports are closed as above, and a clone’s identity (adb key, console token) differs from the image’s.
- From units 4.1 and 4.2: each model streams a frame and gives a key frame on demand.
- From units 5.1 and 5.2: a tap changes the screen, and Japanese text with an accented letter, pasted, arrives, with the clipboard put back.
- From units 7.1 and 7.2: the tree has elements, and a screenshot matches the stream’s size.
An image that fails is not promoted, and the last one stays (049). Pinning
idb does not pin its fit with Xcode, the runtime and the server: the record’s
runtime names all of them, and the doctor fails an offer whose
combination the check has not passed. If a new Xcode breaks idb, the macOS
image’s refresh keeps building, and the ios image stays on its last good
build until idb catches up. Writing bb-box’s own server on the same private
frameworks is a possible way out, not a proven one.
Build phases
Phase 1 was the research. Each unit below is one builder’s PR, on the target OS’s box: a Mac box for iOS, a Linux box with KVM for Android. Each ends in a live check through BB, on a separate BB in a box with bb-box from the unit’s branch, with what it measured. Units side by side run in parallel. Each unit brings what it introduces: the reference and the skill text for each tool and parameter, the setup guide for each image, the settings keys for each kind, doctor checks for what it needs from a host, and conformance cases for what it adds. The “Also” column names them.
Phase 2: foundations, before any device kind is offered
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 2.1 The data model and the kinds | src/core/device.ts and the model table; validators for every record and message; supports; the compile-time cases that must fail; ios and android in KIND_INFO with no offers; KindCapabilities.device; DisplayCapability.shows; the box record’s device; box_acquire’s device and bb box acquire --device, with their refusals |
the kinds and tools reference; conformance cases for device capabilities, run where an offer has a device | on a separate BB: box_acquire({kind: "ios"}) refused, naming no offer; device on a linux kind refused; box_list, box_status and the settings page unchanged for every other kind |
|
| 2.2 The device server’s life | bb-box-device with connect, the socket, its lock, the LaunchAgent (ThrottleInterval 1) and the systemd unit (RestartSec=1, its start limit); the protobuf codec, proved against idb’s and the emulator’s .proto files; HELLO, STATE, ANSWER; request IDs, deadlines and the intent file; the lease; delivery by version, deferral and rollback; the test driver with key frames on demand; the test-device setting |
the setting’s row; a doctor check of each box’s server version; conformance cases on the test device | on a Linux box and a Mac box with the test device: the server killed and back within 2 s; a crash loop ending in words; a reload of bb-box with two instances delivering once; a delivery waiting while a person holds control, then made at hand-back; a version that never answers rolled back | 2.1 |
| 2.3 Display routing and the device display | displays by ID in the route, the kept-session key and the relay; input and clipboard per display, control per box; the device display as plain video, ignoring the video setting and VNC; stills where video cannot play; Device and Desktop | the desktops guide’s section on device displays | a linux-desktop-cpu box with the test device: both displays together, switched for 2 minutes; input, clipboard, a hidden tab and a reconnect each reach only the display meant; with WebCodecs off, stills with their words, never the host; E18 | 2.2 |
| 2.4 The Android encoder and what devices cost | bb-box-h264, with key frames on command, and its tests; frames from the emulator to it, as E6 decides; the android kind’s default and least sizes, from E17 |
none outside the image yet | on a Linux box with KVM and the SDK installed by the research’s script: E17, E6 and E7, each model, each number with its method |
Phase 3: the images
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 3.1 The Android template | setup/proxmox-qemu/build.sh android on the Linux VM template; the pins; the AVDs and snapshots; what is kept and cleared; the firewall unit, failing closed; the image record with each runtime and snapshot origin; the check; the monthly refresh |
the proxmox-qemu android-templates setting; doctor checks for KVM, the template, its build’s age and the disk it needs; the Android setup guide |
the template builds and a refresh is promoted; a box from it boots each model from its snapshot, timed; the ports from another box, a container and a tailnet peer, IPv4 and IPv6, during boot and after a restart; no token and a wrong token refused; two clones’ identities differ; E2, E13, E19, E24 | 2.1, 2.4 |
| 3.2 The iOS image | setup/tart/prepare-ios-image.sh; idb pinned with its notice; each model booted once; what is kept and cleared; the record with each runtime; the check |
the tart ios-image setting; doctor checks for idb’s version against Xcode’s, and Device Hub not running; the iOS setup guide |
a fresh box boots each model, timed (E1); typing “Hello idb 123” (E13); an app run from Xcode in the host, and whether Device Hub opens (E20); E24 | 2.1 |
Phase 4: the drivers, and the kinds offered
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 4.1 The Android driver: boot, video, recovery | the emulator’s service with its token; attach and reattach; frames through bb-box-h264; key frames; every row of When things stop and start on Android; the android kind offered on Proxmox QEMU where the host has KVM |
the New box form’s device step; the skill’s text for device boxes; box_list and box_status lines | 060’s harness through BB on each model: first frame, frame rate still and busy, CPU, memory; the encoder killed; the emulator killed; box stop and start; a cold restart; E3 after 1 h and 24 h suspended; E23 | 2.3, 3.1 |
| 4.2 The iOS driver: boot, video, recovery | idb_companion on its socket; attach and reattach; its frames and key frames; the same rows on iOS; the Device Hub watch; the ios kind offered on Tart |
as 4.1, for iOS | the same harness through BB; idb’s stream killed; box stop and start; E4 after 1 h and 24 h suspended; E5 | 2.3, 3.2 |
Phase 5: touch, keys and shared control
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 5.1 Input, shared, and on Android | owner generations and fencing; lifting on every end; the wheel as a swipe; Alt and drag, and two fingers on a touch screen; committed text; batched paste with the clipboard saved and put back; touch echo to watchers; geometry generations and stale-geometry; all of it on Android |
the desktops guide’s device input section | E14 through BB, with and without a Gradle build running, and the first tap after a reconnect; a person taking control halfway through the agent’s swipe and halfway through its paste; a reload while a person holds control; Japanese through the browser’s input method, Arabic, an emoji, dead keys; a trackpad scrolling a list; a pinch on a phone’s BB; E8, E21 | 4.1 |
| 5.2 Input on iOS | the HID stream, opened as control is taken; keys mapped from W3C names; paste through simctl pbcopy; Allow Paste answered by the server; pinch (E9) |
the same checks, with an xcodebuild running; the first tap after taking control quick, not 578 ms; E8, E9, E14, E21 |
4.2, 5.1 |
Phase 6: the frame, the controls and box_device
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 6.1 The frame | the frame per form, posture and orientation; content drawn by the server’s transform; fit, Pixel for pixel, Show frame; light and dark; rotation and fold animations held until the new frame; booting, asleep and failed screens | screenshots of every model in both themes; rotation and fold driven by adb and idb in box_run, the animation’s frame times; a tap after a turn landing where drawn |
5.1, 5.2 | |
| 6.2 Android controls and box_device | the Android driver’s controls and read-backs; network presets; the box_device tool with its parameters, refusals and outcome-unknown; supports for every Android action; the person’s control refusal |
the tools reference and the skill for box_device |
every Android action by box_device, its effect seen or read back; each read-back’s time; each font scale set and read back; a control sent twice by ID done once; E10, E11, E22 |
5.1 |
| 6.3 iOS controls | the iOS driver’s controls and read-backs; battery as part of the status bar | every iOS action by box_device, its effect seen; each content size set and read back; status bar live clearing the battery, in the answer’s words; E11 |
5.2, 6.2 | |
| 6.4 The toolbar | the toolbar, popovers, More menu, the narrow row and the device picker; a click taking control | the panel’s guide | each control clicked on both platforms and its effect seen; a switch to a model not booted before, with its warning; at 360 px, 480 px and 200% zoom; by keyboard, by touch, and with a screen reader; the agent’s box_device home refused while a person holds control |
6.1, 6.2, 6.3 |
Phase 7: computer use, recording and spares
| Unit | Builds | Also | Live check | After |
|---|---|---|---|---|
| 7.1 iOS computer use | the computer tools on the device: tree with identifiers, screenshot, act, type, launch; references by identifier, refusing ambiguous ones; scroll in its container; set_value on fields only; settling after actions | the computer tools reference for device boxes | an agent opens Settings, General, About by references, types a URL into Safari, scrolls a long list, sets a text field and is refused on a slider, and meets two rows with one label; each tool’s time | 6.3 |
| 7.2 Android computer use | the same with uiautomator dump |
the same in Settings and Chrome; each tool’s time, the tree’s 1.9 s noted | 6.2; beside 7.1 | |
| 7.3 Android’s tree in tens of milliseconds | a UiAutomation server in the device: Appium’s UiAutomator2 server (Apache-2.0), or one of bb-box’s own run with app_process as scrcpy runs; it gives way to an app’s instrumentation tests, and the dump is used while it does |
E12: tree times before and after; the same references found; an instrumentation test run beside it | 7.2 | |
| 7.4 Recording | box_record on device boxes: the monotonic timeline, segments with key frames, still stretches held, gaps captioned, the frame drawn by ffmpeg on both OSes | the box_record reference for devices | a 60 s clip on each platform with a rotation and, on the Pixel Fold, a fold, and two captioned steps; a clip of a still screen; a long still end; no viewer open; the server killed mid-recording; E15 | 6.1 |
| 7.5 Spares and switching on claim | suspended enrolled Tart spares; hibernated android spares; the default of one each where a host has room; switching on claim; stale spares retired | the spares rows of the configuration reference; doctor words for a spare with no room | five claims of each path in Spares and ready times, on each platform, timed; a failed switch rolled back; E16, E24 | 4.1, 4.2, 6.4 |
Experiments
| What | Settles | Unit | |
|---|---|---|---|
| E1 | A fresh Tart box from the ios image: time to booted, per model | the iOS ready time, and a switch to a model not booted in this box | 3.2 |
| E2 | An AVD snapshot made in the image build loads in a box, and on another host’s CPU | whether a new android box boots in 2 to 4 s or 16 to 18 s | 3.1 |
| E3 | The emulator survives its VM’s hibernation and resume, after 1 h and after 24 h: frames, gRPC, adb, the device’s clock, held touches | whether an android box’s stop and start, and an android spare, keep the device running | 4.1 |
| E4 | A booted simulator survives its Tart VM’s suspend and resume, after 1 h and after 24 h: frames, HID, the clock | the same for ios | 4.2 |
| E5 | Each message of idb’s video stream is one whole access unit; idb gives a key frame on request on a still screen, with SPS and PPS | whether the iOS driver needs a parser, and how it makes key frames | 4.2 |
| E6 | The emulator’s MMAP frames to bb-box-h264, against RGB over gRPC through Node: CPU at the busy frame rate |
how frames reach the encoder | 2.4 |
| E7 | x264 against OpenH264 in bb-box-h264: ms a frame at each model’s size, and the picture at 8 Mbit/s |
the encoder’s cost under each licence choice | 2.4 |
| E8 | Which space each device takes touches in: all four orientations, an app locked upright, both screens of the Pixel Fold, half open, a downscaled agent screenshot, a turn mid-gesture | the server’s transform | 5.1, 5.2 |
| E9 | idb’s HID stream with two touches, for a pinch | whether iOS pinches from the viewer | 5.2 |
| E10 | SystemUI’s demo mode for a clean status bar on Android 15 | whether Android has status_bar | 6.2 |
| E11 | Which way left and right turn on each platform | that the toolbar’s arrows turn the way they point | 6.2, 6.3 |
| E12 | A UiAutomation server in the device against uiautomator dump; beside an app’s instrumentation tests; back to the dump when it fails |
Android’s tree time | 7.3 |
| E13 | Autocorrect off by preferences, checked by typing “Hello idb 123” | the image’s keyboard defaults | 3.1, 3.2 |
| E14 | Tap to screen through BB, on pixels decoded in the browser: 100 taps, median, 95th percentile and worst; gaps between frames; with a build running in the box, and on Android with the emulator pinned to its cores; the first tap after a reconnect | the device bar, below | 5.1, 5.2 |
| E15 | Segments decoded at 15 fps keep still stretches cut as on desktops | the recording’s clip | 7.4 |
| E16 | Every path’s time to a booted device and every switch’s to its first drawn frame, five times each, with a failed switch | the ready-time and switch tables | 7.5 |
| E17 | The Pixel Fold closed, half open and open, and the Pixel Tablet, against the Pixel 8: frames a second busy, cores drawing, ms a frame in bb-box-h264, memory after 10 minutes’ use; and two device boxes on one host |
the android kind’s sizes per model, and whether the tablet needs more than 8 cores | 2.4 |
| E18 | Safari on macOS and on iOS: whether WebCodecs decodes the device’s H.264 at each model’s size, and the stream plays | whether stills are Safari’s way, or a fallback seldom seen | 2.3 |
| E19 | Whether the pinned emulator can bind its gRPC to loopback only | whether the firewall is the only wall, or the second | 3.1 |
| E20 | Whether running an app from Xcode in the host opens Device Hub, and whether a preference stops it | the iOS host desktop’s warning and the image’s default | 3.2 |
| E21 | How soon after a paste the clipboard can be put back on each platform without the paste reading the old text; Allow Paste found and answered | the paste’s wait | 5.1, 5.2 |
| E22 | An app’s own request fails offline, is slow on slow, and is quick again on online | that the network presets do what they say | 6.2 |
| E23 | What the emulator’s next boot keeps when it was killed without saving its snapshot | what a crash of the VM loses | 4.1 |
| E24 | Disk, allocated and apparent: each image, a used device, a snapshot save, a hibernated or suspended spare, and a build and trial beside the current image | the sizes above, and the room a spare needs | 3.1, 3.2, 7.5 |
The device bar
Devices are held to about 90 ms at the median from a tap to the screen through BB, against 50 ms for desktops (060), and E14 reports the 95th percentile and the worst beside it. The reasons:
- Most of it is the device’s own time from a touch to drawing: 50 to 60 ms in the box before any stream, on both platforms. No stream can remove it.
- The stream itself adds 1 to 2 ms on iOS and about 20 ms on Android, where the device has no hardware encoder.
- Device Hub’s own window, captured, was slower: 86 ms in the box alone, before BB.
- The rest is BB’s path, which desktops share: about 25 ms.
If E14 shows the median over 100 ms, or a 95th percentile over 150 ms on an idle box, the unit stops and the lead decides.
Android’s bar, from unit 5.1’s E14: about 70 to 90 ms at the median through BB, 67 ms idle and 87 ms with two Gradle builds busy in the box. Its floor is the device’s own drawing on the CPU, 50 to 60 ms from a touch to the encoded frame, which a host with KVM and a GPU would lower. Its 95th percentile through BB, 76 to 346 ms idle, is BB’s server standing still while the input waits, which desktops share (#1158).
Changes to the brief
- Switching is under 3 s only for an iOS model booted before in that box. The brief said both platforms switch warm in under 3 s. Android takes 5.0 to 6.7 s end to end, stopping included. iOS takes the shutdown and 1.3 to 1.9 s for a device booted before in the same VM, 13.1 s for a clone of one booted once, and 14 to 68 s for a first boot. E1 and E16 measure it, and the picker warns.
- Refused, not held. The brief said the agent’s device input waits while a person holds control, as on desktops. Desktops refuse at once, and so does this. Reads go on, which desktops do not allow, and input already queued is dropped.
- A toolbar click takes control. The brief kept one owner of input. That stays; a click on a control now takes control as Take control does, rather than asking the person to take it first.
- box_device has
lock,wakeandrecentsadded, andscreenshotandopen_urlleft tocomputer_screenshotandcomputer_launch. - The device server is one program for both platforms, in TypeScript, delivered by the plugin by version. On iOS it fronts idb_companion, which the brief named as the stream.
- No host desktop on android boxes in v1, and on ios boxes the host’s desktop is the person’s only.
- Spares wait suspended or hibernated with the device booted, not running, so they hold no memory; off by default, an operator’s choice.
Rejected
- Device Hub’s or the emulator’s window, captured. A third of the device’s pixels on a VM’s screen, 22 to 32 ms slower, and on iOS it takes the pasteboard. Both notes.
- scrcpy for the emulator. It encodes in the guest with Android’s software encoder, and fell to 12 fps at full size. It stays the way for real phones later.
- The
resizableAndroid profile, switched in place. It switches only with the emulator’s window open. - Several booted devices per box. About 1 GiB each on iOS and 5 GB on Android, for a switch of a few seconds that one device already gives.
- Apple’s device chrome from the box’s Xcode. It is Apple’s artwork, and needs a licence call; a frame of our own does not.
- A tool call that waits for a person’s hand-back. It holds the agent’s turn for as long as the person tests.
- A server in Swift or Go. A second language for one program, a build in each image, and a protocol that could drift from the plugin’s.
- Delivering the server whenever its hash differs. Two plugin instances during a reload would each send their own.
- A paste per character. It replaces the device’s clipboard each time, and shows Android’s notice once a letter.
- The host’s Linux desktop on android boxes. Xtigervnc, Selkies and Cua in a VM whose emulator already takes 7 of 8 cores when busy, for a need not yet shown.
- Falling back to the host’s desktop where device video cannot play. A person’s clicks would reach the wrong screen.
Consequences
- A thread gets a phone, a foldable or a tablet, shown as the owner pictured it, which the agent drives through the tools it already knows, and records.
- Device video is over 060’s desktop bar: about 80 to 90 ms from a tap to the screen through BB, most of it the device’s own time.
- An Android device draws on the CPU: busy, the phone takes 7 of 8 cores and shows at most about 35 fps. The foldable open and the tablet have 1.6 times its pixels, and E17 says what they cost; they may need more cores or show fewer frames. A host with both KVM and a GPU would lift this; none has both today.
- A build in the box competes with drawing: a Gradle build during a scroll will drop the frame rate. E14 measures by how much, and whether pinning the emulator to its cores helps.
- An ios box takes a Mac’s macOS VM slot, so ios and macOS boxes compete for a Mac’s two.
- The Android template is about 18 GB larger than the Linux VM template, and each model added costs about 4.5 GB more. A suspended or hibernated spare costs its memory again in disk.
- The device server’s protocol moves with the plugin; the images change only for what they install.
- idb_companion rides on Apple’s private frameworks. A new Xcode can stop the ios kind until idb follows; the last good image keeps it running meanwhile.
- adb and simctl in
box_runcan send input while a person holds control. bb-box’s own tools cannot. - Device boxes have no sound in v1.
Defaults set by the lead
These answer the first draft’s open questions. They are written into the note above; the owner may still change any of them.
- Models: iPhone 18 Pro, iPhone 17 and iPad Pro 13-inch (M5); Pixel 8, Pixel Fold and Pixel Tablet. iPhone 18 Pro and Pixel 8 are the defaults. The ios image grows by about 0.6 GB, the Android template by about 18 GB, about 4.5 GB a model.
- Spares: one ios and one android by default, kept only where a host has room for them; none elsewhere. The owner changed this on 28 October 2026: spares are off by default, an opt-in.
- iOS network: no control in v1.
- The host’s desktop: the computer tools drive the device only. A person may open an ios box’s host desktop, with the warning about Device Hub. An android box has no host desktop in v1.
- The device bar: about 80 to 90 ms from tap to screen through BB, for the reasons under The device bar.
The owner’s answers
Given on 26 October 2026, to the questions the revision left open:
-
Licences.
- Android’s encoder: both.
bb-box-h264uses x264 where the operator’s own image build has built it from source, and OpenH264, with Cisco’s patent-licensed binary as Windows uses it, where it has not. bb-box never ships an x264 binary. E7 measures OpenH264’s cost, and the encoder in use is shown in the box’s details. - idb_companion: Meta’s pinned bottle in the ios image, with its notice. bb-box’s own server only if idb falls behind Xcode.
- The Android SDK’s terms: the operator accepts them, as a step they confirm in the setup guide, and the template never leaves their install.
Before bb-box is published with device boxes, the exact SDK and system image terms, x264’s obligations for a helper built in the image, and the notices each image must carry are checked, with legal advice if the owner wants it.
- Android’s encoder: both.
-
A toolbar click takes control, as Take control does, and says so.
-
The lead’s defaults stand: the first 6 models, spares where a host has room, no iOS network control in v1, the computer tools drive the device only, and about 80 to 90 ms from tap to screen as the device bar.
-
Spares are an opt-in (28 October 2026): “it should be an opt-in configuration and off default”. This replaces the spares default in 3.
Review answers
Two reviews read the first draft: one by Claude Fable 5.1 (F below) and one by GPT-6 Astra (A below). Both said to build after changes. Where they agreed, the note changed.
Claude Fable 5.1
| Finding | Answer | |
|---|---|---|
| F1 | Tablet and foldable performance is not measured; the kind’s size rests on the phone | Changed. E17 measures them in unit 2.4, before the Android viewer; the kind’s sizes come from it; Context and Consequences say what is not known |
| F2 | Delivery by hash fights itself across a reload; a restart breaks a recording | Changed. Delivery is by version, only forward, only by the lease holder, never under a person’s control or a recording, logged as 045 does: Delivering the server’s code |
| F3 | launchd’s 10 s throttle, systemd’s start limit, touches held when a connection drops, box stop and start | Changed. ThrottleInterval 1, RestartSec=1 and a start limit that ends in words; touches lifted on every end; stop and start in When things stop and start, checked in 4.1 and 4.2 |
| F4 | A paste per character clobbers the clipboard and floods prompts | Changed. Batched, the clipboard saved and put back, Allow Paste answered by the server: Text other than ASCII. Type text is suggested for non-English text |
| F5 | The device display has no fallback and no place in the video settings | Changed. It ignores the video setting and VNC, falls back to stills with words, and Safari is checked in 2.3 (E18) |
| F6 | Illegal states in the data model; iOS status bar and battery conflated; no runtime | Changed. Discriminated models and state, posture on foldables only, network Android only, battery inside iOS’s status bar override, runtime on each offered device: The data shape |
| F7 | The host desktop can start Device Hub | Changed. A warning in the Desktop view, a watch with Quit Device Hub, and E20 for Xcode’s Run and a preference |
| F8 | 2.1 is three units; doctor, docs, settings, forms come late; template build and refresh unplaced | Changed. 2.1 is split into 2.1, 2.2 and 2.3; each unit names its docs, settings, doctor and conformance; the template’s build and monthly refresh are in 3.1 |
| F9 | Docker in the box can undo the firewall | Changed. A table of bb-box’s own at priority -300, which Docker does not manage; checked from a container and a tailnet peer; the emulator does not start without it |
| F10 | The host desktop on android boxes is scope not asked for | Changed. No host desktop on android in v1; the template is the Linux VM template’s |
| F11 | Licences left open; unused runtimes in the iOS image | Changed. Licences are an open question with options. The runtimes stay: the ios image is an APFS clone of the macOS image, so removing them from it saves nothing while the macOS image keeps them |
| F12 | Every toolbar control needs Take control first | Changed. A toolbar click takes control and says so; open question 2. person-has-control has device words |
| F13 | Smaller points: lock has no wake; three key vocabularies; Alt sent while pinching; auto-rotate; audio; suspended Tart spares are new; builds compete with drawing | Changed, each: lock and wake with awake in the state; W3C names for agents; Alt never sent while it pinches; auto-rotate on in the image; no sound in v1; unit 7.5 names the Tart backend change; builds measured in E14, with core pinning, and in Consequences |
GPT-6 Astra
| Finding | Answer | |
|---|---|---|
| A1 | The viewer cannot tell two Selkies displays apart | Changed. Displays by ID in the route, sessions and relay; input and clipboard per display; no fall-through to the host; unit 2.3 is this alone |
| A2 | The shared types allow illegal states | Changed. Discriminated arms, exact screen counts, posture on foldables only, lifecycle variants with an error only when failed, validators at every boundary, compile-time negative cases in 2.1 |
| A3 | No durable contract for switching and recovery | Changed. Request IDs and stored answers, deadlines, outcome-unknown, a written intent per switch step, reattach not restart, rollback to the old model, and the event table: Operations and recovery. Stale sockets and discovery files are cleared |
| A4 | Refusing new input does not stop input in flight | Changed. Owner generations fence input; takeover drops queued input, lifts gestures and abandons an unsent paste; hand-back, switch and restart lift all; control belongs to the viewer across displays: Fencing input. Checked in 5.1 mid-swipe and mid-paste |
| A5 | The driver cannot ask for a key frame; no bounded queues | Changed. VideoSource.keyframe(), key frames with SPS and PPS, one raw frame slot, per-viewer pacing, a bounded recorder queue, encoder restart: The stream. E5 asks idb for key frames on a still screen |
| A6 | Port protection is not fail-closed | Changed. A firewall unit the emulator requires and checks before each start; IPv4 and IPv6; all four ports; no and wrong tokens checked; a token per start; discovery files by live process; tokens and keys cleared from the image. E19 tries a loopback bind |
| A7 | No runtime identity, no gate for idb’s private APIs | Changed. OfferedDevice.runtime names the runtime, Xcode or emulator and companion; the image’s check grows to a real stream, tap, paste, tree and screenshot before promotion; the doctor fails an untested combination; the last good image stays. Our own server is named as unproven |
| A8 | Rotation needs physical, content and video geometry | Changed. Geometry has all three, an active screen on foldables and a generation; stale points are refused; input waits for the new frame; the animation holds until the frame comes; E8 covers every case named |
| A9 | Latency and capacity rest on the easiest case | Changed. The estimate is labelled provisional; E14 measures decoded pixels in the browser with the tail, under a build and after a reconnect; E17 covers the larger devices and two boxes on a host; a least size for the kind. Declined: a lower stream resolution as an automatic fallback in v1; too small a box is refused instead |
| A10 | Viewer input is less complete than claimed | Changed. Committed text apart from keys; the wheel as a swipe; Alt and drag, and two fingers on touch screens; right click as a long press; checked with a real input method, dead keys and a trackpad in 5.1 |
| A11 | The image contract is not complete enough for a refresh | Changed. A kept and cleared table; pins by URL and SHA-256; snapshot origin in the record; the record’s path corrected; project images keep the standard devices; 049’s trial and promotion; stale spares retired |
| A12 | The licence conclusions are not settled | Changed. Put to the owner as open question 1, with a check before publication. This note no longer says the SDK question is settled |
| A13 | State values claim more than the controls prove | Changed. Observed against Override, with outside changes; complete network presets checked by an app’s requests (E22); iOS battery named as the status bar’s picture; read-back times to be measured; each platform’s own text-size steps. Geometry is read at once, not every 10 s |
| A14 | App installation advertises an unsupported case | Changed. Simulator builds only on iOS, with words for device builds; APKs and split APKs on Android, words for bundles; the toolchain is the project’s image; replacing an installed app is refused under a person’s control. Declined: a build-install-launch check in every unit; it is in the computer use units’ checks through the agent’s own work |
| A15 | Agent tools need stronger targeting and semantics | Changed. Identifiers and actionable flags in elements; ambiguous references refused; set_value on fields only; scroll within its container, stopping when still; settling after actions; relative turn against absolute to; parameters exactly per action; outcome-unknown. E12 covers instrumentation tests and the fallback |
| A16 | The phase plan understates dependencies | Changed. Foundations split into 2.1 to 2.3; the codec proved in 2.2; the helper in 2.4 before its image in 3.1; images after 2.1; drivers’ read-backs before the frame; doctor, settings, guides, the form, the CLI and conformance named in each unit’s Also. Conformance runs the test device everywhere, and real device cases wherever an offer has one |
| A17 | Recording needs an idle and failure contract | Changed. A monotonic timeline, a key frame with SPS and PPS per segment, still stretches held to the stop, gaps captioned, ffmpeg’s filters checked on both OSes, and a live check of a still clip, no viewer and a server killed mid-recording. A server update waits for the recording |
| A18 | Switch times understated; app data not explained | Changed. End-to-end switch times, 5.0 to 6.7 s on Android; apps per device, said in the picker; a failed switch restores the old model; E16 times to a drawn frame |
| A19 | Storage and spares need admission rules | Changed. E24 measures disk, allocated and apparent, with builds and trials beside; a spare only where a host has room after its reserve; per kind, not per host. Declined in part at first: spares were to be on by default where there is room. The owner made them an opt-in, off by default, on 28 October 2026 |
| A20 | The toolbar needs a usability gate | Changed. Explicit narrow widths and a row of the most used; a check at 360 px, 480 px and 200% zoom, by keyboard, touch and screen reader; blocked controls explained without hover, and a click takes control; screenshot downloads where the clipboard is refused; Open link added to More; reboot and location say what they do |