Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a5f744b
test: describeDeviceDataTable passes for iosDeviceDataTable (#757)
V3RON Oct 20, 2026
ece9186
feat(ios): iosDeviceDataTable stores a simulator's driver data in ios…
V3RON Oct 20, 2026
e01b31f
test: describeDeviceDataTable passes for androidDeviceDataTable (#757)
V3RON Oct 20, 2026
6d30e17
feat(android): androidDeviceDataTable stores an emulator's driver dat…
V3RON Oct 20, 2026
421a040
test: iosDeviceDataTable write refuses data that is not the driver's …
V3RON Oct 20, 2026
7f8f664
feat(ios): iosDeviceDataTable refuses data that is not the driver's s…
V3RON Oct 20, 2026
651b102
test: androidDeviceDataTable write refuses data that is not the drive…
V3RON Oct 20, 2026
5b27028
feat(android): androidDeviceDataTable refuses data that is not the dr…
V3RON Oct 20, 2026
563ebf4
test: resolveDeviceDataTables answers the iOS and Android tables with…
V3RON Oct 20, 2026
5ec0ea8
feat(daemon): production device-data tables hold the iOS and Android …
V3RON Oct 20, 2026
a7d0f55
test: a registry restarted on one state.db returns an iOS and an Andr…
V3RON Oct 20, 2026
49aa554
docs(drivers): the manuals name each driver's device-data table and i…
V3RON Oct 20, 2026
90b029b
fix(android): androidDeviceDataTable refuses a port that is not an in…
V3RON Oct 20, 2026
1b50737
Merge remote-tracking branch 'origin/task/761' into task/757
V3RON Oct 20, 2026
6d6ac4d
Merge remote-tracking branch 'origin/task/761' into task/757
V3RON Oct 20, 2026
05524ec
docs(ios): the iOS driver manual fits in 150 lines (#757)
V3RON Oct 20, 2026
132ec81
Merge remote-tracking branch 'origin/task/761' into task/757
V3RON Oct 20, 2026
fccee53
Merge remote-tracking branch 'origin/task/761' into task/757
V3RON Oct 20, 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 src/core/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const memorySqlite: SqliteOpener = {
open: (_path, options) => new NodeSqliteOpener().open(":memory:", options),
};

function openMemoryState(owners: readonly SqliteSchemaOwner[]): Promise<StateDatabase> {
export function openMemoryState(owners: readonly SqliteSchemaOwner[]): Promise<StateDatabase> {
return openStateDatabase({
dataDirectory: "/memory",
filesystem: new MemoryFilesystem(),
Expand Down
78 changes: 78 additions & 0 deletions src/daemon/device-data-tables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

import { EventBus } from "../bus/index.js";
import { Registry, registryOwner } from "../core/index.js";
import { androidDeviceDataTable } from "../drivers/android/index.js";
import { iosDeviceDataTable } from "../drivers/ios/index.js";
import { FakeClock, NodeFilesystem, NodeSqliteOpener, NoopLogger } from "../ports/index.js";
import { openStateDatabase } from "../state/index.js";
import { productionDeviceDataTables } from "./main.js";

describe("the production device-data tables", () => {
it("a registry restarted on one state.db returns an iOS and an Android device's driverData unchanged", async () => {
const directory = await mkdtemp(join(tmpdir(), "simlock-device-data-"));
try {
const clock = new FakeClock(1_000);
const iosData = {
deviceTypeId: "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
name: "simlock-ios-1",
runtimeId: "com.apple.CoreSimulator.SimRuntime.iOS-18-4",
slimMarkToken: "tok",
slimSignature: "sig",
udid: "9F2C0000-0000-0000-0000-000000000001",
};
const androidData = {
avdName: "simlock_1",
configHash: "ab12",
port: 5554,
serial: "emulator-5554",
};
let next = 0;
const open = async () =>
Registry.load({
clock,
deviceData: productionDeviceDataTables(),
eventBus: new EventBus(clock),
idGenerator: { generate: () => String((next += 1)) },
state: await openStateDatabase({
dataDirectory: directory,
filesystem: new NodeFilesystem(),
logger: new NoopLogger(),
oldFiles: [],
owners: [registryOwner, iosDeviceDataTable, androidDeviceDataTable],
sqlite: new NodeSqliteOpener(),
upgradeStep: "remove it.",
}),
});
const registry = await open();
await registry.registerDevice({
driverData: iosData,
driverDeviceId: "drv-ios",
provisionDuration: 1,
spec: { model: "iPhone 16", osVersion: "18.4", physical: false, platform: "ios" },
});
await registry.registerDevice({
driverData: androidData,
driverDeviceId: "drv-android",
provisionDuration: 1,
spec: { model: "Pixel 8", osVersion: "35", physical: false, platform: "android" },
});

const restarted = await open();
const byDriverId = new Map(
restarted.snapshot.virtualDevices.map((device) => [
device.driverDeviceId,
device.driverData,
]),
);
expect(byDriverId.get("drv-ios")).toStrictEqual(iosData);
expect(byDriverId.get("drv-android")).toStrictEqual(androidData);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
});
9 changes: 6 additions & 3 deletions src/daemon/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
} from "../core/testing.js";
import { openStateDatabase } from "../state/index.js";
import { fakeModule, type FakeModule } from "../modules/testing.js";
import { IosSimctlDriver } from "../drivers/ios/index.js";
import { androidDeviceDataTable } from "../drivers/android/index.js";
import { IosSimctlDriver, iosDeviceDataTable } from "../drivers/ios/index.js";
import { EventBus, type EventEnvelope } from "../bus/index.js";
import { readHistoryEvents, seedHistory } from "../history/testing.js";
import { DAEMON_PROTOCOL_VERSION } from "../daemon-protocol/index.js";
Expand Down Expand Up @@ -3766,8 +3767,10 @@ describe("startDaemon state.db", () => {
expect(await resolveDeviceDataTables({ deviceDataTables: optionMap })).toBe(optionMap);

delete process.env.SIMLOCK_DRIVERS_MODULE;
expect(await resolveDeviceDataTables({})).toEqual(productionDeviceDataTables());
expect(productionDeviceDataTables().size).toBe(0);
const production = await resolveDeviceDataTables({});
expect(production).toEqual(productionDeviceDataTables());
expect(production.get("ios")).toBe(iosDeviceDataTable);
expect(production.get("android")).toBe(androidDeviceDataTable);

process.env.SIMLOCK_DRIVERS_MODULE = await writeModule(
"export function createDrivers() { return []; }",
Expand Down
9 changes: 7 additions & 2 deletions src/daemon/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ import {
hostAbiFor,
SdkMissingError,
type AndroidEmulatorLaunchOptions,
androidDeviceDataTable,
androidPrerequisites,
} from "../drivers/android/index.js";
import {
IOS_PASSTHROUGH_TOOLS,
IosSimctlDriver,
type SlimmedFact,
iosDeviceDataTable,
iosPrerequisites,
} from "../drivers/ios/index.js";
import { type Auth, createAuth } from "../auth/index.js";
Expand Down Expand Up @@ -1120,9 +1122,12 @@ const WORKER_UPGRADE_STEP =
const GATEWAY_UPGRADE_STEP =
"remove <path> and start again; then create each worker's join token again with `simlock --gateway token create --role worker`, set it as that worker's `gateway.token`, and drain workers again.";

/** why: the drivers' table code, which the production map fills in once the real drivers have tables. */
/** why: both tables whatever the host's platform, so a device whose driver was refused still loads. */
export function productionDeviceDataTables(): ReadonlyMap<Platform, DeviceDataTable> {
return new Map();
return new Map([
["android", androidDeviceDataTable],
["ios", iosDeviceDataTable],
]);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/drivers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Manuals: [Android](android/AGENTS.md), [iOS](ios/AGENTS.md). Flows: [device sele
(absolute), default `$SIMLOCK_HOME/devices/<platform>`.
A refusal (`OwnedRootError`) skips that platform only and emits `driver.root-rejected`.
2. **Scope every command to the root.** Ownership is membership in the root, never a name prefix.
3. **Opaque to core.** `address`, `driverData`, `deviceRoot` and `drivers.<platform>.*` are carried unread.
3. **Opaque to core.** `address`, `driverData`, `deviceRoot` and `drivers.<platform>.*` are carried unread. Each driver package exports a `DeviceDataTable` (`iosDeviceDataTable`, `androidDeviceDataTable`) that stores its `driverData` in its own `state.db` table, built without a driver instance so it loads where the driver is refused; `daemon/main.ts` `productionDeviceDataTables` passes both on every host.
4. **Prerequisites** — `iosPrerequisites`, `androidPrerequisites` return a `PrerequisiteCheck`, not a
`Driver` method; built even when the driver did not start, run only by `doctor`.
5. **Installers** — `installer-process.ts`: `runInstallerProcess` parses `%` progress from both
Expand Down
29 changes: 15 additions & 14 deletions src/drivers/android/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@ Parent: [drivers](../AGENTS.md). Flows: [device selection](../../../docs/interna

## Files

| File | Role |
| -------------------------- | ------------------------------------------------------------------------------- |
| `index.ts` | `AndroidDriver`: every `Driver` verb, console-port allocator, passthrough rules |
| `adb-server.ts` | `AdbServerSupervisor`: start, adopt, reap Simlock's adb server |
| `adb-registrar.ts` | `AdbRegistrar`: announces an emulator (`host:emulator:<adbPort>`) |
| `screen/` | Screen capture: scrcpy client ([screen](screen/AGENTS.md)) |
| `adb-host-protocol.ts` | adb host-protocol framing (`adbHostRequest`, `OKAY`/`FAIL`), for both callers |
| `default-adb-server.ts` | `DefaultAdbServerGate`: protocol check of the server on 5037 |
| `physical-handler.ts` | `AndroidPhysicalHandler`: enrol, presence, reclaim, passthrough for phones |
| `device-profile-source.ts` | `DeviceProfileRegistry`; SDK and devices.xml profile sources |
| `profiles/index.ts` | `LibraryDeviceProfileSource`: Simlock's 16 profiles |
| `image-fit.ts` | `fits`, `misfitReason`: which system image a profile takes |
| `sdk-paths.ts` | `locateSdk`: SDK root and tool paths, shared with `prerequisites.ts` |
| `data.ts` | `AndroidDriverData`: `avdName`, `configHash`, `imageIdentity`, `port`, `serial` |
| File | Role |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `index.ts` | `AndroidDriver`: every `Driver` verb, console-port allocator, passthrough rules |
| `device-data.ts` | `androidDeviceDataTable`: owner `android-device-data` version 1, table `android_device_data` (`avd_name`, `config_hash`, nullable `image_identity`, `port`, `serial`) |
| `adb-server.ts` | `AdbServerSupervisor`: start, adopt, reap Simlock's adb server |
| `adb-registrar.ts` | `AdbRegistrar`: announces an emulator (`host:emulator:<adbPort>`) |
| `screen/` | Screen capture: scrcpy client ([screen](screen/AGENTS.md)) |
| `adb-host-protocol.ts` | adb host-protocol framing (`adbHostRequest`, `OKAY`/`FAIL`), for both callers |
| `default-adb-server.ts` | `DefaultAdbServerGate`: protocol check of the server on 5037 |
| `physical-handler.ts` | `AndroidPhysicalHandler`: enrol, presence, reclaim, passthrough for phones |
| `device-profile-source.ts` | `DeviceProfileRegistry`; SDK and devices.xml profile sources |
| `profiles/index.ts` | `LibraryDeviceProfileSource`: Simlock's 16 profiles |
| `image-fit.ts` | `fits`, `misfitReason`: which system image a profile takes |
| `sdk-paths.ts` | `locateSdk`: SDK root and tool paths, shared with `prerequisites.ts` |
| `data.ts` | `AndroidDriverData`: `avdName`, `configHash`, `imageIdentity`, `port`, `serial` |

## How it works

Expand Down
36 changes: 36 additions & 0 deletions src/drivers/android/device-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { describeDeviceDataTable, openMemoryState } from "../../core/testing.js";
import { androidDeviceDataTable } from "./device-data.js";

const base = {
avdName: "simlock_1",
configHash: "ab12",
port: 5554,
serial: "emulator-5554",
};

describeDeviceDataTable("androidDeviceDataTable", androidDeviceDataTable, [
{ ...base, imageIdentity: "system-images;android-35;google_apis;arm64-v8a@3" },
base,
]);

describe("androidDeviceDataTable write", () => {
it("refuses, naming the platform and the device, a missing required key, a required key of the wrong type, an optional key of the wrong type and an extra key", async () => {
const { serial: _serial, ...missing } = base;
const cases: Record<string, unknown> = {
"missing required key": missing,
"required key of the wrong type": { ...base, port: "5554" },
"port that is not an integer": { ...base, port: 5554.5 },
"port that is NaN": { ...base, port: Number.NaN },
"optional key of the wrong type": { ...base, imageIdentity: 5 },
"extra key": { ...base, extra: "x" },
};
const state = await openMemoryState([androidDeviceDataTable]);
for (const [label, value] of Object.entries(cases)) {
expect(() => androidDeviceDataTable.write(state.database, "dev_1", value), label).toThrow(
"Invalid android driver data for device dev_1",
);
}
state.close();
});
});
76 changes: 76 additions & 0 deletions src/drivers/android/device-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { DeviceDataTable } from "../../core/index.js";
import type { AndroidDriverData } from "./data.js";

interface AndroidDeviceDataRow {
readonly avd_name: string;
readonly config_hash: string;
readonly image_identity: string | null;
readonly port: number;
readonly serial: string;
}

const STRING_KEYS = ["avdName", "configHash", "serial"] as const;
const KNOWN_KEYS = new Set<string>([...STRING_KEYS, "imageIdentity", "port"]);

function isAndroidDeviceData(value: unknown): value is AndroidDriverData {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
return (
Object.keys(record).every((key) => KNOWN_KEYS.has(key)) &&
STRING_KEYS.every((key) => typeof record[key] === "string") &&
Number.isInteger(record.port) &&
(record.imageIdentity === undefined || typeof record.imageIdentity === "string")
);
}

export const androidDeviceDataTable: DeviceDataTable = {
migrations: [
`CREATE TABLE android_device_data (
device_id TEXT PRIMARY KEY,
avd_name TEXT NOT NULL,
config_hash TEXT NOT NULL,
image_identity TEXT,
port INTEGER NOT NULL,
serial TEXT NOT NULL
) STRICT`,
],
name: "android-device-data",
read(database, deviceId) {
const row = database
.prepare(
"SELECT avd_name, config_hash, image_identity, port, serial FROM android_device_data WHERE device_id = ?",
)
.get(deviceId) as AndroidDeviceDataRow | undefined;
if (row === undefined) throw new Error(`No android driver data for device ${deviceId}`);
return {
avdName: row.avd_name,
configHash: row.config_hash,
...(row.image_identity === null ? {} : { imageIdentity: row.image_identity }),
port: row.port,
serial: row.serial,
};
},
version: 1,
write(database, deviceId, driverData) {
if (!isAndroidDeviceData(driverData)) {
throw new Error(`Invalid android driver data for device ${deviceId}`);
}
const data = driverData;
database
.prepare(
`INSERT INTO android_device_data (device_id, avd_name, config_hash, image_identity, port, serial)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (device_id) DO UPDATE SET avd_name = excluded.avd_name,
config_hash = excluded.config_hash, image_identity = excluded.image_identity,
port = excluded.port, serial = excluded.serial`,
)
.run(
deviceId,
data.avdName,
data.configHash,
data.imageIdentity ?? null,
data.port,
data.serial,
);
},
};
1 change: 1 addition & 0 deletions src/drivers/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2697,6 +2697,7 @@ function portAllocatorFor(processRunner: ProcessRunner, adb: string): PortAlloca
allocationsByRunner.set(processRunner, allocator);
return allocator;
}
export { androidDeviceDataTable } from "./device-data.js";
export { androidPrerequisites } from "./prerequisites.js";

function virtualReclaimStrategy(clean: "standard" | "full"): "snapshot" | "wipe" {
Expand Down
18 changes: 9 additions & 9 deletions src/drivers/ios/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ Parent: [drivers](../AGENTS.md). Flows: [device selection](../../../docs/interna

## Files

| File | Role |
| ------------------- | ------------------------------------------------------------------------------------------- |
| `index.ts` | `IosSimctlDriver`: every `Driver` verb, slim pass, `simctl` passthrough rules |
| `physical.ts` | `IosPhysicalHandler`: enrol, presence, reclaim through `devicectl` |
| `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 |
| `screen-capture.ts` | `createIosScreenCapture`: runs `vendor/screen-capture/` on a simulator or a physical iPhone |
| File | Role |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `index.ts` | `IosSimctlDriver`: every `Driver` verb, slim pass, `simctl` passthrough rules |
| `device-data.ts` | `iosDeviceDataTable`: owner `ios-device-data` version 1, table `ios_device_data` (`udid`, `name`, `device_type_id`, `runtime_id`, nullable `slim_signature`, `slim_mark_token`) |
| `physical.ts` | `IosPhysicalHandler`: enrol, presence, reclaim through `devicectl` |
| `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 |
| `screen-capture.ts` | `createIosScreenCapture`: runs `vendor/screen-capture/` on a simulator or a physical iPhone |

## How it works

Expand Down Expand Up @@ -80,7 +81,6 @@ Passthrough —
before `--`. Adds `--device <udid>` unless the caller names one.

Lease environment: virtual `SIMLOCK_IOS_DEVICE_SET=<root>`; physical `SIMLOCK_DEVICE_UDID=<udid>`.

Screen — `screenCapture` (only when `create` is given a launcher): `simlock-screen-capture
<udid> --codec avcc --fps --bitrate --max-dimension` with `SIMLOCK_IOS_DEVICE_SET=<root>` for a
simulator; a physical iPhone runs `simlock-device-capture` with the same arguments, no such
Expand Down
Loading
Loading