diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9a324243..2303eebe 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.157", "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/docs/telemetry.md b/docs/telemetry.md index eac6d374..5f3f235a 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -191,10 +191,27 @@ 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. Android throttling runs through a local proxy on the Mac, so its adb +failures report under `device_settings.network`; throttling a device that already +uses its own proxy counts only through `server.error.outcome`. When the plugin first +starts an Android backend, it clears a throttle left by a plugin process that exited; +unexpected adb failures there report under `device_settings.network_recovery`, and a +device that went away counts only through `server.error.outcome`. Proxy +addresses and traffic are not sent. Since 0.1.155, expected device setting and app +control outcomes reach `server.error.outcome` as documented; earlier releases skipped +them. 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/package-lock.json b/package-lock.json index afd92294..02da3a09 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.157", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.151", + "version": "0.1.157", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/inter": "^5.3.0", diff --git a/package.json b/package.json index 4c61c0ed..353eaf3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.151", + "version": "0.1.157", "private": true, "type": "module", "engines": { diff --git a/runtimes/serve-emu/src/adb.ts b/runtimes/serve-emu/src/adb.ts index 925efda2..829b60d0 100644 --- a/runtimes/serve-emu/src/adb.ts +++ b/runtimes/serve-emu/src/adb.ts @@ -1,3 +1,4 @@ +// Modified for Mobile Dev: status bar demo mode and Wi-Fi-only network toggling. import { spawn } from "node:child_process"; import { execBuffer, execText, type ExecResult } from "./exec.ts"; @@ -32,6 +33,8 @@ export type NetworkStatus = { mobileData: string; }; }; +export type StatusBarMode = "clean" | "live"; +export type StatusBarStatus = { mode: StatusBarMode }; function execFailed(result: ExecResult): boolean { return result.status !== 0 || result.error !== null; @@ -325,7 +328,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 +338,62 @@ export async function setNetworkEnabled( } return getNetworkStatus(serial, runExec); } + +const DEMO_BROADCAST = "am broadcast -a com.android.systemui.demo -e command"; +// 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 -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. +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" + ? [ + // SystemUI ignores demo broadcasts until its settings observer sees demo mode allowed. + '[ "$(settings get global sysui_demo_allowed)" = 1 ] || { settings put global sysui_demo_allowed 1 && sleep 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..39ff008a 100644 --- a/runtimes/serve-emu/src/api/routes/devices.ts +++ b/runtimes/serve-emu/src/api/routes/devices.ts @@ -1,11 +1,14 @@ +// Modified for Mobile Dev: status bar demo mode. import { getFontScale, getNetworkStatus, getNightMode, + getStatusBar, getUserRotation, setFontScale, setNetworkEnabled, setNightMode, + setStatusBar, setUserRotation, type NightMode, type OrientationMode, @@ -447,5 +450,61 @@ export function deviceRoutes(): ApiRoute[] { } }, }, + { + 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) { + return errorResponse(err); + } + }, + }, ]; } diff --git a/runtimes/serve-emu/src/server/api-boundary.ts b/runtimes/serve-emu/src/server/api-boundary.ts index b5e8493e..bf62c1db 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: 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..78550ca3 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: status bar demo mode. import { parseGesture, type Gesture } from "./control-contracts"; /** Stable error codes sent by every JSON API failure. */ @@ -98,6 +99,9 @@ export type NetworkStatus = { }; 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; @@ -352,6 +356,10 @@ export type ApiContractMap = { GET: EndpointContract; POST: EndpointContract<{ enabled: boolean }, NetworkResponse>; }; + "/api/status-bar": { + GET: EndpointContract; + POST: EndpointContract<{ mode: StatusBarMode }, StatusBarResponse>; + }; "/api/logcat": { GET: EndpointContract }; "/api/screenshot": { GET: EndpointContract; @@ -679,6 +687,13 @@ export function parseNetworkResponse(value: unknown): NetworkResponse { }; } +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 +1080,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/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index 5e38c496..c650b71d 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 applies right away to standard app networking such as fetch and OkHttp, leaves local dev servers like Metro at full speed, and is unavailable while the device uses its own proxy. 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 b21a8475..fff63bb3 100644 --- a/src/server/app-control/android.ts +++ b/src/server/app-control/android.ts @@ -122,6 +122,18 @@ 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; } +// Android 12 and older have no locale service. cmd says so with exit status 20, or 0 when chained. +const localeUnavailable = /Can't find service: locale|Unknown command/; + +// "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 (localeUnavailable.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 { @@ -136,14 +148,19 @@ export class AndroidDriver implements AppDriver { } // am and pm report rejected intents and permissions on stdout, sometimes with exit status 0. - private async shell(action: string, command: string, timeout = 15000) { + // Output matching `tolerate` is returned even when the command fails, for callers that parse it. + private async shell(action: string, command: string, timeout = 15000, tolerate?: RegExp) { const rejected = (output: string) => { const failure = /^(?:Error: |Exception occurred while executing)[^\n]*/m.exec(output) ?? /^Error[^\n]*/m.exec(output); return failure ? new ExpectedOperationError("invalid_input", `${action} failed: ${failure[0]}`) : undefined; }; let output: string; try { output = await this.commands.run(await this.commands.adb(), ["-s", this.deviceId, "shell", command], { timeout }); } - catch (error) { throw rejected(commandOutput(error)) ?? commandError(error, action); } + catch (error) { + const failed = commandOutput(error); + if (tolerate?.test(failed)) return failed; + throw rejected(failed) ?? commandError(error, action); + } const failure = rejected(output); if (failure) throw failure; return output; @@ -222,6 +239,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}`, 15000, localeUnavailable)); } + + 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}`, 15000, localeUnavailable); + 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 12d6cd4e..f449fdc0 100644 --- a/src/server/app-control/driver.ts +++ b/src/server/app-control/driver.ts @@ -22,6 +22,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 f57a6a5c..6c3a9a52 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 { @@ -132,6 +141,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 43aa51c7..b638a0ca 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 { appDetailsBatch, appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, permissionStates } from "../../shared/app-control.ts"; +import { appDetailsBatch, appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, languageTagSchema, permissionStates } from "../../shared/app-control.ts"; import { systemCommands } from "./commands.ts"; import type { Commands } from "./commands.ts"; import { AppDetailsCache, listApps } from "./driver.ts"; @@ -87,6 +87,22 @@ export function registerAppControlTools(server: McpServer, driverFor: DriverFact inputSchema: { bundleId, permission: z.enum([...appPermissions, "all"]), state: z.enum(permissionStates).describe("allow grants, deny blocks, ask resets so the app prompts again.") }, description: "Allow, deny, or reset an app permission without the system prompt. iOS simulators lack camera and notifications; Android lacks reminders and motion. Changes may restart the app." }, async (driver, input) => ({ 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/server/device-settings.ts b/src/server/device-settings.ts index 13dd72f9..d5ad8e62 100644 --- a/src/server/device-settings.ts +++ b/src/server/device-settings.ts @@ -3,106 +3,226 @@ 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 { AndroidNetworkThrottle, type ThrottleReading } from "./network-throttle/android.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, 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(), z.object({ platform: z.literal("android"), id: androidIdSchema }).strict(), ]); type Target = z.infer; +type Patch = Partial; 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.`); +const post = (body: unknown): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); -export class DeviceSettingsService { +// Each read fills its own settings; a failed one is listed in errors without hiding the others. +async function collect(settings: DeviceSettings, reads: Record Promise>): Promise { + const labels = Object.keys(reads); + const results = await Promise.allSettled(Object.values(reads).map(read => read())); + const errors = results.flatMap((result, index) => { + if (result.status === "fulfilled") { Object.assign(settings, result.value); return []; } + return [`${labels[index]}: ${errorMessage(result.reason)}`]; + }); + if (errors.length) settings.errors = errors; + return settings; +} + +// One platform's settings. Changes it cannot make throw before its backend starts. +interface PlatformSettings { + read(id: string): Promise; + update(id: string, change: DeviceSetting): Promise; +} + +// 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 iosInterfaceSchema = z.object({ appearance: z.string(), contentSize: z.string(), increaseContrast: z.string() }); +const contrast = z.enum(["enabled", "disabled"]); + +function iosInterface(value: unknown): Patch { + const data = iosInterfaceSchema.parse(value); + const mode = appearance.safeParse(data.appearance); + const size = z.enum(contentSizes).safeParse(data.contentSize); + const highContrast = contrast.safeParse(data.increaseContrast); + return { + ...(mode.success && { appearance: mode.data }), + ...(size.success && { contentSize: size.data }), + ...(highContrast.success && { increaseContrast: highContrast.data === "enabled" }), + }; +} + +class IosSettings implements PlatformSettings { private readonly baguette: Baguette; - private readonly android: ServeEmu; - constructor(baguette: Baguette, android: ServeEmu) { this.baguette = baguette; this.android = android; } - - private iosReading(value: unknown): DeviceSettings { - const data = z.object({ appearance: z.string(), contentSize: z.string(), increaseContrast: z.string() }).parse(value); - return { - locationSupported: true, - ...(appearance.safeParse(data.appearance).success ? { appearance: data.appearance as DeviceSettings["appearance"] } : {}), - ...(z.enum(contentSizes).safeParse(data.contentSize).success ? { contentSize: data.contentSize as DeviceSettings["contentSize"] } : {}), - ...(["enabled", "disabled"].includes(data.increaseContrast) ? { increaseContrast: data.increaseContrast === "enabled" } : {}), - }; + constructor(baguette: Baguette) { this.baguette = baguette; } + + private async simulator(id: string) { + await this.baguette.device(id, true); + return `/simulators/${id}`; } - 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 backend = await this.android.start(target.id); - const settings: DeviceSettings = { locationSupported: /^emulator-\d+$/.test(target.id) }; - 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; }], - ] 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)}`] : []); - if (errors.length) settings.errors = errors; - return settings; + async read(id: string) { + const path = await this.simulator(id); + // The status bar and network are optional extras; a failure there keeps the display controls. + const [display, settings] = await Promise.all([ + this.baguette.json(`${path}/interface.json`).then(iosInterface), + collect({ locationSupported: true }, { + "Status bar": async () => ({ statusBar: Object.keys(z.record(z.string(), z.unknown()).parse(await this.baguette.json(`${path}/status-bar`))).length > 0 ? "clean" : "live" }), + Network: async () => ({ network: iosNetwork(await this.baguette.json(`${path}/network`)) }), + }), + ]); + return { ...settings, ...display }; } - 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."); - 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 }; - } - 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."); - const backend = await this.android.start(target.id); + async update(id: string, change: DeviceSetting): Promise { switch (change.setting) { - case "appearance": { - const data = z.object({ nightMode: z.object({ mode: appearance }) }).parse(await this.android.json(backend.url, "/api/night-mode", this.post({ mode: change.value }))); - return { appearance: data.nightMode.mode }; - } - case "fontScale": { - const data = z.object({ fontScale: z.object({ scale: z.number() }) }).parse(await this.android.json(backend.url, "/api/font-scale", this.post({ scale: change.value }))); - return { fontScale: data.fontScale.scale }; + case "appearance": + if (change.value === "auto") throw unsupported("Baguette"); + return this.setInterface(id, "appearance", change.value); + case "contentSize": return this.setInterface(id, "contentSize", change.value); + case "increaseContrast": return this.setInterface(id, "increaseContrast", change.value ? "enabled" : "disabled"); + case "location": { + const path = await this.simulator(id); + await this.baguette.json(`${path}/location`, change.value === null ? { method: "DELETE" } : post(change.value)); + return { location: change.value }; } - case "orientation": { - const data = z.object({ orientation: z.object({ orientation }) }).parse(await this.android.json(backend.url, "/api/orientation", this.post({ orientation: change.value }))); - return { orientation: data.orientation.orientation }; + case "statusBar": { + const path = await this.simulator(id); + await this.baguette.json(`${path}/status-bar`, change.value === "live" ? { method: "DELETE" } : post(cleanIosStatusBar)); + return { statusBar: change.value }; } - case "location": { - const data = z.object({ location: locationSchema.loose() }).parse(await this.android.json(backend.url, "/api/location", this.post(change.value!))); - return { location: data.location }; + case "network": { + // Conditioning is injected into apps at launch, so Baguette keeps it until it is cleared. + const path = await this.simulator(id); + if (change.value === "normal") { await this.baguette.json(`${path}/network`, { method: "DELETE" }); return { network: "normal" }; } + return { network: iosNetwork(await this.baguette.json(`${path}/network`, post(change.value === "offline" ? { offline: true } : { profile: change.value }))) }; } - default: throw new Error("serve-emu does not expose this setting."); + default: throw unsupported("Baguette"); } } - private post(body: object): RequestInit { return { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; } + private async setInterface(id: string, key: string, value: string): Promise { + const path = await this.simulator(id); + const response = await this.baguette.json(`${path}/interface`, post({ [key]: 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(id); + return iosInterface(response); + } +} + +// serve-emu reads and writes these settings through one endpoint each; a write answers like a read. +// `field` names the value in the request body; without one the value is the body. +type AndroidEndpoint = { label: string; path: string; field: string | null; patch: (response: unknown) => Patch }; +const androidEndpoints: Partial> = { + appearance: { label: "Appearance", path: "/api/night-mode", field: "mode", patch: response => { + const mode = appearance.safeParse(z.object({ nightMode: z.object({ mode: z.string() }) }).parse(response).nightMode.mode); + return mode.success ? { appearance: mode.data } : {}; + } }, + fontScale: { label: "Text size", path: "/api/font-scale", field: "scale", + patch: response => ({ fontScale: z.object({ fontScale: z.object({ scale: z.number().min(0.7).max(2) }) }).parse(response).fontScale.scale }) }, + orientation: { label: "Rotation", path: "/api/orientation", field: "orientation", patch: response => { + const rotation = orientation.safeParse(z.object({ orientation: z.object({ orientation: z.string() }) }).parse(response).orientation.orientation); + return rotation.success ? { orientation: rotation.data } : {}; + } }, + location: { label: "Location", path: "/api/location", field: null, + patch: response => ({ location: z.object({ location: locationSchema.loose().nullable() }).parse(response).location }) }, + statusBar: { label: "Status bar", path: "/api/status-bar", field: "mode", + patch: response => ({ statusBar: z.object({ statusBar: z.object({ mode: z.enum(["clean", "live"]) }) }).parse(response).statusBar.mode }) }, +}; + +const radiosSchema = z.object({ network: z.object({ enabled: z.boolean().nullable() }).loose() }); +const radiosOff = (value: unknown) => radiosSchema.parse(value).network.enabled === false; +const isEmulator = (id: string) => /^emulator-\d+$/.test(id); +// Radios that are off read as offline, and a proxy the user set up reads as custom; the panel leaves it alone. +const androidNetwork = (offline: boolean, throttle: ThrottleReading): NetworkCondition | "custom" => + offline ? "offline" : "foreignProxy" in throttle ? "custom" : throttle.preset ?? "normal"; + +class AndroidSettings implements PlatformSettings { + private readonly android: ServeEmu; + private readonly throttle: AndroidNetworkThrottle; + constructor(android: ServeEmu, throttle: AndroidNetworkThrottle) { this.android = android; this.throttle = throttle; } + + private async backend(id: string) { return (await this.android.start(id)).url; } + + async read(id: string) { + const url = await this.backend(id); + const reads: Record Promise> = {}; + for (const { label, path, patch } of Object.values(androidEndpoints)) reads[label] = async () => patch(await this.android.json(url, path)); + reads.Network = async () => { + const [radios, throttle] = await Promise.all([this.android.json(url, "/api/network"), this.throttle.read(id)]); + return { network: androidNetwork(radiosOff(radios), throttle) }; + }; + return collect({ locationSupported: isEmulator(id) }, reads); + } + + async update(id: string, change: DeviceSetting): Promise { + if (change.setting === "network") return { network: await this.setNetwork(id, change.value) }; + const endpoint = androidEndpoints[change.setting]; + // Only emulators take a simulated location, and serve-emu cannot clear it. + if (!endpoint || change.setting === "location" && (change.value === null || !isEmulator(id))) throw unsupported("serve-emu"); + const url = await this.backend(id); + return endpoint.patch(await this.android.json(url, endpoint.path, post(endpoint.field ? { [endpoint.field]: change.value } : change.value))); + } + + // Offline turns the radios off; the presets slow traffic through a proxy on the Mac. + private async setNetwork(id: string, condition: NetworkCondition) { + const url = await this.backend(id); + const offline = condition === "offline"; + const throttle = await this.throttle.set(id, condition === "normal" || offline ? null : condition); + // Toggling the radios restarts Wi-Fi, so they change only when going offline or coming back. + if (radiosOff(await this.android.json(url, "/api/network")) !== offline) await this.android.json(url, "/api/network", post({ enabled: !offline })); + return androidNetwork(offline, throttle); + } +} + +export class DeviceSettingsService { + readonly throttle: AndroidNetworkThrottle; + private readonly platforms: Record; + + constructor(baguette: Baguette, android: ServeEmu, throttle = new AndroidNetworkThrottle()) { + this.throttle = throttle; + this.platforms = { ios: new IosSettings(baguette), android: new AndroidSettings(android, throttle) }; + } + + read(target: Target) { return this.platforms[target.platform].read(target.id); } + update(target: Target, change: DeviceSetting) { return this.platforms[target.platform].update(target.id, change); } } 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) { + // Expected outcomes carry fixed reasons and are counted without a report. + captureServerError(expectedOutcome(error) === undefined ? new Error("Device setting command failed.") : error, `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))); + const stopRecovery = android.onStart(serial => { + // A device that went away is an expected outcome; the next backend start checks it again. + service.throttle.recover(serial).catch(error => captureServerError(expectedOutcome(error) === undefined ? new Error("Network throttle recovery failed.") : error, "device_settings.network_recovery")); + }); + // Android throttling must be undone before the plugin exits, or the device keeps a dead proxy. + return () => { stopRecovery(); return service.throttle.close(); }; } diff --git a/src/server/network-throttle/android.ts b/src/server/network-throttle/android.ts new file mode 100644 index 00000000..0fc4cd6b --- /dev/null +++ b/src/server/network-throttle/android.ts @@ -0,0 +1,133 @@ +import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { errorMessage } from "../../shared/protocol.ts"; +import { networkPresets, type NetworkPreset } from "../../shared/device-settings.ts"; +import { runAdb, systemCommands, type Commands } from "../app-control/commands.ts"; +import { proxyToken, ThrottleProxy } from "./proxy.ts"; + +const marker = "mobile_dev_network_throttle"; +const emulatorSerial = /^emulator-\d+$/; +type DeviceState = { proxy: string | null; owner: { token: string; port: number; preset: NetworkPreset } | null }; +export type ThrottleReading = { preset: NetworkPreset | null } | { foreignProxy: string }; + +export function parseThrottleState(output: string): DeviceState { + const [proxyLine = "", markerLine = ""] = output.split("\n").map(line => line.trim()); + const proxy = proxyLine === "null" || proxyLine === "" || proxyLine === ":0" ? null : proxyLine; + const match = /^([0-9a-f-]{36}):(\d+):(lte|3g|edge)$/.exec(markerLine); + return { proxy, owner: match ? { token: match[1], port: Number(match[2]), preset: match[3] as NetworkPreset } : null }; +} + +// Android throttling points the device's global proxy at a ThrottleProxy on the Mac. A marker setting +// records which proxy owns the device so any plugin process can tell its own setup from the user's +// proxy and clear a setup whose process is gone, which would otherwise leave the device offline. +export class AndroidNetworkThrottle { + private readonly proxies = new Map(); + private readonly recovered = new Set(); + // Throttle changes run one at a time, so concurrent views cannot start two proxies for a device. + private queue: Promise = Promise.resolve(); + private readonly commands: Commands; + private readonly alive: (port: number) => Promise; + + constructor(commands: Commands = systemCommands, alive = proxyToken) { this.commands = commands; this.alive = alive; } + + private serialized(task: () => Promise): Promise { + const run = this.queue.then(task); + this.queue = run.catch(() => undefined); + return run; + } + + private shell(serial: string, action: string, command: string) { return runAdb(this.commands, serial, action, ["shell", command], 10000); } + + // Phones reach the Mac's proxy through adb reverse; emulators reach it at 10.0.2.2. + private async reverse(serial: string, action: string, args: string[]) { + if (emulatorSerial.test(serial)) return; + // A listener that is already gone needs no removal. + try { await runAdb(this.commands, serial, action, ["reverse", ...args], 10000); } + catch (error) { if (!/not found/i.test(errorMessage(error))) throw error; } + } + + private async state(serial: string) { + return parseThrottleState(await this.shell(serial, "Reading the network throttle", `settings get global http_proxy; settings get global ${marker}`)); + } + + private proxyAddress(serial: string, port: number) { return `${emulatorSerial.test(serial) ? "10.0.2.2" : "127.0.0.1"}:${port}`; } + + // A marker only counts while the device still points at the proxy it names. + private ours(serial: string, state: DeviceState) { + return state.owner !== null && state.proxy === this.proxyAddress(serial, state.owner.port); + } + + private async clear(serial: string, port: number) { + await this.shell(serial, "Clearing the network throttle", `settings put global http_proxy :0 && settings delete global http_proxy && settings delete global ${marker}`); + await this.reverse(serial, "Clearing the network throttle", ["--remove", `tcp:${port}`]); + } + + // A plugin process that crashed leaves the device pointing at a dead proxy. Checking once when this + // process first reaches a device brings it back online without waiting for the settings panel. + async recover(serial: string) { + if (this.recovered.has(serial)) return; + this.recovered.add(serial); + try { await this.read(serial); } + catch (error) { this.recovered.delete(serial); throw error; } + } + + read(serial: string): Promise { + return this.serialized(async () => { + const state = await this.state(serial); + if (state.owner && !this.ours(serial, state)) { + // Someone replaced or removed the proxy; drop the marker but leave their setting alone. + await this.shell(serial, "Reading the network throttle", `settings delete global ${marker}`); + } else if (state.owner) { + if (await this.alive(state.owner.port) !== state.owner.token) { + // The owning plugin process is gone, so the device would have no internet. + await this.clear(serial, state.owner.port); + return { preset: null }; + } + // adb reverse ends when a phone reconnects; restore it for a proxy this process owns. + if (this.proxies.get(serial)?.port === state.owner.port) await this.reverse(serial, "Restoring the network throttle", [`tcp:${state.owner.port}`, `tcp:${state.owner.port}`]); + return { preset: state.owner.preset }; + } + return state.proxy ? { foreignProxy: state.proxy } : { preset: null }; + }); + } + + // Returns what the device uses afterwards: the user's own proxy stays in place. + set(serial: string, preset: NetworkPreset | null): Promise { + return this.serialized(async () => { + const state = await this.state(serial); + const ours = this.ours(serial, state); + const current = this.proxies.get(serial); + if (preset === null) { + if (ours) await this.clear(serial, state.owner!.port); + if (current) { this.proxies.delete(serial); await current.proxy.close(); } + return state.proxy && !ours ? { foreignProxy: state.proxy } : { preset: null }; + } + if (state.proxy && !ours) { + throw new ExpectedOperationError("invalid_input", `This device already uses the proxy ${state.proxy}. Remove it to simulate a slower network.`); + } + const link = current ?? await this.start(); + this.proxies.set(serial, link); + // Presets match iOS; latency is the round trip, so each direction carries half. + link.proxy.profile = networkPresets[preset]; + await this.reverse(serial, "Throttling the network", [`tcp:${link.port}`, `tcp:${link.port}`]); + await this.shell(serial, "Throttling the network", `settings put global http_proxy ${this.proxyAddress(serial, link.port)} && settings put global ${marker} ${link.proxy.token}:${link.port}:${preset}`); + return { preset }; + }); + } + + private async start() { + const proxy = new ThrottleProxy(networkPresets.lte); + return { proxy, port: await proxy.listen() }; + } + + // Devices still pointing at this process's proxies would lose internet once it exits. + async close() { + await this.queue; + await Promise.allSettled([...this.proxies].map(async ([serial, { proxy, port }]) => { + try { + const state = await this.state(serial); + if (this.ours(serial, state) && state.owner!.token === proxy.token) await this.clear(serial, port); + } finally { await proxy.close(); } + })); + this.proxies.clear(); + } +} diff --git a/src/server/network-throttle/proxy.ts b/src/server/network-throttle/proxy.ts new file mode 100644 index 00000000..15661622 --- /dev/null +++ b/src/server/network-throttle/proxy.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import { createServer, request as httpRequest, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { connect, isIP, type Socket } from "node:net"; +import type { Duplex, Readable, Writable } from "node:stream"; + +export type LinkProfile = { latencyMs: number; bandwidthKbps: number }; + +// One direction of a simulated link, shared by every connection on the device so they compete for +// the same bandwidth. Half the round-trip latency applies each way. +export class LinkPacer { + profile: LinkProfile; + private nextFree = 0; + private readonly now: () => number; + constructor(profile: LinkProfile, now = () => performance.now()) { this.profile = profile; this.now = now; } + arrival(bytes: number): number { + const start = Math.max(this.now(), this.nextFree); + this.nextFree = start + bytes * 8 / this.profile.bandwidthKbps; + return this.nextFree + this.profile.latencyMs / 2; + } +} + +const chunkBytes = 16 * 1024; +const queuedLimit = 256 * 1024; + +// Copies source to destination and calls `end` once everything has been written. With a pacer, bytes +// and the end of the stream arrive when the link would deliver them: one timer drains a single queue, +// so they keep their order however Node orders timers, and the source pauses while too much is in flight. +export function relay(source: Readable, destination: Writable, pacer?: LinkPacer, end = () => { destination.end(); }) { + if (!pacer) { + source.pipe(destination, { end: false }); + source.once("end", end); + return; + } + // A null piece marks the end of the stream. + const queue: { piece: Buffer | null; at: number }[] = []; + let queued = 0; + let timer: NodeJS.Timeout | undefined; + const schedule = () => { timer = setTimeout(drain, Math.max(0, Math.ceil(queue[0].at - performance.now()))); }; + const drain = () => { + timer = undefined; + while (queue.length && queue[0].at <= performance.now()) { + const { piece } = queue.shift()!; + if (piece === null) { end(); return; } + queued -= piece.length; + if (destination.writable) destination.write(piece); + } + if (queued < queuedLimit && source.isPaused()) source.resume(); + if (queue.length) schedule(); + }; + source.on("data", (data: Buffer) => { + for (let offset = 0; offset < data.length; offset += chunkBytes) { + const piece = data.subarray(offset, offset + chunkBytes); + queue.push({ piece, at: pacer.arrival(piece.length) }); + queued += piece.length; + } + if (queued >= queuedLimit) source.pause(); + if (!timer) schedule(); + }); + source.once("end", () => { + queue.push({ piece: null, at: pacer.arrival(0) }); + if (!timer) schedule(); + }); +} + +// Development servers on the Mac or the local network stay at full speed, such as Metro at 10.0.2.2:8081. +export function isLocalDestination(host: string) { + const name = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (name === "localhost" || name.endsWith(".local") || name === "::1") return true; + if (isIP(name) !== 4) return false; + const [a, b] = name.split(".").map(Number); + return a === 127 || a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254; +} + +// The emulator reaches the Mac at 10.0.2.2, and adb reverse maps a phone's localhost to it. +const hostAddress = (host: string) => /^(?:10\.0\.2\.2|localhost)$/i.test(host) ? "127.0.0.1" : host.replace(/^\[|\]$/g, ""); +const healthPath = "/mobile-dev-network-throttle"; +type Link = { down: LinkPacer; up: LinkPacer }; + +// An HTTP proxy that throttles tunnels and plain requests to the profile's latency and bandwidth. +export class ThrottleProxy { + readonly token = randomUUID(); + private readonly server: Server; + private readonly sockets = new Set(); + private readonly link: Link; + private readonly isLocal: (host: string) => boolean; + + constructor(profile: LinkProfile, isLocal = isLocalDestination) { + this.link = { down: new LinkPacer(profile), up: new LinkPacer(profile) }; + this.isLocal = isLocal; + this.server = createServer((request, response) => this.forward(request, response)); + this.server.on("connect", (request: IncomingMessage, socket: Duplex, head: Buffer) => this.tunnel(request, socket as Socket, head)); + this.server.on("connection", socket => { this.sockets.add(socket); socket.once("close", () => this.sockets.delete(socket)); }); + } + + set profile(profile: LinkProfile) { this.link.down.profile = profile; this.link.up.profile = profile; } + + listen(): Promise { + return new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(0, "127.0.0.1", () => resolve((this.server.address() as { port: number }).port)); + }); + } + + // Local destinations get no pacers, so they pass through at full speed. + private pacers(host: string): Partial { return this.isLocal(host) ? {} : this.link; } + + private tunnel(request: IncomingMessage, client: Socket, head: Buffer) { + const target = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(request.url ?? ""); + if (!target) { client.end("HTTP/1.1 400 Bad Request\r\n\r\n"); return; } + const { down, up } = this.pacers(target[1]); + // Each side half-closes on its own, so bytes still being paced reach the app after the server + // closes. The app's socket is released once both directions are done. + const upstream = connect({ port: Number(target[2]), host: hostAddress(target[1]), allowHalfOpen: true }); + this.sockets.add(upstream); + const release = () => { if (upstream.destroyed && client.writableFinished) client.destroy(); }; + upstream.once("close", () => { this.sockets.delete(upstream); release(); }); + client.once("close", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); + client.on("error", () => upstream.destroy()); + upstream.once("connect", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + relay(upstream, client, down, () => { client.end(release); }); + relay(client, upstream, up); + }); + } + + private forward(request: IncomingMessage, response: ServerResponse) { + // The plugin checks a device's proxy is still alive by asking for the owner's token. + if (request.url === healthPath) { response.end(this.token); return; } + let url: URL; + try { url = new URL(request.url ?? ""); } catch { response.writeHead(400).end(); return; } + if (url.protocol !== "http:") { response.writeHead(400).end(); return; } + const { down, up } = this.pacers(url.hostname); + const headers = { ...request.headers }; + delete headers["proxy-connection"]; + delete headers["proxy-authorization"]; + // Like the response below, the request's headers go out with its first paced bytes or its paced end. + let replied = false; + const upstream = httpRequest({ host: hostAddress(url.hostname), port: url.port || 80, path: `${url.pathname}${url.search}`, method: request.method, headers }, reply => { + replied = true; + // Headers go out with the first body bytes, so they share the body's paced arrival. + response.writeHead(reply.statusCode ?? 502, reply.headers); + reply.on("error", () => response.destroy()); + relay(reply, response, down); + }); + // A reply that breaks off midway closes the app's connection; one that never started is a 502. + upstream.on("error", () => { if (replied) response.destroy(); else response.writeHead(502).end(); }); + // A request the app gave up on stops downloading, so it no longer uses the link. + response.once("close", () => { if (!response.writableFinished) upstream.destroy(); }); + request.on("error", () => upstream.destroy()); + relay(request, upstream, up); + } + + close() { + for (const socket of this.sockets) socket.destroy(); + return new Promise(resolve => this.server.close(() => resolve())); + } +} + +// The token of the proxy listening on this port, or null when none answers. +export async function proxyToken(port: number): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}${healthPath}`, { signal: AbortSignal.timeout(1000) }); + return response.ok ? await response.text() : null; + } catch { return null; } +} diff --git a/src/server/plugin.ts b/src/server/plugin.ts index 382654a4..51d30559 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -114,7 +114,7 @@ export async function createPlugin(html: string | (() => Promise), b const closeFps = fps.closeDevice(deviceId); await Promise.all([closeCpu, closeFps]); }); - registerDeviceSettingsTools(server, baguette, android); + const closeDeviceSettings = registerDeviceSettingsTools(server, baguette, android); registerAppControlTools(server); const automaticRepairs = new Map }>(); @@ -419,9 +419,11 @@ export async function createPlugin(html: string | (() => Promise), b async close() { updates.close(); stopRecordingStorageMetrics(); + // Clearing Android throttles waits on adb, so it runs alongside the other cleanup. + const deviceSettingsClosed = closeDeviceSettings(); await recordings.close(); closeInspection(); closeAndroid(); streams.close(); - try { await Promise.all([logs.close(), cpu.close(), fps.close(), closeIosMirror()]); } + try { await Promise.all([logs.close(), cpu.close(), fps.close(), closeIosMirror(), deviceSettingsClosed]); } finally { baguette.dispose(); await server.close(); } }, }; diff --git a/src/server/serve-emu.ts b/src/server/serve-emu.ts index 988604ff..71fd5732 100644 --- a/src/server/serve-emu.ts +++ b/src/server/serve-emu.ts @@ -20,6 +20,8 @@ import { androidSdkRoots } from "./android-sdk.ts"; 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(), z.object({ message: z.string() }).loose()]).optional() }).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 }; @@ -44,6 +46,7 @@ export class ServeEmu { private readonly backendContexts = new WeakMap(); private disposed = false; private readonly external?: URL; + private readonly startListeners = new Set<(id: string) => void>(); constructor(baseUrl = process.env.SERVE_EMU_URL) { if (baseUrl) this.external = parseBaseUrl(baseUrl, "SERVE_EMU_URL"); @@ -195,10 +198,12 @@ 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."); - return payload; + const payload = response.ok ? await response.json() : await response.json().catch(() => undefined); + const rejection = serveEmuErrorSchema.safeParse(payload); + if (response.ok && !rejection.success) return payload; + const error = rejection.data?.error; + const message = (typeof error === "string" ? error : error?.message) || (response.ok ? "serve-emu rejected the request." : `serve-emu returned HTTP ${response.status} for ${path}.`); + throw new Error(message); } private async health(url: URL) { @@ -211,11 +216,20 @@ export class ServeEmu { if ((await this.health(url)).serial !== id) throw new SimulatorUnavailableError("The Android backend switched to another device. Refresh and reconnect the selected device."); } + // Listeners run after each successful start and must handle their own failures. + onStart(listener: (id: string) => void) { + this.startListeners.add(listener); + return () => { this.startListeners.delete(listener); }; + } + async start(id: string): Promise { if (this.disposed) throw new Error("The plugin server has closed."); let pending = this.starting.get(id); if (!pending) { - pending = this.ensureStarted(id).finally(() => this.starting.delete(id)); + pending = this.ensureStarted(id).then(backend => { + for (const listener of this.startListeners) listener(id); + return backend; + }).finally(() => this.starting.delete(id)); this.starting.set(id, pending); } return pending; diff --git a/src/shared/app-control.ts b/src/shared/app-control.ts index 2152540e..776cd89d 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)) }); // The most apps one icon request may name; the panel asks in batches of this size. diff --git a/src/shared/device-settings.ts b/src/shared/device-settings.ts index 3ae7356e..050d9ea1 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,8 @@ 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"; errors?: string[]; }; diff --git a/src/shared/version.ts b/src/shared/version.ts index a7321c5d..d3e9a79c 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.157"; diff --git a/src/ui/app-tools.ts b/src/ui/app-tools.ts index 4726b7f6..70b6bade 100644 --- a/src/ui/app-tools.ts +++ b/src/ui/app-tools.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import { appDetailsBatch, appDetailsSchema, appLinksSchema, appPermissionsSchema, installedAppsSchema, platformPermissions } from "../shared/app-control.ts"; +import { appDetailsBatch, appDetailsSchema, appLanguageSchema, appLinksSchema, appPermissionsSchema, installedAppsSchema, platformPermissions } from "../shared/app-control.ts"; import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "../shared/app-control.ts"; import { measureUiOperation } from "./telemetry.ts"; import { elementStores } from "./element-store.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; @@ -18,6 +18,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; @@ -41,9 +43,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() }; @@ -120,7 +122,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[] { @@ -133,14 +135,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/src/ui/components/device-settings.tsx b/src/ui/components/device-settings.tsx index bc7cb441..82d6d56c 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, MapPinIcon, MinusIcon, MoonIcon, PlusIcon, RotateCwIcon, Settings2Icon, SmartphoneIcon, SunIcon } from "lucide-react"; +import { contentSizes, networkConditions, networkPresets, 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"; @@ -71,12 +72,91 @@ function Row({ label, htmlFor, id, children }: { label: string; htmlFor?: string ; } +// The current state in two fixed lines, so changing a setting never moves the panel. +function StatusCard({ icon, name, detail, children }: { icon?: React.ReactNode; name: string; detail: string; children?: React.ReactNode }) { + return
+ {icon} +

{name}

{detail}

+ {children} +
; +} + +function Pills({ label, value, options, onSelect }: { label: string; value: string; options: readonly { value: string; label: string }[]; onSelect: (value: string) => void }) { + return + {options.map(option => {option.label})} + ; +} + +const speed = (kbps: number) => kbps >= 1000 ? `${kbps / 1000} Mbps` : `${kbps} kbps`; + +function describeNetwork(network: NetworkCondition | "custom", platform: "ios" | "android") { + if (network === "custom") return platform === "ios" ? { name: "Custom throttle", detail: "Pick a condition to replace it." } : { name: "Your own proxy", detail: "Remove it to simulate a slower network." }; + const name = network === "normal" ? "Full speed" : network === "offline" ? "No connection" : `${speed(networkPresets[network].bandwidthKbps)} · ${networkPresets[network].latencyMs} ms`; + // Baguette injects conditioning into apps as they launch; Android applies it right away. + if (platform === "ios") return { name, detail: "Applies to new app launches." }; + return { name, detail: network === "normal" ? "Applies right away." : network === "offline" ? "Wi-Fi and mobile data are off." : "Local dev servers stay full speed." }; +} + +const networkOptions = networkConditions.map(condition => ({ value: condition, label: networkLabels[condition] })); + +function NetworkGroup({ platform, network, store, appTools }: { platform: "ios" | "android"; network: NetworkCondition | "custom"; store: DeviceSettingsStore; appTools: AppToolsStore }) { + const appState = useSyncExternalStore(appTools.subscribe, appTools.getSnapshot); + const status = describeNetwork(network, platform); + return +
+ { + const condition = networkConditions.find(entry => entry === value); + if (condition) void store.change({ setting: "network", value: condition }); + }} /> + + {/* iOS apps pick up a condition when they launch. */} + {platform === "ios" && appTools.app && } + +
+
; +} + function describeLocation(location: Location | null | undefined, platform: "ios" | "android") { if (location === undefined) return { name: "Not set from this panel", detail: platform === "ios" ? "The simulator doesn't report its location." : "Pick a place to simulate." }; if (location === null) return { name: "No simulated location", detail: "Pick a place to simulate." }; return { name: presetFor(location)?.label ?? "Custom location", detail: `${location.latitude.toFixed(4)}, ${location.longitude.toFixed(4)}` }; } +const placeOptions = [...locationPresets.map(entry => ({ value: entry.value, label: entry.city })), { value: "custom", label: "Custom…" }]; + +// Keyed by device, so the coordinate form starts closed for each one. +function LocationGroup({ platform, location, store }: { platform: "ios" | "android"; location: Location | null | undefined; store: DeviceSettingsStore }) { + const id = useId(); + const [customOpen, setCustomOpen] = useState(false); + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + useEffect(() => { + setLatitude(location ? String(location.latitude) : ""); + setLongitude(location ? String(location.longitude) : ""); + }, [location?.latitude, location?.longitude]); + const place = describeLocation(location, platform); + const preset = customOpen ? "custom" : location ? presetFor(location)?.value ?? "custom" : ""; + return + } name={place.name} detail={place.detail}> + {/* Only the iOS simulator can drop a simulated location. */} + {platform === "ios" && location !== null && } + +
+ { + if (value === "custom" || value === "") { setCustomOpen(value === "custom"); return; } + const entry = locationPresets.find(item => item.value === value); + setCustomOpen(false); + if (entry) void store.change({ setting: "location", value: { latitude: entry.latitude, longitude: entry.longitude } }); + }} /> +
+ {customOpen &&
{ event.preventDefault(); if (latitude.trim() && longitude.trim()) void store.change({ setting: "location", value: { latitude: Number(latitude), longitude: Number(longitude) } }); }}> +
setLatitude(event.target.value)} />
+
setLongitude(event.target.value)} />
+ +
} +
; +} + export function DeviceSettings({ platform }: { platform: "ios" | "android" }) { const [store] = useState(() => new DeviceSettingsStore()); const [appTools] = useState(() => new AppToolsStore()); @@ -85,21 +165,11 @@ export function DeviceSettings({ platform }: { platform: "ios" | "android" }) { const [open, setOpen] = useState(false); const [tab, setTab] = useState<"app" | "device">("app"); const activeTab = appState.disabled ? "device" : tab; - const [customOpen, setCustomOpen] = useState(false); - const [latitude, setLatitude] = useState(""); - const [longitude, setLongitude] = useState(""); const id = useId(); const settings = state.settings; const disabled = state.disabled; - useEffect(() => { - setLatitude(settings?.location ? String(settings.location.latitude) : ""); - setLongitude(settings?.location ? String(settings.location.longitude) : ""); - }, [state.deviceId, settings?.location?.latitude, settings?.location?.longitude]); useEffect(() => { if (state.disabled) setOpen(false); }, [state.disabled]); - useEffect(() => { setOpen(false); setCustomOpen(false); }, [state.deviceId]); - const location = settings?.location; - const place = describeLocation(location, platform); - const preset = customOpen ? "custom" : location ? presetFor(location)?.value ?? "custom" : ""; + useEffect(() => { setOpen(false); }, [state.deviceId]); return
{ if (element) { bindDeviceSettings(element, store); bindAppTools(element, appTools); } }}> { if (!value) { setOpen(false); return; } @@ -129,28 +199,10 @@ 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.locationSupported && -
- -

{place.name}

{place.detail}

- {/* Only the iOS simulator can drop a simulated location. */} - {platform === "ios" && location !== null && } -
- { - if (value === "custom" || value === "") { setCustomOpen(value === "custom"); return; } - const entry = locationPresets.find(item => item.value === value); - setCustomOpen(false); - if (entry) void store.change({ setting: "location", value: { latitude: entry.latitude, longitude: entry.longitude } }); - }}> - {[...locationPresets.map(entry => ({ value: entry.value, label: entry.city })), { value: "custom", label: "Custom…" }].map(entry => {entry.label})} - - {customOpen &&
{ event.preventDefault(); if (latitude.trim() && longitude.trim()) void store.change({ setting: "location", value: { latitude: Number(latitude), longitude: Number(longitude) } }); }}> -
setLatitude(event.target.value)} />
-
setLongitude(event.target.value)} />
- -
} -
} + {settings.network !== undefined && } + {settings.locationSupported && } } store.toggleFrame(value)} /> 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 }: