Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
"version": "0.1.139",
"version": "0.1.140",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
Expand Down
2 changes: 1 addition & 1 deletion docs/devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ You can request fullscreen or a tool-only workflow. Planning, docs, code review,

Install Android SDK platform-tools and emulator. Create an AVD in Android Studio or connect an Android device and authorize adb access. The Android panel lists devices without booting one. Selecting an AVD boots it if needed, then starts the bundled Node.js serve-emu CLI on a private loopback port. Home, Back, Recents, Lock, pointer gestures, and typing use scrcpy's control socket. Closing the panel leaves the emulator running. AVDs start without a separate emulator window. A failed emulator process reports its exit right away instead of waiting for the boot timeout.

The Android dropdown shows **Connected devices** first, with USB or Wi-Fi labels, then **Emulators**. It refreshes every three seconds while the Android panel is visible, and when opening the dropdown. Physical devices use ADB discovery: enable USB debugging and authorize the computer, or pair the device for wireless debugging. Offline and unauthorized devices remain visible with their state. Selecting an authorized phone connects its screen and shares its serial and transport with the chat. Bundled scrcpy mirrors and controls the phone without installing a companion app. Physical Android devices support the existing screen, input, screenshot, native log, and performance tools; emulator boot and stop controls do not apply to them.
The Android dropdown shows **Connected devices** first, with USB or Wi-Fi labels, then **Emulators**. It refreshes every three seconds while the Android panel is visible, and when opening the dropdown. Physical devices use ADB discovery: enable USB debugging and authorize the computer, or pair the device for wireless debugging. Offline and unauthorized devices remain visible with their state. Emulators keep their AVD name while unauthorized. An emulator restored from a quick-boot snapshot can stay unauthorized because adb does not repeat the key exchange; the panel reconnects adb at most every 5 seconds while an AVD boots, and for up to 15 seconds after you select an already running emulator. Selecting an authorized phone connects its screen and shares its serial and transport with the chat. Bundled scrcpy mirrors and controls the phone without installing a companion app. Physical Android devices support the existing screen, input, screenshot, native log, and performance tools; emulator boot and stop controls do not apply to them.

Android H.264 packets travel through MCP resource reads. The panel decodes them with WebCodecs, so the host must support H.264 `VideoDecoder`. Each device gets its own backend and each panel gets its own stream session. Decoder errors and video backlog request a fresh keyframe on the same connection. The panel drops delta frames until it can decode that keyframe. Requests have a cooldown, and stale decoder callbacks cannot repaint a closed stream. The plugin reuses a matching serve-emu server at port 3300. Set `SERVE_EMU_URL` to reuse another loopback HTTP server; it must already stream the selected serial. Closing MCP stops only backends the plugin started.

Expand Down
2 changes: 2 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

[Back to README](../README.md) · [Contributing](../CONTRIBUTING.md)

Since 0.1.140, `android.emulator.adb_reconnects` counts adb transport reconnects for emulators that stay unauthorized, with a fixed `trigger` (`boot` while waiting for an AVD, `selection` for an already running emulator). It uses the simulator surface and Android emulator context and sends no serials, AVD names or adb output. Existing Android startup diagnostics and MCP tool errors cover the boot outcome.

Since 0.1.139, `ios.accessibility_bridge.restarts` counts restarts of the simulator's CoreSimulatorBridge after a describe-UI read reported no accessibility data, with a fixed `outcome` (`recovered`, `unavailable` or `failed`). Failed restarts also report a handled `simulator.accessibility_bridge` error. The existing `inspection.tool` errors, sampled MCP traces and `ui.annotations.inspection` timings cover the retried read. No device identifiers, accessibility content or command output are sent.

Since 0.1.134, physical iOS capture runs in the shared `ios-mirror-service` Node process. Existing native packet-processing, input acknowledgement, connection, resource, and crash instrumentation stays attached to the active capture. Native resource measurements now overlap that service's Node process rather than each chat's MCP process. The service uses centralized server initialization, its packaged development/release environment, and server-owned anonymous installation/process-session identity. UI decode/render timings and sampled MCP operations retain their existing boundaries. Subscriber keyframe requests no longer invalidate the shared native queue or release another panel’s touch. The native packet-processing and recovery measurements remain on this active path; the rebuilt addon retains matching debug symbols.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
"version": "0.1.139",
"version": "0.1.140",
"private": true,
"type": "module",
"engines": {
Expand Down
12 changes: 12 additions & 0 deletions scripts/build-serve-emu.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ async function sourceHash() {
return hash.digest("hex");
}

// npm ci records what it installed. Refuse to package dependencies from an older lockfile.
async function assertInstalledDependencies() {
const packages = async path => {
const text = await readFile(join(runtime, path), "utf8").catch(() => "{}");
const entries = Object.entries(JSON.parse(text).packages ?? {}).filter(([name]) => name);
return JSON.stringify(entries.map(([name, item]) => [name, item.version]).sort());
};
const [locked, installed] = await Promise.all([packages("package-lock.json"), packages("node_modules/.package-lock.json")]);
if (locked !== installed) throw new Error("runtimes/serve-emu/node_modules does not match its package-lock.json. Run npm run vendor:serve-emu.");
}

export async function buildServeEmu(directory) {
const outputDirectory = resolve(directory);
if (outputDirectory === runtime) throw new Error("Build the Android runtime into a separate output directory.");
Expand All @@ -48,6 +59,7 @@ export async function buildServeEmu(directory) {
const scrcpyClientPath = join(runtime, "src/scrcpy.ts");
const scrcpyClientSHA256 = await sha256(scrcpyClientPath);
const sourceSHA256 = await sourceHash();
await assertInstalledDependencies();
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
for (const path of ["src", "scripts", "node_modules", "vendor", "LICENSE", "README.md", "upstream.json", "package-lock.json"]) {
Expand Down
49 changes: 37 additions & 12 deletions src/server/serve-emu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { adbPath } from "./native-logs.ts";
import { errorMessage, parseBaseUrl } from "../shared/protocol.ts";
import type { SimulatorDevice, Status } from "../shared/protocol.ts";
import { SimulatorUnavailableError } from "./simulator-unavailable.ts";
import { recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts";
import { recordAndroidEmulatorReconnect, recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts";
import { androidStartupMessageSchema, androidDeviceState, setAndroidStartupDiagnostic } from "../shared/android-startup-diagnostics.ts";
import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummary } from "../shared/android-startup-diagnostics.ts";

Expand All @@ -35,6 +35,7 @@ export function androidDisplaySize(output: string, video: { width: number; heigh

export class ServeEmu {
private readonly avdNames = new Map<string, string>();
private readonly reconnects = new Map<string, number>();
private readonly backends = new Map<string, Backend>();
private readonly booting = new Map<string, Promise<Status>>();
private readonly lifetime = new AbortController();
Expand Down Expand Up @@ -79,7 +80,8 @@ export class ServeEmu {
const model = rawModel?.replace(/_/g, " ");
let name = model ?? match[1];
const emulatorDevice = /^emulator-\d+$/.test(match[1]);
if (emulatorDevice && match[2] === "device") {
// The emulator console names the AVD before adb authorizes the device.
if (emulatorDevice) {
const response = await execute(adb, ["-s", match[1], "emu", "avd", "name"], { timeout: 3000 }).catch(() => undefined);
const avdName = parseAvdName(response?.stdout);
if (avdName) this.avdNames.set(match[1], avdName);
Expand All @@ -93,7 +95,7 @@ export class ServeEmu {
})()];
});
const running = await Promise.all(runningReads);
for (const serial of this.avdNames.keys()) if (!running.some(device => device.udid === serial)) this.avdNames.delete(serial);
for (const serials of [this.avdNames, this.reconnects]) for (const serial of serials.keys()) if (!running.some(device => device.udid === serial)) serials.delete(serial);
const avdOutput = avds.stdout.trim();
const avdLines = avdOutput.split(/\r?\n/);
const stoppedNames = avdLines.filter(name => {
Expand Down Expand Up @@ -133,22 +135,45 @@ export class ServeEmu {
private async ensureBooted(id: string): Promise<Status> {
const device = await this.device(id);
if (device.state === "Booted") return this.list();
if (device.kind === "emulator" && device.state === "unauthorized") return this.awaitRunningEmulator(id);
if (!id.startsWith("avd:")) throw new SimulatorUnavailableError("Reconnect and authorize this Android device through adb.");
const adb = await adbPath();
// Launch separately from serve-emu so closing the panel never stops the AVD.
return bootAndroidEmulator({
name: device.name, executable: await this.emulatorPath(), signal: this.lifetime.signal,
ready: async () => {
const status = await this.list();
if (!status.connected) throw new Error(status.error);
const running = status.devices.find(item => item.kind === "emulator" && item.name === device.name && item.state === "Booted");
if (!running) return;
const response = await execute(adb, ["-s", running.udid, "shell", "getprop", "sys.boot_completed"], { timeout: 3000 }).catch(() => undefined);
if (response?.stdout.trim() === "1") return status;
},
ready: () => this.emulatorReady(item => item.name === device.name, "boot"),
});
}

// Ready means adb authorized the emulator and Android finished booting.
private async emulatorReady(matches: (device: SimulatorDevice) => boolean, trigger: "boot" | "selection"): Promise<Status | undefined> {
const status = await this.list();
if (!status.connected) throw new Error(status.error);
const running = status.devices.find(item => item.kind === "emulator" && matches(item));
if (running?.state === "unauthorized") await this.reconnectEmulator(running.udid, trigger);
if (running?.state !== "Booted") return;
const response = await execute(await adbPath(), ["-s", running.udid, "shell", "getprop", "sys.boot_completed"], { timeout: 3000 }).catch(() => undefined);
if (response?.stdout.trim() === "1") return status;
}

// A quick-boot snapshot can restore an emulator after adb has marked it unauthorized, and adb
// never checks again. Reconnecting the transport repeats the key exchange, at most every 5 s.
private async reconnectEmulator(serial: string, trigger: "boot" | "selection") {
if (Date.now() - (this.reconnects.get(serial) ?? -Infinity) < 5000) return;
this.reconnects.set(serial, Date.now());
recordAndroidEmulatorReconnect(trigger);
await execute(await adbPath(), ["-s", serial, "reconnect"], { timeout: 5000 }).catch(() => {});
}

private async awaitRunningEmulator(serial: string): Promise<Status> {
const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(15000)]);
while (!signal.aborted) {
const status = await this.emulatorReady(device => device.udid === serial, "selection");
if (status) return status;
await delay(500, undefined, { signal }).catch(() => {});
}
throw new SimulatorUnavailableError("The Android emulator is still unauthorized. Restart it with a cold boot, or run adb kill-server and try again.");
}

async shutdown(id: string) {
androidIdSchema.parse(id);
if (!/^emulator-\d+$/.test(id)) throw new Error("Only Android emulators can be shut down from this panel.");
Expand Down
6 changes: 6 additions & 0 deletions src/server/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export function recordAccessibilityBridgeRestart(outcome: "recovered" | "unavail
Sentry.metrics.count("ios.accessibility_bridge.restarts", 1, { attributes });
}

export function recordAndroidEmulatorReconnect(trigger: "boot" | "selection") {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", surface: "simulator", device_platform: "android", device_kind: "emulator", trigger };
Sentry.metrics.count("android.emulator.adb_reconnects", 1, { attributes });
}

export function recordAndroidBackendStartup(duration: number, outcome: "ready" | "failed") {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", surface: "simulator", device_platform: "android", outcome };
Expand Down
2 changes: 1 addition & 1 deletion src/shared/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const PLUGIN_VERSION = "0.1.139";
export const PLUGIN_VERSION = "0.1.140";
17 changes: 17 additions & 0 deletions tests/android-devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ if (args[0] === "devices") {
process.stdout.write(output);
}
else if (args.join(" ") === "-s emulator-5554 emu avd name") console.log("RunningAVD\\nOK");
else if (args.join(" ") === "-s emulator-5554 shell getprop sys.boot_completed") console.log("1");
else if (args.join(" ") === "-s emulator-5554 reconnect") {
const output = fs.readFileSync(${JSON.stringify(devicesPath)}, "utf8");
fs.writeFileSync(${JSON.stringify(devicesPath)}, output.replace("emulator-5554 unauthorized", "emulator-5554 device"));
}
else process.exit(1);
`, { mode: 0o755 });
const emulator = join(emulatorDirectory, "emulator");
Expand Down Expand Up @@ -104,3 +109,15 @@ test("Android discovery identifies USB, TCP, mDNS and IPv6 phones separately fro
const remainingPhysical = disconnected.devices.filter(device => device.kind === "physical");
assert.equal(remainingPhysical.length, 0);
});

test("an emulator restored while adb marked it unauthorized keeps its AVD name and reconnects when selected", async t => {
const f = await fixture(t, "List of devices attached\nemulator-5554 unauthorized transport_id:7\n", ["RunningAVD"]);
const before = await f.backend.list();
// The console names the AVD, so it is not listed again as a stopped AVD.
assert.deepEqual(before.devices, [{ udid: "emulator-5554", name: "RunningAVD", state: "unauthorized", runtime: "Android", platform: "android", kind: "emulator" }]);
const status = await f.backend.boot("emulator-5554");
assert.equal(status.devices[0].state, "Booted");
assert.ok((await f.calls()).some(args => args.join(" ") === "-s emulator-5554 shell getprop sys.boot_completed"), "Selection waits for Android to finish booting.");
const reconnects = (await f.calls()).filter(args => args.join(" ") === "-s emulator-5554 reconnect");
assert.equal(reconnects.length, 1);
});
Loading