From f74a6339cc31bbb8273e8133da06da22c80c4181 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 14:42:32 +0200 Subject: [PATCH 01/18] feat(serve-emu): throttle emulator networks and switch to a clean status bar /api/network now reads and sets the emulator console's latency and bandwidth alongside the radio toggle, and rejects throttles for physical devices. The new /api/status-bar enters or leaves SystemUI demo mode at 9:41 with full battery and signal. Demo mode cannot be queried, so entering it records the boot ID and a reboot reads back as live. Wi-Fi-only devices without a telephony service can still go offline. --- runtimes/serve-emu/src/adb.ts | 142 +++++++++++++++++- runtimes/serve-emu/src/api/routes/devices.ts | 95 +++++++++++- runtimes/serve-emu/src/server/api-boundary.ts | 2 + .../serve-emu/src/shared/api-contracts.ts | 30 +++- tests/serve-emu-device-conditions.test.ts | 105 +++++++++++++ 5 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 tests/serve-emu-device-conditions.test.ts diff --git a/runtimes/serve-emu/src/adb.ts b/runtimes/serve-emu/src/adb.ts index 925efda2..ee567dc4 100644 --- a/runtimes/serve-emu/src/adb.ts +++ b/runtimes/serve-emu/src/adb.ts @@ -1,3 +1,4 @@ +// Modified for Mobile Dev: emulator network throttling and status bar demo mode. import { spawn } from "node:child_process"; import { execBuffer, execText, type ExecResult } from "./exec.ts"; @@ -31,7 +32,13 @@ export type NetworkStatus = { wifi: string; mobileData: string; }; + /** Emulator console throttle; null on physical devices. */ + throttle?: NetworkThrottle | null; }; +/** Latency in ms and symmetric bandwidth in kbps; null bandwidth is unlimited. */ +export type NetworkThrottle = { latencyMs: number; bandwidthKbps: number | null }; +export type StatusBarMode = "clean" | "live"; +export type StatusBarStatus = { mode: StatusBarMode }; function execFailed(result: ExecResult): boolean { return result.status !== 0 || result.error !== null; @@ -325,7 +332,9 @@ export async function setNetworkEnabled( const r = await runExec("adb", ["-s", serial, "shell", ...args], { timeout: ADB_MUTATION_TIMEOUT_MS, }); - if (execFailed(r)) { + // Wi-Fi-only devices have no telephony service to toggle mobile data on. + const noTelephony = service === "data" && /Can't find service: phone/.test(`${r.stdout}${r.stderr}`); + if (execFailed(r) && !noTelephony) { throw new Error( `adb shell ${args.join(" ")} failed: ${execFailure(r)}`, ); @@ -333,3 +342,134 @@ export async function setNetworkEnabled( } return getNetworkStatus(serial, runExec); } + +const EMULATOR_SERIAL = /^emulator-\d+$/; + +export function isEmulatorSerial(serial: string): boolean { + return EMULATOR_SERIAL.test(serial); +} + +// The console answers OK or KO on stdout, and adb can exit 0 after a KO. +async function emulatorConsole( + serial: string, + args: string[], + runExec: typeof execText = execText, +): Promise { + if (!isEmulatorSerial(serial)) { + throw new Error("network throttling is supported for Android Emulator serials only"); + } + const r = await runExec("adb", ["-s", serial, "emu", ...args], { + timeout: ADB_MUTATION_TIMEOUT_MS, + }); + const output = `${r.stdout}${r.stderr}`.trim(); + if (execFailed(r) || /^KO\b/m.test(output)) { + throw new Error(`adb emu ${args.join(" ")} failed: ${output || execFailure(r)}`); + } + return output; +} + +export function parseEmulatorNetworkStatus(output: string): NetworkThrottle { + const download = /download speed:\s*(\d+) bits\/s/.exec(output); + const latency = /minimum latency:\s*(\d+) ms/.exec(output); + if (!download || !latency) { + throw new Error("adb emu network status returned an unexpected format"); + } + const bits = Number(download[1]); + return { latencyMs: Number(latency[1]), bandwidthKbps: bits === 0 ? null : bits / 1000 }; +} + +export async function getEmulatorThrottle( + serial: string, + runExec: typeof execText = execText, +): Promise { + if (!isEmulatorSerial(serial)) return null; + return parseEmulatorNetworkStatus( + await emulatorConsole(serial, ["network", "status"], runExec), + ); +} + +export function parseNetworkThrottle(value: unknown): NetworkThrottle { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("throttle must be an object"); + } + const { latencyMs, bandwidthKbps } = value as Record; + if (!Number.isInteger(latencyMs) || (latencyMs as number) < 0 || (latencyMs as number) > 10_000) { + throw new Error("throttle.latencyMs must be an integer between 0 and 10000"); + } + if ( + bandwidthKbps !== null && + (!Number.isInteger(bandwidthKbps) || (bandwidthKbps as number) < 1 || (bandwidthKbps as number) > 1_000_000) + ) { + throw new Error("throttle.bandwidthKbps must be null or an integer between 1 and 1000000"); + } + return { latencyMs: latencyMs as number, bandwidthKbps: bandwidthKbps as number | null }; +} + +/** Throttles upload and download equally; the emulator applies it to all guest traffic. */ +export async function setEmulatorThrottle( + serial: string, + throttle: NetworkThrottle, + runExec: typeof execText = execText, +): Promise { + const speed = throttle.bandwidthKbps === null + ? "full" + : `${throttle.bandwidthKbps}:${throttle.bandwidthKbps}`; + await emulatorConsole(serial, ["network", "speed", speed], runExec); + await emulatorConsole(serial, ["network", "delay", String(throttle.latencyMs)], runExec); + return getEmulatorThrottle(serial, runExec); +} + +const DEMO_BROADCAST = "am broadcast -a com.android.systemui.demo -e command"; +// A clean marketing status bar: 9:41, full battery and signal, no notification icons. +const CLEAN_STATUS_BAR = [ + "enter", + "clock -e hhmm 0941", + "battery -e level 100 -e plugged false", + "network -e wifi show -e level 4", + "network -e mobile show -e datatype none -e level 4", + "notifications -e visible false", +].map((command) => `${DEMO_BROADCAST} ${command}`); +// Demo mode cannot be queried and ends on reboot, so entering it records the boot it belongs to. +const STATUS_BAR_MARKER = "mobile_dev_status_bar_boot"; +const BOOT_ID = "cat /proc/sys/kernel/random/boot_id"; + +export async function getStatusBar( + serial: string, + runExec: typeof execText = execText, +): Promise { + const r = await runExec( + "adb", + ["-s", serial, "shell", `settings get global ${STATUS_BAR_MARKER}; ${BOOT_ID}`], + { timeout: ADB_QUERY_TIMEOUT_MS }, + ); + if (execFailed(r)) { + throw new Error(`reading the status bar mode failed: ${execFailure(r)}`); + } + const [marker, boot] = r.stdout.trim().split(/\s+/); + return { mode: marker && marker === boot ? "clean" : "live" }; +} + +export async function setStatusBar( + serial: string, + mode: StatusBarMode, + runExec: typeof execText = execText, +): Promise { + const command = mode === "clean" + ? [ + "settings put global sysui_demo_allowed 1", + ...CLEAN_STATUS_BAR, + `settings put global ${STATUS_BAR_MARKER} $(${BOOT_ID})`, + ] + : [ + `${DEMO_BROADCAST} exit`, + "settings put global sysui_demo_allowed 0", + `settings delete global ${STATUS_BAR_MARKER}`, + ]; + const r = await runExec("adb", ["-s", serial, "shell", command.join(" && ")], { + timeout: ADB_MUTATION_TIMEOUT_MS, + }); + if (execFailed(r)) { + throw new Error(`setting the status bar to ${mode} failed: ${execFailure(r)}`); + } + return getStatusBar(serial, runExec); +} diff --git a/runtimes/serve-emu/src/api/routes/devices.ts b/runtimes/serve-emu/src/api/routes/devices.ts index 902d843b..bb16c30d 100644 --- a/runtimes/serve-emu/src/api/routes/devices.ts +++ b/runtimes/serve-emu/src/api/routes/devices.ts @@ -1,15 +1,32 @@ +// Modified for Mobile Dev: emulator network throttling and status bar demo mode. import { + getEmulatorThrottle, getFontScale, getNetworkStatus, getNightMode, + getStatusBar, getUserRotation, + isEmulatorSerial, + parseNetworkThrottle, + setEmulatorThrottle, setFontScale, setNetworkEnabled, setNightMode, + setStatusBar, setUserRotation, + type NetworkStatus, + type NetworkThrottle, type NightMode, type OrientationMode, } from "../../adb.ts"; + +async function networkWithThrottle(serial: string, status?: NetworkStatus): Promise { + const [radios, throttle] = await Promise.all([ + status ?? getNetworkStatus(serial), + getEmulatorThrottle(serial), + ]); + return { ...radios, throttle }; +} import type { ApiDependencies } from "../dependencies.ts"; import type { ApiRoute } from "../router.ts"; @@ -400,7 +417,7 @@ export function deviceRoutes(): ApiRoute[] { return Response.json({ ok: true, network: await runForContext(requestContext, (context) => - getNetworkStatus(context.serial), + networkWithThrottle(context.serial), ), }); } catch (err) { @@ -432,14 +449,82 @@ export function deviceRoutes(): ApiRoute[] { ) { throw new Error("network payload must be an object"); } - const enabled = (payload as Record).enabled; - if (typeof enabled !== "boolean") { + const { enabled, throttle: rawThrottle } = payload as Record; + if (enabled !== undefined && typeof enabled !== "boolean") { throw new Error("enabled must be a boolean"); } + if (enabled === undefined && rawThrottle === undefined) { + throw new Error("network payload needs enabled or throttle"); + } + const throttle: NetworkThrottle | undefined = + rawThrottle === undefined ? undefined : parseNetworkThrottle(rawThrottle); + if (throttle && !isEmulatorSerial(requestContext.serial)) { + throw new Error("network throttling is supported for Android Emulator serials only"); + } return Response.json({ ok: true, - network: await runForContext(requestContext, (context) => - setNetworkEnabled(context.serial, enabled), + network: await runForContext(requestContext, async (context) => { + if (throttle) await setEmulatorThrottle(context.serial, throttle); + const radios = enabled === undefined + ? undefined + : await setNetworkEnabled(context.serial, enabled); + return networkWithThrottle(context.serial, radios); + }), + }); + } catch (err) { + return errorResponse(err); + } + }, + }, + { + method: "GET", + path: "/api/status-bar", + handler: async ({ deps }) => { + const { runForContext, requestContext, errorResponse } = deps; + try { + return Response.json({ + ok: true, + statusBar: await runForContext(requestContext, (context) => + getStatusBar(context.serial), + ), + }); + } catch (err) { + return errorResponse(err); + } + }, + }, + { + method: "POST", + path: "/api/status-bar", + handler: async ({ request: req, deps }) => { + const { + runForContext, + requestContext, + errorResponse, + readJsonBody, + MAX_JSON_BODY_BYTES, + } = deps; + try { + const payload = await readJsonBody( + req, + MAX_JSON_BODY_BYTES, + requestContext, + ); + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) + ) { + throw new Error("status bar payload must be an object"); + } + const mode = (payload as Record).mode; + if (mode !== "clean" && mode !== "live") { + throw new Error("mode must be clean or live"); + } + return Response.json({ + ok: true, + statusBar: await runForContext(requestContext, (context) => + setStatusBar(context.serial, mode), ), }); } catch (err) { diff --git a/runtimes/serve-emu/src/server/api-boundary.ts b/runtimes/serve-emu/src/server/api-boundary.ts index b5e8493e..45b5d090 100644 --- a/runtimes/serve-emu/src/server/api-boundary.ts +++ b/runtimes/serve-emu/src/server/api-boundary.ts @@ -1,3 +1,4 @@ +// Modified for Mobile Dev: emulator network throttling and status bar demo mode. export const MAX_JSON_BODY_BYTES = 8 * 1024; export const MAX_ROUTE_BODY_BYTES = 2 * 1024 * 1024; @@ -29,6 +30,7 @@ export const API_ROUTE_METHODS = { "/api/night-mode": ["GET", "POST"], "/api/font-scale": ["GET", "POST"], "/api/network": ["GET", "POST"], + "/api/status-bar": ["GET", "POST"], "/api/logcat": ["GET"], "/api/screenshot": ["GET", "POST"], "/api/foreground": ["GET"], diff --git a/runtimes/serve-emu/src/shared/api-contracts.ts b/runtimes/serve-emu/src/shared/api-contracts.ts index b71de520..20bf9eea 100644 --- a/runtimes/serve-emu/src/shared/api-contracts.ts +++ b/runtimes/serve-emu/src/shared/api-contracts.ts @@ -1,3 +1,4 @@ +// Modified for Mobile Dev: emulator network throttling and status bar demo mode. import { parseGesture, type Gesture } from "./control-contracts"; /** Stable error codes sent by every JSON API failure. */ @@ -95,9 +96,14 @@ export type NetworkStatus = { wifi: NetworkRadioStatus; mobileData: NetworkRadioStatus; raw: { wifi: string; mobileData: string }; + throttle?: NetworkThrottle | null; }; +export type NetworkThrottle = { latencyMs: number; bandwidthKbps: number | null }; export type NetworkResponse = ApiSuccess<{ network: NetworkStatus }>; +export type StatusBarMode = "clean" | "live"; +export type StatusBarResponse = ApiSuccess<{ statusBar: { mode: StatusBarMode } }>; + export type ForegroundApp = { packageName: string | null; activity: string | null; @@ -350,7 +356,11 @@ export type ApiContractMap = { }; "/api/network": { GET: EndpointContract; - POST: EndpointContract<{ enabled: boolean }, NetworkResponse>; + POST: EndpointContract<{ enabled?: boolean; throttle?: NetworkThrottle }, NetworkResponse>; + }; + "/api/status-bar": { + GET: EndpointContract; + POST: EndpointContract<{ mode: StatusBarMode }, StatusBarResponse>; }; "/api/logcat": { GET: EndpointContract }; "/api/screenshot": { @@ -675,10 +685,27 @@ export function parseNetworkResponse(value: unknown): NetworkResponse { wifi: string(raw.wifi, "network.raw.wifi"), mobileData: string(raw.mobileData, "network.raw.mobileData"), }, + ...(status.throttle === undefined ? {} : { throttle: parseNetworkThrottle(status.throttle) }), }, }; } +function parseNetworkThrottle(value: unknown): NetworkThrottle | null { + if (value === null) return null; + const throttle = record(value, "network.throttle"); + return { + latencyMs: number(throttle.latencyMs, "network.throttle.latencyMs"), + bandwidthKbps: throttle.bandwidthKbps === null ? null : number(throttle.bandwidthKbps, "network.throttle.bandwidthKbps"), + }; +} + +export function parseStatusBarResponse(value: unknown): StatusBarResponse { + const root = record(value, "status bar response"); + if (root.ok !== true) fail("status bar response.ok must be true"); + const status = record(root.statusBar, "statusBar"); + return { ok: true, statusBar: { mode: oneOf(status.mode, ["clean", "live"] as const, "statusBar.mode") } }; +} + function parseForegroundApp(value: unknown): ForegroundApp { const item = record(value, "foreground app"); return { @@ -1065,6 +1092,7 @@ export const API_SUCCESS_PARSERS = { "/api/night-mode": { GET: parseNightModeResponse, POST: parseNightModeResponse }, "/api/font-scale": { GET: parseFontScaleResponse, POST: parseFontScaleResponse }, "/api/network": { GET: parseNetworkResponse, POST: parseNetworkResponse }, + "/api/status-bar": { GET: parseStatusBarResponse, POST: parseStatusBarResponse }, "/api/logcat": { GET: unsupportedStreamingResponse }, "/api/screenshot": { GET: parseScreenshotResponse, POST: parseScreenshotResponse }, "/api/foreground": { GET: parseForegroundResponse }, diff --git a/tests/serve-emu-device-conditions.test.ts b/tests/serve-emu-device-conditions.test.ts new file mode 100644 index 00000000..e27a6da1 --- /dev/null +++ b/tests/serve-emu-device-conditions.test.ts @@ -0,0 +1,105 @@ +import test, { before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +type Adb = typeof import("../runtimes/serve-emu/src/adb.ts"); +type Exec = NonNullable[1]>; +let directory: string; +let adb: Adb; + +// serve-emu uses TypeScript syntax Node cannot strip, so the module is bundled like the packaged runtime. +before(async () => { + directory = await mkdtemp(join(tmpdir(), "mobile-dev-emu-conditions-")); + const output = join(directory, "adb.mjs"); + await build({ entryPoints: [resolve("runtimes/serve-emu/src/adb.ts")], outfile: output, bundle: true, platform: "node", format: "esm", target: "node22.18" }); + adb = await import(pathToFileURL(output).href); +}); + +after(async () => { await rm(directory, { recursive: true, force: true }); }); + +function device(respond: (args: string[]) => { stdout?: string; stderr?: string; status?: number }) { + const calls: string[][] = []; + const run = (async (_file: string, args: string[]) => { + calls.push(args); + const { stdout = "", stderr = "", status = 0 } = respond(args); + return { status, signal: null, stdout, stderr, timedOut: false, error: null }; + }) as Exec; + return { run, calls }; +} + +const consoleStatus = (bits: number, latency: number) => `Current network status: + download speed: ${bits} bits/s (${bits / 8192} KB/s) + upload speed: ${bits} bits/s (${bits / 8192} KB/s) + minimum latency: ${latency} ms + maximum latency: ${latency} ms +OK +`; + +test("emulator throttles go through the console and read back from network status", async () => { + assert.deepEqual(adb.parseEmulatorNetworkStatus(consoleStatus(0, 0)), { latencyMs: 0, bandwidthKbps: null }); + assert.deepEqual(adb.parseEmulatorNetworkStatus(consoleStatus(780000, 200)), { latencyMs: 200, bandwidthKbps: 780 }); + assert.throws(() => adb.parseEmulatorNetworkStatus("KO: unknown command"), /unexpected format/); + + let status = consoleStatus(0, 0); + const { run, calls } = device(args => { + if (args.slice(3).join(" ") === "network status") return { stdout: status }; + if (args[4] === "speed") status = consoleStatus(args[5] === "full" ? 0 : Number(args[5].split(":")[1]) * 1000, 0); + if (args[4] === "delay") status = status.replace(/latency:\s+\d+ ms/g, `latency: ${args[5]} ms`); + return { stdout: "OK\n" }; + }); + assert.deepEqual(await adb.setEmulatorThrottle("emulator-5554", { latencyMs: 200, bandwidthKbps: 780 }, run), { latencyMs: 200, bandwidthKbps: 780 }); + assert.deepEqual(calls.slice(0, 2), [["-s", "emulator-5554", "emu", "network", "speed", "780:780"], ["-s", "emulator-5554", "emu", "network", "delay", "200"]]); + assert.deepEqual(await adb.setEmulatorThrottle("emulator-5554", { latencyMs: 0, bandwidthKbps: null }, run), { latencyMs: 0, bandwidthKbps: null }); + assert.equal(calls.at(-3)?.[5], "full"); + + const rejected = device(() => ({ stdout: "KO: bad speed\n" })); + await assert.rejects(adb.setEmulatorThrottle("emulator-5554", { latencyMs: 0, bandwidthKbps: 1 }, rejected.run), /KO: bad speed/, "a KO answer fails even with exit status 0"); + const phone = device(() => ({ stdout: "OK\n" })); + assert.equal(await adb.getEmulatorThrottle("R5CT1234", phone.run), null); + await assert.rejects(adb.setEmulatorThrottle("R5CT1234", { latencyMs: 0, bandwidthKbps: null }, phone.run), /Emulator serials only/); + assert.equal(phone.calls.length, 0); + + assert.deepEqual(adb.parseNetworkThrottle({ latencyMs: 800, bandwidthKbps: 240 }), { latencyMs: 800, bandwidthKbps: 240 }); + for (const invalid of [{ latencyMs: -1, bandwidthKbps: null }, { latencyMs: 1.5, bandwidthKbps: null }, { latencyMs: 0, bandwidthKbps: 0 }, { latencyMs: 0 }, "3g"]) { + assert.throws(() => adb.parseNetworkThrottle(invalid), /throttle/); + } +}); + +test("a clean status bar holds only for the boot that entered demo mode", async () => { + let marker = "null"; + let boot = "boot-1"; + const { run, calls } = device(args => { + const command = args[3]; + if (command.startsWith("settings get global mobile_dev_status_bar_boot")) return { stdout: `${marker}\n${boot}\n` }; + if (command.includes("settings put global mobile_dev_status_bar_boot")) marker = boot; + if (command.includes("settings delete global mobile_dev_status_bar_boot")) marker = "null"; + return { stdout: "Broadcast completed: result=0\n" }; + }); + assert.deepEqual(await adb.getStatusBar("emulator-5554", run), { mode: "live" }); + assert.deepEqual(await adb.setStatusBar("emulator-5554", "clean", run), { mode: "clean" }); + const entered = calls.find(args => args[3].includes("command enter"))![3]; + assert.match(entered, /^settings put global sysui_demo_allowed 1 && am broadcast -a com\.android\.systemui\.demo -e command enter && /); + assert.match(entered, /-e command clock -e hhmm 0941/); + assert.match(entered, /settings put global mobile_dev_status_bar_boot \$\(cat \/proc\/sys\/kernel\/random\/boot_id\)$/); + boot = "boot-2"; + assert.deepEqual(await adb.getStatusBar("emulator-5554", run), { mode: "live" }, "a reboot leaves demo mode"); + boot = "boot-1"; + assert.deepEqual(await adb.setStatusBar("emulator-5554", "live", run), { mode: "live" }); + assert.match(calls.at(-2)![3], /command exit && settings put global sysui_demo_allowed 0 && settings delete global mobile_dev_status_bar_boot$/); +}); + +test("Wi-Fi-only devices still go offline and back without a telephony service", async () => { + const { run, calls } = device(args => { + if (args[3] === "svc" && args[4] === "data") return { stderr: "cmd: Can't find service: phone\n", status: 20 }; + if (args[3] === "settings") return { stdout: args[6] === "wifi_on" ? "0\n" : "null\n" }; + return {}; + }); + assert.equal((await adb.setNetworkEnabled("R5CT1234", false, run)).enabled, false); + assert.deepEqual(calls.slice(0, 2).map(args => args.slice(3)), [["svc", "wifi", "disable"], ["svc", "data", "disable"]]); + const broken = device(args => args[4] === "wifi" ? { stderr: "Failure\n", status: 1 } : {}); + await assert.rejects(adb.setNetworkEnabled("R5CT1234", true, broken.run), /svc wifi enable failed/); +}); From a87f77078ef04a7cdf64e3097fc77314b9e3b3a6 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 14:42:33 +0200 Subject: [PATCH 02/18] feat(settings): add a clean status bar and network conditions The Device tab switches to a clean status bar and simulates LTE, 3G, Edge, or offline networks. iOS uses Baguette's status-bar and network endpoints, and Android uses serve-emu, with the same latency and bandwidth for each preset. Physical Android devices offer only offline. On iOS the panel explains that throttling reaches apps launched afterwards and offers a relaunch. serve-emu rejection messages now reach the panel instead of a bare HTTP status. --- src/server/device-settings.ts | 100 +++++++++++++++++++++++--- src/server/serve-emu.ts | 9 ++- src/shared/device-settings.ts | 16 +++++ src/ui/components/device-settings.tsx | 22 +++++- src/ui/device-settings.ts | 6 ++ tests/device-settings.test.ts | 66 ++++++++++++++++- 6 files changed, 200 insertions(+), 19 deletions(-) diff --git a/src/server/device-settings.ts b/src/server/device-settings.ts index 13dd72f9..3855b6be 100644 --- a/src/server/device-settings.ts +++ b/src/server/device-settings.ts @@ -3,8 +3,10 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Baguette } from "./baguette.ts"; import { androidIdSchema, type ServeEmu } from "./serve-emu.ts"; +import { captureServerError } from "./telemetry.ts"; import { errorMessage, udidSchema } from "../shared/protocol.ts"; -import { contentSizes, deviceSettingSchema, locationSchema, type DeviceSetting, type DeviceSettings } from "../shared/device-settings.ts"; +import { ExpectedOperationError, expectedOutcome } from "../shared/error-reporting.ts"; +import { contentSizes, deviceSettingSchema, locationSchema, networkConditions, networkPresets, type DeviceSetting, type DeviceSettings, type NetworkCondition, type NetworkPreset } from "../shared/device-settings.ts"; const targetSchema = z.discriminatedUnion("platform", [ z.object({ platform: z.literal("ios"), id: udidSchema }).strict(), @@ -13,6 +15,29 @@ const targetSchema = z.discriminatedUnion("platform", [ type Target = z.infer; const appearance = z.enum(["light", "dark", "auto"]); const orientation = z.enum(["auto", "portrait", "landscape"]); +const unsupported = (backend: string) => new ExpectedOperationError("invalid_input", `${backend} does not expose this setting.`); + +// Baguette names its presets; the numbers in networkPresets match them. +const baguetteNetworkSchema = z.object({ active: z.boolean(), profile: z.string().nullish(), offline: z.boolean().optional() }).loose(); +function iosNetwork(value: unknown): DeviceSettings["network"] { + const data = baguetteNetworkSchema.parse(value); + if (!data.active) return "normal"; + if (data.offline) return "offline"; + return data.profile && data.profile in networkPresets ? data.profile as NetworkPreset : "custom"; +} +// A clean status bar is the marketing one: 9:41, full battery, full signal. +const cleanIosStatusBar = { time: "9:41", batteryState: "charged", batteryLevel: 100, wifiMode: "active", wifiBars: 3, cellularMode: "active", cellularBars: 4, operatorName: "" }; + +const throttleSchema = z.object({ latencyMs: z.number(), bandwidthKbps: z.number().nullable() }); +const androidNetworkSchema = z.object({ network: z.object({ enabled: z.boolean().nullable(), throttle: throttleSchema.nullish() }).loose() }); +function androidNetwork(value: unknown): DeviceSettings["network"] { + const { enabled, throttle } = androidNetworkSchema.parse(value).network; + if (enabled === false) return "offline"; + if (!throttle || throttle.latencyMs === 0 && throttle.bandwidthKbps === null) return "normal"; + const preset = Object.entries(networkPresets).find(([, entry]) => entry.latencyMs === throttle.latencyMs && entry.bandwidthKbps === throttle.bandwidthKbps); + return preset ? preset[0] as NetworkPreset : "custom"; +} +const isEmulator = (id: string) => /^emulator-\d+$/.test(id); export class DeviceSettingsService { private readonly baguette: Baguette; @@ -32,15 +57,34 @@ export class DeviceSettingsService { async read(target: Target): Promise { if (target.platform === "ios") { await this.baguette.device(target.id, true); - return this.iosReading(await this.baguette.json(`/simulators/${target.id}/interface.json`)); + const path = `/simulators/${target.id}`; + // The status bar and network are optional extras; a failure there keeps the display controls. + const [display, statusBar, network] = await Promise.allSettled([ + this.baguette.json(`${path}/interface.json`), + this.baguette.json(`${path}/status-bar`), + this.baguette.json(`${path}/network`), + ]); + if (display.status === "rejected") throw display.reason; + const settings: DeviceSettings = { ...this.iosReading(display.value), networkConditions: [...networkConditions] }; + const errors: string[] = []; + if (statusBar.status === "fulfilled") settings.statusBar = Object.keys(z.record(z.string(), z.unknown()).parse(statusBar.value)).length > 0 ? "clean" : "live"; + else errors.push(`Status bar: ${errorMessage(statusBar.reason)}`); + if (network.status === "fulfilled") settings.network = iosNetwork(network.value); + else errors.push(`Network: ${errorMessage(network.reason)}`); + if (errors.length) settings.errors = errors; + return settings; } const backend = await this.android.start(target.id); - const settings: DeviceSettings = { locationSupported: /^emulator-\d+$/.test(target.id) }; + const emulator = isEmulator(target.id); + // The emulator console throttles; a physical device can only turn its radios off. + const settings: DeviceSettings = { locationSupported: emulator, networkConditions: emulator ? [...networkConditions] : ["normal", "offline"] }; const reads = [ ["Appearance", "/api/night-mode", (value: unknown) => { const data = z.object({ nightMode: z.object({ mode: z.string() }) }).parse(value); const parsed = appearance.safeParse(data.nightMode.mode); if (parsed.success) settings.appearance = parsed.data; }], ["Text size", "/api/font-scale", (value: unknown) => { settings.fontScale = z.object({ fontScale: z.object({ scale: z.number().min(0.7).max(2) }) }).parse(value).fontScale.scale; }], ["Rotation", "/api/orientation", (value: unknown) => { const data = z.object({ orientation: z.object({ orientation: z.string() }) }).parse(value); const parsed = orientation.safeParse(data.orientation.orientation); if (parsed.success) settings.orientation = parsed.data; }], ["Location", "/api/location", (value: unknown) => { settings.location = z.object({ location: locationSchema.loose().nullable() }).parse(value).location; }], + ["Status bar", "/api/status-bar", (value: unknown) => { settings.statusBar = z.object({ statusBar: z.object({ mode: z.enum(["clean", "live"]) }) }).parse(value).statusBar.mode; }], + ["Network", "/api/network", (value: unknown) => { settings.network = androidNetwork(value); }], ] as const; const results = await Promise.allSettled(reads.map(async ([, path, parse]) => parse(await this.android.json(backend.url, path)))); const errors = results.flatMap((result, index) => result.status === "rejected" ? [`${reads[index][0]}: ${errorMessage(result.reason)}`] : []); @@ -51,20 +95,32 @@ export class DeviceSettingsService { async update(target: Target, change: DeviceSetting): Promise> { // Validate the platform before starting a backend or applying a setting. if (target.platform === "ios") { - if (["fontScale", "orientation"].includes(change.setting) || change.setting === "appearance" && change.value === "auto") throw new Error("Baguette does not expose this setting."); + if (["fontScale", "orientation"].includes(change.setting) || change.setting === "appearance" && change.value === "auto") throw unsupported("Baguette"); await this.baguette.device(target.id, true); const path = `/simulators/${target.id}`; if (change.setting === "location") { await this.baguette.json(`${path}/location`, change.value === null ? { method: "DELETE" } : this.post(change.value)); return { location: change.value }; } + if (change.setting === "statusBar") { + await this.baguette.json(`${path}/status-bar`, change.value === "live" ? { method: "DELETE" } : this.post(cleanIosStatusBar)); + return { statusBar: change.value }; + } + if (change.setting === "network") { + // Conditioning is injected into apps at launch, so Baguette keeps it until it is cleared. + if (change.value === "normal") { await this.baguette.json(`${path}/network`, { method: "DELETE" }); return { network: "normal" }; } + return { network: iosNetwork(await this.baguette.json(`${path}/network`, this.post(change.value === "offline" ? { offline: true } : { profile: change.value }))) }; + } const value = change.setting === "increaseContrast" ? (change.value ? "enabled" : "disabled") : change.value; const response = await this.baguette.json(`${path}/interface`, this.post({ [change.setting]: value })); // Baguette can report a successful write whose read-back failed. if (z.object({ ok: z.literal(true), applied: z.array(z.string()) }).safeParse(response).success) return this.read(target); return this.iosReading(response); } - if (["contentSize", "increaseContrast"].includes(change.setting) || change.setting === "location" && (change.value === null || !/^emulator-\d+$/.test(target.id))) throw new Error("serve-emu does not expose this setting."); + if (["contentSize", "increaseContrast"].includes(change.setting) || change.setting === "location" && (change.value === null || !isEmulator(target.id))) throw unsupported("serve-emu"); + if (change.setting === "network" && !isEmulator(target.id) && change.value !== "normal" && change.value !== "offline") { + throw new ExpectedOperationError("invalid_input", "Network throttling needs an Android emulator. Physical devices can only go offline."); + } const backend = await this.android.start(target.id); switch (change.setting) { case "appearance": { @@ -83,26 +139,48 @@ export class DeviceSettingsService { const data = z.object({ location: locationSchema.loose() }).parse(await this.android.json(backend.url, "/api/location", this.post(change.value!))); return { location: data.location }; } - default: throw new Error("serve-emu does not expose this setting."); + case "statusBar": { + const data = z.object({ statusBar: z.object({ mode: z.enum(["clean", "live"]) }) }).parse(await this.android.json(backend.url, "/api/status-bar", this.post({ mode: change.value }))); + return { statusBar: data.statusBar.mode }; + } + case "network": { + const current = await this.android.json(backend.url, "/api/network"); + const body = this.androidNetworkChange(androidNetwork(current) === "offline", isEmulator(target.id), change.value); + // A physical device that is already online has nothing to change. + return { network: androidNetwork(body ? await this.android.json(backend.url, "/api/network", this.post(body)) : current) }; + } + default: throw unsupported("serve-emu"); } } + // Toggling the radios restarts Wi-Fi, so they change only when going offline or coming back. + private androidNetworkChange(offline: boolean, emulator: boolean, value: NetworkCondition) { + if (value === "offline") return { enabled: false }; + const throttle = value === "normal" ? { latencyMs: 0, bandwidthKbps: null } : networkPresets[value]; + const body = { ...(offline ? { enabled: true } : {}), ...(emulator ? { throttle } : {}) }; + return Object.keys(body).length > 0 ? body : undefined; + } + private post(body: object): RequestInit { return { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; } } export function registerDeviceSettingsTools(server: McpServer, baguette: Baguette, android: ServeEmu) { const service = new DeviceSettingsService(baguette, android); const annotations = { destructiveHint: false, openWorldHint: false }; - const guarded = (handler: (input: T) => Promise) => async (input: T) => { + // Backend messages can contain device IDs, so Sentry receives only the setting name. + const guarded = (action: (input: T) => string, handler: (input: T) => Promise) => async (input: T) => { try { const settings = await handler(input); return { content: [{ type: "text" as const, text: JSON.stringify(settings) }], structuredContent: { settings } }; } - catch (error) { return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] }; } + catch (error) { + if (expectedOutcome(error) === undefined) captureServerError(new Error("Device setting command failed."), `device_settings.${action(input)}`); + return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] }; + } }; registerAppTool(server, "mobile_device_settings", { - title: "Read device settings", description: "Read the selected running device's display and location settings exposed by Baguette or serve-emu.", + title: "Read device settings", description: "Read the selected running device's display, location, status bar, and network settings exposed by Baguette or serve-emu.", inputSchema: { target: targetSchema }, annotations: { ...annotations, readOnlyHint: true }, _meta: { ui: { visibility: ["app"] } }, - }, guarded(({ target }: { target: Target }) => service.read(target))); + }, guarded<{ target: Target }>(() => "read", ({ target }) => service.read(target))); registerAppTool(server, "mobile_update_device_setting", { title: "Change device setting", description: "Change one supported setting on the selected running device without restarting its stream.", inputSchema: { target: targetSchema, change: deviceSettingSchema }, annotations: { ...annotations, readOnlyHint: false }, _meta: { ui: { visibility: ["app"] } }, - }, guarded(({ target, change }: { target: Target; change: DeviceSetting }) => service.update(target, change))); + }, guarded<{ target: Target; change: DeviceSetting }>(({ change }) => change.setting, ({ target, change }) => service.update(target, change))); } diff --git a/src/server/serve-emu.ts b/src/server/serve-emu.ts index b1c8c39f..e293376b 100644 --- a/src/server/serve-emu.ts +++ b/src/server/serve-emu.ts @@ -20,6 +20,8 @@ import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummar const execute = promisify(execFile); export const androidIdSchema = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:\[\]%-]+$/); +// Route handlers send { ok: false, error: message }; the API boundary sends { ok: false, error: { code, message } }. +const serveEmuErrorSchema = z.object({ ok: z.literal(false), error: z.union([z.string().min(1), z.object({ message: z.string().min(1) }).loose()]) }); const healthSchema = z.object({ serial: androidIdSchema, codec: z.string(), size: z.object({ width: z.number().positive(), height: z.number().positive() }) }); type Backend = { url: URL; child?: ChildProcess }; @@ -196,9 +198,10 @@ export class ServeEmu { async json(url: URL, path: string, options: RequestInit = {}) { const response = await fetch(new URL(path, url), { ...options, redirect: "error", signal: AbortSignal.timeout(10000) }); - if (!response.ok) throw new Error(`serve-emu returned HTTP ${response.status} for ${path}.`); - const payload = await response.json(); - if (payload.ok === false) throw new Error(payload.error ?? "serve-emu rejected the request."); + const payload = response.ok ? await response.json() : await response.json().catch(() => undefined); + const rejection = serveEmuErrorSchema.safeParse(payload).data?.error; + const message = typeof rejection === "string" ? rejection : rejection?.message; + if (!response.ok || rejection) throw new Error(message ?? `serve-emu returned HTTP ${response.status} for ${path}.`); return payload; } diff --git a/src/shared/device-settings.ts b/src/shared/device-settings.ts index 3ae7356e..88da7d39 100644 --- a/src/shared/device-settings.ts +++ b/src/shared/device-settings.ts @@ -1,6 +1,15 @@ import { z } from "zod"; export const contentSizes = ["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"] as const; +// Throttles match Baguette's presets so both platforms simulate the same connection. +export const networkPresets = { + lte: { latencyMs: 130, bandwidthKbps: 50000 }, + "3g": { latencyMs: 200, bandwidthKbps: 780 }, + edge: { latencyMs: 800, bandwidthKbps: 240 }, +} as const; +export type NetworkPreset = keyof typeof networkPresets; +export const networkConditions = ["normal", "lte", "3g", "edge", "offline"] as const satisfies readonly ("normal" | "offline" | NetworkPreset)[]; +export type NetworkCondition = typeof networkConditions[number]; export const locationSchema = z.object({ latitude: z.number().finite().min(-90).max(90), longitude: z.number().finite().min(-180).max(180) }).strict(); export const deviceSettingSchema = z.discriminatedUnion("setting", [ z.object({ setting: z.literal("appearance"), value: z.enum(["light", "dark", "auto"]) }).strict(), @@ -9,6 +18,8 @@ export const deviceSettingSchema = z.discriminatedUnion("setting", [ z.object({ setting: z.literal("increaseContrast"), value: z.boolean() }).strict(), z.object({ setting: z.literal("orientation"), value: z.enum(["auto", "portrait", "landscape"]) }).strict(), z.object({ setting: z.literal("location"), value: locationSchema.nullable() }).strict(), + z.object({ setting: z.literal("statusBar"), value: z.enum(["clean", "live"]) }).strict(), + z.object({ setting: z.literal("network"), value: z.enum(networkConditions) }).strict(), ]); export type DeviceSetting = z.infer; export type DeviceSettings = { @@ -19,5 +30,10 @@ export type DeviceSettings = { orientation?: "auto" | "portrait" | "landscape"; location?: z.infer | null; locationSupported: boolean; + statusBar?: "clean" | "live"; + // "custom" is a throttle set outside this panel; it is shown but never sent. + network?: NetworkCondition | "custom"; + // The conditions this device can simulate; physical Android devices only go offline. + networkConditions?: NetworkCondition[]; errors?: string[]; }; diff --git a/src/ui/components/device-settings.tsx b/src/ui/components/device-settings.tsx index bc7cb441..a0dbe7d9 100644 --- a/src/ui/components/device-settings.tsx +++ b/src/ui/components/device-settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useState, useSyncExternalStore } from "react"; -import { AppWindowIcon, MapPinIcon, MinusIcon, MoonIcon, PlusIcon, Settings2Icon, SmartphoneIcon, SunIcon } from "lucide-react"; -import { contentSizes } from "../../shared/device-settings"; +import { AppWindowIcon, InfoIcon, MapPinIcon, MinusIcon, MoonIcon, PlusIcon, RotateCwIcon, Settings2Icon, SmartphoneIcon, SunIcon } from "lucide-react"; +import { contentSizes, type NetworkCondition } from "../../shared/device-settings"; import { bindDeviceSettings, DeviceSettingsStore } from "../device-settings"; import { AppToolsStore, bindAppTools } from "../app-tools"; import { AppTools } from "./app-tools"; @@ -30,6 +30,7 @@ const locationPresets = [ ]; type Location = { latitude: number; longitude: number }; const presetFor = (location: Location) => locationPresets.find(entry => entry.latitude === location.latitude && entry.longitude === location.longitude); +const networkLabels: Record = { normal: "Normal", lte: "LTE", "3g": "3G", edge: "Edge", offline: "Offline" }; const rotationOptions = [{ value: "auto", label: "Auto" }, { value: "portrait", label: "Portrait" }, { value: "landscape", label: "Landscape" }]; // One right-hand column, so every control lines up. const control = "w-38"; @@ -129,7 +130,24 @@ export function DeviceSettings({ platform }: { platform: "ios" | "android" }) { {settings.fontScale !== undefined && void store.change({ setting: "fontScale", value: Number(value) })} />} {settings.increaseContrast !== undefined && void store.change({ setting: "increaseContrast", value })} />} {settings.orientation !== undefined && void store.change({ setting: "orientation", value: value as "auto" | "portrait" | "landscape" })} />} + {settings.statusBar !== undefined && void store.change({ setting: "statusBar", value: value ? "clean" : "live" })} />} + {settings.network !== undefined && settings.networkConditions && + { + const condition = settings.networkConditions?.find(entry => entry === value); + if (condition) void store.change({ setting: "network", value: condition }); + }}> + {settings.networkConditions.map(condition => {networkLabels[condition]})} + + {settings.network === "custom" &&

A custom throttle is active. Pick a condition to replace it.

} + {platform === "android" && settings.networkConditions.length === 2 &&

Throttling needs an emulator.

} + {/* Baguette injects conditioning into apps as they launch. */} + {platform === "ios" && settings.network !== "normal" &&
+ +

Applies to URLSession and fetch requests in apps launched after it was turned on.

+ {appTools.app && } +
} +
} {settings.locationSupported &&
diff --git a/src/ui/device-settings.ts b/src/ui/device-settings.ts index 68cd5f55..1b69b9b3 100644 --- a/src/ui/device-settings.ts +++ b/src/ui/device-settings.ts @@ -1,4 +1,5 @@ import type { DeviceSetting, DeviceSettings } from "../shared/device-settings.ts"; +import { countUiEvent, getUiTelemetryAttributes, recordUiTiming } from "./telemetry.ts"; type PendingChange = { change: DeviceSetting; resolve: () => void }; @@ -72,11 +73,16 @@ export class DeviceSettingsStore { try { while (this.revision === revision && this.pending.length) { const current = this.pending[0]; + // Results that finish after a surface or device change are not attributed to the new context. + const startedAt = performance.now(); + const context = getUiTelemetryAttributes(); try { const patch = await request(current.change); + if (getUiTelemetryAttributes() === context) recordUiTiming("ui.device_settings.change", performance.now() - startedAt); if (this.revision !== revision) return; this.confirmed = { ...this.confirmed!, ...patch }; } catch (error) { + if (getUiTelemetryAttributes() === context) countUiEvent("ui.device_settings.failure"); if (this.revision !== revision) return; this.update({ error: error instanceof Error ? error.message : String(error) }); } finally { current.resolve(); } diff --git a/tests/device-settings.test.ts b/tests/device-settings.test.ts index 6c0603a2..296d4daf 100644 --- a/tests/device-settings.test.ts +++ b/tests/device-settings.test.ts @@ -5,12 +5,16 @@ import { DeviceSettingsStore } from "../src/ui/device-settings.ts"; import type { Baguette } from "../src/server/baguette.ts"; import type { ServeEmu } from "../src/server/serve-emu.ts"; import { deviceSettingSchema } from "../src/shared/device-settings.ts"; +import { expectedOutcome } from "../src/shared/error-reporting.ts"; const ios = { platform: "ios", id: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE" } as const; const android = { platform: "android", id: "emulator-5554" } as const; function fixture() { const requests: { platform: string; id: string; path: string; method: string; body?: object }[] = []; const values = { appearance: "dark", increaseContrast: "disabled", contentSize: "large" }; + let statusBar: object = {}; + let iosNetwork: object = { ok: true, active: false, profiles: ["wifi", "lte", "3g", "edge"] }; + let android_ = { enabled: true as boolean | null, throttle: { latencyMs: 0, bandwidthKbps: null as number | null }, statusBar: "live" }; let failFont = false; const baguette = { async device(id: string, booted: boolean) { assert.equal(id, ios.id); assert.equal(booted, true); }, @@ -18,6 +22,12 @@ function fixture() { const body = options.body ? JSON.parse(String(options.body)) : undefined; requests.push({ platform: "ios", id: ios.id, path, method: options.method ?? "GET", body }); if (path.endsWith("/location")) return { ok: true }; + if (path.endsWith("/status-bar")) { if (options.method === "DELETE") statusBar = {}; else if (body) statusBar = body; return options.method ? { ok: true } : statusBar; } + if (path.endsWith("/network")) { + if (options.method === "DELETE") iosNetwork = { ok: true, active: false }; + else if (body) iosNetwork = { ok: true, active: true, profile: body.profile ?? null, offline: body.offline ?? false, latencyMs: 200 }; + return iosNetwork; + } if (body) Object.assign(values, body); return { ...values }; }, @@ -31,26 +41,76 @@ function fixture() { if (path === "/api/font-scale") { if (failFont) throw new Error("Not available"); return { ok: true, fontScale: { scale: body?.scale ?? 1 } }; } if (path === "/api/orientation") return { ok: true, orientation: { orientation: body?.orientation ?? "auto" } }; if (path === "/api/location") return { ...(body ? { ok: true } : {}), location: body ?? null }; + if (path === "/api/status-bar") { if (body) android_.statusBar = body.mode; return { ok: true, statusBar: { mode: android_.statusBar } }; } + if (path === "/api/network") { + if (body?.enabled !== undefined) android_.enabled = body.enabled; + if (body?.throttle) android_.throttle = body.throttle; + const emulator = url.pathname.includes("emulator-"); + return { ok: true, network: { enabled: android_.enabled, wifi: "enabled", mobileData: "enabled", raw: { wifi: "1", mobileData: "1" }, throttle: emulator ? android_.throttle : null } }; + } throw new Error("Unexpected path"); }, } as unknown as ServeEmu; - return { service: new DeviceSettingsService(baguette, serveEmu), requests, failFont() { failFont = true; } }; + return { service: new DeviceSettingsService(baguette, serveEmu), requests, failFont() { failFont = true; }, setIosNetwork(value: object) { iosNetwork = value; }, setAndroid(value: Partial) { android_ = { ...android_, ...value }; } }; } test("settings preserve platform values and apply changes through the selected backend", async () => { const f = fixture(); - assert.deepEqual(await f.service.read(ios), { appearance: "dark", increaseContrast: false, contentSize: "large", locationSupported: true }); + assert.deepEqual(await f.service.read(ios), { appearance: "dark", increaseContrast: false, contentSize: "large", locationSupported: true, statusBar: "live", network: "normal", networkConditions: ["normal", "lte", "3g", "edge", "offline"] }); await f.service.update(ios, { setting: "increaseContrast", value: true }); assert.deepEqual(f.requests.at(-1)?.body, { increaseContrast: "enabled" }); await f.service.update(ios, { setting: "location", value: null }); assert.equal(f.requests.at(-1)?.method, "DELETE"); - assert.deepEqual(await f.service.read(android), { appearance: "light", fontScale: 1, orientation: "auto", location: null, locationSupported: true }); + assert.deepEqual(await f.service.read(android), { appearance: "light", fontScale: 1, orientation: "auto", location: null, locationSupported: true, statusBar: "live", network: "normal", networkConditions: ["normal", "lte", "3g", "edge", "offline"] }); assert.deepEqual(await f.service.update(android, { setting: "appearance", value: "dark" }), { appearance: "dark" }); assert.equal(f.requests.at(-1)?.id, android.id); assert.deepEqual(f.requests.at(-1)?.body, { mode: "dark" }); assert.deepEqual(await f.service.update(android, { setting: "location", value: { latitude: 48.2, longitude: 16.3 } }), { location: { latitude: 48.2, longitude: 16.3 } }); }); +test("status bar and network conditions use the same presets on both platforms", async () => { + const f = fixture(); + assert.deepEqual(await f.service.update(ios, { setting: "statusBar", value: "clean" }), { statusBar: "clean" }); + assert.equal(f.requests.at(-1)?.method, "POST"); + assert.equal((f.requests.at(-1)?.body as { time: string }).time, "9:41"); + assert.equal((await f.service.read(ios)).statusBar, "clean"); + await f.service.update(ios, { setting: "statusBar", value: "live" }); + assert.equal(f.requests.at(-1)?.method, "DELETE"); + assert.deepEqual(await f.service.update(ios, { setting: "network", value: "3g" }), { network: "3g" }); + assert.deepEqual(f.requests.at(-1)?.body, { profile: "3g" }); + assert.deepEqual(await f.service.update(ios, { setting: "network", value: "offline" }), { network: "offline" }); + assert.deepEqual(f.requests.at(-1)?.body, { offline: true }); + assert.deepEqual(await f.service.update(ios, { setting: "network", value: "normal" }), { network: "normal" }); + assert.equal(f.requests.at(-1)?.method, "DELETE"); + f.setIosNetwork({ ok: true, active: true, profile: null, offline: false, latencyMs: 300 }); + assert.equal((await f.service.read(ios)).network, "custom", "a throttle set elsewhere is shown, not mistaken for normal"); + + assert.deepEqual(await f.service.update(android, { setting: "statusBar", value: "clean" }), { statusBar: "clean" }); + assert.deepEqual(f.requests.at(-1)?.body, { mode: "clean" }); + assert.deepEqual(await f.service.update(android, { setting: "network", value: "edge" }), { network: "edge" }); + assert.deepEqual(f.requests.at(-1)?.body, { throttle: { latencyMs: 800, bandwidthKbps: 240 } }, "radios stay untouched while online"); + assert.deepEqual(await f.service.update(android, { setting: "network", value: "offline" }), { network: "offline" }); + assert.deepEqual(f.requests.at(-1)?.body, { enabled: false }); + assert.deepEqual(await f.service.update(android, { setting: "network", value: "normal" }), { network: "normal" }); + assert.deepEqual(f.requests.at(-1)?.body, { enabled: true, throttle: { latencyMs: 0, bandwidthKbps: null } }); + + const phone = { platform: "android", id: "R5CT1234" } as const; + f.setAndroid({ enabled: true }); + const settings = await f.service.read(phone); + assert.deepEqual(settings.networkConditions, ["normal", "offline"]); + assert.equal(settings.network, "normal"); + const before = f.requests.length; + await assert.rejects(f.service.update(phone, { setting: "network", value: "3g" }), (error: unknown) => expectedOutcome(error) === "invalid_input" && /needs an Android emulator/.test((error as Error).message)); + assert.equal(f.requests.length, before); + await f.service.update(phone, { setting: "network", value: "offline" }); + assert.deepEqual(f.requests.at(-1)?.body, { enabled: false }); + await f.service.update(phone, { setting: "network", value: "normal" }); + assert.deepEqual(f.requests.at(-1)?.body, { enabled: true }); + const reads = f.requests.length; + assert.deepEqual(await f.service.update(phone, { setting: "network", value: "normal" }), { network: "normal" }); + assert.equal(f.requests.length, reads + 1, "an online phone is only read, never sent an empty change"); +}); + test("unsupported settings never reach a backend and partial reads keep working controls", async () => { const f = fixture(); for (const [target, change] of [[ios, { setting: "fontScale", value: 1.5 }], [ios, { setting: "appearance", value: "auto" }], [android, { setting: "increaseContrast", value: true }], [android, { setting: "location", value: null }]] as const) { From feddccddc7a148b7543abae0a4c1b2e71b89d6ee Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 14:42:33 +0200 Subject: [PATCH 03/18] feat(app-tools): run an app in another language mobile_set_app_language sets one app's language and region, then relaunches it, leaving the device language unchanged. iOS simulators write AppleLanguages and AppleLocale to the app's own preferences, like Settings > Apps > Language; Android 13 and later use cmd locale. The App tab gains a Language section with common languages, including right-to-left ones. Device setting changes now report fixed-message errors and round-trip timings. --- docs/telemetry.md | 18 +++++-- skills/mobile-dev/SKILL.md | 3 +- src/server/app-control/android.ts | 18 +++++++ src/server/app-control/driver.ts | 3 ++ src/server/app-control/simulator.ts | 38 +++++++++++++++ src/server/app-control/tools.ts | 18 ++++++- src/shared/app-control.ts | 5 ++ src/ui/app-tools.ts | 31 ++++++++---- src/ui/components/app-tools.tsx | 29 +++++++++++- tests/app-control.test.ts | 73 +++++++++++++++++++++++++++-- tests/react-ui.test.ts | 20 ++++++++ 11 files changed, 237 insertions(+), 19 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index ed91356a..0d2063f4 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -175,10 +175,20 @@ unrequested permission, or a stopped device count only through memory cache and are returned only to the panel. The device popover's App tab records `ui.app_tools.list`, `.icons`, `.launch`, -`.stop`, `.open_url`, `.permission`, and `.push` round trips in milliseconds with -bounded aggregate windows, and counts `ui.app_tools.failure`. Results that finish -after a surface or device change are not recorded. Recent URLs and the last picked -app stay in the panel's local storage and are never sent. +`.stop`, `.open_url`, `.permission`, `.push`, and `.language` round trips in +milliseconds with bounded aggregate windows, and counts `ui.app_tools.failure`. +Results that finish after a surface or device change are not recorded. Recent URLs +and the last picked app stay in the panel's local storage and are never sent. + +Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same +app control coverage under `app_control.language` and `app_control.set_language`. +Language tags are not sent. The device settings tools report unexpected Baguette or +serve-emu failures under `device_settings.read` or `device_settings.` with a +fixed message; settings a device cannot change, such as network throttling on a +physical Android device, count only through `server.error.outcome`. The Device tab +records `ui.device_settings.change` round trips in milliseconds and counts +`ui.device_settings.failure`, with the same surface and device-change exclusions as +the App tab. Setting values, throttle presets, and device IDs are not sent. Saved chart cards use the `recording` surface and view. Existing readiness, interaction and frame-pacing coverage is preserved. `ui.recording.process` and diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index 5e38c496..85048fe9 100644 --- a/skills/mobile-dev/SKILL.md +++ b/skills/mobile-dev/SKILL.md @@ -37,8 +37,9 @@ These tools work on iOS simulators and Android devices with `{ platform, deviceI - `mobile_open_url` opens a deep link or web URL. Prefer it to step-by-step navigation when the app declares a route for the changed screen, then read the screen to confirm it opened. - `mobile_set_app_permission` allows, denies, or resets (`ask`) a permission so system prompts do not interrupt a flow; `permission: "all"` with `state: "ask"` restores first-launch prompts. iOS simulators cannot change camera or notification access, and changes can restart the app. - `mobile_send_push` sends an APNs payload to an app on an iOS simulator. Android push needs Firebase Cloud Messaging and is not supported. +- `mobile_set_app_language` runs one app in another language and region, such as `de`, `pt-BR`, or `ar` for right-to-left layout, then relaunches it. `language: null` returns the app to the device language. The device language does not change. Android needs version 13 or later. -The panel's settings button opens the same controls under its App tab. +The panel's settings button opens the same controls under its App tab. Its Device tab also switches to a clean status bar (9:41, full battery and signal) for screenshots and simulates LTE, 3G, Edge, or offline networks. Those device settings are panel-only, so ask the user to set them. Android throttling needs an emulator; physical Android devices can only go offline. iOS throttling applies to URLSession and fetch requests in apps launched after it was turned on, so relaunch the app after the user enables it. ### Choosing a task's devices diff --git a/src/server/app-control/android.ts b/src/server/app-control/android.ts index f2e01d3e..a4dea7eb 100644 --- a/src/server/app-control/android.ts +++ b/src/server/app-control/android.ts @@ -122,6 +122,15 @@ export function pickAndroidIconFile(resources: string, iconPath: string): string return entries.filter(entry => /\.(?:png|webp)$/i.test(entry.path)).sort((a, b) => rank(b.config) - rank(a.config))[0]?.path ?? null; } +// "Locales for com.example for user 0 are [de-AT,en]"; an empty list follows the device. +export function parseAndroidAppLocales(output: string): string | null { + if (/Unknown package/.test(output)) throw new ExpectedOperationError("invalid_input", "The app is not installed on this device."); + if (/Can't find service: locale|Unknown command/.test(output)) throw new ExpectedOperationError("unsupported_platform", "Per-app languages need Android 13 or later."); + const list = /are \[([^\]]*)\]/.exec(output); + if (!list) throw new Error("Reading the app language failed: unexpected cmd locale output."); + return list[1].split(",").map(entry => entry.trim()).find(Boolean) ?? null; +} + const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; export class AndroidDriver implements AppDriver { @@ -220,6 +229,15 @@ export class AndroidDriver implements AppDriver { return this.permissions(bundleId); } + async language(bundleId: string) { return parseAndroidAppLocales(await this.shell("Reading the app language", `cmd locale get-app-locales ${bundleId}`)); } + + async setLanguage(bundleId: string, language: string | null) { + // set-app-locales reports an unknown package or missing service on stdout with exit status 0. + const output = await this.shell("Changing the app language", `cmd locale set-app-locales ${bundleId}${language === null ? "" : ` --locales ${language}`}; cmd locale get-app-locales ${bundleId}`); + const applied = parseAndroidAppLocales(output); + if (applied?.toLowerCase() !== language?.toLowerCase()) throw new Error("Changing the app language failed: Android kept a different language."); + } + async push(): Promise { throw new ExpectedOperationError("unsupported_platform", "Test push notifications are available on iOS simulators. Android needs Firebase Cloud Messaging."); } diff --git a/src/server/app-control/driver.ts b/src/server/app-control/driver.ts index 9419bba6..f4f7f649 100644 --- a/src/server/app-control/driver.ts +++ b/src/server/app-control/driver.ts @@ -20,6 +20,9 @@ export interface AppDriver { permissions(bundleId: string): Promise; setPermission(bundleId: string, permission: AppPermission | "all", state: PermissionState): Promise; push(bundleId: string, payload: Record): Promise; + // The app's own language, or null when it follows the device. Apps pick it up on their next launch. + language(bundleId: string): Promise; + setLanguage(bundleId: string, language: string | null): Promise; } export async function listApps(driver: AppDriver): Promise { diff --git a/src/server/app-control/simulator.ts b/src/server/app-control/simulator.ts index 3a39415b..2c62ec4b 100644 --- a/src/server/app-control/simulator.ts +++ b/src/server/app-control/simulator.ts @@ -34,6 +34,15 @@ export function parseIosUrlSchemes(json: string): string[] { return rankAppLinks((types.data ?? []).flatMap(type => type.CFBundleURLSchemes ?? []).filter(scheme => urlSchemePattern.test(scheme)).map(scheme => `${scheme}://`)); } +// defaults prints arrays in the old plist style, quoting only entries with punctuation. +export function parseDefaultsArray(output: string): string[] { + const body = /^\(\s*([\s\S]*?)\s*\)\s*$/.exec(output.trim())?.[1]; + return body ? body.split(",").map(entry => entry.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean) : []; +} + +// iOS reads the app's own AppleLanguages before the device's, like Settings > Apps > Language. +export const iosLocale = (language: string) => language.replaceAll("-", "_"); + const iconFilesSchema = z.object({ CFBundlePrimaryIcon: z.object({ CFBundleIconFiles: z.array(z.string()).optional() }).loose().optional() }).loose(); export class SimulatorDriver implements AppDriver { @@ -128,6 +137,35 @@ export class SimulatorDriver implements AppDriver { return platformPermissions.ios.map(entry => ({ permission: entry, state: permission === "all" || permission === entry ? state : null })); } + private async preferences(bundleId: string) { + const container = await this.simctl("Finding the app data", ["get_app_container", this.deviceId, bundleId, "data"], + { known: [[/No such file or directory/, `${bundleId} is not installed on this simulator.`]] }); + return join(container.trim(), "Library/Preferences", bundleId); + } + + // A missing key or domain means the app follows the device language. + private defaults(action: string, args: string[]) { + return this.simctl(action, ["spawn", this.deviceId, "defaults", ...args], { tolerate: /Domain '[^']*' not found|Could not find key/ }); + } + + async language(bundleId: string) { + const languages = parseDefaultsArray(await this.defaults("Reading the app language", ["read", await this.preferences(bundleId), "AppleLanguages"])); + return languages[0] ?? null; + } + + async setLanguage(bundleId: string, language: string | null) { + const domain = await this.preferences(bundleId); + // A running app writes its cached preferences back on exit, so it stops first. + await this.stop(bundleId); + if (language === null) { + await this.defaults("Resetting the app language", ["delete", domain, "AppleLanguages"]); + await this.defaults("Resetting the app language", ["delete", domain, "AppleLocale"]); + return; + } + await this.defaults("Changing the app language", ["write", domain, "AppleLanguages", "-array", language]); + await this.defaults("Changing the app language", ["write", domain, "AppleLocale", "-string", iosLocale(language)]); + } + async push(bundleId: string, payload: Record) { const json = JSON.stringify(payload); if (Buffer.byteLength(json) > 4096) throw new ExpectedOperationError("invalid_input", "Push payloads must be 4 KB or smaller."); diff --git a/src/server/app-control/tools.ts b/src/server/app-control/tools.ts index aa7631bb..ff0254d1 100644 --- a/src/server/app-control/tools.ts +++ b/src/server/app-control/tools.ts @@ -5,7 +5,7 @@ import { androidIdSchema } from "../serve-emu.ts"; import { captureServerError } from "../telemetry.ts"; import { errorMessage, udidSchema } from "../../shared/protocol.ts"; import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; -import { appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, permissionStates, platformPermissions } from "../../shared/app-control.ts"; +import { appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, languageTagSchema, permissionStates, platformPermissions } from "../../shared/app-control.ts"; import { systemCommands } from "./commands.ts"; import type { Commands } from "./commands.ts"; import { AppDetailsCache, listApps } from "./driver.ts"; @@ -91,6 +91,22 @@ export function registerAppControlTools(server: McpServer, driverFor: DriverFact } return { permissions: await driver.setPermission(input.bundleId, input.permission, input.state) }; }); + tool("mobile_app_language", "language", { title: "Read app language", readOnly: true, panelOnly: true, inputSchema: { bundleId }, + description: "Read the language an app uses instead of the device language, or null." }, + async (driver, input) => ({ language: await driver.language(input.bundleId) })); + tool("mobile_set_app_language", "set_language", { title: "Set app language", + inputSchema: { bundleId, language: languageTagSchema.nullable().describe("Language tag such as de, pt-BR, or zh-Hans; null follows the device language again.") }, + description: "Run one app in another language and region to check translations, then relaunch it. The device language is unchanged. Android needs version 13 or later." }, + async (driver, input) => { + await driver.setLanguage(input.bundleId, input.language); + try { await driver.launch(input.bundleId); } + catch (error) { + // The language is already saved, so say so instead of reporting the whole change as failed. + if (error instanceof Error) error.message = `Changed the app language, but relaunching failed: ${error.message}`; + throw error; + } + return { language: input.language, launched: input.bundleId }; + }); tool("mobile_send_push", "push", { title: "Send test push", inputSchema: { bundleId, payload: payloadSchema.describe("APNs payload with an aps object, such as {\"aps\":{\"alert\":\"Hi\"}}.") }, description: "Send a test push notification to an app on an iOS simulator." }, diff --git a/src/shared/app-control.ts b/src/shared/app-control.ts index 435a9eba..5f23211d 100644 --- a/src/shared/app-control.ts +++ b/src/shared/app-control.ts @@ -27,6 +27,11 @@ const permissionEntry = z.object({ permission: z.enum(appPermissions), state: z. export const appPermissionsSchema = z.object({ permissions: z.array(permissionEntry) }); export type AppPermissionEntry = z.infer; +// BCP 47 language tags with an optional script and region, such as de, pt-BR, or zh-Hans-CN. +export const languageTagSchema = z.string().min(2).max(35).regex(/^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|\d{3}))?$/, "Expected a language tag, such as de, pt-BR, or zh-Hans."); +// Null when the app follows the device language. +export const appLanguageSchema = z.object({ language: languageTagSchema.nullable() }); + export const appLinksSchema = z.object({ links: z.array(z.string().max(2048)) }); // A 64px PNG icon and, on Android, the app label that replaces the package name. diff --git a/src/ui/app-tools.ts b/src/ui/app-tools.ts index 77fb4509..88b87183 100644 --- a/src/ui/app-tools.ts +++ b/src/ui/app-tools.ts @@ -1,11 +1,11 @@ import { z } from "zod"; -import { appDetailsSchema, appLinksSchema, appPermissionsSchema, installedAppsSchema, platformPermissions } from "../shared/app-control.ts"; +import { appDetailsSchema, appLanguageSchema, appLinksSchema, appPermissionsSchema, installedAppsSchema, platformPermissions } from "../shared/app-control.ts"; import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "../shared/app-control.ts"; import { countUiEvent, getUiTelemetryAttributes, recordUiTiming } from "./telemetry.ts"; -export type AppToolAction = "list" | "icons" | "launch" | "stop" | "open_url" | "permission" | "push"; +export type AppToolAction = "list" | "icons" | "launch" | "stop" | "open_url" | "permission" | "push" | "language"; export type AppEntry = InstalledApp & { icon?: string }; -export type AppSection = "permissions" | "push"; +export type AppSection = "permissions" | "language" | "push"; export type AppToolsState = { platform: "ios" | "android"; deviceId: string; @@ -17,6 +17,8 @@ export type AppToolsState = { // Null until the first app's permissions load; later switches keep the previous rows until the new ones arrive. permissions: AppPermissionEntry[] | null; links: string[]; + // Undefined while loading; null when the app follows the device language. + language: string | null | undefined; busy: AppToolAction | null; error: string; status: string; @@ -40,9 +42,9 @@ function storedValue(key: string, schema: z.ZodType, fallback: T) { } const recentUrls = storedValue("mobile-dev:recent-urls:v1", z.array(z.string()).max(5), []); -const openSection = storedValue("mobile-dev:app-section:v1", z.enum(["permissions", "push"]).nullable(), null); +const openSection = storedValue("mobile-dev:app-section:v1", z.enum(["permissions", "language", "push"]).nullable(), null); const lastPicked = storedValue("mobile-dev:last-app:v1", z.record(z.string(), z.string()), {}); -const deviceDefaults = { loading: false, apps: [], runningBundleIds: [], bundleId: null, permissions: null, links: [], busy: null, error: "", status: "" } satisfies Partial; +const deviceDefaults = { loading: false, apps: [], runningBundleIds: [], bundleId: null, permissions: null, links: [], language: undefined, busy: null, error: "", status: "" } satisfies Partial; export class AppToolsStore { private state: AppToolsState = { platform: "ios", deviceId: "", disabled: true, ...deviceDefaults, recentUrls: recentUrls.read(), section: openSection.read() }; @@ -126,7 +128,7 @@ export class AppToolsStore { // Keep the previous rows and suggestions until the new app's arrive, so the popover keeps its height. private switchTo(bundleId: string | null): Partial { if (bundleId === this.state.bundleId) return {}; - return { bundleId, permissions: this.state.permissions && this.withApplied(this.state.permissions.map(entry => ({ ...entry, state: null })), bundleId) }; + return { bundleId, language: undefined, permissions: this.state.permissions && this.withApplied(this.state.permissions.map(entry => ({ ...entry, state: null })), bundleId) }; } private withApplied(entries: AppPermissionEntry[], bundleId = this.state.bundleId): AppPermissionEntry[] { @@ -139,14 +141,17 @@ export class AppToolsStore { const bundleId = this.state.bundleId; if (!bundleId) return; const current = this.current(bundleId); - const [permissions, links] = await Promise.allSettled([ + const [permissions, links, language] = await Promise.allSettled([ this.tool("mobile_app_permissions", { bundleId }).then(data => appPermissionsSchema.parse(data).permissions), this.tool("mobile_app_links", { bundleId }).then(data => appLinksSchema.parse(data).links), + this.tool("mobile_app_language", { bundleId }).then(data => appLanguageSchema.parse(data).language), ]); if (!current()) return; - // Suggestions are optional, so only a permission failure is shown. + // Suggestions and the language are optional, so only a permission failure is shown. this.update({ links: links.status === "fulfilled" ? links.value : [], + // Older Android releases have no per-app language; the section then offers only the device language. + language: language.status === "fulfilled" ? language.value : null, ...(permissions.status === "fulfilled" ? { permissions: this.withApplied(permissions.value) } : { error: message(permissions.reason) }), }); } @@ -220,6 +225,16 @@ export class AppToolsStore { }); } + // Apps read their language at launch, so the server relaunches the app after changing it. + setLanguage(language: string | null) { + return this.run("language", app => `Relaunched ${app.name}${language ? ` in ${language}` : " in the device language"}.`, async (app, current) => { + const result = appLanguageSchema.parse(await this.tool("mobile_set_app_language", { bundleId: app.bundleId, language })); + if (!current()) return; + this.markRunning(app.bundleId, true); + if (app.bundleId === this.state.bundleId) this.update({ language: result.language }); + }); + } + setSection(section: AppSection | null) { openSection.write(section); this.update({ section }); diff --git a/src/ui/components/app-tools.tsx b/src/ui/components/app-tools.tsx index 4daaea8a..373f39f6 100644 --- a/src/ui/components/app-tools.tsx +++ b/src/ui/components/app-tools.tsx @@ -1,6 +1,6 @@ import { useId, useRef, useState, useSyncExternalStore } from "react"; import type { LucideIcon } from "lucide-react"; -import { ActivityIcon, ArrowRightIcon, BellIcon, BracesIcon, CalendarIcon, CameraIcon, ChevronDownIcon, ClockIcon, ContactIcon, ImageIcon, InfoIcon, ListChecksIcon, MapPinIcon, MicIcon, RotateCcwIcon, RotateCwIcon, SendIcon, ShieldIcon, SquareIcon } from "lucide-react"; +import { ActivityIcon, ArrowRightIcon, BellIcon, BracesIcon, CalendarIcon, CameraIcon, ChevronDownIcon, ClockIcon, ContactIcon, ImageIcon, InfoIcon, LanguagesIcon, ListChecksIcon, MapPinIcon, MicIcon, RotateCcwIcon, RotateCwIcon, SendIcon, ShieldIcon, SquareIcon } from "lucide-react"; import { z } from "zod"; import type { AppPermission, PermissionState } from "../../shared/app-control"; import type { AppEntry, AppSection, AppToolsState, AppToolsStore } from "../app-tools"; @@ -8,7 +8,7 @@ import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "./ui/input-group"; import { Textarea } from "./ui/textarea"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "./ui/base-select"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "./ui/base-select"; import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; @@ -26,6 +26,11 @@ const permissionOptions = [ { value: "ask", label: "Ask", on: "data-[state=on]:text-foreground" }, ] as const satisfies readonly { value: PermissionState; label: string; on: string }[]; const payloadSchema = z.record(z.string(), z.unknown()); +// Common translation targets, including right-to-left scripts that change the layout. +const languageTags = ["en", "en-GB", "de", "fr", "es", "es-MX", "it", "pt-BR", "nl", "pl", "sv", "tr", "ru", "ja", "ko", "zh-Hans", "zh-Hant", "hi", "ar", "he"]; +const languageNames = new Intl.DisplayNames(["en"], { type: "language" }); +const languageLabel = (tag: string) => { try { return languageNames.of(tag) ?? tag; } catch { return tag; } }; +const deviceLanguage = "device"; // A collapsible row; the App tab keeps at most one open so the popover stays short. function Disclosure({ section, state, store, icon: Icon, title, summary, children }: { section: AppSection; state: AppToolsState; store: AppToolsStore; icon: LucideIcon; title: string; summary?: React.ReactNode; children: React.ReactNode }) { @@ -143,6 +148,25 @@ function Permissions({ store, state, blocked }: SectionProps) { ; } +function Language({ store, state, blocked }: SectionProps) { + const current = state.language; + const tags = current && !languageTags.some(tag => tag.toLowerCase() === current.toLowerCase()) ? [current, ...languageTags] : languageTags; + const items = [{ value: deviceLanguage, label: "Device language" }, ...tags.map(tag => ({ value: tag, label: languageLabel(tag) }))]; + const selected = items.find(item => item.value.toLowerCase() === (current ?? deviceLanguage).toLowerCase())?.value ?? deviceLanguage; + return + +

Changing it relaunches the app. The device keeps its language.

+
; +} + function PushNotification({ store, state, app, blocked }: SectionProps) { const [title, setTitle] = useState(""); const [body, setBody] = useState(""); @@ -193,6 +217,7 @@ export function AppTools({ store }: { store: AppToolsStore }) {
+ {state.platform === "ios" && }
{error} diff --git a/tests/app-control.test.ts b/tests/app-control.test.ts index efc1af63..76cba6d8 100644 --- a/tests/app-control.test.ts +++ b/tests/app-control.test.ts @@ -6,13 +6,14 @@ import { join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { parseIosUrlSchemes, parseSimulatorApps } from "../src/server/app-control/simulator.ts"; -import { androidPermissionStates, parseAndroidAppLinks, parseAndroidBadging, parseAndroidRuntimePermissions, parseLauncherActivity, pickAndroidIconFile } from "../src/server/app-control/android.ts"; +import { iosLocale, parseDefaultsArray, parseIosUrlSchemes, parseSimulatorApps } from "../src/server/app-control/simulator.ts"; +import { androidPermissionStates, parseAndroidAppLinks, parseAndroidAppLocales, parseAndroidBadging, parseAndroidRuntimePermissions, parseLauncherActivity, pickAndroidIconFile } from "../src/server/app-control/android.ts"; import { listApps } from "../src/server/app-control/driver.ts"; import type { AppDriver } from "../src/server/app-control/driver.ts"; import { createDrivers, registerAppControlTools } from "../src/server/app-control/tools.ts"; import type { CommandRunner } from "../src/server/app-control/commands.ts"; -import { expectedOutcome } from "../src/shared/error-reporting.ts"; +import { ExpectedOperationError, expectedOutcome } from "../src/shared/error-reporting.ts"; +import { languageTagSchema } from "../src/shared/app-control.ts"; const ios = { platform: "ios", deviceId: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE" } as const; const android = { platform: "android", deviceId: "emulator-5554" } as const; @@ -250,3 +251,69 @@ test("Android icons resolve adaptive launcher icons to their sharpest raster ver assert.equal(pickAndroidIconFile(resources, "res/legacy.png"), "res/legacy.png", "an unlisted raster icon is used directly"); assert.equal(pickAndroidIconFile(resources, "res/vector.xml"), null); }); + +test("app languages are written to the app's own preferences on iOS and through cmd locale on Android", async () => { + assert.deepEqual(parseDefaultsArray('(\n "de-AT",\n en\n)\n'), ["de-AT", "en"]); + assert.deepEqual(parseDefaultsArray("()"), []); + assert.equal(iosLocale("zh-Hans-CN"), "zh_Hans_CN"); + assert.equal(parseAndroidAppLocales("Locales for com.acme for user 0 are [de-AT,en]\n"), "de-AT"); + assert.equal(parseAndroidAppLocales("Locales for com.acme for user 0 are []\n"), null); + assert.throws(() => parseAndroidAppLocales("Unknown package com.nope for userId 0\n"), (error: unknown) => expectedOutcome(error) === "invalid_input"); + assert.throws(() => parseAndroidAppLocales("cmd: Can't find service: locale\n"), (error: unknown) => expectedOutcome(error) === "unsupported_platform"); + for (const tag of ["de", "pt-BR", "zh-Hans", "zh-Hans-CN", "es-419"]) assert.equal(languageTagSchema.safeParse(tag).success, true, tag); + for (const tag of ["d", "de_AT", "de AT", "de;rm", "en-GB-oxendict"]) assert.equal(languageTagSchema.safeParse(tag).success, false, tag); + + const container = "/data/Containers/Data/Application/ABC"; + const domain = `${container}/Library/Preferences/com.acme`; + let stored: string | undefined; + const { simulator, emulator, calls } = fixture((file, args) => { + if (args[1] === "get_app_container") return `${container}\n`; + const defaults = args[1] === "spawn" ? args.slice(4) : undefined; + if (defaults?.[0] === "read") return stored ? `(\n "${stored}"\n)\n` : failure("Error: Could not find key 'AppleLanguages' in domain 'com.acme'."); + if (defaults?.[0] === "write" && defaults[2] === "AppleLanguages") stored = defaults[4]; + if (defaults?.[0] === "delete") return stored === undefined ? failure("Error: Domain 'com.acme' not found.") : (stored = undefined, ""); + if (file === "adb") return (args[3] ?? "").includes("--locales de-AT") ? "Locales for com.acme for user 0 are [de-AT]\n" : "Locales for com.acme for user 0 are []\n"; + return ""; + }); + assert.equal(await simulator.language("com.acme"), null); + await simulator.setLanguage("com.acme", "de-AT"); + assert.equal(await simulator.language("com.acme"), "de-AT"); + const writes = calls.filter(call => call.args[1] === "terminate" || call.args.includes("write")).map(call => call.args.slice(1)); + assert.deepEqual(writes, [ + ["terminate", ios.deviceId, "com.acme"], + ["spawn", ios.deviceId, "defaults", "write", domain, "AppleLanguages", "-array", "de-AT"], + ["spawn", ios.deviceId, "defaults", "write", domain, "AppleLocale", "-string", "de_AT"], + ], "the app stops before its preferences change"); + await simulator.setLanguage("com.acme", null); + await simulator.setLanguage("com.acme", null); + assert.equal(await simulator.language("com.acme"), null); + + await emulator.setLanguage("com.acme", "de-AT"); + assert.equal(calls.at(-1)?.args[3], "cmd locale set-app-locales com.acme --locales de-AT; cmd locale get-app-locales com.acme"); + await emulator.setLanguage("com.acme", null); + assert.equal(calls.at(-1)?.args[3], "cmd locale set-app-locales com.acme; cmd locale get-app-locales com.acme"); + await assert.rejects(emulator.setLanguage("com.acme", "fr"), /Android kept a different language/); +}); + +test("setting an app language relaunches it so the new language is visible", async t => { + const steps: string[] = []; + const server = new McpServer({ name: "app-control-test", version: "1" }); + registerAppControlTools(server, () => ({ + setLanguage: async (bundleId: string, language: string | null) => { steps.push(`language ${bundleId} ${language}`); }, + launch: async (bundleId: string) => { steps.push(`launch ${bundleId}`); if (bundleId === "com.helper") throw new ExpectedOperationError("invalid_input", "com.helper has no launcher activity."); }, + }) as unknown as AppDriver); + const client = new Client({ name: "test", version: "1" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + t.after(async () => { await client.close(); await server.close(); }); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ name: "mobile_set_app_language", arguments: { ...android, bundleId: "com.acme", language: "ar" } }); + assert.deepEqual(result.structuredContent, { language: "ar", launched: "com.acme" }); + assert.deepEqual(steps, ["language com.acme ar", "launch com.acme"]); + const invalid = await client.callTool({ name: "mobile_set_app_language", arguments: { ...android, bundleId: "com.acme", language: "de; reboot" } }); + assert.equal(invalid.isError, true); + assert.equal(steps.length, 2, "invalid tags never reach a device shell"); + const unlaunchable = await client.callTool({ name: "mobile_set_app_language", arguments: { ...android, bundleId: "com.helper", language: "de" } }); + assert.equal(unlaunchable.isError, true); + assert.match(JSON.stringify(unlaunchable.content), /Changed the app language, but relaunching failed: com\.helper has no launcher activity/); +}); diff --git a/tests/react-ui.test.ts b/tests/react-ui.test.ts index c00b90c5..84a34fa7 100644 --- a/tests/react-ui.test.ts +++ b/tests/react-ui.test.ts @@ -202,8 +202,14 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim if (name === "mobile_set_app_permission") return { permissions: ["location", "photos"].map(permission => ({ permission, state: permission === args.permission ? args.state : null })) }; if (name === "mobile_app_links") return { links: ["acme://"] }; if (name === "mobile_app_icons") return { apps: { "com.acme.shop": { icon: "data:image/png;base64,iVBORw0KGgo=" } } }; + if (name === "mobile_app_language") return { language: null }; + if (name === "mobile_set_app_language") return { language: args.language, launched: args.bundleId }; return {}; }; + iosSettings.request = async (change: { setting: string; value: unknown } | undefined) => { + if (change) { changes.push(change); return { [change.setting]: change.value }; } + return { appearance: "light", contentSize: "large", increaseContrast: false, locationSupported: true, statusBar: "live", network: "normal", networkConditions: ["normal", "lte", "3g", "edge", "offline"] }; + }; await act(async () => { appTools.configure("ios", "iphone", false); }); await act(async () => { settingsButton.click(); }); const toolsPopover = dom.window.document.querySelector('[data-slot="popover-content"][aria-label="Device settings"]')!; @@ -233,9 +239,23 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim assert.equal(urlInput.value, "acme://", "declared schemes fill the field without opening"); assert.equal(appCalls.at(-1)?.name, "mobile_open_url"); assert.equal(appCalls.filter(call => call.name === "mobile_open_url").length, 1); + const languageToggle = [...toolsPopover.querySelectorAll("button")].find(button => button.textContent?.startsWith("Language"))!; + assert.ok(languageToggle.textContent?.includes("Device language"), "apps start on the device language"); + await act(async () => { await appTools.setLanguage("de"); }); + assert.deepEqual(appCalls.at(-1), { name: "mobile_set_app_language", args: { platform: "ios", deviceId: "iphone", bundleId: "com.acme.shop", language: "de" } }); + assert.ok(languageToggle.textContent?.includes("German")); + assert.ok(toolsPopover.textContent?.includes("Relaunched Acme Shop in de.")); const deviceTab = [...toolsPopover.querySelectorAll('[role="tab"]')].find(tab => tab.textContent === "Device")!; await act(async () => { deviceTab.click(); }); assert.ok(toolsPopover.textContent?.includes("Increase contrast")); + assert.ok(toolsPopover.textContent?.includes("Clean status bar")); + assert.equal(toolsPopover.textContent?.includes("Relaunch app"), false, "no relaunch hint while the network is normal"); + const slowNetwork = [...toolsPopover.querySelectorAll('[aria-label="Network condition"] button')].find(button => button.textContent === "3G")!; + await act(async () => { slowNetwork.click(); }); + assert.deepEqual(changes.at(-1), { setting: "network", value: "3g" }); + const relaunch = [...toolsPopover.querySelectorAll("button")].find(button => button.textContent === "Relaunch app")!; + await act(async () => { relaunch.click(); }); + assert.equal(appCalls.at(-1)?.name, "mobile_launch_app", "iOS throttling offers a relaunch so the app picks it up"); await act(async () => { settingsButton.click(); }); await selectTool("logs"); const errorMessage = '\nCannot read property id of undefined'; From 969bcaa40daa4c213b4b21f109475ccda7c54e9c Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 14:42:37 +0200 Subject: [PATCH 04/18] chore(release): bump version to 0.1.152 --- .codex-plugin/plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/shared/version.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9a324243..cb2afa39 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.151", + "version": "0.1.152", "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" diff --git a/package-lock.json b/package-lock.json index afd92294..ae580d9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.151", + "version": "0.1.152", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.151", + "version": "0.1.152", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/inter": "^5.3.0", diff --git a/package.json b/package.json index 4c61c0ed..67beb8f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.151", + "version": "0.1.152", "private": true, "type": "module", "engines": { diff --git a/src/shared/version.ts b/src/shared/version.ts index a7321c5d..87b24af6 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.151"; +export const PLUGIN_VERSION = "0.1.152"; From 1e6b39b061d625777086dae0f7a0488ebb06c689 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 15:51:01 +0200 Subject: [PATCH 05/18] fix(serve-emu): hide the mobile icon in the clean Android status bar Android 16's demo mode ignores the mobile data type and signal level and draws an empty "3G" icon, so the clean status bar hides the mobile icon and shows Wi-Fi as fully connected instead of with a no-internet mark. Demo mode is re-entered first because mobile changes only apply in a fresh demo session. --- runtimes/serve-emu/src/adb.ts | 9 ++++++--- tests/serve-emu-device-conditions.test.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/runtimes/serve-emu/src/adb.ts b/runtimes/serve-emu/src/adb.ts index ee567dc4..9b76ccc4 100644 --- a/runtimes/serve-emu/src/adb.ts +++ b/runtimes/serve-emu/src/adb.ts @@ -420,13 +420,16 @@ export async function setEmulatorThrottle( } const DEMO_BROADCAST = "am broadcast -a com.android.systemui.demo -e command"; -// A clean marketing status bar: 9:41, full battery and signal, no notification icons. +// A clean marketing status bar: 9:41, full battery, full connected Wi-Fi, no notification icons. +// Android 16's demo mode ignores the mobile data type and level, drawing an empty "3G" icon, +// so the mobile icon is hidden. Re-entering demo mode makes the mobile command take effect. const CLEAN_STATUS_BAR = [ + "exit", "enter", "clock -e hhmm 0941", "battery -e level 100 -e plugged false", - "network -e wifi show -e level 4", - "network -e mobile show -e datatype none -e level 4", + "network -e wifi show -e level 4 -e fully true", + "network -e mobile hide", "notifications -e visible false", ].map((command) => `${DEMO_BROADCAST} ${command}`); // Demo mode cannot be queried and ends on reboot, so entering it records the boot it belongs to. diff --git a/tests/serve-emu-device-conditions.test.ts b/tests/serve-emu-device-conditions.test.ts index e27a6da1..033395d8 100644 --- a/tests/serve-emu-device-conditions.test.ts +++ b/tests/serve-emu-device-conditions.test.ts @@ -82,7 +82,8 @@ test("a clean status bar holds only for the boot that entered demo mode", async assert.deepEqual(await adb.getStatusBar("emulator-5554", run), { mode: "live" }); assert.deepEqual(await adb.setStatusBar("emulator-5554", "clean", run), { mode: "clean" }); const entered = calls.find(args => args[3].includes("command enter"))![3]; - assert.match(entered, /^settings put global sysui_demo_allowed 1 && am broadcast -a com\.android\.systemui\.demo -e command enter && /); + assert.match(entered, /^settings put global sysui_demo_allowed 1 && am broadcast -a com\.android\.systemui\.demo -e command exit && am broadcast -a com\.android\.systemui\.demo -e command enter && /); + assert.match(entered, /-e command network -e wifi show -e level 4 -e fully true && .*-e command network -e mobile hide/); assert.match(entered, /-e command clock -e hhmm 0941/); assert.match(entered, /settings put global mobile_dev_status_bar_boot \$\(cat \/proc\/sys\/kernel\/random\/boot_id\)$/); boot = "boot-2"; From 0caf37c1127b0bb46415b12ea3bcd158a75d0ce1 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Wed, 14 Oct 2026 15:51:49 +0200 Subject: [PATCH 06/18] fix(ui): remove the device screen focus ring The ring sat 8px outside the screen and drifted off the Android frame's corners. The screen still takes focus on click or drag, so typing reaches the device. --- src/ui/components/simulator-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/simulator-view.tsx b/src/ui/components/simulator-view.tsx index d1cf8ad9..9245f6a9 100644 --- a/src/ui/components/simulator-view.tsx +++ b/src/ui/components/simulator-view.tsx @@ -33,7 +33,7 @@ export const SimulatorView = memo(function SimulatorView({ platform, visible }: