diff --git a/README.md b/README.md index 969ee819..a13e6b6c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,47 @@ npx @swmansion/argent init --- +## Physical iOS devices (experimental) + +Argent can drive a **physical iPhone** — no app installed on the device — over Apple's +CoreDevice "remote control" services (the same path Xcode's device window uses), via +[`pymobiledevice3`](https://github.com/doronz88/pymobiledevice3). Supported interactions: +`screenshot`, `gesture-tap`, `gesture-swipe`, `button`, and `launch-app`. The device shows +up in `list-devices` with `kind: "device"`. + +**Requirements** + +- **iOS 27 or later** — Apple gates host-driven input ("remote control") to iOS 27+; on + earlier versions the device reports `CoreDeviceError 9021` and only screenshots work. +- macOS with Xcode, and `pymobiledevice3` installed (e.g. `pipx install pymobiledevice3`). +- The iPhone connected, unlocked, trusted, with **Developer Mode** on. + +**Setup** + +1. Enable the feature flag: + ```sh + argent enable physical-ios-devices + ``` +2. Connect the iPhone (unlocked, trusted, Developer Mode on). + +`list-devices` then includes the iPhone, and the supported tools work against its UDID. +The first interaction (or `boot-device`) starts the required CoreDevice tunnel +automatically: Argent shows a standard macOS authorization prompt — branded as Argent, +with Touch ID / password — to launch `pymobiledevice3 remote tunneld` as root (creating the +tunnel interface needs root once; every other command runs unprivileged). No manual `sudo`. + +If the prompt is declined or there's no GUI session (headless), start the tunnel manually +and leave it running: `sudo pymobiledevice3 remote tunneld`. + +**Limitations / notes** + +- Not supported on physical iOS yet: keyboard/typing, pinch & rotate (multi-touch), + `open-url`, and `reinstall-app` — these return a clear "not supported" error. +- Overrides: `ARGENT_PYMOBILEDEVICE3` (path to the binary), `ARGENT_PMD3_TUNNELD_PORT` + (defaults to `49151`). + +--- + ## Installation #### Prerequisites diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index 31bbbc65..89aa9954 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -73,6 +73,17 @@ const AX_TCP_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/tcp/ax-service"); const BIN_DIR = path.resolve(__dirname, "../bin"); const AX_BIN_DEST = path.resolve(BIN_DIR, "darwin/ax-service"); const AX_TCP_BIN_DEST = path.resolve(BIN_DIR, "darwin/tcp/ax-service"); +// argent-device-auth: macOS host helper for the branded physical-iOS tunnel +// auth prompt. Best-effort — only present once argent-private publishes the +// signed binary; until then physical iOS falls back to the osascript prompt. +const DEVICE_AUTH_BIN_SRC = path.resolve(BIN_SRC_ROOT, "darwin/argent-device-auth"); +const DEVICE_AUTH_BIN_DEST = path.resolve(BIN_DIR, "darwin/argent-device-auth"); +// Argent icon shown in that prompt (committed under native-devtools-ios/assets). +const DEVICE_ICON_SRC = path.resolve( + WORKSPACE_ROOT, + "packages/native-devtools-ios/assets/argent-icon.png" +); +const DEVICE_ICON_DEST = path.resolve(__dirname, "../assets/argent-icon.png"); // Host platform keys (see hostPlatformKey() in @argent/native-devtools-ios): // darwin is a universal binary; Linux ships one single-arch ELF per key. const SUPPORTED_HOST_PLATFORMS = ["darwin", "linux", "linux-arm64"]; @@ -169,6 +180,27 @@ const ASSETS = [ copiedLabel: "ax-service (tcp) binary", missLabel: "ax-service (tcp) binary", }, + // macOS host helper for the branded physical-iOS tunnel auth prompt. + // Best-effort: present once argent-private publishes the signed binary; + // until then physical iOS falls back to the (unbranded) osascript prompt. + { + kind: "file", + src: DEVICE_AUTH_BIN_SRC, + dest: DEVICE_AUTH_BIN_DEST, + mode: 0o755, + required: false, + copiedLabel: "argent-device-auth binary", + missLabel: "argent-device-auth binary", + }, + // Argent icon shown in the device-auth prompt (committed; best-effort copy). + { + kind: "file", + src: DEVICE_ICON_SRC, + dest: DEVICE_ICON_DEST, + required: false, + copiedLabel: "device-auth icon", + missLabel: "device-auth icon", + }, // Android host-side Perfetto trace processor: the third-party WASM engine // (trace_processor.wasm + emscripten glue + the EngineBase decoder + LICENSE). // wasm-trace-processor.ts resolves these via diff --git a/packages/configuration-core/src/flags.ts b/packages/configuration-core/src/flags.ts index 8da2e23c..13689169 100644 --- a/packages/configuration-core/src/flags.ts +++ b/packages/configuration-core/src/flags.ts @@ -52,6 +52,11 @@ export const FLAG_REGISTRY: readonly FlagDefinition[] = [ name: "artifacts-list-endpoint", description: "Expose GET /artifacts for remote artifact inventory consumers.", }, + { + name: "physical-ios-devices", + description: + "Discover and control physical iOS devices (iOS 27+) over Apple's CoreDevice tunnel via pymobiledevice3. Requires a running `sudo pymobiledevice3 remote tunneld`. Supports screenshot, tap, swipe, and hardware buttons.", + }, ]; // Look up a flag's definition — exported for consumers that want the diff --git a/packages/native-devtools-ios/assets/argent-icon.png b/packages/native-devtools-ios/assets/argent-icon.png new file mode 100644 index 00000000..dc54076a Binary files /dev/null and b/packages/native-devtools-ios/assets/argent-icon.png differ diff --git a/packages/native-devtools-ios/src/index.ts b/packages/native-devtools-ios/src/index.ts index 4a5eee6e..d22d1dc0 100644 --- a/packages/native-devtools-ios/src/index.ts +++ b/packages/native-devtools-ios/src/index.ts @@ -9,6 +9,9 @@ import * as fs from "node:fs"; const DYLIB_DIR = process.env.ARGENT_NATIVE_DEVTOOLS_DIR ?? path.join(__dirname, "..", "dylibs"); const BIN_DIR = process.env.ARGENT_SIMULATOR_SERVER_DIR ?? path.join(__dirname, "..", "bin"); const DYLIB_TCP_DIR = process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR ?? path.join(DYLIB_DIR, "tcp"); +// Committed static assets (e.g. the Argent icon shown in the device-auth prompt). +const ASSETS_DIR = + process.env.ARGENT_NATIVE_DEVTOOLS_ASSETS_DIR ?? path.join(__dirname, "..", "assets"); // iOS Simulator only runs on macOS, so the dylibs that get injected into it // and the ax-service that gets `simctl spawn`d into it are only ever usable @@ -115,3 +118,24 @@ export function axServiceBinaryPathTcp(): string { requireDarwin("ax-service (tcp)"); return requireBinIn(platformTcpBinDir(), "ax-service"); } + +// argent-device-auth is the macOS host helper that pops the branded macOS +// authorization modal (password / Touch ID) and runs a command as root — +// Argent uses it to start the physical-iOS CoreDevice tunnel without a manual +// `sudo`. Unlike the resolvers above it returns null (rather than throwing) +// when absent, so callers can fall back to a less-branded escalation path. +// Override the binary with ARGENT_DEVICE_AUTH_HELPER (absolute path). +export function deviceAuthHelperPath(): string | null { + if (process.platform !== "darwin") return null; + const override = process.env.ARGENT_DEVICE_AUTH_HELPER; + if (override) return fs.existsSync(override) ? override : null; + const p = path.join(platformBinDir(), "argent-device-auth"); + return fs.existsSync(p) ? p : null; +} + +// Path to the Argent icon shown in the device-auth prompt, or null if missing. +// Override with ARGENT_DEVICE_ICON (absolute path to a PNG/icns). +export function argentIconPath(): string | null { + const p = process.env.ARGENT_DEVICE_ICON ?? path.join(ASSETS_DIR, "argent-icon.png"); + return fs.existsSync(p) ? p : null; +} diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index e0a377fe..03096d5c 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -309,6 +309,14 @@ export const axServiceBlueprint: ServiceBlueprint = { `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as ${device.platform} — describe uses uiautomator on Android and the CDP DOM walker on Chromium, neither of which needs this service.` ); } + if (device.kind === "device") { + // ax-service uses `xcrun simctl spawn`, which only works on simulators. + // Physical iPhones are driven over CoreDevice and have no accessibility/ + // describe path yet. + throw new Error( + `${AX_SERVICE_NAMESPACE} is iOS-simulator-only. The physical device '${device.id}' is driven over CoreDevice; describe/accessibility is not supported on physical iOS yet.` + ); + } // Reject before spawning. An undefined `device.id` slips through when an // inner tool is invoked via a wrapper that doesn't re-validate the inner // schema. Without this guard `getSocketPath(undefined).slice` would crash diff --git a/packages/tool-server/src/blueprints/core-device.ts b/packages/tool-server/src/blueprints/core-device.ts new file mode 100644 index 00000000..ef9280ec --- /dev/null +++ b/packages/tool-server/src/blueprints/core-device.ts @@ -0,0 +1,464 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { + TypedEventEmitter, + type DeviceInfo, + type ServiceBlueprint, + type ServiceInstance, + type ServiceEvents, +} from "@argent/registry"; +import { isFlagEnabled } from "@argent/configuration-core"; +import { deviceAuthHelperPath, argentIconPath } from "@argent/native-devtools-ios"; + +const execFileAsync = promisify(execFile); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const CORE_DEVICE_NAMESPACE = "CoreDevice"; + +// Opt-in flag (also gates discovery in list-devices). The privileged tunnel +// start must not be reachable unless the user enabled the experimental feature. +const PHYSICAL_IOS_FLAG = "physical-ios-devices"; + +// The registry's `ServiceRef.options` is typed as `Record`; +// the intersection adds the implicit string index signature an interface lacks. +type CoreDeviceFactoryOptions = Record & { device: DeviceInfo }; + +/** + * Backend for a physical iOS device, driven over Apple's CoreDevice "remote + * control" services via the `pymobiledevice3` CLI — no app installed on the + * device. This is a separate blueprint from the simulator-server because real + * iPhones speak an entirely different transport (the iOS-17+ RemoteXPC tunnel), + * mirroring how physical Android uses its own `android_device` controller. + * + * Requirements (all surfaced as actionable errors): iOS 27+ (Apple gates the + * touch/"remote control" services to 27.0+), `pymobiledevice3` installed, and a + * running CoreDevice tunnel (`sudo pymobiledevice3 remote tunneld`, which needs + * root to create the tunnel interface — every command here then runs unprivileged). + */ +export interface CoreDeviceApi { + /** Capture a PNG to a temp file and return its path. */ + screenshot(): Promise<{ path: string }>; + /** Tap at normalized (x, y) in 0..1. */ + tap(x: number, y: number): Promise; + /** Swipe/drag from (fromX, fromY) to (toX, toY), all normalized 0..1. */ + swipe(fromX: number, fromY: number, toX: number, toY: number, durationMs: number): Promise; + /** Press a hardware button by its pymobiledevice3 name (home/lock/volume-up/volume-down/...). */ + button(name: string): Promise; +} + +export function coreDeviceRef(device: DeviceInfo): { + urn: string; + options: CoreDeviceFactoryOptions; +} { + return { + urn: `${CORE_DEVICE_NAMESPACE}:${device.id}`, + options: { device }, + }; +} + +interface Rsd { + address: string; + port: number; +} + +function tunneldPort(): number { + const raw = process.env.ARGENT_PMD3_TUNNELD_PORT; + const n = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isInteger(n) && n > 0 && n <= 65535 ? n : 49151; +} + +/** Resolve the pymobiledevice3 executable: env override, common install dirs, then PATH. */ +function resolvePmd3(): string { + const override = process.env.ARGENT_PYMOBILEDEVICE3; + if (override) return override; + const candidates = [ + join(homedir(), ".local", "bin", "pymobiledevice3"), + "/opt/homebrew/bin/pymobiledevice3", + "/usr/local/bin/pymobiledevice3", + ]; + for (const c of candidates) if (existsSync(c)) return c; + return "pymobiledevice3"; +} + +/** + * Fail fast with an install hint when pymobiledevice3 is missing, rather than + * surfacing a raw `spawn ENOENT` from the first interaction. Runs `version` (a + * cheap subcommand); a missing binary throws ENOENT, anything else is tolerated. + */ +async function verifyPmd3Available(pmd3: string): Promise { + try { + await execFileAsync(pmd3, ["version"], { timeout: 10_000 }); + } catch (err) { + if ((err as { code?: string })?.code === "ENOENT") { + throw new Error( + `pymobiledevice3 was not found (tried "${pmd3}"). Physical iOS control needs it — ` + + `install it (e.g. \`pipx install pymobiledevice3\`) or set ARGENT_PYMOBILEDEVICE3 to its path.`, + { cause: err } + ); + } + // Non-ENOENT (odd `version` failure): don't block; a real command will report. + } +} + +/** Resolve pymobiledevice3 to an absolute path — the privileged root shell does + * not inherit the user's PATH (so `~/.local/bin` is invisible to it). */ +async function resolvePmd3Absolute(): Promise { + const p = resolvePmd3(); + if (p.startsWith("/")) return p; + try { + const { stdout } = await execFileAsync("/usr/bin/which", [p], { timeout: 5_000 }); + const abs = stdout.trim(); + if (abs.startsWith("/")) return abs; + } catch { + // fall through to the install hint + } + throw new Error( + `pymobiledevice3 was not found on PATH. Install it (e.g. \`pipx install pymobiledevice3\`) ` + + `or set ARGENT_PYMOBILEDEVICE3 to its absolute path.` + ); +} + +/** Single-quote a string for safe use as one /bin/sh word. */ +function shellSingleQuote(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** Escape a /bin/sh command string for embedding inside an AppleScript double-quoted literal. */ +export function appleScriptQuote(cmd: string): string { + return cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * The /bin/sh command (run as root) that starts a daemonized tunneld on `port`. + * HOME is pinned to root's home so pymobiledevice3 finds its RemoteXPC pairing + * records: the privileged-exec environment (Authorization Services) doesn't + * inherit a usable HOME, which otherwise leaves tunneld unable to form the + * device tunnel (it starts but never registers one). sudo set HOME=/var/root + * implicitly; here we set it explicitly. + * + * The log goes under /var/root (root's home, mode 0700) rather than a predictable + * path in world-writable /tmp: this command runs as root, and a root `>` redirect + * to a /tmp path a non-root user can pre-symlink is a classic privileged-tmp + * clobber (CWE-59). + */ +export function tunneldStartCommand(pmd3Abs: string, port: number): string { + return `HOME=/var/root ${shellSingleQuote(pmd3Abs)} remote tunneld --port ${port} -d > /var/root/argent-coredevice-tunneld.log 2>&1`; +} + +/** + * Start `pymobiledevice3 remote tunneld` as root via the standard macOS + * authorization dialog (password / Touch ID where the OS offers it) — so users + * never run sudo by hand. AppleScript's `with administrator privileges` routes + * through Authorization Services and shows the system modal in the active GUI + * session; `-d` daemonizes so the privileged shell returns immediately, leaving + * tunneld running as root for the rest of the session. Throws if the user + * cancels or no GUI session is available (headless) — callers fall back to the + * manual `sudo` instructions. + */ +async function startTunneldWithPrivilege(pmd3Abs: string, port: number): Promise { + const shellCmd = tunneldStartCommand(pmd3Abs, port); + // Generous timeout: the user has to see and approve the modal. + const timeout = 120_000; + + // Preferred: the signed host helper, which shows the modal branded as + // "Argent" with the Argent icon + a clear message via Authorization Services. + const helper = deviceAuthHelperPath(); + if (helper) { + const icon = argentIconPath() ?? ""; + const prompt = "Argent needs administrator access to connect to a physical iOS device."; + try { + await execFileAsync(helper, [icon, prompt, "/bin/sh", "-c", shellCmd], { timeout }); + return; + } catch (err) { + // Exit 3 = the user explicitly cancelled the prompt — respect that and + // don't pop a second (osascript) prompt. Any other failure (a broken, + // unsigned, quarantined, or 0-byte helper binary) degrades to the + // osascript admin prompt below rather than hard-failing. + if ((err as { code?: unknown }).code === 3) throw err; + } + } + + // Fallback when the helper isn't installed (e.g. a dev tree without the signed + // binary), is unusable, or there's no GUI: the generic osascript admin prompt. + // Functional but unbranded ("osascript wants to make changes"). + const appleScript = `do shell script "${appleScriptQuote(shellCmd)}" with administrator privileges`; + await execFileAsync("osascript", ["-e", appleScript], { timeout }); +} + +// Dedupe concurrent escalation prompts: parallel interactions share one modal. +// Cleared after settle so a later call can retry if the user cancelled. +let tunnelStartInFlight: Promise | null = null; + +function tunnelHelp(udid: string, reason: string): string { + const port = tunneldPort(); + return ( + `Physical iOS control needs a CoreDevice tunnel for ${udid}, but ${reason} ` + + `(checked tunneld at 127.0.0.1:${port}). Argent tries to start it automatically via the macOS ` + + `authorization prompt; if that was declined or no GUI session is available, start it manually ` + + `and leave it running:\n sudo pymobiledevice3 remote tunneld\n` + + `Also ensure the iPhone is on iOS 27+, unlocked, and trusted. ` + + `(Override the port with ARGENT_PMD3_TUNNELD_PORT.)` + ); +} + +/** + * Look up the device's RSD endpoint from a running `pymobiledevice3 remote + * tunneld` (its local REST API at 127.0.0.1:). Re-resolved per command so + * a tunneld restart mid-session is picked up without re-creating the service. + */ +async function resolveTunnel(udid: string): Promise { + const port = tunneldPort(); + let payload: Record>; + try { + const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(4000) }); + // Something other than tunneld could be bound to the port; don't parse an + // error page as a tunnel list. + if (!res.ok) throw new Error(`tunneld responded HTTP ${res.status}`); + payload = (await res.json()) as typeof payload; + } catch (err) { + throw new Error(tunnelHelp(udid, "tunneld is not running"), { cause: err }); + } + const entry = payload?.[udid]; + const t = Array.isArray(entry) ? entry[0] : undefined; + if (!t?.["tunnel-address"] || t["tunnel-port"] == null) { + throw new Error(tunnelHelp(udid, "no active tunnel is registered for it")); + } + return { address: String(t["tunnel-address"]), port: Number(t["tunnel-port"]) }; +} + +/** + * Whether anything is serving on the tunneld port (any HTTP response counts). + * Distinguishes "tunneld not running → start it" from "tunneld running but this + * device's tunnel hasn't formed yet → just wait", so we don't pop a second, + * pointless root prompt (or fail trying to bind an already-used port). + */ +async function isTunneldReachable(port: number): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(4000) }); + return true; + } catch { + return false; + } +} + +/** + * Return the device's RSD endpoint, auto-starting tunneld via the macOS auth + * modal when it isn't running — so the user never types sudo. Gated behind the + * physical-ios-devices flag (the privileged escalation is opt-in). If tunneld is + * already running but this device's tunnel hasn't registered (e.g. the iPhone is + * locked), it waits rather than popping a second, pointless prompt. + */ +export async function ensureCoreDeviceTunnel(udid: string): Promise { + if (!isFlagEnabled(PHYSICAL_IOS_FLAG)) { + throw new Error( + `Physical iOS support is disabled. Enable it with: argent enable ${PHYSICAL_IOS_FLAG}` + ); + } + try { + return await resolveTunnel(udid); + } catch (notRunning) { + if (process.platform !== "darwin") throw notRunning; + const port = tunneldPort(); + + // Only escalate (root prompt) when tunneld isn't running at all. If it IS + // running, the device just needs to be unlocked/trusted for the handshake — + // don't re-prompt, just poll. + const reachable = await isTunneldReachable(port); + if (!reachable) { + if (!tunnelStartInFlight) { + tunnelStartInFlight = (async () => { + const pmd3Abs = await resolvePmd3Absolute(); + await startTunneldWithPrivilege(pmd3Abs, port); + })(); + } + try { + await tunnelStartInFlight; + } catch (escalationErr) { + tunnelStartInFlight = null; // allow a later retry + throw new Error(tunnelHelp(udid, "the authorization prompt was cancelled or unavailable"), { + cause: escalationErr, + }); + } + tunnelStartInFlight = null; + } + + // Poll for this device's tunnel to register (handshake needs it unlocked & trusted). + for (let i = 0; i < 15; i++) { + await sleep(2_000); + try { + return await resolveTunnel(udid); + } catch { + // keep polling + } + } + throw new Error( + tunnelHelp( + udid, + reachable + ? "tunneld is running but the device tunnel did not form — is the iPhone unlocked and trusted?" + : "the tunnel did not come up after starting tunneld" + ), + { cause: notRunning } + ); + } +} + +/** Extract a concise, human-readable line from a (possibly huge/binary) pmd3 failure. */ +function conciseError(label: string, err: unknown): Error { + const e = err as { stderr?: string; stdout?: string; message?: string }; + const blob = [e?.stderr, e?.stdout, e?.message] + .filter((v): v is string => typeof v === "string") + .join("\n"); + const line = + blob + .split("\n") + .map((s) => s.trim()) + .find((l) => /requires iOS|error|fail|not |unable|refused|timed out/i.test(l)) ?? + e?.message ?? + "unknown error"; + return new Error(`CoreDevice ${label} failed: ${line.slice(0, 240)}`, { cause: err }); +} + +const COREDEVICE = ["developer", "core-device"]; +const HID = [...COREDEVICE, "universal-hid-service"]; + +/** Normalized 0..1 → the device's 0..65535 HID coordinate space. */ +export function toHid(v: number): number { + return Math.max(0, Math.min(65535, Math.round(v * 65535))); +} + +/** + * Ensure the personalized DeveloperDiskImage is mounted (the CoreDevice services + * live in it). Idempotent: when already mounted, pymobiledevice3 exits non-zero + * with "already mounted", which we treat as success. CoreDevice usually mounts + * it automatically when the device connects, so this is a best-effort fallback — + * a genuine mount failure surfaces later as an actionable per-command error. + */ +async function ensureMounted(pmd3: string, rsd: Rsd): Promise { + try { + await execFileAsync(pmd3, ["mounter", "auto-mount", "--rsd", rsd.address, String(rsd.port)], { + timeout: 60_000, + }); + } catch { + // Best-effort: when the DDI is already mounted pymobiledevice3 exits non-zero + // with "already mounted"; a genuine mount failure surfaces later as an + // actionable per-command error. Either way, don't fail service creation here. + } +} + +export const coreDeviceBlueprint: ServiceBlueprint = { + namespace: CORE_DEVICE_NAMESPACE, + getURN(device: DeviceInfo) { + return `${CORE_DEVICE_NAMESPACE}:${device.id}`; + }, + async factory(_deps, _payload, options) { + const opts = options as unknown as CoreDeviceFactoryOptions | undefined; + const device = opts?.device; + if (!device?.id) { + throw new Error( + `${CORE_DEVICE_NAMESPACE}.factory requires a resolved DeviceInfo via options.device. ` + + `Use coreDeviceRef(device) when registering the service ref.` + ); + } + if (device.platform !== "ios" || device.kind !== "device") { + throw new Error( + `${CORE_DEVICE_NAMESPACE} only drives physical iOS devices; got ${device.platform}/${device.kind}.` + ); + } + + const pmd3 = resolvePmd3(); + const udid = device.id; + + // Surface setup problems up front, in the order a user fixes them: install + // pymobiledevice3, then ensure the tunnel (auto-started via the macOS auth + // modal if needed — no manual sudo), then mount the DDI. + await verifyPmd3Available(pmd3); + const rsd = await ensureCoreDeviceTunnel(udid); + await ensureMounted(pmd3, rsd); + + const run = async (label: string, args: string[], timeout: number): Promise => { + const tunnel = await resolveTunnel(udid); + try { + const { stdout } = await execFileAsync( + pmd3, + [...args, "--rsd", tunnel.address, String(tunnel.port)], + { timeout, maxBuffer: 16 * 1024 * 1024 } + ); + return stdout; + } catch (err) { + throw conciseError(label, err); + } + }; + + const events = new TypedEventEmitter(); + + const api: CoreDeviceApi = { + async screenshot() { + const path = join(tmpdir(), `argent-ios-shot-${randomUUID()}.png`); + await run("screenshot", [...COREDEVICE, "screen-capture", "screenshot", path], 20_000); + return { path }; + }, + async tap(x, y) { + const hx = toHid(x); + const hy = toHid(y); + // A zero-duration CONTACT+RELEASE (pmd3's `tap`) is dropped by iOS — a + // tap must dwell. Emit a short held drag with a tiny (~3px) move, away + // from the edge so it never clips, which registers as a real tap. + const hy2 = hy <= 65535 - 120 ? hy + 96 : hy - 96; + await run( + "tap", + [ + ...HID, + "drag", + String(hx), + String(hy), + String(hx), + String(hy2), + "--steps", + "3", + "--duration", + "0.15", + ], + 15_000 + ); + }, + async swipe(fromX, fromY, toX, toY, durationMs) { + const steps = Math.max(2, Math.min(60, Math.round(durationMs / 16))); + const seconds = (durationMs / 1000).toFixed(3); + await run( + "swipe", + [ + ...HID, + "drag", + String(toHid(fromX)), + String(toHid(fromY)), + String(toHid(toX)), + String(toHid(toY)), + "--steps", + String(steps), + "--duration", + seconds, + ], + 15_000 + ); + }, + async button(name) { + await run("button", [...COREDEVICE, "hid", "button", name, "press"], 10_000); + }, + }; + + const instance: ServiceInstance = { + api, + // Stateless: each interaction is a fresh pymobiledevice3 invocation, so + // there is no long-lived process to tear down. + dispose: async () => {}, + events, + }; + return instance; + }, +}; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 9f12c75e..4ede657e 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -367,6 +367,14 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint {}); } else if (device.platform === "android") { diff --git a/packages/tool-server/src/tools/button/index.ts b/packages/tool-server/src/tools/button/index.ts index a0493857..659b92cf 100644 --- a/packages/tool-server/src/tools/button/index.ts +++ b/packages/tool-server/src/tools/button/index.ts @@ -1,10 +1,22 @@ import { z } from "zod"; -import type { Platform, ToolCapability, ToolDefinition } from "@argent/registry"; +import type { Platform, ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; -import { resolveDevice } from "../../utils/device-info"; +import { coreDeviceRef, type CoreDeviceApi } from "../../blueprints/core-device"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import { UnsupportedOperationError } from "../../utils/capability"; import { sendCommand } from "../../utils/simulator-client"; +// Argent button name → pymobiledevice3 CoreDevice HID button name. CoreDevice +// exposes the physical buttons only; appSwitch (a SpringBoard gesture) and the +// iPhone 15 Pro action button have no HID equivalent, so they are omitted and +// rejected with a clear error on physical iOS. +const COREDEVICE_BUTTON: Partial> = { + home: "home", + power: "lock", + volumeUp: "volume-up", + volumeDown: "volume-down", +}; + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const zodSchema = z.object({ @@ -52,9 +64,13 @@ Returns { pressed: buttonName }. Fails if the simulator-server / emulator backend is not reachable for the given device.`, zodSchema, capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (isPhysicalIos(device)) { + return { coreDevice: coreDeviceRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(services, params) { const device = resolveDevice(params.udid); if (!BUTTONS_BY_PLATFORM[device.platform].has(params.button)) { @@ -64,6 +80,19 @@ Fails if the simulator-server / emulator backend is not reachable for the given `button '${params.button}' is not available on ${device.platform}` ); } + if (isPhysicalIos(device)) { + const name = COREDEVICE_BUTTON[params.button]; + if (!name) { + throw new UnsupportedOperationError( + "button", + device, + `button '${params.button}' is not available on physical iOS (CoreDevice exposes home, power, volumeUp, volumeDown)` + ); + } + const coreDevice = services.coreDevice as CoreDeviceApi; + await coreDevice.button(name); + return { pressed: params.button }; + } const api = services.simulatorServer as SimulatorServerApi; sendCommand(api, { cmd: "button", diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index b9094455..dd124944 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -27,6 +27,8 @@ import { import { ensureDep } from "../../utils/check-deps"; import { linuxBootDiagnostics } from "../../utils/linux-preflight"; import { listIosSimulators } from "../../utils/ios-devices"; +import { isPhysicalIosUdid } from "../../utils/device-info"; +import { ensureCoreDeviceTunnel } from "../../blueprints/core-device"; import { bootElectronApp, type ElectronBootResult } from "./boot-electron"; const execFileAsync = promisify(execFile); @@ -410,6 +412,17 @@ async function bootIos( `Pass \`avdName\` (Android) instead of \`udid\` (iOS) to boot a device from this host.` ); } + + // A physical iPhone is already powered on — there is nothing to "boot". It is + // driven over CoreDevice (see core-device blueprint), not the simulator-server. + // Use this as the explicit "prepare" step: ensure the CoreDevice tunnel is up, + // auto-starting it via the macOS authorization prompt if needed (no manual + // sudo). Surfaces a clear error if the prompt is declined / unavailable. + if (isPhysicalIosUdid(udid)) { + await ensureCoreDeviceTunnel(udid); + return { platform: "ios", udid, booted: true }; + } + await ensureDep("xcrun"); const simState = await listIosSimulators() diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts index cdbd0478..485aaa8f 100644 --- a/packages/tool-server/src/tools/devices/list-devices.ts +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -1,10 +1,25 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; +import { isFlagEnabled } from "@argent/configuration-core"; import { listAndroidDevices, listAvds } from "../../utils/adb"; -import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices"; +import { listIosSimulators, listIosDevices } from "../../utils/ios-devices"; import { discoverChromiumDevices, type ChromiumDevice } from "../../utils/chromium-discovery"; -type IosDevice = IosSimulator & { platform: "ios" }; +const PHYSICAL_IOS_FLAG = "physical-ios-devices"; + +type IosDevice = { + platform: "ios"; + udid: string; + name: string; + state: string; + // "simulator" for an `xcrun simctl` simulator, "device" for a physical iPhone + // discovered via `xcrun devicectl` and driven over CoreDevice (pymobiledevice3). + kind: "simulator" | "device"; + // simulators only (the iOS runtime, e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-5") + runtime?: string; + // physical devices only (Apple product type, e.g. "iPhone15,4") + productType?: string | null; +}; type AndroidDevice = { platform: "android"; @@ -26,10 +41,13 @@ type ListDevicesResult = { avds: Array<{ name: string }>; }; +// A simulator is ready when "Booted"; a physical device is ready when "connected". +const iosReady = (d: IosDevice): boolean => d.state === "Booted" || d.state === "connected"; + function sortIos(a: IosDevice, b: IosDevice): number { - const aBooted = a.state === "Booted" ? 0 : 1; - const bBooted = b.state === "Booted" ? 0 : 1; - if (aBooted !== bBooted) return aBooted - bBooted; + const aReady = iosReady(a) ? 0 : 1; + const bReady = iosReady(b) ? 0 : 1; + if (aReady !== bReady) return aReady - bReady; const aIpad = a.name.includes("iPad") ? 1 : 0; const bIpad = b.name.includes("iPad") ? 1 : 0; return aIpad - bIpad; @@ -47,7 +65,7 @@ function sortAndroid(a: AndroidDevice, b: AndroidDevice): number { // Float booted/ready devices to the top of the merged list regardless of // platform — without this, all iOS entries are emitted before any Android. function readinessRank(d: IosDevice | AndroidDevice | ChromiumDevice): number { - if (d.platform === "ios") return d.state === "Booted" ? 0 : 1; + if (d.platform === "ios") return iosReady(d) ? 0 : 1; if (d.platform === "android") return d.state === "device" ? 0 : 1; return 0; // Chromium entries are only listed when their CDP is responsive } @@ -60,6 +78,7 @@ export const listDevicesTool: ToolDefinition, ListDevicesR Use at the start of a session to pick a target id ('udid' for iOS entries, 'serial' for Android, 'id' for Chromium) to pass to interaction tools, and to see which targets are already running. Returns { devices, avds } where each device carries a 'platform' discriminator ('ios', 'android', or 'chromium'), and 'avds' lists Android AVDs that can be booted via boot-device. Android entries also carry a 'kind' ('emulator' for a local AVD, 'device' for a physical phone connected over USB / wireless adb) — physical phones are detected from \`adb devices\` (any serial that is not an \`emulator-*\` one) and are driven through the same interaction tools as emulators; they do not need boot-device (just connect the phone with USB debugging authorised). +iOS entries likewise carry a 'kind' ('simulator', or 'device' for a connected physical iPhone). Physical iOS devices require the 'physical-ios-devices' flag (\`argent enable physical-ios-devices\`), iOS 27+, and a running CoreDevice tunnel (\`sudo pymobiledevice3 remote tunneld\`); they support screenshot, gesture-tap, gesture-swipe, and button. Chromium apps are discovered by probing CDP debugging ports (default 9222; extend via the ARGENT_CHROMIUM_PORTS= env var). They must already be running with --remote-debugging-port= — use boot-device with chromiumAppPath to launch one. Booted/ready devices are listed first. Platforms whose CLI is unavailable are silently omitted — an empty result usually means xcode-select or Android platform-tools is not installed.`, alwaysLoad: true, @@ -68,13 +87,36 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si zodSchema, services: () => ({}), async execute(_services, _params) { - const [ios, android, avds, chromium] = await Promise.all([ + const physicalIosEnabled = isFlagEnabled(PHYSICAL_IOS_FLAG); + const [ios, iosPhysical, android, avds, chromium] = await Promise.all([ listIosSimulators(), + physicalIosEnabled ? listIosDevices().catch(() => []) : Promise.resolve([]), listAndroidDevices().catch(() => []), listAvds(), discoverChromiumDevices().catch(() => []), ]); - const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); + const iosTagged: IosDevice[] = [ + ...ios.map( + (s): IosDevice => ({ + platform: "ios", + kind: "simulator", + udid: s.udid, + name: s.name, + state: s.state, + runtime: s.runtime, + }) + ), + ...iosPhysical.map( + (d): IosDevice => ({ + platform: "ios", + kind: "device", + udid: d.udid, + name: d.name, + state: d.state, + productType: d.productType, + }) + ), + ]; iosTagged.sort(sortIos); const androidTagged: AndroidDevice[] = android.map((d) => ({ platform: "android", diff --git a/packages/tool-server/src/tools/gesture-swipe/index.ts b/packages/tool-server/src/tools/gesture-swipe/index.ts index 0388cbd9..bf80582e 100644 --- a/packages/tool-server/src/tools/gesture-swipe/index.ts +++ b/packages/tool-server/src/tools/gesture-swipe/index.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; -import { resolveDevice } from "../../utils/device-info"; +import { coreDeviceRef, type CoreDeviceApi } from "../../blueprints/core-device"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -44,12 +45,22 @@ Use when you need to scroll a list, dismiss a modal, drag an element, or navigat searchHint: "swipe scroll drag pan gesture device simulator emulator touch move", zodSchema, capability, - services: (params) => ({ - simulatorServer: simulatorServerRef(resolveDevice(params.udid)), - }), + services: (params): Record => { + const device = resolveDevice(params.udid); + if (isPhysicalIos(device)) { + return { coreDevice: coreDeviceRef(device) }; + } + return { simulatorServer: simulatorServerRef(device) }; + }, async execute(services, params) { const duration = params.durationMs ?? 300; const timestampMs = Date.now(); + const device = resolveDevice(params.udid); + if (isPhysicalIos(device)) { + const coreDevice = services.coreDevice as CoreDeviceApi; + await coreDevice.swipe(params.fromX, params.fromY, params.toX, params.toY, duration); + return { swiped: true, timestampMs }; + } const api = services.simulatorServer as SimulatorServerApi; const steps = Math.max(1, Math.round(duration / 16)); diff --git a/packages/tool-server/src/tools/gesture-tap/index.ts b/packages/tool-server/src/tools/gesture-tap/index.ts index b9f74d28..c33e8486 100644 --- a/packages/tool-server/src/tools/gesture-tap/index.ts +++ b/packages/tool-server/src/tools/gesture-tap/index.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; import { chromiumCdpRef, type ChromiumCdpApi } from "../../blueprints/chromium-cdp"; -import { resolveDevice } from "../../utils/device-info"; +import { coreDeviceRef, type CoreDeviceApi } from "../../blueprints/core-device"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import { sendCommand } from "../../utils/simulator-client"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -54,6 +55,9 @@ Before tapping, determine the correct coordinates by using discovery tools — p if (device.platform === "chromium") { return { chromium: chromiumCdpRef(device) }; } + if (isPhysicalIos(device)) { + return { coreDevice: coreDeviceRef(device) }; + } return { simulatorServer: simulatorServerRef(device) }; }, async execute(services, params) { @@ -64,6 +68,11 @@ Before tapping, determine the correct coordinates by using discovery tools — p await tapChromium(chromium, params.x, params.y); return { tapped: true, timestampMs }; } + if (isPhysicalIos(device)) { + const coreDevice = services.coreDevice as CoreDeviceApi; + await coreDevice.tap(params.x, params.y); + return { tapped: true, timestampMs }; + } const api = services.simulatorServer as SimulatorServerApi; sendCommand(api, { cmd: "touch", diff --git a/packages/tool-server/src/tools/launch-app/index.ts b/packages/tool-server/src/tools/launch-app/index.ts index 90670b96..bc9b8a40 100644 --- a/packages/tool-server/src/tools/launch-app/index.ts +++ b/packages/tool-server/src/tools/launch-app/index.ts @@ -3,7 +3,7 @@ import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registr import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; import { chromiumCdpRef } from "../../blueprints/chromium-cdp"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { resolveDevice } from "../../utils/device-info"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import type { LaunchAppAndroidServices, LaunchAppIosServices, LaunchAppResult } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; @@ -65,7 +65,11 @@ Common Android packages: com.android.settings, com.android.chrome, com.google.an // Only iOS needs the native-devtools service for launch-time injection. Chromium needs its CDP session. services: (params): Record => { const device = resolveDevice(params.udid); - if (device.platform === "ios") return { nativeDevtools: nativeDevtoolsRef(device) }; + // Physical iOS is driven over CoreDevice (devicectl launch in the handler) and + // has no native-devtools; resolving that service eagerly would throw its + // simulator-only guard before the handler ever runs. + if (device.platform === "ios" && !isPhysicalIos(device)) + return { nativeDevtools: nativeDevtoolsRef(device) }; if (device.platform === "chromium") return { chromium: chromiumCdpRef(device) }; return {}; }, diff --git a/packages/tool-server/src/tools/launch-app/platforms/ios.ts b/packages/tool-server/src/tools/launch-app/platforms/ios.ts index ddc5814c..6c37e586 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/ios.ts @@ -8,7 +8,22 @@ const execFileAsync = promisify(execFile); export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (services, params) => { + handler: async (services, params, device) => { + // Physical iPhones are driven via CoreDevice — launch through devicectl + // (the app must already be installed/signed on the device). The + // native-devtools precheck is simulator-only, so it is skipped here. + if (device.kind === "device") { + await execFileAsync("xcrun", [ + "devicectl", + "device", + "process", + "launch", + "--device", + params.udid, + params.bundleId, + ]); + return { launched: true, bundleId: params.bundleId }; + } const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); if (blocked) return blocked; await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); diff --git a/packages/tool-server/src/tools/open-url/platforms/ios.ts b/packages/tool-server/src/tools/open-url/platforms/ios.ts index 00fc99ad..810b2c37 100644 --- a/packages/tool-server/src/tools/open-url/platforms/ios.ts +++ b/packages/tool-server/src/tools/open-url/platforms/ios.ts @@ -7,7 +7,13 @@ const execFileAsync = promisify(execFile); export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (_services, params) => { + handler: async (_services, params, device) => { + if (device.kind === "device") { + // CoreDevice/devicectl has no deep-link/open-url surface for physical + // iOS; only screenshot, gesture-tap, gesture-swipe, button, and launch-app + // are supported there today. + throw new Error("open-url is not supported on physical iOS devices."); + } await execFileAsync("xcrun", ["simctl", "openurl", params.udid, params.url]); return { opened: true, url: params.url }; }, diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts index 9cd143ee..5e8c2fc2 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts @@ -8,7 +8,12 @@ const execFileAsync = promisify(execFile); export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (_services, params) => { + handler: async (_services, params, device) => { + if (device.kind === "device") { + // Installing on a physical iPhone needs a device-signed .app and a + // provisioning profile; that is out of scope for the CoreDevice path. + throw new Error("reinstall-app is not supported on physical iOS devices."); + } const { udid, bundleId, appPath } = params; const absolute = resolvePath(appPath); try { diff --git a/packages/tool-server/src/tools/restart-app/index.ts b/packages/tool-server/src/tools/restart-app/index.ts index 76767393..0c6d6217 100644 --- a/packages/tool-server/src/tools/restart-app/index.ts +++ b/packages/tool-server/src/tools/restart-app/index.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { nativeDevtoolsRef } from "../../blueprints/native-devtools"; import { dispatchByPlatform } from "../../utils/cross-platform-tool"; -import { resolveDevice } from "../../utils/device-info"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import type { RestartAppAndroidServices, RestartAppIosServices, RestartAppResult } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; @@ -53,7 +53,11 @@ Returns { restarted, bundleId }. Fails if the app is not installed.`, // Only iOS needs the native-devtools service for relaunch injection. services: (params): Record => { const device = resolveDevice(params.udid); - return device.platform === "ios" ? { nativeDevtools: nativeDevtoolsRef(device) } : {}; + // Physical iOS has no native-devtools (it's rejected in the handler); + // resolving that service eagerly would throw its simulator-only guard first. + return device.platform === "ios" && !isPhysicalIos(device) + ? { nativeDevtools: nativeDevtoolsRef(device) } + : {}; }, execute: dispatchByPlatform< RestartAppIosServices, diff --git a/packages/tool-server/src/tools/restart-app/platforms/ios.ts b/packages/tool-server/src/tools/restart-app/platforms/ios.ts index a02e7050..d56865ea 100644 --- a/packages/tool-server/src/tools/restart-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/restart-app/platforms/ios.ts @@ -8,8 +8,11 @@ const execFileAsync = promisify(execFile); export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (services, params) => { + handler: async (services, params, device) => { const { udid, bundleId } = params; + if (device.kind === "device") { + throw new Error("restart-app is not supported on physical iOS devices."); + } const blocked = await precheckNativeDevtools(services.nativeDevtools, udid); if (blocked) return blocked; try { diff --git a/packages/tool-server/src/tools/screenshot/index.ts b/packages/tool-server/src/tools/screenshot/index.ts index 80ff4ccc..e46334f9 100644 --- a/packages/tool-server/src/tools/screenshot/index.ts +++ b/packages/tool-server/src/tools/screenshot/index.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; import { chromiumCdpRef, type ChromiumCdpApi } from "../../blueprints/chromium-cdp"; -import { resolveDevice } from "../../utils/device-info"; +import { coreDeviceRef, type CoreDeviceApi } from "../../blueprints/core-device"; +import { resolveDevice, isPhysicalIos } from "../../utils/device-info"; import { httpScreenshot } from "../../utils/simulator-client"; import { requireArtifacts, type ArtifactHandle } from "../../artifacts"; @@ -73,6 +74,9 @@ Fails if the simulator-server / emulator backend / Chromium CDP is not reachable if (device.platform === "chromium") { return { chromium: chromiumCdpRef(device) }; } + if (isPhysicalIos(device)) { + return { coreDevice: coreDeviceRef(device) }; + } return { simulatorServer: simulatorServerRef(device) }; }, async execute(services, params, ctx) { @@ -87,6 +91,14 @@ Fails if the simulator-server / emulator backend / Chromium CDP is not reachable const image = await requireArtifacts(ctx).register(path, { mimeType: "image/png" }); return { image }; } + if (isPhysicalIos(device)) { + // CoreDevice returns a full-resolution PNG; rotation/scale/downscaler are + // simulator/Chromium-only knobs and don't apply to the device capture. + const coreDevice = services.coreDevice as CoreDeviceApi; + const { path } = await coreDevice.screenshot(); + const image = await requireArtifacts(ctx).register(path, { mimeType: "image/png" }); + return { image }; + } const api = services.simulatorServer as SimulatorServerApi; const signal = ctx?.signal ?? AbortSignal.timeout(16_000); const { path } = await httpScreenshot(api, params.rotation, signal, params.scale); diff --git a/packages/tool-server/src/utils/device-info.ts b/packages/tool-server/src/utils/device-info.ts index fe41bc36..ecccc094 100644 --- a/packages/tool-server/src/utils/device-info.ts +++ b/packages/tool-server/src/utils/device-info.ts @@ -11,12 +11,29 @@ import type { DeviceInfo, DeviceKind, Platform } from "@argent/registry"; const IOS_UDID_SHAPE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/; +/** + * Physical iPhone/iPad UDID shape on Apple silicon devices (A12+/iOS 17+): + * an 8-hex ECID prefix, a single dash, then 16 hex — e.g. + * `00008120-000E6D0C0ABBA01E`. This is distinct from the simulator UUID + * (four dashes) so a real device can be told apart from a simulator by shape + * alone, the same way Android emulators vs phones are distinguished. Older + * 40-hex device UDIDs belong to pre-A12 hardware that tops out well below the + * iOS 27 floor for the CoreDevice control path, so they are intentionally not matched. + */ +const IOS_PHYSICAL_UDID_SHAPE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/; + export const CHROMIUM_ID_PREFIX = "chromium-cdp-"; +/** Whether a udid is a physical iOS device (vs a simulator UUID), by shape. */ +export function isPhysicalIosUdid(udid: string): boolean { + return IOS_PHYSICAL_UDID_SHAPE.test(udid); +} + /** Returns the platform a `udid` belongs to based on its shape. */ export function classifyDevice(udid: string): Platform { if (udid.startsWith(CHROMIUM_ID_PREFIX)) return "chromium"; - return IOS_UDID_SHAPE.test(udid) ? "ios" : "android"; + if (IOS_UDID_SHAPE.test(udid) || IOS_PHYSICAL_UDID_SHAPE.test(udid)) return "ios"; + return "android"; } /** @@ -47,7 +64,9 @@ export function resolveDevice(udid: string): DeviceInfo { const platform = classifyDevice(udid); const kind: DeviceKind = platform === "ios" - ? "simulator" + ? isPhysicalIosUdid(udid) + ? "device" + : "simulator" : platform === "android" ? isAndroidEmulatorSerial(udid) ? "emulator" @@ -56,6 +75,11 @@ export function resolveDevice(udid: string): DeviceInfo { return { id: udid, platform, kind }; } +/** A physical iOS device (driven via CoreDevice/pymobiledevice3, not the simulator-server). */ +export function isPhysicalIos(device: DeviceInfo): boolean { + return device.platform === "ios" && device.kind === "device"; +} + /** Parses the CDP port out of a chromium device id. Returns null if the id is malformed. */ export function parseChromiumCdpPort(udid: string): number | null { if (!udid.startsWith(CHROMIUM_ID_PREFIX)) return null; diff --git a/packages/tool-server/src/utils/ios-devices.ts b/packages/tool-server/src/utils/ios-devices.ts index 8a9530b8..68da26bb 100644 --- a/packages/tool-server/src/utils/ios-devices.ts +++ b/packages/tool-server/src/utils/ios-devices.ts @@ -1,5 +1,9 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readFile, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; const execFileAsync = promisify(execFile); @@ -10,6 +14,25 @@ export interface IosSimulator { runtime: string; } +export interface IosPhysicalDevice { + udid: string; + name: string; + /** Apple product type, e.g. "iPhone15,4". Null when devicectl omits it. */ + productType: string | null; + /** Always "connected" — only currently-reachable devices are returned. */ + state: string; +} + +interface DevicectlDevice { + hardwareProperties?: { udid?: string; platform?: string; productType?: string }; + deviceProperties?: { name?: string }; + connectionProperties?: { transportType?: string; tunnelState?: string }; +} + +interface DevicectlOutput { + result?: { devices?: DevicectlDevice[] }; +} + interface SimctlDevice { udid: string; name: string; @@ -46,3 +69,53 @@ export async function listIosSimulators(): Promise { return []; } } + +/** + * List connected physical iOS devices via `xcrun devicectl list devices`. + * + * devicectl only emits stable machine output to a file (`--json-output`), never + * stdout, so we write to a temp file and parse it. We keep only devices that are + * actually reachable right now: iOS platform with a `connectionProperties.transportType` + * (wired/network). Paired-but-offline devices carry a `tunnelState: "unavailable"` + * and no `transportType`, and are dropped — listing them would invite taps that + * can't land. Returns an empty array on any failure so the rest of `list-devices` + * stays usable on non-mac hosts or without Xcode. + */ +export function parsePhysicalIosDevices(data: DevicectlOutput): IosPhysicalDevice[] { + const out: IosPhysicalDevice[] = []; + for (const d of data.result?.devices ?? []) { + const udid = d.hardwareProperties?.udid; + const platform = d.hardwareProperties?.platform; + const transport = d.connectionProperties?.transportType; + // iOS only (skip watchOS/tvOS), and only currently-connected devices: a + // reachable device reports a `transportType` (wired/network); paired-but- + // offline ones carry `tunnelState: "unavailable"` and no transport. + if (!udid || platform !== "iOS" || !transport) continue; + if (d.connectionProperties?.tunnelState === "unavailable") continue; + out.push({ + udid, + name: d.deviceProperties?.name ?? "iPhone", + productType: d.hardwareProperties?.productType ?? null, + state: "connected", + }); + } + return out; +} + +export async function listIosDevices(): Promise { + if (process.platform !== "darwin") return []; + const outPath = join(tmpdir(), `argent-devicectl-${randomUUID()}.json`); + try { + await execFileAsync( + "xcrun", + ["devicectl", "list", "devices", "--quiet", "--json-output", outPath], + { timeout: 15_000 } + ); + const data: DevicectlOutput = JSON.parse(await readFile(outPath, "utf8")); + return parsePhysicalIosDevices(data); + } catch { + return []; + } finally { + await rm(outPath, { force: true }).catch(() => {}); + } +} diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index fd594420..9dc435cc 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -1,5 +1,6 @@ import { Registry } from "@argent/registry"; import { simulatorServerBlueprint } from "../blueprints/simulator-server"; +import { coreDeviceBlueprint } from "../blueprints/core-device"; import { nativeDevtoolsBlueprint } from "../blueprints/native-devtools"; import { androidDevtoolsBlueprint } from "../blueprints/android-devtools"; import { axServiceBlueprint } from "../blueprints/ax-service"; @@ -81,6 +82,7 @@ export function createRegistry(): Registry { const registry = new Registry(); registry.registerBlueprint(simulatorServerBlueprint); + registry.registerBlueprint(coreDeviceBlueprint); registry.registerBlueprint(jsRuntimeDebuggerBlueprint); registry.registerBlueprint(networkInspectorBlueprint); registry.registerBlueprint(reactProfilerSessionBlueprint); diff --git a/packages/tool-server/test/physical-ios.test.ts b/packages/tool-server/test/physical-ios.test.ts new file mode 100644 index 00000000..625cf07a --- /dev/null +++ b/packages/tool-server/test/physical-ios.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { + classifyDevice, + resolveDevice, + isPhysicalIos, + isPhysicalIosUdid, +} from "../src/utils/device-info"; +import { parsePhysicalIosDevices } from "../src/utils/ios-devices"; +import { toHid, tunneldStartCommand, appleScriptQuote } from "../src/blueprints/core-device"; +import { launchAppTool } from "../src/tools/launch-app"; +import { restartAppTool } from "../src/tools/restart-app"; + +// A real iPhone's UDID: 8-hex ECID, one dash, 16 hex. +const PHYSICAL_UDID = "00008120-000E6D0C0ABBA01E"; +const SIM_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; + +describe("physical iOS classification", () => { + it("classifies a physical iPhone UDID as ios", () => { + expect(classifyDevice(PHYSICAL_UDID)).toBe("ios"); + }); + + it("resolves a physical iPhone UDID to ios + device", () => { + const d = resolveDevice(PHYSICAL_UDID); + expect(d.platform).toBe("ios"); + expect(d.kind).toBe("device"); + expect(d.id).toBe(PHYSICAL_UDID); + }); + + it("still resolves a simulator UUID to ios + simulator", () => { + const d = resolveDevice(SIM_UDID); + expect(d.platform).toBe("ios"); + expect(d.kind).toBe("simulator"); + }); + + it("isPhysicalIosUdid distinguishes device from simulator shapes", () => { + expect(isPhysicalIosUdid(PHYSICAL_UDID)).toBe(true); + expect(isPhysicalIosUdid(SIM_UDID)).toBe(false); + }); + + it("isPhysicalIos is true only for ios+device", () => { + expect(isPhysicalIos(resolveDevice(PHYSICAL_UDID))).toBe(true); + expect(isPhysicalIos(resolveDevice(SIM_UDID))).toBe(false); + expect(isPhysicalIos(resolveDevice("emulator-5554"))).toBe(false); + }); + + it("does not reclassify Android serials as physical iOS", () => { + // No 8hex-16hex Android serials in the wild; guard against regressions. + expect(classifyDevice("HT82A0203045")).toBe("android"); + expect(classifyDevice("192.168.1.5:5555")).toBe("android"); + expect(resolveDevice("HT82A0203045").kind).toBe("device"); + }); +}); + +describe("parsePhysicalIosDevices (devicectl JSON)", () => { + const sample = { + result: { + devices: [ + // Connected iPhone — kept. + { + hardwareProperties: { udid: PHYSICAL_UDID, platform: "iOS", productType: "iPhone15,4" }, + deviceProperties: { name: "iPhone 15" }, + connectionProperties: { transportType: "wired", tunnelState: "disconnected" }, + }, + // Paired but offline (no transportType, tunnelState unavailable) — dropped. + { + hardwareProperties: { + udid: "00008030-00096526219B802E", + platform: "iOS", + productType: "iPhone12,8", + }, + deviceProperties: { name: "Old iPhone" }, + connectionProperties: { tunnelState: "unavailable" }, + }, + // A connected Apple Watch — dropped (not iOS). + { + hardwareProperties: { + udid: "11111111-2222222222222222", + platform: "watchOS", + productType: "Watch7,1", + }, + deviceProperties: { name: "Watch" }, + connectionProperties: { transportType: "wired" }, + }, + ], + }, + }; + + it("keeps only connected iOS devices with the right fields", () => { + const out = parsePhysicalIosDevices(sample); + expect(out).toEqual([ + { udid: PHYSICAL_UDID, name: "iPhone 15", productType: "iPhone15,4", state: "connected" }, + ]); + }); + + it("returns [] for empty/missing input", () => { + expect(parsePhysicalIosDevices({})).toEqual([]); + expect(parsePhysicalIosDevices({ result: { devices: [] } })).toEqual([]); + }); +}); + +describe("toHid (normalized 0..1 → 0..65535)", () => { + it("maps endpoints and midpoint", () => { + expect(toHid(0)).toBe(0); + expect(toHid(1)).toBe(65535); + expect(toHid(0.5)).toBe(32768); + }); + + it("clamps out-of-range input", () => { + expect(toHid(-0.2)).toBe(0); + expect(toHid(1.5)).toBe(65535); + }); +}); + +describe("privileged tunnel start command", () => { + it("single-quotes the binary path, pins HOME, passes the port + daemonize flag", () => { + const cmd = tunneldStartCommand("/Users/me/.local/bin/pymobiledevice3", 49151); + expect(cmd).toContain("HOME=/var/root"); + expect(cmd).toContain("'/Users/me/.local/bin/pymobiledevice3' remote tunneld --port 49151 -d"); + }); + + it("escapes a single quote in the path so it can't break out of the sh word", () => { + const cmd = tunneldStartCommand("/Users/o'brien/pmd3", 5000); + // ' -> '\'' so the whole path stays one shell word + expect(cmd).toContain(`'/Users/o'\\''brien/pmd3' remote tunneld`); + }); + + it("escapes backslashes and double-quotes for the AppleScript string literal", () => { + expect(appleScriptQuote('a "b" c')).toBe('a \\"b\\" c'); + expect(appleScriptQuote("a\\b")).toBe("a\\\\b"); + }); +}); + +describe("lifecycle tools don't resolve native-devtools for physical iOS", () => { + // Regression guard: the registry resolves a tool's services() BEFORE execute(), + // and the native-devtools service throws a simulator-only guard for physical + // devices. So resolving it for a physical iPhone would break launch-app (a + // supported tool) and mask restart-app's intended rejection message. Simulators + // must still get it. + const params = (udid: string) => ({ udid, bundleId: "com.apple.Preferences" }); + + it("launch-app: omit for physical device, keep for simulator", () => { + expect(launchAppTool.services(params(PHYSICAL_UDID)).nativeDevtools).toBeUndefined(); + expect(launchAppTool.services(params(SIM_UDID)).nativeDevtools).toBeDefined(); + }); + + it("restart-app: omit for physical device, keep for simulator", () => { + expect(restartAppTool.services(params(PHYSICAL_UDID)).nativeDevtools).toBeUndefined(); + expect(restartAppTool.services(params(SIM_UDID)).nativeDevtools).toBeDefined(); + }); +}); diff --git a/scripts/download-native-binaries.sh b/scripts/download-native-binaries.sh index 65f23450..ab11d8c5 100755 --- a/scripts/download-native-binaries.sh +++ b/scripts/download-native-binaries.sh @@ -63,6 +63,23 @@ gh release download "${TAG}" \ --clobber chmod +x "${IOS_BIN_DIR}/ax-service" +# argent-device-auth is the macOS host helper that shows the branded admin +# prompt to start the physical-iOS CoreDevice tunnel as root. Like ax-service it +# is a darwin-only binary, resolved at bin/darwin/argent-device-auth. +# Optional: until the argent-private build publishes it, the release won't carry +# it — physical-iOS then falls back to the (unbranded) osascript admin prompt, +# so a missing asset must not fail the whole download. +echo " Downloading argent-device-auth..." +if gh release download "${TAG}" \ + --repo "${REPO}" \ + --pattern "argent-device-auth" \ + --dir "${IOS_BIN_DIR}" \ + --clobber 2>/dev/null; then + chmod +x "${IOS_BIN_DIR}/argent-device-auth" +else + echo " (not in this release yet — physical iOS will use the osascript prompt fallback)" +fi + echo " Downloading argent-android-devtools.apk..." # The release publishes the APK under a stable name (no versioning in the # filename) so this script doesn't have to know the version ahead of time; @@ -85,7 +102,8 @@ trap - EXIT echo "Downloaded native binaries to ${DYLIBS_DIR}/, ${IOS_BIN_DIR}/, and ${ANDROID_BIN_DIR}/" if command -v codesign &>/dev/null; then - for f in "${DYLIBS_DIR}"/*.dylib "${IOS_BIN_DIR}/ax-service"; do + for f in "${DYLIBS_DIR}"/*.dylib "${IOS_BIN_DIR}/ax-service" "${IOS_BIN_DIR}/argent-device-auth"; do + [ -f "$f" ] || continue codesign -dvv "$f" 2>&1 || echo "Warning: signature verification failed for $f" done fi