Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
250682a
test: touches, buttons, scrolls and keys on an iOS simulator reach th…
V3RON Oct 26, 2026
c832b39
feat(input): an iOS simulator's device input writes touches to the ca…
V3RON Oct 26, 2026
f13a4d5
test: writeStdin delivers bytes to a child's stdin and rejects when t…
V3RON Oct 26, 2026
e6d0341
feat(ports): a byte pipe's stdin can be written, and a failed write r…
V3RON Oct 26, 2026
c2fc996
test: home on an iOS simulator relaunches SpringBoard and lock, app s…
V3RON Oct 26, 2026
5298249
feat(input): Home on an iOS simulator relaunches SpringBoard with sim…
V3RON Oct 26, 2026
6c10eeb
test: scrolls and keys on an iOS 26 simulator are written to the HID …
V3RON Oct 26, 2026
6400082
feat(input): scrolls and keys reach the iOS HID helper (#1019)
V3RON Oct 26, 2026
5f9dca5
test: a key on an iOS 27 or unknown runtime is dropped and the config…
V3RON Oct 26, 2026
2984ad9
feat(input): keys on an iOS 27 or unknown runtime are dropped and the…
V3RON Oct 26, 2026
dec2af8
test: an iOS open rejects for a missing helper, an early exit or no s…
V3RON Oct 26, 2026
cd26646
feat(input): an iOS open fails for a missing, early-exiting or silent…
V3RON Oct 26, 2026
02aad41
test: a physical iPhone's device input is unavailable and no process …
V3RON Oct 26, 2026
cd2035d
feat(input): a physical iPhone's device input is unavailable (#1019)
V3RON Oct 26, 2026
8b4b142
test: closing stops the iOS helper without failing the input, and a f…
V3RON Oct 26, 2026
01bc334
feat(input): a failed write fails the iOS input and stops the helper …
V3RON Oct 26, 2026
aa4e672
feat(input): the capture program's input mode, the vendored HID injec…
V3RON Oct 26, 2026
1dbf0d0
refactor(input): the iOS input is built beside the driver, not in its…
V3RON Oct 26, 2026
a259c8a
chore: drop scratch files from the branch (#1019)
V3RON Oct 26, 2026
e2082f3
chore(input): the iOS input has a manual, and its tests pin the env, …
V3RON Oct 26, 2026
2ffb9ae
fix(input): scroll messages overlap on the injector so a wheel burst …
V3RON Oct 26, 2026
61436c5
docs(adr): ADR 0049 is Accepted (#1019)
V3RON Oct 27, 2026
e760afc
docs(adr): ADRs 0030 and 0033 are Accepted (#1019)
V3RON Oct 27, 2026
631eb64
docs(ios): tighten the driver manual to 150 lines (#1019)
V3RON Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘
| core/warm-pool | What stays booted, and warm targets | [warm-pool](../../src/core/warm-pool/AGENTS.md) |
| core/capacity | Limits, RAM budget, reservations | [capacity](../../src/core/capacity/AGENTS.md) |
| core/usage | Usage figures from the history rows, read on a thread | [usage](../../src/core/usage/AGENTS.md) |
| drivers | The `Driver` port's two implementations | [drivers](../../src/drivers/AGENTS.md) · [android](../../src/drivers/android/AGENTS.md) · [android screen](../../src/drivers/android/screen/AGENTS.md) · [android input](../../src/drivers/android/input/AGENTS.md) · [ios](../../src/drivers/ios/AGENTS.md) |
| drivers | The `Driver` port's two implementations | [drivers](../../src/drivers/AGENTS.md) · [android](../../src/drivers/android/AGENTS.md) · [android screen](../../src/drivers/android/screen/AGENTS.md) · [android input](../../src/drivers/android/input/AGENTS.md) · [ios](../../src/drivers/ios/AGENTS.md) · [ios input](../../src/drivers/ios/input/AGENTS.md) |
| gateway | Fleet queue, routing, forwarding to workers, relay | [gateway](../../src/gateway/AGENTS.md) · [routing](../../src/gateway/routing/AGENTS.md) |
| alerts | Gateway only: chosen fleet events POSTed to webhook destinations | [alerts](../../src/alerts/AGENTS.md) |
| link | Byte channels over the uplink | [link](../../src/link/AGENTS.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/VENDORED-CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ license text(s) they require under `LICENSES/`, and a `NOTICE.md`.

| What | Upstream | Version / commit | License | Where it lives | Consumed by | Re-verify with |
|---|---|---|---|---|---|---|
| serve-sim's capture and encode pipeline — CoreSimulator framebuffer capture and the H.264 encode path, patched by us, with four files rewritten by us that stay serve-sim's modified files | `EvanBacon/serve-sim` | commit `6d009686ed08f7d8bd19bca9f9f67a9237931f44` | Apache-2.0 for serve-sim's files and our rewrites of four of them; MIT for the files that are ours entirely | `vendor/screen-capture/Sources/`, licenses at `vendor/screen-capture/LICENSES/serve-sim.APACHE-2.0.txt` and `…/idevice.MIT.txt` | the `simlock-screen-capture` and `simlock-device-capture` programs `pnpm build` puts in dist/bin, run by `src/drivers/ios/screen-capture.ts` | the `curl … \| diff -u -` command in [`vendor/screen-capture/NOTICE.md`](../../vendor/screen-capture/NOTICE.md), per file |
| serve-sim's capture, encode and HID-input pipeline — CoreSimulator framebuffer capture, the H.264 encode path and the `HIDInjector` that injects touches and keys, patched by us, with four files rewritten by us that stay serve-sim's modified files | `EvanBacon/serve-sim` | commit `6d009686ed08f7d8bd19bca9f9f67a9237931f44` | Apache-2.0 for serve-sim's files and our rewrites of four of them; MIT for the files that are ours entirely | `vendor/screen-capture/Sources/`, licenses at `vendor/screen-capture/LICENSES/serve-sim.APACHE-2.0.txt` and `…/idevice.MIT.txt` | the `simlock-screen-capture` and `simlock-device-capture` programs `pnpm build` puts in dist/bin, run by `src/drivers/ios/screen-capture.ts` and, in its `--input` mode, `src/drivers/ios/input/` | the `curl … \| diff -u -` command in [`vendor/screen-capture/NOTICE.md`](../../vendor/screen-capture/NOTICE.md), per file |
| the HEVC capability string the iPhone's media-stream offer advertises for payload 100 — one string inside one of our own files | `jkcoxson/idevice` | `master` at the time of taking; the string is what its receiver documents | MIT | `vendor/screen-capture/LICENSES/idevice.MIT.txt`, the string in `vendor/screen-capture/Sources/SimlockMediaBridge/SimlockMediaBridge.m` | `simlock-device-capture`, whose offer a physical iPhone accepts without painting torn pictures | the string in `Sources/SimlockMediaBridge/SimlockMediaBridge.m` against idevice's receiver, and its `LICENSE.txt` at `https://raw.githubusercontent.com/jkcoxson/idevice/master/LICENSE.txt` |
| `scrcpy-server` — the Android screen-streaming server artifact, byte-identical to upstream's release asset | `Genymobile/scrcpy` | release `v5.0.1`; revision built `dc8a55a24c27ae78ae202b5a3288da681d0f141a` | Apache-2.0; the Kotlin standard library inside it adds BSD-3-Clause (ThreeTen-BP) and BSL-1.0 (Boost) code | `vendor/scrcpy/scrcpy-server-v5.0.1`, licenses at `vendor/scrcpy/LICENSES/scrcpy.APACHE-2.0.txt` and `vendor/scrcpy/LICENSES/threetenbp.BSD-3-Clause.txt`, digest in `vendor/scrcpy/SHA256SUMS.txt` | the Android driver's screen capture, which pushes it unmodified to a leased emulator; the client that drives it is ours, in `src/drivers/android/`, and is not vendored | `cd vendor/scrcpy && shasum -a 256 -c SHA256SUMS.txt`, and the same against upstream's own release `SHA256SUMS.txt`, whose GPG signature `vendor/scrcpy/NOTICE.md` says how to check |

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0030. Optional modules share one contract and load only when turned on

- **Status:** Accepted — not yet implemented; §2 narrowed by [ADR 0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md); §3 narrowed by [ADR 0049](0049-a-lease-holder-watches-and-drives-its-devices-screen.md)
- **Status:** Accepted; §2 narrowed by [ADR 0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md); §3 narrowed by [ADR 0049](0049-a-lease-holder-watches-and-drives-its-devices-screen.md)
- **Date:** 2026-10-15
- **Issue:** [#591](https://github.com/callstackincubator/simlock/issues/591)
- **Supersedes:** nothing. Narrows [ADR
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0033. A sign-in proxy is a second way to sign in, and auth is one module

- **Status:** Accepted — not yet implemented
- **Status:** Accepted
- **Date:** 2026-10-19
- **Issue:** [#701](https://github.com/callstackincubator/simlock/issues/701)
- **Supersedes:** nothing. Amends [ADR
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0049. A lease holder watches and drives its device's screen

- **Status:** Accepted — not yet implemented
- **Status:** Accepted
- **Date:** 2026-10-26
- **Issue:** [#1013](https://github.com/callstackincubator/simlock/issues/1013)
- **Narrows:** [ADR 0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md)
Expand Down
6 changes: 3 additions & 3 deletions docs/internal/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ the status is stale.
| [0027](0027-dependency-cruiser-checks-every-component-boundary.md) | dependency-cruiser checks every component's boundary and the module graph | Accepted |
| [0028](0028-the-uplink-carries-byte-channels-and-small-messages-go-first.md) | The uplink carries byte channels beside its messages, and small messages go first | Accepted; §4 narrowed by 0049 |
| [0029](0029-host-load-is-an-optional-module-whose-readings-ride-on-status.md) | Host load is an optional module whose readings ride on status | Accepted |
| [0030](0030-optional-modules-share-one-contract-and-load-only-when-turned-on.md) | Optional modules share one contract and load only when turned on | Accepted — not yet implemented; §2 narrowed by 0046; §3 narrowed by 0049 |
| [0030](0030-optional-modules-share-one-contract-and-load-only-when-turned-on.md) | Optional modules share one contract and load only when turned on | Accepted; §2 narrowed by 0046; §3 narrowed by 0049 |
| [0031](0031-a-physical-iphone-streams-through-coredevice-behind-a-screen-source-seam.md) | A physical iPhone streams through CoreDevice's media stream, behind a screen-source seam | Accepted |
| [0032](0032-the-event-history-is-a-sqlite-database-with-projections-beside-it.md) | The event history is a SQLite database, with projections beside it | Accepted — not yet implemented; §1 narrowed by 0036 |
| [0033](0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md) | A sign-in proxy is a second way to sign in, and auth is one module | Accepted — not yet implemented |
| [0033](0033-a-sign-in-proxy-is-a-second-way-to-sign-in-and-auth-is-one-module.md) | A sign-in proxy is a second way to sign in, and auth is one module | Accepted |
| [0034](0034-one-worker-per-user-and-the-folder-says-what-a-daemon-is.md) | One worker per user, and the folder says what a daemon is | Accepted — not yet implemented |
| [0035](0035-a-gateway-sends-chosen-fleet-events-to-webhook-destinations.md) | A gateway sends chosen fleet events to webhook destinations | Accepted — not yet implemented |
| [0036](0036-daemon-state-is-a-sqlite-database-whose-tables-each-owner-keeps.md) | Daemon state is a SQLite database, and each owner keeps its own tables | Accepted — not yet implemented |
Expand All @@ -87,4 +87,4 @@ the status is stale.
| [0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md) | A standalone worker serves agent-device by default and hands local agents a private profile | Accepted — not yet implemented; narrows 0023 §1, §3–4, 0030 §2, 0039 and 0043 §4 |
| [0047](0047-device-exec-fills-in-the-leases-device.md) | `device.exec` fills in the lease's device, and refuses another | Accepted — not yet implemented; extends 0005 §19a; narrows 0003 §11 for one MCP tool |
| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` |
| [0049](0049-a-lease-holder-watches-and-drives-its-devices-screen.md) | A lease holder watches and drives its device's screen | Accepted — not yet implemented |
| [0049](0049-a-lease-holder-watches-and-drives-its-devices-screen.md) | A lease holder watches and drives its device's screen | Accepted |
3 changes: 1 addition & 2 deletions e2e/slow-agent-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,7 @@ function defineSuite(target: Target): void {
},
);

// why: the input socket's iOS device input arrives with #1019, so the case waits for it there.
it.skipIf(platform === "ios")(
it(
"an agent taps the centre of tab-status through the lease's input socket, as agent-device's snapshot places it, and the next snapshot shows the Status tab's screen",
{ timeout: 600_000 },
async () => {
Expand Down
1 change: 1 addition & 0 deletions src/daemon/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,7 @@ async function discoverIosDriver(
filesystem: options.filesystem,
idGenerator: options.idGenerator,
instanceId: options.instanceId,
logger: logger.child("ios"),
onSlimmed: emitSlimDiagnostic(options.eventBus),
screenCapture: {
baseEnvironment: process.env,
Expand Down
7 changes: 7 additions & 0 deletions src/drivers/ios/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Parent: [drivers](../AGENTS.md). Flows: [device selection](../../../docs/interna
| `devicectl-exec.ts` | `devicectlExecCommand`: the reviewed `devicectl` list |
| `slim-labels.ts` | `SLIM_CATEGORIES`, `resolveSlimCategories`, `labelsFor`, `slimSignature` |
| `prerequisites.ts` | `iosPrerequisites`: Xcode selected, license accepted, first launch done |
| `input/` | `createIosDeviceInput`: the lease's input socket on a simulator, through `simlock-screen-capture --input`; a physical iPhone answers unavailable |
| `runtime-version.ts` | `iosRuntimeVersionFromId`: the iOS major and minor a runtime id names |
| `screen-capture.ts` | `createIosScreenCapture`: runs `vendor/screen-capture/` on a simulator or a physical iPhone |

## How it works
Expand Down Expand Up @@ -93,6 +95,10 @@ Programs: dist/bin from `pnpm build`, or `drivers.ios.screenCaptureCommand` and
program enforces its pin: a CoreDevice major other than 642 exits 69 with the reason, a 503 for
the viewer. Asked to stop while still starting, a program stops and goes inside its 2 s.

Input — `input` (offered with `screenCapture`, same launcher): `simlock-screen-capture <udid> --input` with the same `SIMLOCK_IOS_DEVICE_SET` rule. stdout: one `0x82` `{width,height}` (the device type's native size) within 10 s of spawn, else the open rejects (also for a missing program or an early exit; the helper is stopped).
stdin: envelopes `[len][tag]{json}` for touch `0x03`, key `0x06` and scroll `0x0b`; a failed write or a later exit calls `onFailed`. Home runs `xcrun simctl [--set <root>] launch <udid> com.apple.springboard` (10 s; a failure is logged); the app switcher, Lock and Back are dropped.
The config is always `portrait`, with `keyboardUnavailable: true` when the runtime is iOS 27 or later or unreadable, and keys are then dropped. Close: stdin, 2 s, SIGKILL. A physical iPhone answers `{unavailable}` and starts nothing.

Advisories — `runtime-cache-unreclaimable` (asset bundles no installed runtime mounts);
`slim-runtime-unsupported` (installed runtimes below 18.5, only when `ios.defaultMode` is `slim`).

Expand Down Expand Up @@ -122,6 +128,7 @@ record of a device's mode; `driverData` holds the slim signature.

## Known gaps

- **Input:** no app switcher, Lock, rotation or keys on iOS 27 runtimes (#1024). The input follows the device's native frame, so after a rotation taps and picture stay in the same frame. After the health monitor reboots a crashed simulator the helper's HID client is bound to the old boot, so taps may vanish until the lease's input reopens.
- **A restart between `simctl create` and the durable mark under `default` (#968)** leaves one unlisted `simlock-<id>` simulator in Xcode's set. Delete it with `xcrun simctl delete <udid>`.
- **A runtime download outlives the runtime (#79).** `simctl runtime delete` leaves its ~8.5 GiB asset bundle in `/System/Library/AssetsV2`. Simlock never deletes it; `doctor` and `component remove` report it. Reclaim in Xcode → Settings → Platforms.
- **Slim costs (#87, #172).** Every reclaim erases, so the next boot re-slims: two boots each
Expand Down
39 changes: 27 additions & 12 deletions src/drivers/ios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ComponentInstallResult,
type ComponentReceipt,
type ComponentRemoval,
type DeviceInputFactory,
COMPONENT_REMOVAL_TIMEOUT_MS,
type ExactDeviceRequest,
type Driver,
Expand Down Expand Up @@ -50,6 +51,7 @@ import type {
Clock,
Filesystem,
IdGenerator,
Logger,
ProcessResult,
ProcessRunner,
} from "../../ports/index.js";
Expand All @@ -58,6 +60,8 @@ import { refuseOwnSetLeftovers, refuseXcodeSetLeftovers } from "./device-set-lef
import { devicectlExecCommand } from "./devicectl-exec.js";
import { fillSimctlDevice } from "./simctl-device.js";
import { IosPhysicalHandler } from "./physical.js";
import { createIosDeviceInput } from "./input/index.js";
import { iosRuntimeVersionFromId } from "./runtime-version.js";
import { createIosScreenCapture } from "./screen-capture.js";
import { labelsFor, resolveSlimCategories, slimSignature } from "./slim-labels.js";

Expand Down Expand Up @@ -216,6 +220,8 @@ export interface IosSimctlDriverOptions {
readonly bootTimeoutMs: number;
};
readonly slimByDefault?: boolean;
// why: the device input logs a Home press that did nothing; nothing else in the driver logs through it.
readonly logger?: Logger;
// why: absent when this host has no way to run the capture program; the driver then offers no
// screen at all rather than one that fails on every open.
readonly screenCapture?: {
Expand Down Expand Up @@ -349,9 +355,11 @@ export class IosSimctlDriver implements Driver {
? {}
: { onLog: options.screenCapture.onLog }),
});
this.input = createInputFor(options, deviceRoot, rootOptions !== undefined);
}

readonly screenCapture: ScreenCapture | undefined;
readonly input: DeviceInputFactory | undefined;

/**
* why: Establishes the device set this driver owns before it can be asked to do anything,
Expand Down Expand Up @@ -1778,6 +1786,24 @@ class IosRuntimeUnavailableForDownloadError extends RuntimeMissingError {
}
}

// why: the input is offered exactly when the screen capture is, because both are the one program.
function createInputFor(
options: IosSimctlDriverOptions,
deviceRoot: string,
ownedRoot: boolean,
): DeviceInputFactory | undefined {
if (options.screenCapture === undefined) return undefined;
return createIosDeviceInput({
baseEnvironment: options.screenCapture.baseEnvironment,
clock: options.clock,
command: configuredScreenCaptureCommand(options) ?? options.screenCapture.command,
...(ownedRoot ? { deviceSet: deviceRoot } : {}),
...(options.logger === undefined ? {} : { logger: options.logger }),
launcher: options.screenCapture.launcher,
processRunner: options.processRunner,
});
}

function configuredScreenCaptureCommand(options: IosSimctlDriverOptions): string | undefined {
const configured = options.driverConfig["screenCaptureCommand"];
return typeof configured === "string" && configured !== "" ? configured : undefined;
Expand Down Expand Up @@ -2257,18 +2283,7 @@ function iosDriverData(device: DriverDevice): IosDriverData {
};
}

export function iosRuntimeVersionFromId(runtimeId: string): readonly [number, number] | undefined {
const match = /iOS-(\d+)(?:-(\d+))?(?:-(\d+))?$/.exec(runtimeId);
if (match === null) {
return undefined;
}
const major = Number.parseInt(match[1] ?? "", 10);
const minor = match[2] === undefined ? 0 : Number.parseInt(match[2], 10);
if (Number.isNaN(major) || Number.isNaN(minor)) {
return undefined;
}
return [major, minor];
}
export { iosRuntimeVersionFromId };

export function supportsPersistentSlim(version: readonly [number, number] | undefined): boolean {
if (version === undefined) {
Expand Down
21 changes: 21 additions & 0 deletions src/drivers/ios/input/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# iOS device input

Does: taps, scrolls, Home and keys on an iOS simulator, through `simlock-screen-capture <udid> --input`; a physical iPhone answers unavailable.
Entry: `index.ts` → `createIosDeviceInput(options)`, built by `IosSimctlDriver` as its `input` whenever it has a `screenCapture`. It is the implementation of core's `DeviceInputFactory` (`core/input.ts`).
Uses: core (`DeviceInput`, `InputMessage`, the screen envelope), ports (`BytePipeLauncher`, `Clock`, `Logger`, `ProcessRunner`), `../runtime-version.ts`, `../screen-capture.ts` (`stopProgram`). Parent: [iOS driver](../AGENTS.md). Consumer: [input](../../../input/AGENTS.md).

## Files

| File | Role |
| ----------------- | ------------------------------------------- |
| `index.ts` | `createIosDeviceInput`, entered only here |
| `device-input.ts` | One input: open, send, Home, failure, close |

## How it works

The wire, the 10 s ready limit, Home, the dropped buttons and keys, and close are described under "Input" in the [iOS driver manual](../AGENTS.md).

## Rules

- The helper's HID client and the runtime rule for keys stay here; core and the input module name neither.
- Home is `xcrun simctl` here and nowhere in the helper (one owner).
1 change: 1 addition & 0 deletions src/drivers/ios/input/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
Loading
Loading