From b2d1372330d378b0e2c9d8be191bdaad75a16558 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Thu, 21 May 2026 11:02:46 +0200 Subject: [PATCH 1/9] make ax-service a socket client, add optional TCP connection to native tools --- .gitignore | 1 + packages/argent-private | 2 +- packages/native-devtools-ios/scripts/build.sh | 49 ++- packages/native-devtools-ios/src/index.ts | 29 +- .../tool-server/src/blueprints/ax-service.ts | 381 +++++++++++------- .../src/blueprints/native-devtools.ts | 98 ++++- packages/tool-server/test/boot-device.test.ts | 10 +- .../tool-server/test/describe-tool.test.ts | 5 +- 8 files changed, 389 insertions(+), 186 deletions(-) diff --git a/.gitignore b/.gitignore index b831138c..d3f94468 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ packages/*/node_modules/ # Downloaded signed binaries (fetched via scripts/download-*.sh) packages/native-devtools-ios/bin/simulator-server packages/native-devtools-ios/bin/ax-service +packages/native-devtools-ios/bin/tcp/ packages/native-devtools-ios/dylibs/ # Argent package generated assets (copied/bundled at build time) diff --git a/packages/argent-private b/packages/argent-private index 6fe36079..82213764 160000 --- a/packages/argent-private +++ b/packages/argent-private @@ -1 +1 @@ -Subproject commit 6fe36079b010cb4c81a2380d31a0443d5ebde635 +Subproject commit 82213764cab00cb4c087c91c5282ba0d92fb1cfe diff --git a/packages/native-devtools-ios/scripts/build.sh b/packages/native-devtools-ios/scripts/build.sh index 259be373..7cc3e842 100644 --- a/packages/native-devtools-ios/scripts/build.sh +++ b/packages/native-devtools-ios/scripts/build.sh @@ -2,11 +2,15 @@ # Build native-devtools-ios dylibs for Argent. # ObjC source lives in the argent-private submodule at packages/argent-private. -# Run from the workspace root: bash packages/native-devtools-ios/scripts/build.sh [dev|release] -# Or from this package: bash scripts/build.sh [dev|release] +# Run from the workspace root: bash packages/native-devtools-ios/scripts/build.sh [dev|release] [--transport unix|tcp] +# Or from this package: bash scripts/build.sh [dev|release] [--transport unix|tcp] # -# Usage: build.sh [dev|release] +# Usage: build.sh [dev|release] [--transport unix|tcp] # mode: dev (fast) or release (optimized, optional signing). Default: release. +# --transport: unix (default) builds against AF_UNIX sockets at /tmp/*.sock paths; +# tcp builds with -DARGENT_USE_TCP=1 so the dylib/daemon use AF_INET +# on 127.0.0.1 with a port. Artifacts go to dylibs/tcp/ and bin/tcp/ +# so both variants can coexist. # # Environment: # PREBUILT_NATIVE_DEVTOOLS_IOS - if set, copy this path to dylibs/ instead of building (CI on non-macOS) @@ -17,10 +21,25 @@ set -euo pipefail -MODE="${1:-release}" - -if [[ "$MODE" != "dev" && "$MODE" != "release" ]]; then - echo "Usage: build.sh [dev|release]" >&2 +MODE="release" +TRANSPORT="unix" + +while (($#)); do + case "$1" in + dev|release) + MODE="$1"; shift ;; + --transport) + TRANSPORT="${2:-}"; shift 2 ;; + --transport=*) + TRANSPORT="${1#--transport=}"; shift ;; + *) + echo "Usage: build.sh [dev|release] [--transport unix|tcp]" >&2 + exit 1 ;; + esac +done + +if [[ "$TRANSPORT" != "unix" && "$TRANSPORT" != "tcp" ]]; then + echo "Invalid --transport '$TRANSPORT'. Expected unix or tcp." >&2 exit 1 fi @@ -28,7 +47,14 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE_DIR="$(cd "${ROOT_DIR}/../.." && pwd)" SUBMODULE_DIR="${WORKSPACE_DIR}/packages/argent-private/packages/native-devtools-ios" SRC_DIR="${SUBMODULE_DIR}/Sources/NativeDevtoolsIos" -DEST_DIR="${ROOT_DIR}/dylibs" + +if [[ "$TRANSPORT" == "tcp" ]]; then + DEST_DIR="${ROOT_DIR}/dylibs/tcp" + BIN_DIR="${ROOT_DIR}/bin/tcp" +else + DEST_DIR="${ROOT_DIR}/dylibs" + BIN_DIR="${ROOT_DIR}/bin" +fi # Verify the submodule is initialised before trying to build. if [[ ! -d "${SRC_DIR}" ]]; then @@ -81,6 +107,9 @@ EXTRA_CFLAGS=() if [[ "$MODE" == "release" ]]; then EXTRA_CFLAGS=(-Os -DNDEBUG) fi +if [[ "$TRANSPORT" == "tcp" ]]; then + EXTRA_CFLAGS+=(-DARGENT_USE_TCP=1) +fi echo "Building libNativeDevtoolsIos.dylib..." xcrun --sdk iphonesimulator clang \ @@ -134,7 +163,7 @@ xcrun --sdk iphonesimulator clang \ echo "Building ax-service..." AX_SRC_DIR="${SUBMODULE_DIR}/Sources/AXService" -AX_DEST="${ROOT_DIR}/bin/ax-service" +AX_DEST="${BIN_DIR}/ax-service" mkdir -p "$(dirname "$AX_DEST")" if [[ -n "${PREBUILT_AX_SERVICE:-}" ]]; then @@ -162,4 +191,4 @@ if [[ "$MODE" == "release" ]]; then fi fi -echo "Done. Dylibs written to ${DEST_DIR}/" +echo "Done. Dylibs written to ${DEST_DIR}/ (transport=${TRANSPORT})" diff --git a/packages/native-devtools-ios/src/index.ts b/packages/native-devtools-ios/src/index.ts index 57fefbf5..6ffa8259 100644 --- a/packages/native-devtools-ios/src/index.ts +++ b/packages/native-devtools-ios/src/index.ts @@ -5,20 +5,25 @@ import * as fs from "node:fs"; // ARGENT_NATIVE_DEVTOOLS_DIR lets the launcher override the dylib directory, // matching the same pattern used by ARGENT_SIMULATOR_SERVER_DIR. const DYLIB_DIR = process.env.ARGENT_NATIVE_DEVTOOLS_DIR ?? path.join(__dirname, "..", "dylibs"); +const DYLIB_TCP_DIR = process.env.ARGENT_NATIVE_DEVTOOLS_TCP_DIR ?? path.join(DYLIB_DIR, "tcp"); -function requireDylib(name: string): string { - const p = path.join(DYLIB_DIR, name); +function requireDylibIn(dir: string, name: string): string { + const p = path.join(dir, name); if (!fs.existsSync(p)) { throw new Error(`Native devtools dylib not found: ${p}`); } return p; } -export const bootstrapDylibPath = () => requireDylib("libArgentInjectionBootstrap.dylib"); -export const nativeDevtoolsDylibPath = () => requireDylib("libNativeDevtoolsIos.dylib"); -export const keyboardPatchDylibPath = () => requireDylib("libKeyboardPatch.dylib"); +export const bootstrapDylibPath = () => requireDylibIn(DYLIB_DIR, "libArgentInjectionBootstrap.dylib"); +export const nativeDevtoolsDylibPath = () => requireDylibIn(DYLIB_DIR, "libNativeDevtoolsIos.dylib"); +export const keyboardPatchDylibPath = () => requireDylibIn(DYLIB_DIR, "libKeyboardPatch.dylib"); + +export const bootstrapDylibPathTcp = () => requireDylibIn(DYLIB_TCP_DIR, "libArgentInjectionBootstrap.dylib"); +export const nativeDevtoolsDylibPathTcp = () => requireDylibIn(DYLIB_TCP_DIR, "libNativeDevtoolsIos.dylib"); const BIN_DIR = process.env.ARGENT_SIMULATOR_SERVER_DIR ?? path.join(__dirname, "..", "bin"); +const BIN_TCP_DIR = process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR ?? path.join(BIN_DIR, "tcp"); export function simulatorServerBinaryPath(): string { const p = path.join(BIN_DIR, "simulator-server"); @@ -32,10 +37,18 @@ export function simulatorServerBinaryDir(): string { return BIN_DIR; } -export function axServiceBinaryPath(): string { - const p = path.join(BIN_DIR, "ax-service"); +function requireBinIn(dir: string, name: string): string { + const p = path.join(dir, name); if (!fs.existsSync(p)) { - throw new Error(`ax-service binary not found: ${p}`); + throw new Error(`${name} binary not found: ${p}`); } return p; } + +export function axServiceBinaryPath(): string { + return requireBinIn(BIN_DIR, "ax-service"); +} + +export function axServiceBinaryPathTcp(): string { + return requireBinIn(BIN_TCP_DIR, "ax-service"); +} diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index c73227a2..d73f57b4 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as fsAsync from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import * as readline from "node:readline"; import { promisify } from "node:util"; import { execFile, ChildProcess } from "node:child_process"; import { @@ -12,28 +13,39 @@ import { type ServiceInstance, type ServiceEvents, } from "@argent/registry"; -import { axServiceBinaryPath } from "@argent/native-devtools-ios"; +import { axServiceBinaryPath, axServiceBinaryPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; const execFileAsync = promisify(execFile); export const AX_SERVICE_NAMESPACE = "AXService"; +export type AXServiceTransport = "unix" | "tcp"; + +export const AX_SERVICE_TCP_PORT = Number(process.env.AX_SERVICE_TCP_PORT) || 9231; + // Same DeviceInfo-via-options pattern as the other iOS-only blueprints. -type AxServiceFactoryOptions = Record & { device: DeviceInfo }; +type AxServiceFactoryOptions = Record & { + device: DeviceInfo; + transport?: AXServiceTransport; +}; /** * Build the `ServiceRef` for the AX service keyed by an already-resolved * `DeviceInfo`. The factory's iOS-only check uses the caller's classification * rather than running its own. */ -export function axServiceRef(device: DeviceInfo): { +export function axServiceRef( + device: DeviceInfo, + { transport = "unix" }: { transport?: AXServiceTransport } = {} +): { urn: string; options: AxServiceFactoryOptions; } { + const transportSuffix = transport === "tcp" ? ":tcp" : ""; return { - urn: `${AX_SERVICE_NAMESPACE}:${device.id}`, - options: { device }, + urn: `${AX_SERVICE_NAMESPACE}:${device.id}${transportSuffix}`, + options: { device, transport }, }; } @@ -63,53 +75,14 @@ function getSocketPath(udid: string): string { return `/tmp/ax-${udid.slice(0, 8)}.sock`; } -function querySocket(socketPath: string, command: string, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - let data = ""; - let settled = false; - - const timer = setTimeout(() => { - if (!settled) { - settled = true; - client.destroy(); - reject(new Error(`ax-service query timed out: ${command}`)); - } - }, timeoutMs); - - const client = net.createConnection(socketPath, () => { - client.write(command + "\n"); - }); +type AXEndpoint = + | { transport: "unix"; socketPath: string } + | { transport: "tcp"; port: number }; - client.on("data", (chunk) => { - data += chunk.toString(); - }); - - client.on("end", () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(data.trim()); - } - }); - - client.on("error", (err) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(err); - } - }); - }); -} - -async function pingDaemon(socketPath: string): Promise { - try { - const raw = await querySocket(socketPath, "ping", 2000); - const parsed = JSON.parse(raw); - return parsed.status === "ok"; - } catch { - return false; - } +interface PendingRpc { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; } export async function ensureAutomationEnabled(udid: string): Promise { @@ -209,86 +182,110 @@ export async function setAccessibilityPrefsPreBoot(udid: string): Promise } } -async function killExistingDaemon(socketPath: string): Promise { - try { - const raw = await querySocket(socketPath, "ping", 2000); - const parsed = JSON.parse(raw); - if (parsed.pid) process.kill(parsed.pid, "SIGTERM"); - } catch {} - try { - fs.unlinkSync(socketPath); - } catch {} -} - -function spawnDaemon(udid: string, socketPath: string): Promise { +// Listen on the chosen transport. Unix: pre-unlink stale socket from previous +// runs so listen() doesn't EADDRINUSE. +function startListener( + endpoint: AXEndpoint, + onConnection: (socket: net.Socket) => void +): Promise { return new Promise((resolve, reject) => { - let binaryPath: string; - try { - binaryPath = axServiceBinaryPath(); - } catch (err) { - reject(err); - return; + if (endpoint.transport === "unix") { + try { + fs.unlinkSync(endpoint.socketPath); + } catch {} + } + + const server = net.createServer(onConnection); + server.once("error", reject); + + const onListening = () => { + server.off("error", reject); + resolve(server); + }; + + if (endpoint.transport === "tcp") { + server.listen(endpoint.port, "127.0.0.1", onListening); + } else { + server.listen(endpoint.socketPath, onListening); } + }); +} +function spawnDaemon(udid: string, endpoint: AXEndpoint): ChildProcess { + const binaryPath = + endpoint.transport === "tcp" ? axServiceBinaryPathTcp() : axServiceBinaryPath(); + + const endpointArgs = + endpoint.transport === "tcp" + ? ["--port", String(endpoint.port)] + : ["--socket", endpoint.socketPath]; + + const proc = execFile( + "xcrun", + ["simctl", "spawn", udid, binaryPath, ...endpointArgs, "--timeout", "3600"], + { encoding: "utf8" } + ) as ChildProcess; + + // Defense-in-depth: a missing udid here would crash the process — + // throwing inside an async listener bypasses promise rejection and + // bubbles up as `uncaughtException`, which the tool-server treats as + // fatal. Tag with "?" instead of dereferencing. + const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; + proc.stderr?.on("data", (data: string) => { + process.stderr.write(`[ax-service ${udidTag}] ${data}`); + }); + + return proc; +} + +// Wait for either the daemon's TCP/UDS connection or an early exit. +// Resolves with the connected socket; rejects on timeout or daemon failure. +function waitForDaemonConnection( + server: net.Server, + proc: ChildProcess, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { let settled = false; - const proc = execFile( - "xcrun", - ["simctl", "spawn", udid, binaryPath, "--socket", socketPath, "--timeout", "3600"], - { encoding: "utf8" } - ) as ChildProcess; - const timer = setTimeout(() => { - if (!settled) { - settled = true; - proc.kill(); - reject(new Error("Timed out waiting for ax-service to become ready")); - } - }, 10_000); - - let stdoutBuf = ""; - proc.stdout?.on("data", (chunk: string) => { - stdoutBuf += chunk; - const lines = stdoutBuf.split("\n"); - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line.trim()); - if (msg.status === "ready" && !settled) { - settled = true; - clearTimeout(timer); - resolve(proc); - return; - } - } catch { - // not JSON yet — accumulate - } - } - }); + const onConnection = (socket: net.Socket) => { + if (settled) return; + settled = true; + cleanup(); + resolve(socket); + }; - // Defense-in-depth: a missing udid here would crash the process — - // throwing inside an async listener bypasses promise rejection and - // bubbles up as `uncaughtException`, which the tool-server treats as - // fatal. Tag with "?" instead of dereferencing. - const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; - proc.stderr?.on("data", (data: string) => { - process.stderr.write(`[ax-service ${udidTag}] ${data}`); - }); + const onExit = (code: number | null) => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`ax-service exited with code ${code} before connecting`)); + }; - proc.on("exit", (code) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(new Error(`ax-service exited with code ${code} before becoming ready`)); - } - }); + const onError = (err: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(err); + }; - proc.on("error", (err) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(err); - } - }); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Timed out waiting for ax-service to connect")); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timer); + server.off("connection", onConnection); + proc.off("exit", onExit); + proc.off("error", onError); + }; + + server.on("connection", onConnection); + proc.on("exit", onExit); + proc.on("error", onError); }); } @@ -325,40 +322,127 @@ export const axServiceBlueprint: ServiceBlueprint = { } const udid = device.id; - const socketPath = getSocketPath(udid); + const transport: AXServiceTransport = opts.transport ?? "unix"; + const endpoint: AXEndpoint = + transport === "tcp" + ? { transport: "tcp", port: AX_SERVICE_TCP_PORT } + : { transport: "unix", socketPath: getSocketPath(udid) }; const events = new TypedEventEmitter(); + const pendingRpc = new Map(); + let nextRpcId = 1; + let daemonSocket: net.Socket | null = null; + let disposed = false; + + const failPending = (err: Error): void => { + for (const { reject, timer } of pendingRpc.values()) { + clearTimeout(timer); + reject(err); + } + pendingRpc.clear(); + }; + await ensureAutomationEnabled(udid); const entitlementBypassActive = await isEntitlementBypassActive(udid); - await killExistingDaemon(socketPath); - const proc = await spawnDaemon(udid, socketPath); + // Host listens first, then we spawn the daemon and wait for it to dial in. + const server = await startListener(endpoint, (socket) => { + if (daemonSocket && !daemonSocket.destroyed) { + // A second connection (e.g. respawned daemon) replaces the previous one. + daemonSocket.destroy(); + } + daemonSocket = socket; + + const rl = readline.createInterface({ input: socket }); + rl.on("line", (raw) => { + let msg: { id?: number; result?: unknown; error?: unknown }; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (typeof msg.id !== "number") return; + const pending = pendingRpc.get(msg.id); + if (!pending) return; + pendingRpc.delete(msg.id); + clearTimeout(pending.timer); + if (msg.error !== undefined && msg.error !== null) { + pending.reject( + new Error(typeof msg.error === "string" ? msg.error : JSON.stringify(msg.error)) + ); + } else { + pending.resolve(msg.result); + } + }); + + socket.on("close", () => { + rl.close(); + if (daemonSocket === socket) { + daemonSocket = null; + if (!disposed) { + const err = new Error("ax-service daemon disconnected"); + failPending(err); + events.emit("terminated", err); + } + } + }); + + socket.on("error", () => { + // close handler does the cleanup + }); + }); + + const proc = spawnDaemon(udid, endpoint); proc.on("exit", (code) => { - events.emit("terminated", new Error(`ax-service exited with code ${code}`)); + if (disposed) return; + const err = new Error(`ax-service exited with code ${code}`); + failPending(err); + events.emit("terminated", err); }); proc.on("error", (err) => { + if (disposed) return; + failPending(err); events.emit("terminated", err); }); - async function query(command: string): Promise { - try { - const raw = await querySocket(socketPath, command); - return JSON.parse(raw); - } catch (err) { - events.emit("terminated", err instanceof Error ? err : new Error(String(err))); - throw err; + try { + daemonSocket = await waitForDaemonConnection(server, proc, 10_000); + } catch (err) { + // Tear down whatever started so we don't leak a server or process. + if (!proc.killed) proc.kill("SIGTERM"); + server.close(); + if (endpoint.transport === "unix") { + try { + fs.unlinkSync(endpoint.socketPath); + } catch {} } + throw err; + } + + function query(command: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + if (!daemonSocket || daemonSocket.destroyed) { + reject(new Error("ax-service not connected")); + return; + } + const id = nextRpcId++; + const timer = setTimeout(() => { + if (pendingRpc.has(id)) { + pendingRpc.delete(id); + reject(new Error(`ax-service query timed out: ${command}`)); + } + }, timeoutMs); + pendingRpc.set(id, { resolve, reject, timer }); + daemonSocket.write(JSON.stringify({ id, command }) + "\n"); + }); } const api: AXServiceApi = { degraded: !entitlementBypassActive, async describe(): Promise { - const result = (await query("describe")) as AXDescribeResponse & { - error?: string; - }; - if (result.error) throw new Error(`ax-service describe error: ${result.error}`); + const result = (await query("describe", 10_000)) as AXDescribeResponse; return { alertVisible: result.alertVisible ?? false, screenFrame: result.screenFrame, @@ -372,19 +456,32 @@ export const axServiceBlueprint: ServiceBlueprint = { }, async ping(): Promise { - return pingDaemon(socketPath); + try { + const result = (await query("ping", 2000)) as { status?: string }; + return result.status === "ok"; + } catch { + return false; + } }, }; const instance: ServiceInstance = { api, dispose: async () => { + disposed = true; + failPending(new Error("ax-service disposed")); + if (daemonSocket && !daemonSocket.destroyed) { + daemonSocket.destroy(); + } if (proc && !proc.killed) { proc.kill("SIGTERM"); } - try { - fs.unlinkSync(socketPath); - } catch {} + server.close(); + if (endpoint.transport === "unix") { + try { + fs.unlinkSync(endpoint.socketPath); + } catch {} + } }, events, }; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 44a75811..87e4b458 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -10,9 +10,14 @@ import { type ServiceBlueprint, type ServiceEvents, } from "@argent/registry"; -import { bootstrapDylibPath } from "@argent/native-devtools-ios"; +import { bootstrapDylibPath, bootstrapDylibPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; +export type NativeDevtoolsTransport = "unix" | "tcp"; + +export const NATIVE_DEVTOOLS_TCP_PORT = + Number(process.env.NATIVE_DEVTOOLS_TCP_PORT) || 9230; + const execFileAsync = promisify(execFile); export const NATIVE_DEVTOOLS_NAMESPACE = "NativeDevtools"; @@ -91,15 +96,22 @@ export async function precheckNativeDevtools( return null; } -type NativeDevtoolsFactoryOptions = Record & { device: DeviceInfo }; +type NativeDevtoolsFactoryOptions = Record & { + device: DeviceInfo; + transport?: NativeDevtoolsTransport; +}; -export function nativeDevtoolsRef(device: DeviceInfo): { +export function nativeDevtoolsRef( + device: DeviceInfo, + { transport = "unix" }: { transport?: NativeDevtoolsTransport } = {} +): { urn: string; options: NativeDevtoolsFactoryOptions; } { + const transportSuffix = transport === "tcp" ? ":tcp" : ""; return { - urn: `${NATIVE_DEVTOOLS_NAMESPACE}:${device.id}`, - options: { device }, + urn: `${NATIVE_DEVTOOLS_NAMESPACE}:${device.id}${transportSuffix}`, + options: { device, transport }, }; } @@ -245,8 +257,12 @@ async function ensureAccessibilityEnabled(udid: string): Promise { ); } -async function ensureEnv(udid: string, socketPath: string): Promise { - const bootstrapPath = bootstrapDylibPath(); +async function ensureEnv( + udid: string, + endpoint: { transport: "unix"; socketPath: string } | { transport: "tcp"; port: number } +): Promise { + const bootstrapPath = + endpoint.transport === "tcp" ? bootstrapDylibPathTcp() : bootstrapDylibPath(); // Read from launchctl inside the simulator (via simctl spawn) instead of // `simctl getenv`. The latter silently truncates values longer than 127 bytes, @@ -269,13 +285,37 @@ async function ensureEnv(udid: string, socketPath: string): Promise { ); } - // Always re-set the socket path — deterministic value, cheap no-op if already correct, + // Always re-set the endpoint env var — deterministic value, cheap no-op if already correct, // ensures correctness after tool-server restarts. - await execFileAsync( - "xcrun", - ["simctl", "spawn", udid, "launchctl", "setenv", "NATIVE_DEVTOOLS_IOS_CDP_SOCKET", socketPath], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ); + if (endpoint.transport === "tcp") { + await execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "launchctl", + "setenv", + "NATIVE_DEVTOOLS_IOS_CDP_PORT", + String(endpoint.port), + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); + } else { + await execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "launchctl", + "setenv", + "NATIVE_DEVTOOLS_IOS_CDP_SOCKET", + endpoint.socketPath, + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); + } // Ensure the accessibility runtime is enabled so that describeScreen works on iOS 26+. await ensureAccessibilityEnabled(udid); @@ -313,6 +353,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); const pendingRpc = new Map< @@ -356,7 +402,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint ensureEnv(udid, socketPath)) + .then(() => ensureEnv(udid, endpoint)) .then(() => { envSetup = true; initFailure = null; @@ -406,10 +452,12 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { @@ -495,7 +543,11 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { ]); expect(resolveService).toHaveBeenCalledWith( "NativeDevtools:11111111-1111-1111-1111-111111111111", - { device: { id: "11111111-1111-1111-1111-111111111111", platform: "ios", kind: "simulator" } } + { + device: { id: "11111111-1111-1111-1111-111111111111", platform: "ios", kind: "simulator" }, + transport: "unix", + } ); // NativeDevtools must be primed AFTER bootstatus returns (launchd env is // only reachable once the simulator is fully up) and BEFORE `open`, so @@ -232,7 +235,10 @@ describe("boot-device — iOS path", () => { ]); expect(resolveService).toHaveBeenCalledWith( "NativeDevtools:22222222-2222-2222-2222-222222222222", - { device: { id: "22222222-2222-2222-2222-222222222222", platform: "ios", kind: "simulator" } } + { + device: { id: "22222222-2222-2222-2222-222222222222", platform: "ios", kind: "simulator" }, + transport: "unix", + } ); }); diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 8db3e8cd..0ce6dfa8 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -326,7 +326,10 @@ describe("describe tool", () => { await tool.execute({}, { udid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" }); expect(registry.resolveService).toHaveBeenCalledWith( "AXService:BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", - { device: { id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", platform: "ios", kind: "simulator" } } + { + device: { id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", platform: "ios", kind: "simulator" }, + transport: "unix", + } ); }); From 3f25200c87b997cfa9ede20a53336c8ba184451d Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Fri, 29 May 2026 10:00:57 +0200 Subject: [PATCH 2/9] fix fmt --- packages/native-devtools-ios/src/index.ts | 12 ++++++++---- packages/tool-server/src/blueprints/ax-service.ts | 4 +--- .../tool-server/src/blueprints/native-devtools.ts | 3 +-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/native-devtools-ios/src/index.ts b/packages/native-devtools-ios/src/index.ts index 6ffa8259..0d1d15c3 100644 --- a/packages/native-devtools-ios/src/index.ts +++ b/packages/native-devtools-ios/src/index.ts @@ -15,12 +15,16 @@ function requireDylibIn(dir: string, name: string): string { return p; } -export const bootstrapDylibPath = () => requireDylibIn(DYLIB_DIR, "libArgentInjectionBootstrap.dylib"); -export const nativeDevtoolsDylibPath = () => requireDylibIn(DYLIB_DIR, "libNativeDevtoolsIos.dylib"); +export const bootstrapDylibPath = () => + requireDylibIn(DYLIB_DIR, "libArgentInjectionBootstrap.dylib"); +export const nativeDevtoolsDylibPath = () => + requireDylibIn(DYLIB_DIR, "libNativeDevtoolsIos.dylib"); export const keyboardPatchDylibPath = () => requireDylibIn(DYLIB_DIR, "libKeyboardPatch.dylib"); -export const bootstrapDylibPathTcp = () => requireDylibIn(DYLIB_TCP_DIR, "libArgentInjectionBootstrap.dylib"); -export const nativeDevtoolsDylibPathTcp = () => requireDylibIn(DYLIB_TCP_DIR, "libNativeDevtoolsIos.dylib"); +export const bootstrapDylibPathTcp = () => + requireDylibIn(DYLIB_TCP_DIR, "libArgentInjectionBootstrap.dylib"); +export const nativeDevtoolsDylibPathTcp = () => + requireDylibIn(DYLIB_TCP_DIR, "libNativeDevtoolsIos.dylib"); const BIN_DIR = process.env.ARGENT_SIMULATOR_SERVER_DIR ?? path.join(__dirname, "..", "bin"); const BIN_TCP_DIR = process.env.ARGENT_SIMULATOR_SERVER_TCP_DIR ?? path.join(BIN_DIR, "tcp"); diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index d73f57b4..3ea0c01f 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -75,9 +75,7 @@ function getSocketPath(udid: string): string { return `/tmp/ax-${udid.slice(0, 8)}.sock`; } -type AXEndpoint = - | { transport: "unix"; socketPath: string } - | { transport: "tcp"; port: number }; +type AXEndpoint = { transport: "unix"; socketPath: string } | { transport: "tcp"; port: number }; interface PendingRpc { resolve: (value: unknown) => void; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 87e4b458..1b009111 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -15,8 +15,7 @@ import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; export type NativeDevtoolsTransport = "unix" | "tcp"; -export const NATIVE_DEVTOOLS_TCP_PORT = - Number(process.env.NATIVE_DEVTOOLS_TCP_PORT) || 9230; +export const NATIVE_DEVTOOLS_TCP_PORT = Number(process.env.NATIVE_DEVTOOLS_TCP_PORT) || 9230; const execFileAsync = promisify(execFile); From 4b666727b40144f8bf641a84351c75f8c8d4d417 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Fri, 29 May 2026 11:09:43 +0200 Subject: [PATCH 3/9] initial sim-remote support --- package-lock.json | 1021 ++++++++++++++++- packages/argent-mcp/src/content.ts | 17 +- packages/argent/package.json | 2 + packages/argent/scripts/bundle-tools.cjs | 12 + packages/registry/src/types.ts | 13 +- packages/tool-server/package.json | 9 +- .../tool-server/src/blueprints/ax-service.ts | 75 +- .../src/blueprints/native-devtools.ts | 63 +- .../src/blueprints/simulator-server.ts | 65 ++ .../tool-server/src/proto/datachannel.proto | 85 ++ .../tool-server/src/tools/button/index.ts | 1 + .../tool-server/src/tools/describe/index.ts | 9 + .../src/tools/devices/boot-device.ts | 73 ++ .../src/tools/devices/list-devices.ts | 61 +- .../src/tools/gesture-custom/index.ts | 1 + .../src/tools/gesture-pinch/index.ts | 1 + .../src/tools/gesture-rotate/index.ts | 1 + .../src/tools/gesture-swipe/index.ts | 1 + .../src/tools/gesture-tap/index.ts | 1 + .../tool-server/src/tools/keyboard/index.ts | 1 + .../tool-server/src/tools/launch-app/index.ts | 8 +- .../tools/launch-app/platforms/ios-remote.ts | 18 + .../native-devtools/native-describe-screen.ts | 2 +- .../native-devtools/native-devtools-status.ts | 2 +- .../native-devtools/native-find-views.ts | 2 +- .../native-devtools/native-full-hierarchy.ts | 2 +- .../native-devtools/native-network-logs.ts | 2 +- .../native-user-interactable-view-at-point.ts | 2 +- .../native-devtools/native-view-at-point.ts | 2 +- .../tool-server/src/tools/open-url/index.ts | 3 + .../tools/open-url/platforms/ios-remote.ts | 11 + packages/tool-server/src/tools/paste/index.ts | 1 + .../src/tools/reinstall-app/index.ts | 3 + .../reinstall-app/platforms/ios-remote.ts | 29 + .../src/tools/restart-app/index.ts | 8 +- .../tools/restart-app/platforms/ios-remote.ts | 24 + .../tool-server/src/tools/rotate/index.ts | 1 + .../src/tools/run-sequence/index.ts | 1 + .../tool-server/src/tools/screenshot/index.ts | 1 + packages/tool-server/src/utils/capability.ts | 7 +- packages/tool-server/src/utils/check-deps.ts | 2 + .../src/utils/cross-platform-tool.ts | 25 + .../src/utils/datachannel-proto.ts | 209 ++++ packages/tool-server/src/utils/device-info.ts | 25 +- packages/tool-server/src/utils/moq-client.ts | 187 +++ packages/tool-server/src/utils/sim-remote.ts | 215 ++++ .../tool-server/src/utils/simulator-client.ts | 162 ++- 47 files changed, 2418 insertions(+), 48 deletions(-) create mode 100644 packages/tool-server/src/proto/datachannel.proto create mode 100644 packages/tool-server/src/tools/launch-app/platforms/ios-remote.ts create mode 100644 packages/tool-server/src/tools/open-url/platforms/ios-remote.ts create mode 100644 packages/tool-server/src/tools/reinstall-app/platforms/ios-remote.ts create mode 100644 packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts create mode 100644 packages/tool-server/src/utils/datachannel-proto.ts create mode 100644 packages/tool-server/src/utils/moq-client.ts create mode 100644 packages/tool-server/src/utils/sim-remote.ts diff --git a/package-lock.json b/package-lock.json index b121a3ce..eec27e35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -516,6 +516,90 @@ "node": ">=18" } }, + "node_modules/@fails-components/webtransport": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@fails-components/webtransport/-/webtransport-1.6.3.tgz", + "integrity": "sha512-eGeaUt1IMeDyHv1t5ocJg0e31e6w+jGDfuMZW+jNjztYkma5HHUIckmImTYwVXRIKepXe8IpQPYiFfGMCcOubA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/debug": "^4.1.7", + "bindings": "^1.5.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fails-components/webtransport-transport-http3-quiche": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@fails-components/webtransport-transport-http3-quiche/-/webtransport-transport-http3-quiche-1.6.3.tgz", + "integrity": "sha512-CRolXTxtwoMGHk/o4dsW1OlVgEfkjN9DFpNEqaYABuKegUYw41MAcS9Fvcr4mkYEvFjGeYKXB/Id7rmwQErHZw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/debug": "^4.1.7", + "bindings": "^1.5.0", + "cmake-js": "^8.0.0", + "debug": "^4.3.4", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fails-components/webtransport-transport-http3-quiche/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@fails-components/webtransport-transport-http3-quiche/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@fails-components/webtransport-transport-http3-quiche/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@fails-components/webtransport/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@fails-components/webtransport/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -528,6 +612,18 @@ "hono": "^4" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "dev": true, @@ -852,6 +948,63 @@ "node": ">= 0.6" } }, + "node_modules/@moq/lite": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@moq/lite/-/lite-0.2.4.tgz", + "integrity": "sha512-pd8AJl10EWjJYxy+rJJYlSJF+DFYudkMc1xkPtUUBzWCqiP9WD254oJo09xamu4ATXrDycg5IDRazqcvQTjZcg==", + "deprecated": "Renamed to @moq/net. See https://moq.dev", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@moq/qmux": "^0.0.6", + "@moq/signals": "^0.1.6", + "async-mutex": "^0.5.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@moq/net": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@moq/net/-/net-0.1.1.tgz", + "integrity": "sha512-HSyHlktvjt6hQoJYV2I9S7Ugcd6wdtQFwupIPFcYovNIEKCNiLEjSyhxPl/pETruJOu/y45CpNv29/4LN4gpag==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@moq/qmux": "^0.0.6", + "@moq/signals": "^0.1.6", + "async-mutex": "^0.5.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@moq/qmux": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@moq/qmux/-/qmux-0.0.6.tgz", + "integrity": "sha512-ISuGz05lUvf1hzHW3Aw3VnsGRJe1w9Qdog3LQ66KS+l+5mzQsPANvW8yOioEe1Z9dJO2G3sAHoGPnzwnsY9SIQ==", + "license": "(MIT OR Apache-2.0)" + }, + "node_modules/@moq/signals": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@moq/signals/-/signals-0.1.6.tgz", + "integrity": "sha512-ic7ttiz6dHXOPoVAfhz4K6LGT2LWdDGTi1x2u8sYSGZ5nOKGWfqDkwYcGvCPlcVQetn3PaeXYSPFiMAC6RO3tQ==", + "license": "(MIT OR Apache-2.0)", + "peerDependencies": { + "@types/react": "^19.1.8", + "react": "^19.0.0", + "solid-js": "^1.9.7" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -900,6 +1053,69 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -1239,6 +1455,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1290,9 +1515,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1543,6 +1773,30 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -1567,11 +1821,60 @@ "node": ">=12" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "license": "MIT", @@ -1594,6 +1897,30 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -1636,6 +1963,117 @@ "node": ">=18" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cmake-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", + "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "fs-extra": "^11.3.3", + "node-api-headers": "^1.8.0", + "rc": "1.2.8", + "semver": "^7.7.3", + "tar": "^7.5.6", + "url-join": "^4.0.1", + "which": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cmake-js/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/cmake-js/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/cmake-js/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/cmake-js/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "dev": true, @@ -1734,6 +2172,30 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "dev": true, @@ -1761,7 +2223,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1800,6 +2261,12 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "license": "MIT", @@ -1807,6 +2274,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -1845,6 +2321,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "license": "MIT" @@ -1883,6 +2368,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "dev": true, @@ -2020,6 +2514,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "license": "MIT", @@ -2074,11 +2574,31 @@ "node": ">= 0.6" } }, - "node_modules/fresh": { - "version": "0.5.2", + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=14.14" } }, "node_modules/fsevents": { @@ -2103,6 +2623,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -2136,6 +2665,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -2146,6 +2681,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "license": "MIT", @@ -2217,10 +2758,36 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -2237,6 +2804,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "license": "MIT" @@ -2266,6 +2842,18 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2527,6 +3115,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -2595,6 +3189,54 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "license": "MIT" @@ -2618,6 +3260,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "license": "MIT", @@ -2625,6 +3273,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "8.6.0", "license": "MIT", @@ -2632,6 +3292,12 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-api-headers": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz", + "integrity": "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==", + "license": "MIT" + }, "node_modules/node-gyp-build": { "version": "4.8.4", "license": "MIT", @@ -2764,6 +3430,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -2780,6 +3473,30 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -2791,6 +3508,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.2", "license": "BSD-3-Clause", @@ -2824,6 +3551,44 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "license": "MIT", @@ -3071,6 +3836,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -3106,6 +3916,50 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/superagent": { "version": "10.3.0", "dev": true, @@ -3178,6 +4032,56 @@ "node": ">=6.6.0" } }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -3315,9 +4219,19 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } }, "node_modules/type-is": { "version": "1.6.18", @@ -3344,9 +4258,17 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "license": "MIT", @@ -3354,6 +4276,18 @@ "node": ">= 0.8" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "license": "MIT", @@ -3579,6 +4513,23 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" @@ -3602,6 +4553,24 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -3617,6 +4586,33 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "dev": true, @@ -3647,6 +4643,8 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@fails-components/webtransport": "^1.6.3", + "@fails-components/webtransport-transport-http3-quiche": "^1.6.3", "@modelcontextprotocol/sdk": "^1.20.0" }, "bin": { @@ -4024,7 +5022,12 @@ "@argent/native-devtools-ios": "file:../native-devtools-ios", "@argent/registry": "file:../registry", "@clack/prompts": "^1.1.0", + "@fails-components/webtransport": "^1.6.3", + "@fails-components/webtransport-transport-http3-quiche": "^1.6.3", + "@moq/lite": "^0.2.4", + "@moq/net": "^0.1.1", "express": "^4.19.2", + "protobufjs": "^7.6.1", "semver": "^7.7.4", "source-map-js": "^1.2.1", "tree-sitter": "^0.21.1", diff --git a/packages/argent-mcp/src/content.ts b/packages/argent-mcp/src/content.ts index bbe3eba3..636b6680 100644 --- a/packages/argent-mcp/src/content.ts +++ b/packages/argent-mcp/src/content.ts @@ -14,11 +14,22 @@ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0 // Without this check, a 404 (file missing), an HTML error page, or any other // non-PNG response would be base64'd and labelled `image/png`, which the // model API rejects with "Image could not be processed" (issue #255). +// +// `file://` URLs are handled directly via the fs module — Node's built-in +// `fetch` only supports `http(s)://`, and the ios-remote screenshot path +// writes PNGs to a temp dir and returns a `file://` URL. async function fetchPngBytes(url: string): Promise { try { - const res = await fetch(url); - if (!res.ok) return null; - const buf = Buffer.from(await res.arrayBuffer()); + let buf: Buffer; + if (url.startsWith("file://")) { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + buf = await readFile(fileURLToPath(url)); + } else { + const res = await fetch(url); + if (!res.ok) return null; + buf = Buffer.from(await res.arrayBuffer()); + } if (buf.length < PNG_SIGNATURE.length) return null; if (!buf.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return null; return buf; diff --git a/packages/argent/package.json b/packages/argent/package.json index f3276205..f5d464ca 100644 --- a/packages/argent/package.json +++ b/packages/argent/package.json @@ -41,6 +41,8 @@ "scripts/postinstall.cjs" ], "dependencies": { + "@fails-components/webtransport": "^1.6.3", + "@fails-components/webtransport-transport-http3-quiche": "^1.6.3", "@modelcontextprotocol/sdk": "^1.20.0" }, "devDependencies": { diff --git a/packages/argent/scripts/bundle-tools.cjs b/packages/argent/scripts/bundle-tools.cjs index ee8aa232..d24b3dbf 100644 --- a/packages/argent/scripts/bundle-tools.cjs +++ b/packages/argent/scripts/bundle-tools.cjs @@ -76,6 +76,17 @@ for (const dir of [BIN_DIR, DYLIBS_DEST, SKILLS_DEST, RULES_DEST, AGENTS_DEST]) fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); // Bundle the tools server +// +// `@fails-components/webtransport` and its http3-quiche transport ship native +// addons (quiche, prebuilt .node binaries) that can't be inlined into a single +// CJS bundle. Keep them external so npm resolves them from the published +// package's `node_modules/` at runtime — they're listed in +// `@swmansion/argent`'s `dependencies` so they install alongside the package. +const TOOL_SERVER_EXTERNAL = [ + "@fails-components/webtransport", + "@fails-components/webtransport-transport-http3-quiche", +]; + esbuild.buildSync({ entryPoints: [TOOLS_ENTRY], bundle: true, @@ -85,6 +96,7 @@ esbuild.buildSync({ outfile: OUT_FILE, alias: ALIASES, mainFields: MAIN_FIELDS, + external: TOOL_SERVER_EXTERNAL, }); console.log(`✓ Bundled tools server → ${path.relative(process.cwd(), OUT_FILE)}`); diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index 87c5c109..92351f3a 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -62,7 +62,7 @@ export interface InvokeToolOptions { // ── Device + Capability Types ── -export type Platform = "ios" | "android"; +export type Platform = "ios" | "android" | "ios-remote"; export type DeviceKind = "simulator" | "emulator" | "device" | "unknown"; @@ -90,6 +90,15 @@ export interface ToolCapability { simulator?: boolean; device?: boolean; }; + /** + * Remote-iOS support, driven via `sim-remote`. Independent matrix from + * `apple` because remote sims have different host-binary requirements + * (`sim-remote` instead of `xcrun`) and a different transport stack + * (MoQ + TCP proxy instead of local WebSocket + Unix sockets). + */ + appleRemote?: { + simulator?: boolean; + }; android?: { emulator?: boolean; device?: boolean; @@ -119,7 +128,7 @@ export interface ToolCapability { * On a missing binary, the HTTP layer returns 424 Failed Dependency with an * install hint the agent can surface verbatim. */ -export type ToolDependency = "adb" | "xcrun" | "emulator"; +export type ToolDependency = "adb" | "xcrun" | "emulator" | "sim-remote"; // ── Tool Types ── diff --git a/packages/tool-server/package.json b/packages/tool-server/package.json index e734b62a..ec501eeb 100644 --- a/packages/tool-server/package.json +++ b/packages/tool-server/package.json @@ -13,11 +13,16 @@ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { - "@argent/registry": "file:../registry", - "@argent/native-devtools-ios": "file:../native-devtools-ios", "@argent/native-devtools-android": "file:../native-devtools-android", + "@argent/native-devtools-ios": "file:../native-devtools-ios", + "@argent/registry": "file:../registry", "@clack/prompts": "^1.1.0", + "@fails-components/webtransport": "^1.6.3", + "@fails-components/webtransport-transport-http3-quiche": "^1.6.3", + "@moq/lite": "^0.2.4", + "@moq/net": "^0.1.1", "express": "^4.19.2", + "protobufjs": "^7.6.1", "semver": "^7.7.4", "source-map-js": "^1.2.1", "tree-sitter": "^0.21.1", diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index 3ea0c01f..90c65529 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -4,6 +4,7 @@ import * as fsAsync from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import * as readline from "node:readline"; +import { EventEmitter } from "node:events"; import { promisify } from "node:util"; import { execFile, ChildProcess } from "node:child_process"; import { @@ -15,6 +16,11 @@ import { } from "@argent/registry"; import { axServiceBinaryPath, axServiceBinaryPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; +import { + proxyStart as simRemoteProxyStart, + proxyStop as simRemoteProxyStop, + setupAxService as simRemoteSetupAxService, +} from "../utils/sim-remote"; const execFileAsync = promisify(execFile); @@ -209,7 +215,44 @@ function startListener( }); } -function spawnDaemon(udid: string, endpoint: AXEndpoint): ChildProcess { +/** + * Local-iOS preflight: ensure AutomationEnabled and probe whether the + * entitlement bypass is active. Returns true when the bypass is in place + * (describe is non-degraded). Skipped on ios-remote — sim-remote owns the + * equivalent setup there. + */ +async function runLocalAxBootstrap(udid: string): Promise { + await ensureAutomationEnabled(udid); + return isEntitlementBypassActive(udid); +} + +function spawnDaemon( + udid: string, + endpoint: AXEndpoint, + options?: { remote?: boolean } +): ChildProcess { + if (options?.remote) { + // ios-remote: the orchestrator-supplied daemon is started by + // `sim-remote setup ax-service`. There is no local child process to + // shepherd — we return a no-op ChildProcess stub so the surrounding + // factory code (exit/error wiring, kill on dispose) still type-checks. + if (endpoint.transport !== "tcp") { + throw new Error("ios-remote ax-service requires TCP transport"); + } + // Fire-and-forget: `sim-remote setup ax-service` returns once the daemon + // has been started inside the remote simulator. We propagate failures + // through the proc's "error" event so factory teardown engages normally. + const noop = new EventEmitter() as unknown as ChildProcess; + (noop as unknown as { kill: () => boolean }).kill = () => true; + void simRemoteSetupAxService(udid, { port: endpoint.port, timeoutSecs: 3600 }).catch( + (err: Error) => { + // Defer the emit so listeners attached after this call still see it. + setImmediate(() => noop.emit("error", err)); + } + ); + return noop; + } + const binaryPath = endpoint.transport === "tcp" ? axServiceBinaryPathTcp() : axServiceBinaryPath(); @@ -304,7 +347,7 @@ export const axServiceBlueprint: ServiceBlueprint = { } const { device } = opts; - if (device.platform !== "ios") { + if (device.platform !== "ios" && device.platform !== "ios-remote") { throw new Error( `${AX_SERVICE_NAMESPACE} is iOS-only. The target '${device.id}' classifies as Android — describe falls back to uiautomator on Android, which does not need this service.` ); @@ -320,7 +363,10 @@ export const axServiceBlueprint: ServiceBlueprint = { } const udid = device.id; - const transport: AXServiceTransport = opts.transport ?? "unix"; + const isRemote = device.platform === "ios-remote"; + // Force TCP on remote — unix sockets do not bridge across the QUIC tunnel + // sim-remote sets up between the orchestrator and the dev's machine. + const transport: AXServiceTransport = isRemote ? "tcp" : (opts.transport ?? "unix"); const endpoint: AXEndpoint = transport === "tcp" ? { transport: "tcp", port: AX_SERVICE_TCP_PORT } @@ -340,8 +386,12 @@ export const axServiceBlueprint: ServiceBlueprint = { pendingRpc.clear(); }; - await ensureAutomationEnabled(udid); - const entitlementBypassActive = await isEntitlementBypassActive(udid); + // ios-remote skips local xcrun calls — sim-remote applies the + // accessibility defaults at boot via `setup accessibility-defaults`, + // and the entitlement-bypass plist is managed there too. We mark the + // service as non-degraded on the assumption sim-remote did the right + // thing; if not, describe will still surface useful errors. + const entitlementBypassActive = isRemote ? true : await runLocalAxBootstrap(udid); // Host listens first, then we spawn the daemon and wait for it to dial in. const server = await startListener(endpoint, (socket) => { @@ -390,7 +440,14 @@ export const axServiceBlueprint: ServiceBlueprint = { }); }); - const proc = spawnDaemon(udid, endpoint); + if (isRemote && endpoint.transport === "tcp") { + // Wire the reverse tunnel BEFORE asking the orchestrator to start the + // daemon — the daemon will dial 127.0.0.1: inside the simulator + // and that dial gets QUIC-forwarded back to our host listener above. + await simRemoteProxyStart(udid, endpoint.port); + } + + const proc = spawnDaemon(udid, endpoint, { remote: isRemote }); proc.on("exit", (code) => { if (disposed) return; @@ -415,6 +472,9 @@ export const axServiceBlueprint: ServiceBlueprint = { fs.unlinkSync(endpoint.socketPath); } catch {} } + if (isRemote && endpoint.transport === "tcp") { + await simRemoteProxyStop(udid, endpoint.port); + } throw err; } @@ -480,6 +540,9 @@ export const axServiceBlueprint: ServiceBlueprint = { fs.unlinkSync(endpoint.socketPath); } catch {} } + if (isRemote && endpoint.transport === "tcp") { + await simRemoteProxyStop(udid, endpoint.port); + } }, events, }; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 1b009111..1215c470 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -12,6 +12,12 @@ import { } from "@argent/registry"; import { bootstrapDylibPath, bootstrapDylibPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; +import { + proxyStart as simRemoteProxyStart, + proxyStop as simRemoteProxyStop, + setupNativeDevtools as simRemoteSetupNativeDevtools, + setupRunningBundleIds as simRemoteRunningBundleIds, +} from "../utils/sim-remote"; export type NativeDevtoolsTransport = "unix" | "tcp"; @@ -320,6 +326,36 @@ async function ensureEnv( await ensureAccessibilityEnabled(udid); } +/** + * Bare basename of the bootstrap dylib the orchestrator should inject. The + * dylib lives on the orchestrator side (it's the TCP variant of the local + * `libArgentInjectionBootstrap.dylib`) and `sim-remote setup native-devtools` + * resolves it by basename against the orchestrator's own dylib directory — + * we never need a local copy. Hardcoding the basename avoids a + * `bootstrapDylibPathTcp()` lookup that would throw on dev machines that + * don't ship the local TCP variant. + */ +const REMOTE_BOOTSTRAP_DYLIB_BASENAME = "libArgentInjectionBootstrap.dylib"; + +/** + * Remote analogue of `ensureEnv` for ios-remote devices. Instead of poking + * launchd via `xcrun simctl spawn`, we delegate dylib injection and + * environment setup to the orchestrator via `sim-remote setup native-devtools`. + * + * The dylib dials `127.0.0.1:` inside the simulator — that + * connection arrives on our host listener via the QUIC reverse tunnel that + * the caller must have already wired with `simRemoteProxyStart`. + */ +async function ensureEnvRemote( + udid: string, + endpoint: { transport: "tcp"; port: number } +): Promise { + await simRemoteSetupNativeDevtools(udid, { + libs: [REMOTE_BOOTSTRAP_DYLIB_BASENAME], + cdpPort: endpoint.port, + }); +} + async function listRunningUIKitApplicationBundleIds(udid: string): Promise> { const { stdout } = await execFileAsync("xcrun", ["simctl", "spawn", udid, "launchctl", "list"], { encoding: "utf8", @@ -352,12 +388,15 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint ensureEnvRemote(udid, endpoint as { transport: "tcp"; port: number }) + : () => ensureEnv(udid, endpoint); + inFlight = Promise.resolve() - .then(() => ensureEnv(udid, endpoint)) + .then(setup) .then(() => { envSetup = true; initFailure = null; @@ -418,7 +461,9 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint => { - const runningBundleIds = await listRunningUIKitApplicationBundleIds(udid); + const runningBundleIds = isRemote + ? new Set(await simRemoteRunningBundleIds(udid)) + : await listRunningUIKitApplicationBundleIds(udid); return runningBundleIds.has(bundleId); }; @@ -548,6 +593,13 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint ` command). */ pressKey(direction: "Down" | "Up", keyCode: number): void; + /** + * Optional alternate transport. Set by the remote (MoQ) blueprint so that + * the shared `sendCommand` / `httpScreenshot` helpers in `simulator-client.ts` + * route through MoQ instead of WebSocket + HTTP. Undefined for local sims. + */ + transport?: import("../utils/simulator-client").SimulatorServerTransport; +} + +/** + * Build the SimulatorServerApi for an ios-remote device. The MoQ client + * connects to the remote simulator-server via WebTransport pinned to the + * fingerprint returned by `sim-remote moq-info`, and a MoQ-backed transport + * is attached so the shared `sendCommand` / `httpScreenshot` helpers route + * touch/screenshot/etc through MoQ instead of the local WS+HTTP path. + */ +async function buildRemoteInstance( + device: DeviceInfo +): Promise> { + const moq = await openMoqClient(device.id); + const events = new TypedEventEmitter(); + + const transport = createMoqTransport(moq, { + pasteText: async (text: string) => { + await simctlPbcopy(device.id, text); + // USB HID keyboard usage ids: 0xE3 = Left GUI (Cmd), 0x19 = V. + // Trigger Cmd+V on the remote sim to fire the actual paste. + const CMD = 0xe3; + const V = 0x19; + await moq.sendControl(encodeKey({ action: "Down", code: CMD })); + await moq.sendControl(encodeKey({ action: "Down", code: V })); + await moq.sendControl(encodeKey({ action: "Up", code: V })); + await moq.sendControl(encodeKey({ action: "Up", code: CMD })); + }, + }); + + // Local sims expose apiUrl/streamUrl as HTTP/WS endpoints; nothing remote + // analogue exists since input/screenshot/video are all in MoQ. Fill these + // with a tagged stub so the few places that read them log clearly instead + // of silently dialing a nonexistent local port. + const stubUrl = `moq+remote://${device.id}`; + + const api: SimulatorServerApi = { + apiUrl: stubUrl, + streamUrl: stubUrl, + pressKey: (direction, keyCode) => { + void moq.sendControl(encodeKey({ action: direction, code: keyCode })); + }, + transport, + }; + + return { + api, + dispose: async () => { + await moq.close(); + }, + events, + }; } function spawnSimulatorServerProcess( @@ -162,6 +223,10 @@ export const simulatorServerBlueprint: ServiceBlueprint {}); } else { diff --git a/packages/tool-server/src/proto/datachannel.proto b/packages/tool-server/src/proto/datachannel.proto new file mode 100644 index 00000000..7eae173f --- /dev/null +++ b/packages/tool-server/src/proto/datachannel.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package datachannel; + +enum TouchAction { + TOUCH_DOWN = 0; + TOUCH_UP = 1; + TOUCH_MOVE = 2; +} + +enum KeyAction { + KEY_DOWN = 0; + KEY_UP = 1; +} + +enum ButtonType { + BUTTON_HOME = 0; + BUTTON_BACK = 1; + BUTTON_POWER = 2; + BUTTON_VOLUME_UP = 3; + BUTTON_VOLUME_DOWN = 4; + BUTTON_APP_SWITCH = 5; + BUTTON_ACTION = 6; +} + +enum RotateDirection { + ROTATE_PORTRAIT = 0; + ROTATE_PORTRAIT_UPSIDE_DOWN = 1; + ROTATE_LANDSCAPE_LEFT = 2; + ROTATE_LANDSCAPE_RIGHT = 3; +} + +message TouchCommand { + TouchAction action = 1; + double x = 2; + double y = 3; + optional double second_x = 4; + optional double second_y = 5; +} + +message KeyCommand { + KeyAction action = 1; + int32 code = 2; +} + +message ButtonCommand { + KeyAction action = 1; + ButtonType button = 2; +} + +message RotateCommand { + RotateDirection direction = 1; +} + +message WheelCommand { + double x = 1; + double y = 2; + double dx = 3; + double dy = 4; +} + +enum DownscalerType { + DOWNSCALER_LANCZOS3 = 0; + DOWNSCALER_BOX = 1; + DOWNSCALER_BILINEAR = 2; + DOWNSCALER_NEAREST = 3; +} + +message ScreenshotCommand { + optional string id = 1; + optional RotateDirection rotation = 2; + optional float scale = 3; + optional DownscalerType downscaler = 4; +} + +message DataChannelCommand { + oneof command { + TouchCommand touch = 1; + KeyCommand key = 2; + ButtonCommand button = 3; + RotateCommand rotate = 4; + WheelCommand wheel = 5; + ScreenshotCommand screenshot = 6; + } +} diff --git a/packages/tool-server/src/tools/button/index.ts b/packages/tool-server/src/tools/button/index.ts index 04e9c4b5..84ffd0c5 100644 --- a/packages/tool-server/src/tools/button/index.ts +++ b/packages/tool-server/src/tools/button/index.ts @@ -21,6 +21,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/describe/index.ts b/packages/tool-server/src/tools/describe/index.ts index 8997a202..d09483db 100644 --- a/packages/tool-server/src/tools/describe/index.ts +++ b/packages/tool-server/src/tools/describe/index.ts @@ -40,6 +40,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -93,6 +94,14 @@ For React Native apps, debugger-component-tree returns React component names wit handler: async (_services, params) => withDescription(await describeAndroid(registry, params.udid, params.bundleId)), }, + iosRemote: { + // describeIos already handles both ax-service (TCP) and native-devtools + // fallback — both blueprints route through sim-remote when the device + // is ios-remote. Only the preflight dep differs. + requires: ["sim-remote"], + handler: async (_services, params, device) => + withDescription(await describeIos(registry, device, params)), + }, }), }; } diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index 2cb3098a..3431e218 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -25,6 +25,14 @@ import { } from "../../utils/adb"; import { ensureDep } from "../../utils/check-deps"; import { listIosSimulators } from "../../utils/ios-devices"; +import { classifyDevice, stripRemotePrefix } from "../../utils/device-info"; +import { + simctlBoot as simRemoteBoot, + simctlBootstatus as simRemoteBootstatus, + simctlListDevices as simRemoteListDevices, + simctlShutdown as simRemoteShutdown, + setupAccessibilityDefaults as simRemoteSetupAccessibilityDefaults, +} from "../../utils/sim-remote"; const execFileAsync = promisify(execFile); @@ -67,6 +75,7 @@ type BootDeviceParams = z.infer; type BootDeviceResult = | { platform: "ios"; udid: string; booted: true } + | { platform: "ios-remote"; udid: string; booted: true } | { platform: "android"; serial: string; avdName: string; booted: true } | NativeDevtoolsInitFailedResult; @@ -266,6 +275,66 @@ async function bootIos( return { platform: "ios", udid, booted: true }; } +/** + * Boot a remote iOS simulator through `sim-remote`. Mirrors `bootIos` but: + * + * - Uses `sim-remote simctl` for boot/shutdown/bootstatus (no local xcrun). + * - Applies accessibility defaults via the orchestrator's + * `sim-remote setup accessibility-defaults` rather than the host pre-boot + * plist write (we have no filesystem access to the remote sim). + * - Pre-warms the native-devtools blueprint so the dylib injection env is + * set inside the remote sim before the app launches. + */ +async function bootIosRemote( + id: string, + registry: Registry, + force?: boolean +): Promise<{ platform: "ios-remote"; udid: string; booted: true } | NativeDevtoolsInitFailedResult> { + await ensureDep("sim-remote"); + const udid = stripRemotePrefix(id); + + // Look up current state via sim-remote. Treat lookup failures as "unknown + // state" — the boot/bootstatus dance below tolerates an already-booted sim. + let simState: string | undefined; + try { + const list = await simRemoteListDevices(); + outer: for (const devices of Object.values(list.devices)) { + for (const d of devices) { + if (d.udid === udid) { + simState = d.state; + break outer; + } + } + } + } catch { + simState = undefined; + } + + if (force && simState === "Booted") { + await simRemoteShutdown(id).catch(() => undefined); + } + + // Boot. `Booted` exit-code error from sim-remote means it's already up — + // benign; the bootstatus call below normalizes the state regardless. + await simRemoteBoot(id).catch((err: Error) => { + if (!/Booted/i.test(err.message)) throw err; + }); + await simRemoteBootstatus(id, { boot: true }); + + // Idempotent: re-applies the three AX defaults every boot in case the + // orchestrator's simulator was wiped between sessions. + await simRemoteSetupAccessibilityDefaults(id).catch(() => undefined); + + const ndRef = nativeDevtoolsRef({ id, platform: "ios-remote", kind: "simulator" }); + const ndApi = await registry.resolveService(ndRef.urn, ndRef.options); + const initFailure = ndApi.getInitFailure(); + if (initFailure?.givenUp) { + return buildInitFailedResult(id, initFailure); + } + + return { platform: "ios-remote", udid: id, booted: true }; +} + // Tight budget for a hot boot attempt. A successful hot boot completes well // under 15 s on fast hardware and under ~45 s on a cold host page cache; the // 90 s ceiling exists to bound the pathological case where snapshot load @@ -725,6 +794,7 @@ function createEarlyExitRacer(getExit: () => Error | null): { // xcrun, etc., and so `list-devices` consumers can rely on uniform metadata. const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -750,6 +820,9 @@ Android boots take 2–10 minutes depending on machine and cold/warm state; the throw new Error("Provide exactly one of `udid` (iOS) or `avdName` (Android)."); } if (hasUdid) { + if (classifyDevice(params.udid!) === "ios-remote") { + return bootIosRemote(params.udid!, registry, params.force); + } return bootIos(params.udid!, registry, params.force); } return bootAndroid({ diff --git a/packages/tool-server/src/tools/devices/list-devices.ts b/packages/tool-server/src/tools/devices/list-devices.ts index 6057859e..7575c4e8 100644 --- a/packages/tool-server/src/tools/devices/list-devices.ts +++ b/packages/tool-server/src/tools/devices/list-devices.ts @@ -2,9 +2,19 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { listAndroidDevices, listAvds } from "../../utils/adb"; import { listIosSimulators, type IosSimulator } from "../../utils/ios-devices"; +import { simctlListDevices } from "../../utils/sim-remote"; +import { withRemotePrefix } from "../../utils/device-info"; type IosDevice = IosSimulator & { platform: "ios" }; +type IosRemoteDevice = { + platform: "ios-remote"; + udid: string; + name: string; + state: string; + runtime: string; +}; + type AndroidDevice = { platform: "android"; serial: string; @@ -16,7 +26,7 @@ type AndroidDevice = { }; type ListDevicesResult = { - devices: Array; + devices: Array; avds: Array<{ name: string }>; }; @@ -40,9 +50,42 @@ 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): number { - if (d.platform === "ios") return d.state === "Booted" ? 0 : 1; - return d.state === "device" ? 0 : 1; +function readinessRank(d: IosDevice | IosRemoteDevice | AndroidDevice): number { + if (d.platform === "android") return d.state === "device" ? 0 : 1; + return d.state === "Booted" ? 0 : 1; +} + +/** + * List remote iOS simulators via `sim-remote`. Returns [] (silently) if + * sim-remote isn't installed or the user isn't logged in — list-devices + * already treats CLI absence as "platform unavailable" rather than failing. + */ +async function listRemoteIosSimulators(): Promise { + try { + const result = await simctlListDevices(); + const out: IosRemoteDevice[] = []; + for (const [runtime, devices] of Object.entries(result.devices)) { + for (const d of devices) { + if (d.isAvailable === false) continue; + out.push({ + platform: "ios-remote", + udid: withRemotePrefix(d.udid), + name: d.name, + state: d.state, + runtime, + }); + } + } + return out; + } catch { + return []; + } +} + +function sortIosRemote(a: IosRemoteDevice, b: IosRemoteDevice): number { + const aBooted = a.state === "Booted" ? 0 : 1; + const bBooted = b.state === "Booted" ? 0 : 1; + return aBooted - bBooted; } const zodSchema = z.object({}); @@ -58,13 +101,15 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si zodSchema, services: () => ({}), async execute(_services, _params) { - const [ios, android, avds] = await Promise.all([ + const [ios, iosRemote, android, avds] = await Promise.all([ listIosSimulators(), + listRemoteIosSimulators(), listAndroidDevices().catch(() => []), listAvds(), ]); const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); iosTagged.sort(sortIos); + iosRemote.sort(sortIosRemote); const androidTagged: AndroidDevice[] = android.map((d) => ({ platform: "android", serial: d.serial, @@ -76,7 +121,11 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si })); androidTagged.sort(sortAndroid); - const devices: Array = [...iosTagged, ...androidTagged]; + const devices: Array = [ + ...iosTagged, + ...iosRemote, + ...androidTagged, + ]; devices.sort((a, b) => readinessRank(a) - readinessRank(b)); return { devices, avds }; diff --git a/packages/tool-server/src/tools/gesture-custom/index.ts b/packages/tool-server/src/tools/gesture-custom/index.ts index 06d95aa4..14ea892a 100644 --- a/packages/tool-server/src/tools/gesture-custom/index.ts +++ b/packages/tool-server/src/tools/gesture-custom/index.ts @@ -50,6 +50,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/gesture-pinch/index.ts b/packages/tool-server/src/tools/gesture-pinch/index.ts index 9de8dc01..5efbcee3 100644 --- a/packages/tool-server/src/tools/gesture-pinch/index.ts +++ b/packages/tool-server/src/tools/gesture-pinch/index.ts @@ -50,6 +50,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/gesture-rotate/index.ts b/packages/tool-server/src/tools/gesture-rotate/index.ts index b9233463..d93b75e6 100644 --- a/packages/tool-server/src/tools/gesture-rotate/index.ts +++ b/packages/tool-server/src/tools/gesture-rotate/index.ts @@ -40,6 +40,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/gesture-swipe/index.ts b/packages/tool-server/src/tools/gesture-swipe/index.ts index 27a80f6f..579e2158 100644 --- a/packages/tool-server/src/tools/gesture-swipe/index.ts +++ b/packages/tool-server/src/tools/gesture-swipe/index.ts @@ -27,6 +27,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/gesture-tap/index.ts b/packages/tool-server/src/tools/gesture-tap/index.ts index 528fc183..94656faa 100644 --- a/packages/tool-server/src/tools/gesture-tap/index.ts +++ b/packages/tool-server/src/tools/gesture-tap/index.ts @@ -21,6 +21,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/keyboard/index.ts b/packages/tool-server/src/tools/keyboard/index.ts index ee0309f5..5461de32 100644 --- a/packages/tool-server/src/tools/keyboard/index.ts +++ b/packages/tool-server/src/tools/keyboard/index.ts @@ -32,6 +32,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/launch-app/index.ts b/packages/tool-server/src/tools/launch-app/index.ts index ff6cf4c6..3c9f3119 100644 --- a/packages/tool-server/src/tools/launch-app/index.ts +++ b/packages/tool-server/src/tools/launch-app/index.ts @@ -6,6 +6,7 @@ import { resolveDevice } from "../../utils/device-info"; import type { LaunchAppAndroidServices, LaunchAppIosServices, LaunchAppResult } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { iosRemoteImpl } from "./platforms/ios-remote"; // Android package grammar is `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+`; // iOS bundle ids use the same reverse-DNS shape with dashes allowed. The union @@ -43,6 +44,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -62,7 +64,10 @@ Common Android packages: com.android.settings, com.android.chrome, com.google.an // Resolving it on Android would force the iOS-only blueprint to spin up. services: (params): Record => { const device = resolveDevice(params.udid); - return device.platform === "ios" ? { nativeDevtools: nativeDevtoolsRef(device) } : {}; + if (device.platform === "ios" || device.platform === "ios-remote") { + return { nativeDevtools: nativeDevtoolsRef(device) }; + } + return {}; }, execute: dispatchByPlatform< LaunchAppIosServices, @@ -74,5 +79,6 @@ Common Android packages: com.android.settings, com.android.chrome, com.google.an capability, ios: iosImpl, android: androidImpl, + iosRemote: iosRemoteImpl, }), }; diff --git a/packages/tool-server/src/tools/launch-app/platforms/ios-remote.ts b/packages/tool-server/src/tools/launch-app/platforms/ios-remote.ts new file mode 100644 index 00000000..ac7ba824 --- /dev/null +++ b/packages/tool-server/src/tools/launch-app/platforms/ios-remote.ts @@ -0,0 +1,18 @@ +import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlLaunch as simRemoteLaunch } from "../../../utils/sim-remote"; +import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; + +/** + * Remote analogue of `iosImpl`. Routes through `sim-remote simctl launch` + * instead of `xcrun simctl launch`; the native-devtools precheck is shared. + */ +export const iosRemoteImpl: PlatformImpl = { + requires: ["sim-remote"], + handler: async (services, params) => { + const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); + if (blocked) return blocked; + await simRemoteLaunch(params.udid, params.bundleId); + return { launched: true, bundleId: params.bundleId }; + }, +}; diff --git a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts index d0fc98e6..5257fb51 100644 --- a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts +++ b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts @@ -41,7 +41,7 @@ type Result = export const nativeDescribeScreenTool: ToolDefinition = { id: "native-describe-screen", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Read the running app's native accessibility screen description via injected native devtools. Returns a flat list of accessibility leaf elements with: diff --git a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts index 5d53529a..b9befb02 100644 --- a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts +++ b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts @@ -27,7 +27,7 @@ type Result = export const nativeDevtoolsStatusTool: ToolDefinition = { id: "native-devtools-status", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Check whether native devtools are connected to a specific app and whether the next launch is prepared for injection. Use when you need to verify native devtools readiness before calling native-full-hierarchy, native-describe-screen, or native-network-logs. diff --git a/packages/tool-server/src/tools/native-devtools/native-find-views.ts b/packages/tool-server/src/tools/native-devtools/native-find-views.ts index fe5e7467..e402351b 100644 --- a/packages/tool-server/src/tools/native-devtools/native-find-views.ts +++ b/packages/tool-server/src/tools/native-devtools/native-find-views.ts @@ -44,7 +44,7 @@ type Result = export const nativeFindViewsTool: ToolDefinition = { id: "native-find-views", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Search for specific UIViews in the running app by class name, accessibility identifier, label, tag, or React Native nativeID. Use when you need to locate a specific view by its properties without dumping the entire hierarchy. Returns { status: "ok", matches } with matching views including their frames, properties, optional ancestors, and optional children. Much more targeted than native-full-hierarchy. diff --git a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts index b44ec811..9e1769d3 100644 --- a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts +++ b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts @@ -58,7 +58,7 @@ type Result = export const nativeFullHierarchyTool: ToolDefinition = { id: "native-full-hierarchy", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Get the complete UIKit view tree for the running app. WARNING: Output can be extremely large (100KB–500KB+) for complex apps, especially those built with SwiftUI. Prefer native-find-views for targeted queries. Use skipClasses / skipClassPrefixes to prune SwiftUI internal subtrees and reduce output size. Use the fields param to request only the properties you need. diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index adc7ccf6..4b618016 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -29,7 +29,7 @@ type Result = export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. diff --git a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts index 8ea724ce..9ea5c6d8 100644 --- a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts @@ -63,7 +63,7 @@ type Result = export const nativeUserInteractableViewAtPointTool: ToolDefinition = { id: "native-user-interactable-view-at-point", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Inspect the deepest UIView at a raw native window point that would actually receive touch input. Unlike native-view-at-point, this respects userInteractionEnabled and is closer to diff --git a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts index 9242a023..aa801f1a 100644 --- a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts @@ -63,7 +63,7 @@ type Result = export const nativeViewAtPointTool: ToolDefinition = { id: "native-view-at-point", requires: ["xcrun"], - capability: { apple: { simulator: true, device: true } }, + capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Inspect the deepest visible UIView at a raw native window point. Unlike native-user-interactable-view-at-point, this ignores userInteractionEnabled, diff --git a/packages/tool-server/src/tools/open-url/index.ts b/packages/tool-server/src/tools/open-url/index.ts index 8023cadd..3face914 100644 --- a/packages/tool-server/src/tools/open-url/index.ts +++ b/packages/tool-server/src/tools/open-url/index.ts @@ -4,6 +4,7 @@ import { dispatchByPlatform } from "../../utils/cross-platform-tool"; import type { OpenUrlResult, OpenUrlServices } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { iosRemoteImpl } from "./platforms/ios-remote"; const zodSchema = z.object({ udid: z @@ -21,6 +22,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -38,5 +40,6 @@ Returns { opened, url }. Fails if no app is registered to handle the URI.`, capability, ios: iosImpl, android: androidImpl, + iosRemote: iosRemoteImpl, }), }; diff --git a/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts b/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts new file mode 100644 index 00000000..9d92bb38 --- /dev/null +++ b/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts @@ -0,0 +1,11 @@ +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlOpenUrl } from "../../../utils/sim-remote"; +import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; + +export const iosRemoteImpl: PlatformImpl = { + requires: ["sim-remote"], + handler: async (_services, params) => { + await simctlOpenUrl(params.udid, params.url); + return { opened: true, url: params.url }; + }, +}; diff --git a/packages/tool-server/src/tools/paste/index.ts b/packages/tool-server/src/tools/paste/index.ts index 0ec050cb..c0ec32e2 100644 --- a/packages/tool-server/src/tools/paste/index.ts +++ b/packages/tool-server/src/tools/paste/index.ts @@ -20,6 +20,7 @@ interface Result { // itself is iOS-only — no platforms/ split needed. const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, }; export const pasteTool: ToolDefinition = { diff --git a/packages/tool-server/src/tools/reinstall-app/index.ts b/packages/tool-server/src/tools/reinstall-app/index.ts index 1f4b6b2a..131158f5 100644 --- a/packages/tool-server/src/tools/reinstall-app/index.ts +++ b/packages/tool-server/src/tools/reinstall-app/index.ts @@ -4,6 +4,7 @@ import { dispatchByPlatform } from "../../utils/cross-platform-tool"; import type { ReinstallAppResult, ReinstallAppServices } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { iosRemoteImpl } from "./platforms/ios-remote"; const zodSchema = z.object({ udid: z @@ -26,6 +27,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -47,5 +49,6 @@ Returns { reinstalled, bundleId }. Fails if the app path does not exist or the p capability, ios: iosImpl, android: androidImpl, + iosRemote: iosRemoteImpl, }), }; diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/ios-remote.ts b/packages/tool-server/src/tools/reinstall-app/platforms/ios-remote.ts new file mode 100644 index 00000000..69c13738 --- /dev/null +++ b/packages/tool-server/src/tools/reinstall-app/platforms/ios-remote.ts @@ -0,0 +1,29 @@ +import { resolve as resolvePath } from "node:path"; +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlInstall, simctlUninstall } from "../../../utils/sim-remote"; +import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; + +/** + * Remote analogue of the iOS impl. `sim-remote simctl install` uploads the + * local `.app` to the orchestrator over QUIC, so this works against a remote + * sim with no extra staging — the developer points at the same on-disk path + * they'd use locally. + */ +export const iosRemoteImpl: PlatformImpl< + ReinstallAppServices, + ReinstallAppParams, + ReinstallAppResult +> = { + requires: ["sim-remote"], + handler: async (_services, params) => { + const { udid, bundleId, appPath } = params; + const absolute = resolvePath(appPath); + try { + await simctlUninstall(udid, bundleId); + } catch { + // App may not be installed — continue to install. + } + await simctlInstall(udid, absolute); + return { reinstalled: true, bundleId }; + }, +}; diff --git a/packages/tool-server/src/tools/restart-app/index.ts b/packages/tool-server/src/tools/restart-app/index.ts index 76767393..f11ff596 100644 --- a/packages/tool-server/src/tools/restart-app/index.ts +++ b/packages/tool-server/src/tools/restart-app/index.ts @@ -6,6 +6,7 @@ import { resolveDevice } from "../../utils/device-info"; import type { RestartAppAndroidServices, RestartAppIosServices, RestartAppResult } from "./types"; import { iosImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; +import { iosRemoteImpl } from "./platforms/ios-remote"; // Bundle id / package name. Head must be letter or underscore so a bundleId // like `--user` can't masquerade as a flag inside `am force-stop …`. @@ -38,6 +39,7 @@ type Params = z.infer; const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; @@ -53,7 +55,10 @@ 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) } : {}; + if (device.platform === "ios" || device.platform === "ios-remote") { + return { nativeDevtools: nativeDevtoolsRef(device) }; + } + return {}; }, execute: dispatchByPlatform< RestartAppIosServices, @@ -65,5 +70,6 @@ Returns { restarted, bundleId }. Fails if the app is not installed.`, capability, ios: iosImpl, android: androidImpl, + iosRemote: iosRemoteImpl, }), }; diff --git a/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts b/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts new file mode 100644 index 00000000..903cc6b4 --- /dev/null +++ b/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts @@ -0,0 +1,24 @@ +import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlLaunch, simctlTerminate } from "../../../utils/sim-remote"; +import type { RestartAppIosServices, RestartAppParams, RestartAppResult } from "../types"; + +export const iosRemoteImpl: PlatformImpl< + RestartAppIosServices, + RestartAppParams, + RestartAppResult +> = { + requires: ["sim-remote"], + handler: async (services, params) => { + const { udid, bundleId } = params; + const blocked = await precheckNativeDevtools(services.nativeDevtools, udid); + if (blocked) return blocked; + try { + await simctlTerminate(udid, bundleId); + } catch { + // App may not be running — ignore. + } + await simctlLaunch(udid, bundleId); + return { restarted: true, bundleId }; + }, +}; diff --git a/packages/tool-server/src/tools/rotate/index.ts b/packages/tool-server/src/tools/rotate/index.ts index 263bd3d6..840f8f00 100644 --- a/packages/tool-server/src/tools/rotate/index.ts +++ b/packages/tool-server/src/tools/rotate/index.ts @@ -19,6 +19,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/run-sequence/index.ts b/packages/tool-server/src/tools/run-sequence/index.ts index fa2708eb..b768af82 100644 --- a/packages/tool-server/src/tools/run-sequence/index.ts +++ b/packages/tool-server/src/tools/run-sequence/index.ts @@ -61,6 +61,7 @@ type RunSequenceResult = { // failure mode is consistent. const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/tools/screenshot/index.ts b/packages/tool-server/src/tools/screenshot/index.ts index e29850de..63f35d97 100644 --- a/packages/tool-server/src/tools/screenshot/index.ts +++ b/packages/tool-server/src/tools/screenshot/index.ts @@ -29,6 +29,7 @@ interface Result { const capability: ToolCapability = { apple: { simulator: true, device: true }, + appleRemote: { simulator: true }, android: { emulator: true, device: true, unknown: true }, }; diff --git a/packages/tool-server/src/utils/capability.ts b/packages/tool-server/src/utils/capability.ts index db29092a..c2d5b892 100644 --- a/packages/tool-server/src/utils/capability.ts +++ b/packages/tool-server/src/utils/capability.ts @@ -71,7 +71,12 @@ export function assertSupported( device: DeviceInfo ): void { if (!capability) return; - const matrix = device.platform === "ios" ? capability.apple : capability.android; + const matrix = + device.platform === "ios" + ? capability.apple + : device.platform === "ios-remote" + ? capability.appleRemote + : capability.android; if (!matrix) { throw new UnsupportedOperationError(toolId, device, `no ${device.platform} support declared`); } diff --git a/packages/tool-server/src/utils/check-deps.ts b/packages/tool-server/src/utils/check-deps.ts index 53e729d0..996e8ea6 100644 --- a/packages/tool-server/src/utils/check-deps.ts +++ b/packages/tool-server/src/utils/check-deps.ts @@ -36,6 +36,8 @@ const INSTALL_HINTS: Record = { adb: "Android SDK Platform Tools not found. Install with `brew install --cask android-platform-tools` or via Android Studio → SDK Manager. If installed, ensure `adb` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/platform-tools/adb`). Only required for Android devices and emulators.", emulator: "Android Emulator not found. Install via Android Studio → SDK Manager → Emulator, or `sdkmanager 'emulator'`. If installed, ensure `emulator` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/emulator/emulator`). Only required to launch new Android emulators via `boot-device`.", + "sim-remote": + "`sim-remote` CLI not found on PATH. Install via the radon-cloud project (see its README) and run `sim-remote login` before invoking any ios-remote tool. Only required for remote iOS simulators.", }; async function probe(dep: ToolDependency): Promise { diff --git a/packages/tool-server/src/utils/cross-platform-tool.ts b/packages/tool-server/src/utils/cross-platform-tool.ts index 6f922c7a..cd5043e5 100644 --- a/packages/tool-server/src/utils/cross-platform-tool.ts +++ b/packages/tool-server/src/utils/cross-platform-tool.ts @@ -52,11 +52,19 @@ export function dispatchByPlatform< AndroidServices, Params extends { udid: string }, Result, + IosRemoteServices = IosServices, >(opts: { toolId: string; capability: ToolCapability; ios: PlatformImpl; android: PlatformImpl; + /** + * Optional ios-remote branch. When omitted, an ios-remote device will hit + * `assertSupported` and fail there if the tool's capability matrix doesn't + * include `appleRemote` — so adding ios-remote support is two changes (this + * branch + the matrix), and the absence of either is a clean 400. + */ + iosRemote?: PlatformImpl; }): ( services: Record, params: Params, @@ -71,6 +79,23 @@ export function dispatchByPlatform< } return opts.ios.handler(services as unknown as IosServices, params, device, invokeOptions); } + if (device.platform === "ios-remote") { + if (!opts.iosRemote) { + throw new Error( + `Tool '${opts.toolId}' declares ios-remote capability but has no iosRemote branch. ` + + `Add an iosRemote PlatformImpl to dispatchByPlatform().` + ); + } + if (opts.iosRemote.requires?.length) { + await ensureDeps(opts.iosRemote.requires); + } + return opts.iosRemote.handler( + services as unknown as IosRemoteServices, + params, + device, + invokeOptions + ); + } if (opts.android.requires?.length) { await ensureDeps(opts.android.requires); } diff --git a/packages/tool-server/src/utils/datachannel-proto.ts b/packages/tool-server/src/utils/datachannel-proto.ts new file mode 100644 index 00000000..42429e70 --- /dev/null +++ b/packages/tool-server/src/utils/datachannel-proto.ts @@ -0,0 +1,209 @@ +import protobuf from "protobufjs"; + +/** + * Vendored copy of `radon-cloud/packages/simulator-server/proto/datachannel.proto`. + * Kept inline so we don't need a build step to ship the .proto file alongside + * the compiled tool-server. If you bump the simulator-server schema, regenerate + * this string from the canonical .proto. + */ +const PROTO_SOURCE = ` +syntax = "proto3"; + +package datachannel; + +enum TouchAction { + TOUCH_DOWN = 0; + TOUCH_UP = 1; + TOUCH_MOVE = 2; +} + +enum KeyAction { + KEY_DOWN = 0; + KEY_UP = 1; +} + +enum ButtonType { + BUTTON_HOME = 0; + BUTTON_BACK = 1; + BUTTON_POWER = 2; + BUTTON_VOLUME_UP = 3; + BUTTON_VOLUME_DOWN = 4; + BUTTON_APP_SWITCH = 5; + BUTTON_ACTION = 6; +} + +enum RotateDirection { + ROTATE_PORTRAIT = 0; + ROTATE_PORTRAIT_UPSIDE_DOWN = 1; + ROTATE_LANDSCAPE_LEFT = 2; + ROTATE_LANDSCAPE_RIGHT = 3; +} + +message TouchCommand { + TouchAction action = 1; + double x = 2; + double y = 3; + optional double second_x = 4; + optional double second_y = 5; +} + +message KeyCommand { + KeyAction action = 1; + int32 code = 2; +} + +message ButtonCommand { + KeyAction action = 1; + ButtonType button = 2; +} + +message RotateCommand { + RotateDirection direction = 1; +} + +message WheelCommand { + double x = 1; + double y = 2; + double dx = 3; + double dy = 4; +} + +enum DownscalerType { + DOWNSCALER_LANCZOS3 = 0; + DOWNSCALER_BOX = 1; + DOWNSCALER_BILINEAR = 2; + DOWNSCALER_NEAREST = 3; +} + +message ScreenshotCommand { + optional string id = 1; + optional RotateDirection rotation = 2; + optional float scale = 3; + optional DownscalerType downscaler = 4; +} + +message DataChannelCommand { + oneof command { + TouchCommand touch = 1; + KeyCommand key = 2; + ButtonCommand button = 3; + RotateCommand rotate = 4; + WheelCommand wheel = 5; + ScreenshotCommand screenshot = 6; + } +} +`; + +const root = protobuf.parse(PROTO_SOURCE, { keepCase: false }).root; +const DataChannelCommand = root.lookupType("datachannel.DataChannelCommand"); + +// Enum value lookups, named by their enum identifier inside the .proto so the +// public API can be friendly strings without leaking protobufjs internals. + +const TouchAction = root.lookupEnum("datachannel.TouchAction").values; +const KeyAction = root.lookupEnum("datachannel.KeyAction").values; +const ButtonType = root.lookupEnum("datachannel.ButtonType").values; +const RotateDirection = root.lookupEnum("datachannel.RotateDirection").values; + +export type TouchActionName = "Down" | "Up" | "Move"; +export type KeyActionName = "Down" | "Up"; +export type ButtonName = + | "home" + | "back" + | "power" + | "volumeUp" + | "volumeDown" + | "appSwitch" + | "actionButton"; +export type RotationName = + | "Portrait" + | "PortraitUpsideDown" + | "LandscapeLeft" + | "LandscapeRight"; + +const TOUCH_ACTION: Record = { + Down: TouchAction.TOUCH_DOWN ?? 0, + Up: TouchAction.TOUCH_UP ?? 1, + Move: TouchAction.TOUCH_MOVE ?? 2, +}; + +const KEY_ACTION: Record = { + Down: KeyAction.KEY_DOWN ?? 0, + Up: KeyAction.KEY_UP ?? 1, +}; + +const BUTTON_TYPE: Record = { + home: ButtonType.BUTTON_HOME ?? 0, + back: ButtonType.BUTTON_BACK ?? 1, + power: ButtonType.BUTTON_POWER ?? 2, + volumeUp: ButtonType.BUTTON_VOLUME_UP ?? 3, + volumeDown: ButtonType.BUTTON_VOLUME_DOWN ?? 4, + appSwitch: ButtonType.BUTTON_APP_SWITCH ?? 5, + actionButton: ButtonType.BUTTON_ACTION ?? 6, +}; + +const ROTATION: Record = { + Portrait: RotateDirection.ROTATE_PORTRAIT ?? 0, + PortraitUpsideDown: RotateDirection.ROTATE_PORTRAIT_UPSIDE_DOWN ?? 1, + LandscapeLeft: RotateDirection.ROTATE_LANDSCAPE_LEFT ?? 2, + LandscapeRight: RotateDirection.ROTATE_LANDSCAPE_RIGHT ?? 3, +}; + +function encode(payload: Record): Uint8Array { + const message = DataChannelCommand.create(payload); + return DataChannelCommand.encode(message).finish(); +} + +export function encodeTouch(opts: { + action: TouchActionName; + x: number; + y: number; + secondX?: number; + secondY?: number; +}): Uint8Array { + const touch: Record = { + action: TOUCH_ACTION[opts.action], + x: opts.x, + y: opts.y, + }; + if (opts.secondX !== undefined) touch.secondX = opts.secondX; + if (opts.secondY !== undefined) touch.secondY = opts.secondY; + return encode({ touch }); +} + +export function encodeKey(opts: { action: KeyActionName; code: number }): Uint8Array { + return encode({ + key: { action: KEY_ACTION[opts.action], code: opts.code }, + }); +} + +export function encodeButton(opts: { action: KeyActionName; button: ButtonName }): Uint8Array { + return encode({ + button: { action: KEY_ACTION[opts.action], button: BUTTON_TYPE[opts.button] }, + }); +} + +export function encodeRotate(direction: RotationName): Uint8Array { + return encode({ rotate: { direction: ROTATION[direction] } }); +} + +export function encodeScreenshot(opts?: { + id?: string; + rotation?: RotationName; + scale?: number; +}): Uint8Array { + const screenshot: Record = {}; + if (opts?.id !== undefined) screenshot.id = opts.id; + if (opts?.rotation !== undefined) screenshot.rotation = ROTATION[opts.rotation]; + if (opts?.scale !== undefined) screenshot.scale = opts.scale; + return encode({ screenshot }); +} + +export function encodeWheel(opts: { + x: number; + y: number; + dx: number; + dy: number; +}): Uint8Array { + return encode({ wheel: { x: opts.x, y: opts.y, dx: opts.dx, dy: opts.dy } }); +} diff --git a/packages/tool-server/src/utils/device-info.ts b/packages/tool-server/src/utils/device-info.ts index d66ab400..9bb8d9f5 100644 --- a/packages/tool-server/src/utils/device-info.ts +++ b/packages/tool-server/src/utils/device-info.ts @@ -10,18 +10,37 @@ 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}$/; +/** + * Prefix used on device ids that route through `sim-remote` to a remote iOS + * simulator. The raw UUID after the prefix is the same RFC-4122 shape as a + * local iOS UDID — the prefix is the only thing that disambiguates a remote + * sim from a local one. + */ +export const REMOTE_PREFIX = "remote:"; + +/** Strip the `remote:` prefix from a device id, returning the bare UDID. */ +export function stripRemotePrefix(id: string): string { + return id.startsWith(REMOTE_PREFIX) ? id.slice(REMOTE_PREFIX.length) : id; +} + +/** Wrap a bare UDID with the `remote:` prefix used by the ios-remote platform. */ +export function withRemotePrefix(udid: string): string { + return udid.startsWith(REMOTE_PREFIX) ? udid : `${REMOTE_PREFIX}${udid}`; +} + /** Returns the platform a `udid` belongs to based on its shape. */ export function classifyDevice(udid: string): Platform { + if (udid.startsWith(REMOTE_PREFIX)) return "ios-remote"; return IOS_UDID_SHAPE.test(udid) ? "ios" : "android"; } /** * Build a `DeviceInfo` from a raw udid. v1 fills the platform and a default - * kind ('simulator' for iOS, 'emulator' for Android) — platform impls can - * enrich with name/state/sdkLevel via simctl/adb if needed. + * kind ('simulator' for iOS / ios-remote, 'emulator' for Android) — platform + * impls can enrich with name/state/sdkLevel via simctl/adb/sim-remote as needed. */ export function resolveDevice(udid: string): DeviceInfo { const platform = classifyDevice(udid); - const kind: DeviceKind = platform === "ios" ? "simulator" : "emulator"; + const kind: DeviceKind = platform === "android" ? "emulator" : "simulator"; return { id: udid, platform, kind }; } diff --git a/packages/tool-server/src/utils/moq-client.ts b/packages/tool-server/src/utils/moq-client.ts new file mode 100644 index 00000000..14f16c8d --- /dev/null +++ b/packages/tool-server/src/utils/moq-client.ts @@ -0,0 +1,187 @@ +/** + * Pure-JS MoQ client used by the remote simulator-server blueprint. + * + * Talks to the simulator-server's MoQ relay (URL + self-signed cert + * fingerprint surfaced by `sim-remote moq-info`). Subscribes to the + * server-published "simulator" broadcast (catalog/video/screenshot tracks) + * and publishes its own broadcast carrying a "control" track that the + * server subscribes to for protobuf-encoded DataChannelCommand input + * (touch / key / button / rotate / wheel / screenshot). + * + * WebTransport in Node is supplied by `@fails-components/webtransport`, + * which is polyfilled onto `globalThis` lazily on first use. + */ + +import * as Moq from "@moq/net"; +import { encodeScreenshot } from "./datachannel-proto"; +import { moqInfo, type MoqInfo } from "./sim-remote"; + +let polyfillReady: Promise | null = null; + +async function ensurePolyfill(): Promise { + if (polyfillReady) return polyfillReady; + polyfillReady = (async () => { + const g = globalThis as Record; + if (typeof g.WebSocket === "undefined") { + const ws = await import("ws"); + g.WebSocket = ws.default; + } + if (typeof g.WebTransport === "undefined") { + const wt = await import("@fails-components/webtransport"); + g.WebTransport = wt.WebTransport; + // quicheLoaded is a one-shot promise that resolves once the bundled + // libquiche binding finishes loading; awaiting it once is enough. + await wt.quicheLoaded; + } + })(); + return polyfillReady; +} + +/** + * `@moq/net`'s `connect()` races WebTransport + WebSocket via `Promise.any`, + * which wraps the underlying errors in an `AggregateError` whose `.message` + * is the generic "All promises were rejected". Surface the actual failure + * (cert pin mismatch, handshake timeout, missing polyfill, etc.) so the + * agent sees what went wrong instead of a useless aggregate. + */ +function unwrapMoqConnectError(err: unknown, url: string): Error { + if (err instanceof AggregateError) { + const parts = err.errors.map((e) => + e instanceof Error ? `${e.name}: ${e.message}` : String(e) + ); + return new Error(`MoQ connect to ${url} failed: ${parts.join(" | ")}`); + } + if (err instanceof Error) { + return new Error(`MoQ connect to ${url} failed: ${err.message}`); + } + return new Error(`MoQ connect to ${url} failed: ${String(err)}`); +} + +function decodeHexFingerprint(fingerprint: string): Uint8Array { + // Accept "AA:BB:CC..." or "aabbcc..." styles — strip separators, lowercase. + const cleaned = fingerprint.replace(/[^0-9a-fA-F]/g, ""); + if (cleaned.length % 2 !== 0) { + throw new Error(`Invalid MoQ certificate fingerprint (odd hex length): ${fingerprint}`); + } + const out = new Uint8Array(cleaned.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(cleaned.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +export interface MoqClient { + /** Send one protobuf-encoded DataChannelCommand frame. Awaits the initial control-track subscription on first call. */ + sendControl(payload: Uint8Array): Promise; + /** Request one screenshot and return the decoded PNG/JPEG bytes. Concurrent calls serialise. */ + screenshot(opts?: { scale?: number }): Promise; + /** Tear down the underlying WebTransport session and any in-flight subscriptions. */ + close(): Promise; +} + +/** + * Open a MoQ session to the simulator-server backing the given remote udid. + * Resolves once the WebTransport handshake completes and the local control + * broadcast is published; the control track itself is awaited lazily on the + * first sendControl call. + */ +export async function openMoqClient(udid: string): Promise { + await ensurePolyfill(); + const info: MoqInfo = await moqInfo(udid); + return openMoqClientFromInfo(info); +} + +export async function openMoqClientFromInfo(info: MoqInfo): Promise { + await ensurePolyfill(); + const url = new URL(info.url); + const fingerprint = decodeHexFingerprint(info.fingerprint); + + let established; + try { + established = await Moq.Connection.connect(url, { + webtransport: { + serverCertificateHashes: [{ algorithm: "sha-256", value: fingerprint }], + }, + // WebSocket fallback is pointless against simulator-server (QUIC-only), + // and the default 500ms head-start delay would cost us latency on every + // reconnect. Disable the race. + websocket: { enabled: false }, + }); + } catch (err) { + throw unwrapMoqConnectError(err, info.url); + } + + // --- Server-published "simulator" broadcast (catalog/video/screenshot) --- + const simulator = established.consume(Moq.Path.from("simulator")); + const screenshotTrack = simulator.subscribe("screenshot", 0); + + // --- Client-published "argent" broadcast (control) --- + const controlBroadcast = new Moq.Broadcast(); + established.publish(Moq.Path.from("argent"), controlBroadcast); + + // The server subscribes to our "control" track when it picks up the + // announcement. Cache the requested Track so subsequent sendControl calls + // don't re-await — the first call may have to wait, the rest are O(1). + let controlTrackPromise: Promise | null = null; + const getControlTrack = (): Promise => { + if (controlTrackPromise) return controlTrackPromise; + controlTrackPromise = (async () => { + // Filter out non-"control" track requests in case the server requests + // anything else in the future. + for (;;) { + const req = await controlBroadcast.requested(); + if (!req) { + throw new Error("MoQ control broadcast closed before server subscribed"); + } + if (req.track.name === "control") return req.track; + } + })(); + return controlTrackPromise; + }; + + // Screenshots: serialise concurrent callers so they don't race for the + // next frame on the shared screenshot track (server has no per-request id). + let screenshotChain: Promise = Promise.resolve(); + let screenshotSeq = 0; + + const api: MoqClient = { + async sendControl(payload: Uint8Array): Promise { + const track = await getControlTrack(); + track.writeFrame(payload); + }, + + async screenshot(opts?: { scale?: number }): Promise { + const run = async (): Promise => { + const id = String(++screenshotSeq); + const cmd = encodeScreenshot({ id, scale: opts?.scale }); + const track = await getControlTrack(); + track.writeFrame(cmd); + const frame = await screenshotTrack.readFrame(); + if (!frame) { + throw new Error("MoQ screenshot track closed before frame arrived"); + } + // Server frames the screenshot as `{"data":""}`. + const json = JSON.parse(new TextDecoder().decode(frame)) as { data?: string }; + if (typeof json.data !== "string") { + throw new Error(`MoQ screenshot frame missing 'data' field: ${JSON.stringify(json)}`); + } + return Buffer.from(json.data, "base64"); + }; + const result = screenshotChain.then(run, run); + // Keep the chain advancing regardless of individual failures. + screenshotChain = result.catch(() => undefined); + return result; + }, + + async close(): Promise { + try { + controlBroadcast.close(); + } catch {} + try { + established.close(); + } catch {} + }, + }; + + return api; +} diff --git a/packages/tool-server/src/utils/sim-remote.ts b/packages/tool-server/src/utils/sim-remote.ts new file mode 100644 index 00000000..ab207026 --- /dev/null +++ b/packages/tool-server/src/utils/sim-remote.ts @@ -0,0 +1,215 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Thin wrapper around the `sim-remote` CLI. + * + * All commands shell out to `sim-remote` and propagate exit-code failures as + * thrown errors with the CLI's stderr appended to the message — so auth and + * orchestrator-side errors reach the agent verbatim instead of being smoothed + * over here. + * + * Each function strips the `remote:` prefix off device ids if present, so + * callers don't have to remember whether the id they're holding has been + * normalised yet. + */ + +import { stripRemotePrefix } from "./device-info"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface SimRemoteOptions { + timeoutMs?: number; + stdin?: string; +} + +async function run(args: string[], options?: SimRemoteOptions): Promise<{ stdout: string }> { + try { + const { stdout } = await execFileAsync("sim-remote", args, { + timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + encoding: "utf8", + // sim-remote pipes stdin through to pbcopy etc. + input: options?.stdin, + } as Parameters[2]); + return { stdout: typeof stdout === "string" ? stdout : stdout.toString("utf8") }; + } catch (err) { + const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }; + const stderr = (e.stderr ?? "").trim(); + const stdout = (e.stdout ?? "").trim(); + const suffix = stderr || stdout || e.message; + throw new Error(`sim-remote ${args.join(" ")} failed: ${suffix}`); + } +} + +// ── simctl ── + +/** + * Shape of `sim-remote simctl list devices --json`. Mirrors Apple's + * `xcrun simctl list devices --json` output: `{ devices: { : [ ... ] } }`. + */ +export interface SimRemoteDevice { + udid: string; + name: string; + state: string; // "Booted" | "Shutdown" | ... + isAvailable?: boolean; + deviceTypeIdentifier?: string; +} + +export interface SimRemoteListDevicesResult { + devices: Record; +} + +export async function simctlListDevices(): Promise { + const { stdout } = await run(["simctl", "list", "devices", "--json"]); + try { + return JSON.parse(stdout) as SimRemoteListDevicesResult; + } catch (err) { + throw new Error( + `sim-remote simctl list devices --json returned non-JSON output: ${(err as Error).message}` + ); + } +} + +export async function simctlBoot(udid: string): Promise { + await run(["simctl", "boot", stripRemotePrefix(udid)]); +} + +export async function simctlShutdown(udid: string): Promise { + await run(["simctl", "shutdown", stripRemotePrefix(udid)]); +} + +export async function simctlBootstatus(udid: string, opts?: { boot?: boolean }): Promise { + const args = ["simctl", "bootstatus"]; + if (opts?.boot) args.push("-b"); + args.push(stripRemotePrefix(udid)); + // Bootstatus may take a long while on cold boot; give it 5 min. + await run(args, { timeoutMs: 5 * 60_000 }); +} + +export async function simctlLaunch( + udid: string, + bundleId: string, + args: string[] = [] +): Promise { + await run(["simctl", "launch", stripRemotePrefix(udid), bundleId, ...args]); +} + +export async function simctlTerminate(udid: string, bundleId: string): Promise { + await run(["simctl", "terminate", stripRemotePrefix(udid), bundleId]); +} + +export async function simctlInstall(udid: string, localAppPath: string): Promise { + // sim-remote uploads the local .app to the orchestrator over QUIC. + // Large bundles can take a while; give 5 min. + await run(["simctl", "install", stripRemotePrefix(udid), localAppPath], { + timeoutMs: 5 * 60_000, + }); +} + +export async function simctlUninstall(udid: string, bundleId: string): Promise { + await run(["simctl", "uninstall", stripRemotePrefix(udid), bundleId]); +} + +export async function simctlOpenUrl(udid: string, url: string): Promise { + await run(["simctl", "openurl", stripRemotePrefix(udid), url]); +} + +/** Copy the given text into the simulator's pasteboard (sim-remote streams stdin). */ +export async function simctlPbcopy(udid: string, text: string): Promise { + await run(["simctl", "pbcopy", stripRemotePrefix(udid)], { stdin: text }); +} + +export async function simctlPbpaste(udid: string): Promise { + const { stdout } = await run(["simctl", "pbpaste", stripRemotePrefix(udid)]); + return stdout; +} + +// ── setup ── + +export async function setupAccessibilityDefaults(udid: string): Promise { + await run(["setup", "accessibility-defaults", stripRemotePrefix(udid)]); +} + +export async function setupNativeDevtools( + udid: string, + opts: { libs: string[]; cdpPort: number } +): Promise { + const args = ["setup", "native-devtools"]; + for (const lib of opts.libs) args.push("--lib", lib); + args.push("--cdp-port", String(opts.cdpPort), stripRemotePrefix(udid)); + await run(args); +} + +/** + * Returns the bundle ids of UIKitApplications currently running on the + * remote simulator (orchestrator-side `setup running-bundle-ids`). + */ +export async function setupRunningBundleIds(udid: string): Promise { + const { stdout } = await run(["setup", "running-bundle-ids", stripRemotePrefix(udid)]); + return stdout + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export async function setupAxService( + udid: string, + opts: { port: number; timeoutSecs?: number } +): Promise { + await run([ + "setup", + "ax-service", + "--port", + String(opts.port), + "--timeout-secs", + String(opts.timeoutSecs ?? 3600), + stripRemotePrefix(udid), + ]); +} + +// ── proxy ── + +/** + * Start a TCP tunnel: incoming connections on the host's `localhost:` + * are forwarded by the daemon to the same port inside the remote simulator. + * + * Idempotent: re-running with the same (udid, port) tolerates "already + * started" errors so blueprints don't have to track tunnel ownership across + * service restarts. + */ +export async function proxyStart(udid: string, port: number): Promise { + try { + await run(["proxy", "start", stripRemotePrefix(udid), String(port)]); + } catch (err) { + const message = (err as Error).message ?? ""; + if (/already/i.test(message)) return; + throw err; + } +} + +export async function proxyStop(udid: string, port: number): Promise { + try { + await run(["proxy", "stop", stripRemotePrefix(udid), String(port)]); + } catch { + // best-effort cleanup + } +} + +// ── moq ── + +export interface MoqInfo { + url: string; + fingerprint: string; +} + +export async function moqInfo(udid: string): Promise { + const { stdout } = await run(["moq-info", stripRemotePrefix(udid)]); + try { + return JSON.parse(stdout) as MoqInfo; + } catch (err) { + throw new Error(`sim-remote moq-info returned non-JSON output: ${(err as Error).message}`); + } +} diff --git a/packages/tool-server/src/utils/simulator-client.ts b/packages/tool-server/src/utils/simulator-client.ts index d8c2b5d7..f3be7487 100644 --- a/packages/tool-server/src/utils/simulator-client.ts +++ b/packages/tool-server/src/utils/simulator-client.ts @@ -1,9 +1,51 @@ import WebSocket from "ws"; import type { SimulatorServerApi } from "../blueprints/simulator-server"; import { toSimulatorNetworkError } from "./format-error"; +import { + encodeButton, + encodeKey, + encodeRotate, + encodeTouch, + type ButtonName, + type KeyActionName, + type RotationName, + type TouchActionName, +} from "./datachannel-proto"; +import type { MoqClient } from "./moq-client"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; const DEFAULT_SCREENSHOT_SCALE = 0.3; +/** + * Transport-level interface every `SimulatorServerApi` produces. Local sims + * back this with the WebSocket+HTTP client; remote sims back it with a MoQ + * client. Keeping the high-level shape (touch/button/rotate/screenshot) + * here means every tool call site stays transport-agnostic. + */ +export interface SimulatorServerTransport { + touch(opts: { + type: TouchActionName; + x: number; + y: number; + secondX?: number; + secondY?: number; + }): void; + button(opts: { direction: KeyActionName; button: ButtonName }): void; + rotate(direction: RotationName): void; + /** Multi-character text paste (host pasteboard → simulator pasteboard + Cmd+V on remote). */ + paste(text: string): Promise | void; + pressKey(direction: KeyActionName, keyCode: number): void; + screenshot(opts?: { + rotation?: RotationName; + scale?: number; + signal?: AbortSignal; + }): Promise<{ url: string; path: string }>; +} + const connections = new Map(); let cmdId = 0; @@ -25,10 +67,18 @@ function getOrCreateWs(api: SimulatorServerApi): WebSocket { } /** - * Send a command to the simulator-server over WebSocket. - * Reuses a single connection per apiUrl. + * Send a JSON command to the simulator-server. + * + * On local sims this goes over the WebSocket; on remote sims (when + * `api.transport` is set) it is routed through the MoQ-backed transport. + * Call sites stay transport-agnostic — they always speak the WebSocket + * command shape (`{cmd: "touch", ...}`). */ -export function sendCommand(api: SimulatorServerApi, cmd: object): void { +export function sendCommand(api: SimulatorServerApi, cmd: Record): void { + if (api.transport) { + routeViaTransport(api.transport, cmd); + return; + } const ws = getOrCreateWs(api); const payload = JSON.stringify({ id: String(++cmdId), ...cmd }); if (ws.readyState === WebSocket.OPEN) { @@ -86,6 +136,10 @@ export function getScreenshotScale(): number { /** * Take a screenshot via the simulator-server HTTP API. + * + * If the api has a `transport` field set (e.g. MoQ for ios-remote), the + * transport's screenshot method is used instead — the response shape + * (`{ url, path }`) is preserved either way. */ export async function httpScreenshot( api: SimulatorServerApi, @@ -93,6 +147,13 @@ export async function httpScreenshot( signal?: AbortSignal, scale?: number ): Promise<{ url: string; path: string }> { + if (api.transport) { + return api.transport.screenshot({ + rotation: rotation as RotationName | undefined, + scale, + signal, + }); + } const resolvedScale = scale ?? getScreenshotScale(); const body: Record = {}; if (rotation) body.rotation = rotation; @@ -119,3 +180,98 @@ export async function httpScreenshot( } return { url: resBody.url, path: resBody.path }; } + +function routeViaTransport(transport: SimulatorServerTransport, cmd: Record): void { + switch (cmd.cmd) { + case "touch": { + // Local WebSocket protocol uses snake_case second_x/second_y (set to + // null when absent); the proto-encoder takes optional secondX/secondY. + const sx = (cmd.second_x ?? cmd.secondX) as number | null | undefined; + const sy = (cmd.second_y ?? cmd.secondY) as number | null | undefined; + transport.touch({ + type: cmd.type as TouchActionName, + x: cmd.x as number, + y: cmd.y as number, + secondX: sx == null ? undefined : sx, + secondY: sy == null ? undefined : sy, + }); + return; + } + case "button": + transport.button({ + direction: cmd.direction as KeyActionName, + button: cmd.button as ButtonName, + }); + return; + case "rotate": + transport.rotate(cmd.direction as RotationName); + return; + case "paste": { + // paste() may be async on remote (pbcopy + Cmd+V); fire and forget + // here to preserve sendCommand's sync shape. Errors land in the host + // process's unhandledRejection logger — same as a websocket send fail. + void Promise.resolve(transport.paste(cmd.text as string)); + return; + } + default: + throw new Error(`MoQ transport does not implement sendCommand cmd '${String(cmd.cmd)}'`); + } +} + +// ── MoQ transport adapter ───────────────────────────────────────────────── + +/** + * Build a `SimulatorServerTransport` that routes touch/button/rotate/key/ + * screenshot operations over an `@moq/net`-backed `MoqClient`. Screenshots + * are written into argent's temp dir to match the local HTTP path's + * `{ url, path }` contract. + */ +export function createMoqTransport( + moq: MoqClient, + options: { pasteText: (text: string) => Promise } +): SimulatorServerTransport { + const screenshotDir = path.join(os.tmpdir(), "argent-remote-screenshots"); + + const writeScreenshotToDisk = async (bytes: Buffer): Promise<{ url: string; path: string }> => { + await fs.mkdir(screenshotDir, { recursive: true }); + const file = path.join(screenshotDir, `${randomUUID()}.png`); + await fs.writeFile(file, bytes); + return { url: pathToFileURL(file).toString(), path: file }; + }; + + return { + touch(opts) { + void moq.sendControl( + encodeTouch({ + action: opts.type, + x: opts.x, + y: opts.y, + secondX: opts.secondX, + secondY: opts.secondY, + }) + ); + }, + button(opts) { + void moq.sendControl( + encodeButton({ action: opts.direction, button: opts.button }) + ); + }, + rotate(direction) { + void moq.sendControl(encodeRotate(direction)); + }, + async paste(text) { + // sim-remote pbcopy + Cmd+V on the remote sim. The cmd+v sequence is + // emitted on the host via the option's pasteText callback so the + // transport stays platform-agnostic. + await options.pasteText(text); + }, + pressKey(direction, keyCode) { + void moq.sendControl(encodeKey({ action: direction, code: keyCode })); + }, + async screenshot(opts) { + const scale = opts?.scale ?? getScreenshotScale(); + const bytes = await moq.screenshot({ scale }); + return writeScreenshotToDisk(bytes); + }, + }; +} From 9bccdde4834dcf0605753c2686399b041cae6921 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Fri, 29 May 2026 12:05:47 +0200 Subject: [PATCH 4/9] refactor: limit branching inside tool/blueprint implementation via strategy pattern --- .../tool-server/src/blueprints/ax-service.ts | 216 ++----------- .../src/blueprints/native-devtools.ts | 223 +------------ .../tools/launch-app/platforms/ios-remote.ts | 11 +- .../src/tools/launch-app/platforms/ios.ts | 14 +- .../src/tools/launch-app/platforms/shared.ts | 18 ++ .../tools/restart-app/platforms/ios-remote.ts | 17 +- .../src/tools/restart-app/platforms/ios.ts | 20 +- .../src/tools/restart-app/platforms/shared.ts | 27 ++ packages/tool-server/src/utils/ax-prefs.ts | 105 ++++++ packages/tool-server/src/utils/ios-host.ts | 301 ++++++++++++++++++ .../tool-server/src/utils/simctl-backend.ts | 29 ++ 11 files changed, 529 insertions(+), 452 deletions(-) create mode 100644 packages/tool-server/src/tools/launch-app/platforms/shared.ts create mode 100644 packages/tool-server/src/tools/restart-app/platforms/shared.ts create mode 100644 packages/tool-server/src/utils/ax-prefs.ts create mode 100644 packages/tool-server/src/utils/ios-host.ts create mode 100644 packages/tool-server/src/utils/simctl-backend.ts diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index 90c65529..e6f5961d 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -1,12 +1,7 @@ import * as net from "node:net"; import * as fs from "node:fs"; -import * as fsAsync from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; import * as readline from "node:readline"; -import { EventEmitter } from "node:events"; -import { promisify } from "node:util"; -import { execFile, ChildProcess } from "node:child_process"; +import { ChildProcess } from "node:child_process"; import { TypedEventEmitter, type DeviceInfo, @@ -14,15 +9,15 @@ import { type ServiceInstance, type ServiceEvents, } from "@argent/registry"; -import { axServiceBinaryPath, axServiceBinaryPathTcp } from "@argent/native-devtools-ios"; -import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; -import { - proxyStart as simRemoteProxyStart, - proxyStop as simRemoteProxyStop, - setupAxService as simRemoteSetupAxService, -} from "../utils/sim-remote"; +import { pickIosHost, type IosEndpoint } from "../utils/ios-host"; -const execFileAsync = promisify(execFile); +// Re-export AX-pref helpers that used to live here so existing callers +// (boot-device, simulator-server) keep their import paths. +export { + ensureAutomationEnabled, + isEntitlementBypassActive, + setAccessibilityPrefsPreBoot, +} from "../utils/ax-prefs"; export const AX_SERVICE_NAMESPACE = "AXService"; @@ -81,115 +76,16 @@ function getSocketPath(udid: string): string { return `/tmp/ax-${udid.slice(0, 8)}.sock`; } -type AXEndpoint = { transport: "unix"; socketPath: string } | { transport: "tcp"; port: number }; - interface PendingRpc { resolve: (value: unknown) => void; reject: (err: Error) => void; timer: ReturnType; } -export async function ensureAutomationEnabled(udid: string): Promise { - await execFileAsync( - "xcrun", - [ - "simctl", - "spawn", - udid, - "defaults", - "write", - "com.apple.Accessibility", - "AutomationEnabled", - "-bool", - "true", - ], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ); -} - -/** - * Check whether `IgnoreAXServerEntitlements` is active on this sim. - * - * iOS 26.5+: SB's AX server rejects unentitled MIG clients with - * kAXError -25215. The pref disables the check, but SB caches it at - * init — writing it post-boot has no effect until the next restart. - * The only effective path is the pre-boot plist write in boot-device. - * - * This read-only probe tells the caller whether the pre-boot write - * happened so describe can surface a degraded-quality hint when it didn't. - */ -export async function isEntitlementBypassActive(udid: string): Promise { - return execFileAsync( - "xcrun", - [ - "simctl", - "spawn", - udid, - "defaults", - "read", - "com.apple.Accessibility", - "IgnoreAXServerEntitlements", - ], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ) - .then(({ stdout }) => stdout.trim() === "1") - .catch(() => false); -} - -/** - * Host-side `com.apple.Accessibility` plist inside the sim's data container. - * Writeable while Shutdown; in-sim cfprefsd overwrites it once Booted. - */ -function accessibilityPlistPath(udid: string): string { - return path.join( - os.homedir(), - "Library/Developer/CoreSimulator/Devices", - udid, - "data/Library/Preferences/com.apple.Accessibility.plist" - ); -} - -/** - * Write the four AX prefs to the sim's host plist BEFORE `simctl boot` so SB - * caches them at AX-server init and never needs the disruptive kickstart - * (which kills the foreground app and dismisses in-flight system alerts). - * - * All four are required on a freshly-erased sim: - * - `IgnoreAXServerEntitlements` bypasses the iOS 26.5+ kAXErrorNotEntitled check. - * - `AutomationEnabled` opts the simctl-spawned ax-service in as an AX client. - * - `AccessibilityEnabled` + `ApplicationAccessibilityEnabled` gate the AT - * subsystem bootstrap. Without them SB never spawns `AccessibilityUIServer` - * and describe returns an empty ROOT even though the entitlement check passes - * (reproduced on a wiped iPhone 17e: AccessibilityUIServer active count = 0 - * without these two; auto-spawns at boot with them). - * - * Caller must ensure the sim is Shutdown — in-sim cfprefsd would otherwise - * overwrite this file on flush. - */ -export async function setAccessibilityPrefsPreBoot(udid: string): Promise { - const plistPath = accessibilityPlistPath(udid); - await fsAsync.mkdir(path.dirname(plistPath), { recursive: true }); - const exists = await fsAsync - .access(plistPath) - .then(() => true) - .catch(() => false); - if (!exists) { - await execFileAsync("plutil", ["-create", "binary1", plistPath]); - } - for (const key of [ - "AutomationEnabled", - "IgnoreAXServerEntitlements", - "AccessibilityEnabled", - "ApplicationAccessibilityEnabled", - ]) { - await execFileAsync("plutil", ["-replace", key, "-bool", "true", plistPath]); - } -} - // Listen on the chosen transport. Unix: pre-unlink stale socket from previous // runs so listen() doesn't EADDRINUSE. function startListener( - endpoint: AXEndpoint, + endpoint: IosEndpoint, onConnection: (socket: net.Socket) => void ): Promise { return new Promise((resolve, reject) => { @@ -215,70 +111,6 @@ function startListener( }); } -/** - * Local-iOS preflight: ensure AutomationEnabled and probe whether the - * entitlement bypass is active. Returns true when the bypass is in place - * (describe is non-degraded). Skipped on ios-remote — sim-remote owns the - * equivalent setup there. - */ -async function runLocalAxBootstrap(udid: string): Promise { - await ensureAutomationEnabled(udid); - return isEntitlementBypassActive(udid); -} - -function spawnDaemon( - udid: string, - endpoint: AXEndpoint, - options?: { remote?: boolean } -): ChildProcess { - if (options?.remote) { - // ios-remote: the orchestrator-supplied daemon is started by - // `sim-remote setup ax-service`. There is no local child process to - // shepherd — we return a no-op ChildProcess stub so the surrounding - // factory code (exit/error wiring, kill on dispose) still type-checks. - if (endpoint.transport !== "tcp") { - throw new Error("ios-remote ax-service requires TCP transport"); - } - // Fire-and-forget: `sim-remote setup ax-service` returns once the daemon - // has been started inside the remote simulator. We propagate failures - // through the proc's "error" event so factory teardown engages normally. - const noop = new EventEmitter() as unknown as ChildProcess; - (noop as unknown as { kill: () => boolean }).kill = () => true; - void simRemoteSetupAxService(udid, { port: endpoint.port, timeoutSecs: 3600 }).catch( - (err: Error) => { - // Defer the emit so listeners attached after this call still see it. - setImmediate(() => noop.emit("error", err)); - } - ); - return noop; - } - - const binaryPath = - endpoint.transport === "tcp" ? axServiceBinaryPathTcp() : axServiceBinaryPath(); - - const endpointArgs = - endpoint.transport === "tcp" - ? ["--port", String(endpoint.port)] - : ["--socket", endpoint.socketPath]; - - const proc = execFile( - "xcrun", - ["simctl", "spawn", udid, binaryPath, ...endpointArgs, "--timeout", "3600"], - { encoding: "utf8" } - ) as ChildProcess; - - // Defense-in-depth: a missing udid here would crash the process — - // throwing inside an async listener bypasses promise rejection and - // bubbles up as `uncaughtException`, which the tool-server treats as - // fatal. Tag with "?" instead of dereferencing. - const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; - proc.stderr?.on("data", (data: string) => { - process.stderr.write(`[ax-service ${udidTag}] ${data}`); - }); - - return proc; -} - // Wait for either the daemon's TCP/UDS connection or an early exit. // Resolves with the connected socket; rejects on timeout or daemon failure. function waitForDaemonConnection( @@ -363,11 +195,11 @@ export const axServiceBlueprint: ServiceBlueprint = { } const udid = device.id; - const isRemote = device.platform === "ios-remote"; + const host = pickIosHost(device); // Force TCP on remote — unix sockets do not bridge across the QUIC tunnel // sim-remote sets up between the orchestrator and the dev's machine. - const transport: AXServiceTransport = isRemote ? "tcp" : (opts.transport ?? "unix"); - const endpoint: AXEndpoint = + const transport: AXServiceTransport = host.requiresTcp ? "tcp" : (opts.transport ?? "unix"); + const endpoint: IosEndpoint = transport === "tcp" ? { transport: "tcp", port: AX_SERVICE_TCP_PORT } : { transport: "unix", socketPath: getSocketPath(udid) }; @@ -386,12 +218,7 @@ export const axServiceBlueprint: ServiceBlueprint = { pendingRpc.clear(); }; - // ios-remote skips local xcrun calls — sim-remote applies the - // accessibility defaults at boot via `setup accessibility-defaults`, - // and the entitlement-bypass plist is managed there too. We mark the - // service as non-degraded on the assumption sim-remote did the right - // thing; if not, describe will still surface useful errors. - const entitlementBypassActive = isRemote ? true : await runLocalAxBootstrap(udid); + const { entitlementBypassActive } = await host.bootstrapAx(udid); // Host listens first, then we spawn the daemon and wait for it to dial in. const server = await startListener(endpoint, (socket) => { @@ -440,14 +267,15 @@ export const axServiceBlueprint: ServiceBlueprint = { }); }); - if (isRemote && endpoint.transport === "tcp") { + if (endpoint.transport === "tcp") { // Wire the reverse tunnel BEFORE asking the orchestrator to start the // daemon — the daemon will dial 127.0.0.1: inside the simulator // and that dial gets QUIC-forwarded back to our host listener above. - await simRemoteProxyStart(udid, endpoint.port); + // No-op on local. + await host.startProxy(udid, endpoint.port); } - const proc = spawnDaemon(udid, endpoint, { remote: isRemote }); + const proc = host.spawnAxDaemon(udid, endpoint); proc.on("exit", (code) => { if (disposed) return; @@ -472,8 +300,8 @@ export const axServiceBlueprint: ServiceBlueprint = { fs.unlinkSync(endpoint.socketPath); } catch {} } - if (isRemote && endpoint.transport === "tcp") { - await simRemoteProxyStop(udid, endpoint.port); + if (endpoint.transport === "tcp") { + await host.stopProxy(udid, endpoint.port); } throw err; } @@ -540,8 +368,8 @@ export const axServiceBlueprint: ServiceBlueprint = { fs.unlinkSync(endpoint.socketPath); } catch {} } - if (isRemote && endpoint.transport === "tcp") { - await simRemoteProxyStop(udid, endpoint.port); + if (endpoint.transport === "tcp") { + await host.stopProxy(udid, endpoint.port); } }, events, diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 1215c470..bc58afeb 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -1,30 +1,21 @@ import * as net from "node:net"; import * as fs from "node:fs"; -import * as path from "node:path"; import * as readline from "node:readline"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { TypedEventEmitter, type DeviceInfo, type ServiceBlueprint, type ServiceEvents, } from "@argent/registry"; -import { bootstrapDylibPath, bootstrapDylibPathTcp } from "@argent/native-devtools-ios"; -import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; -import { - proxyStart as simRemoteProxyStart, - proxyStop as simRemoteProxyStop, - setupNativeDevtools as simRemoteSetupNativeDevtools, - setupRunningBundleIds as simRemoteRunningBundleIds, -} from "../utils/sim-remote"; +import { pickIosHost, buildDyldInsertLibraries } from "../utils/ios-host"; + +// Re-exported for the env-merging unit test that imports it from this module. +export { buildDyldInsertLibraries }; export type NativeDevtoolsTransport = "unix" | "tcp"; export const NATIVE_DEVTOOLS_TCP_PORT = Number(process.env.NATIVE_DEVTOOLS_TCP_PORT) || 9230; -const execFileAsync = promisify(execFile); - export const NATIVE_DEVTOOLS_NAMESPACE = "NativeDevtools"; // Max consecutive init failures per service instance before it stops retrying. @@ -187,190 +178,12 @@ interface AppConnection { networkLog: NetworkEvent[]; } -/** Current bootstrap filename; `libInjectionBootstrap.dylib` is legacy (pre-rename) and still stripped when merging env. */ -const ARGENT_BOOTSTRAP_DYLIB_BASENAMES = new Set([ - "libArgentInjectionBootstrap.dylib", - "libInjectionBootstrap.dylib", -]); - function getNativeDevtoolsSocketPath(udid: string): string { // Deterministic, short — well under the 104-char macOS Unix socket limit // /tmp/argent-nd-XXXXXXXX.sock = 28 chars return `/tmp/argent-nd-${udid.slice(0, 8)}.sock`; } -function splitDyldInsertLibraries(value: string): string[] { - return value - .split(":") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -/** - * Strips Argent bootstrap dylibs (by basename, including the legacy pre-rename name) - * and entries that don't exist on disk (truncated artifacts from the simctl getenv - * 127-byte bug, stale paths from old installs, etc.). - * Entries starting with '@' (loader-path references) are always preserved. - * Third-party dylibs present on disk (e.g. SimCam) are kept verbatim. - */ -function shouldPreserveDyldInsertLibrariesEntry(entry: string, bootstrapPath: string): boolean { - if (entry === bootstrapPath) { - return false; - } - - if (ARGENT_BOOTSTRAP_DYLIB_BASENAMES.has(path.basename(entry))) { - return false; - } - - if (entry.startsWith("@")) { - return true; - } - - return fs.existsSync(entry); -} - -export function buildDyldInsertLibraries(currentValue: string, bootstrapPath: string): string { - const preserved = splitDyldInsertLibraries(currentValue).filter((entry) => - shouldPreserveDyldInsertLibrariesEntry(entry, bootstrapPath) - ); - return [...preserved, bootstrapPath].join(":"); -} - -async function ensureAccessibilityEnabled(udid: string): Promise { - // iOS 26+ requires AccessibilityEnabled and ApplicationAccessibilityEnabled to be set - // in the simulator's defaults for SwiftUI to populate the accessibility tree. - // Without these flags, all UIAccessibility APIs return nil/0 for SwiftUI views. - const flags = ["AccessibilityEnabled", "ApplicationAccessibilityEnabled"]; - await Promise.all( - flags.map((flag) => - execFileAsync( - "xcrun", - [ - "simctl", - "spawn", - udid, - "defaults", - "write", - "com.apple.Accessibility", - flag, - "-bool", - "true", - ], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ) - ) - ); -} - -async function ensureEnv( - udid: string, - endpoint: { transport: "unix"; socketPath: string } | { transport: "tcp"; port: number } -): Promise { - const bootstrapPath = - endpoint.transport === "tcp" ? bootstrapDylibPathTcp() : bootstrapDylibPath(); - - // Read from launchctl inside the simulator (via simctl spawn) instead of - // `simctl getenv`. The latter silently truncates values longer than 127 bytes, - // which corrupts the colon-separated path list and causes stale entries to - // accumulate on every ensureEnv() cycle. - const result = await execFileAsync( - "xcrun", - ["simctl", "spawn", udid, "launchctl", "getenv", "DYLD_INSERT_LIBRARIES"], - { encoding: "utf8", timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ).catch((e) => ({ stdout: (e as NodeJS.ErrnoException & { stdout?: string }).stdout ?? "" })); - - const existing = (result.stdout ?? "").trim(); - const updated = buildDyldInsertLibraries(existing, bootstrapPath); - - if (updated !== existing) { - await execFileAsync( - "xcrun", - ["simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", updated], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ); - } - - // Always re-set the endpoint env var — deterministic value, cheap no-op if already correct, - // ensures correctness after tool-server restarts. - if (endpoint.transport === "tcp") { - await execFileAsync( - "xcrun", - [ - "simctl", - "spawn", - udid, - "launchctl", - "setenv", - "NATIVE_DEVTOOLS_IOS_CDP_PORT", - String(endpoint.port), - ], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ); - } else { - await execFileAsync( - "xcrun", - [ - "simctl", - "spawn", - udid, - "launchctl", - "setenv", - "NATIVE_DEVTOOLS_IOS_CDP_SOCKET", - endpoint.socketPath, - ], - { timeout: SIMCTL_SPAWN_TIMEOUT_MS } - ); - } - - // Ensure the accessibility runtime is enabled so that describeScreen works on iOS 26+. - await ensureAccessibilityEnabled(udid); -} - -/** - * Bare basename of the bootstrap dylib the orchestrator should inject. The - * dylib lives on the orchestrator side (it's the TCP variant of the local - * `libArgentInjectionBootstrap.dylib`) and `sim-remote setup native-devtools` - * resolves it by basename against the orchestrator's own dylib directory — - * we never need a local copy. Hardcoding the basename avoids a - * `bootstrapDylibPathTcp()` lookup that would throw on dev machines that - * don't ship the local TCP variant. - */ -const REMOTE_BOOTSTRAP_DYLIB_BASENAME = "libArgentInjectionBootstrap.dylib"; - -/** - * Remote analogue of `ensureEnv` for ios-remote devices. Instead of poking - * launchd via `xcrun simctl spawn`, we delegate dylib injection and - * environment setup to the orchestrator via `sim-remote setup native-devtools`. - * - * The dylib dials `127.0.0.1:` inside the simulator — that - * connection arrives on our host listener via the QUIC reverse tunnel that - * the caller must have already wired with `simRemoteProxyStart`. - */ -async function ensureEnvRemote( - udid: string, - endpoint: { transport: "tcp"; port: number } -): Promise { - await simRemoteSetupNativeDevtools(udid, { - libs: [REMOTE_BOOTSTRAP_DYLIB_BASENAME], - cdpPort: endpoint.port, - }); -} - -async function listRunningUIKitApplicationBundleIds(udid: string): Promise> { - const { stdout } = await execFileAsync("xcrun", ["simctl", "spawn", udid, "launchctl", "list"], { - encoding: "utf8", - }); - - const bundleIds = new Set(); - for (const line of stdout.split("\n")) { - const match = line.match(/UIKitApplication:([^\[]+)/); - if (match) { - bundleIds.add(match[1].trim()); - } - } - return bundleIds; -} - export const nativeDevtoolsBlueprint: ServiceBlueprint = { namespace: NATIVE_DEVTOOLS_NAMESPACE, @@ -393,10 +206,10 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint ensureEnvRemote(udid, endpoint as { transport: "tcp"; port: number }) - : () => ensureEnv(udid, endpoint); - inFlight = Promise.resolve() - .then(setup) + .then(() => host.setupNativeDevtoolsEnv(udid, endpoint)) .then(() => { envSetup = true; initFailure = null; @@ -461,9 +270,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint => { - const runningBundleIds = isRemote - ? new Set(await simRemoteRunningBundleIds(udid)) - : await listRunningUIKitApplicationBundleIds(udid); + const runningBundleIds = await host.listRunningBundleIds(udid); return runningBundleIds.has(bundleId); }; @@ -593,11 +400,11 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint = { requires: ["sim-remote"], - handler: async (services, params) => { - const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); - if (blocked) return blocked; - await simRemoteLaunch(params.udid, params.bundleId); - return { launched: true, bundleId: params.bundleId }; - }, + handler: buildIosLaunchHandler(remoteSimctl), }; 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..f6acc671 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/ios.ts @@ -1,17 +1,9 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { localSimctl } from "../../../utils/simctl-backend"; import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; - -const execFileAsync = promisify(execFile); +import { buildIosLaunchHandler } from "./shared"; export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (services, params) => { - const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); - if (blocked) return blocked; - await execFileAsync("xcrun", ["simctl", "launch", params.udid, params.bundleId]); - return { launched: true, bundleId: params.bundleId }; - }, + handler: buildIosLaunchHandler(localSimctl), }; diff --git a/packages/tool-server/src/tools/launch-app/platforms/shared.ts b/packages/tool-server/src/tools/launch-app/platforms/shared.ts new file mode 100644 index 00000000..6320228f --- /dev/null +++ b/packages/tool-server/src/tools/launch-app/platforms/shared.ts @@ -0,0 +1,18 @@ +import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; +import type { SimctlBackend } from "../../../utils/simctl-backend"; +import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; + +/** + * Shared iOS handler used by both the local (`xcrun simctl`) and remote + * (`sim-remote simctl`) branches. The native-devtools precheck and the + * launched-payload shape are identical between the two; only the simctl + * verbs differ — parametrised via `backend`. + */ +export function buildIosLaunchHandler(backend: SimctlBackend) { + return async (services: LaunchAppIosServices, params: LaunchAppParams): Promise => { + const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); + if (blocked) return blocked; + await backend.launch(params.udid, params.bundleId); + return { launched: true, bundleId: params.bundleId }; + }; +} diff --git a/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts b/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts index 903cc6b4..3bff600f 100644 --- a/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts +++ b/packages/tool-server/src/tools/restart-app/platforms/ios-remote.ts @@ -1,7 +1,7 @@ -import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import { simctlLaunch, simctlTerminate } from "../../../utils/sim-remote"; +import { remoteSimctl } from "../../../utils/simctl-backend"; import type { RestartAppIosServices, RestartAppParams, RestartAppResult } from "../types"; +import { buildIosRestartHandler } from "./shared"; export const iosRemoteImpl: PlatformImpl< RestartAppIosServices, @@ -9,16 +9,5 @@ export const iosRemoteImpl: PlatformImpl< RestartAppResult > = { requires: ["sim-remote"], - handler: async (services, params) => { - const { udid, bundleId } = params; - const blocked = await precheckNativeDevtools(services.nativeDevtools, udid); - if (blocked) return blocked; - try { - await simctlTerminate(udid, bundleId); - } catch { - // App may not be running — ignore. - } - await simctlLaunch(udid, bundleId); - return { restarted: true, bundleId }; - }, + handler: buildIosRestartHandler(remoteSimctl), }; 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..0da42662 100644 --- a/packages/tool-server/src/tools/restart-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/restart-app/platforms/ios.ts @@ -1,23 +1,9 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { localSimctl } from "../../../utils/simctl-backend"; import type { RestartAppIosServices, RestartAppParams, RestartAppResult } from "../types"; - -const execFileAsync = promisify(execFile); +import { buildIosRestartHandler } from "./shared"; export const iosImpl: PlatformImpl = { requires: ["xcrun"], - handler: async (services, params) => { - const { udid, bundleId } = params; - const blocked = await precheckNativeDevtools(services.nativeDevtools, udid); - if (blocked) return blocked; - try { - await execFileAsync("xcrun", ["simctl", "terminate", udid, bundleId]); - } catch { - // App may not be running — ignore - } - await execFileAsync("xcrun", ["simctl", "launch", udid, bundleId]); - return { restarted: true, bundleId }; - }, + handler: buildIosRestartHandler(localSimctl), }; diff --git a/packages/tool-server/src/tools/restart-app/platforms/shared.ts b/packages/tool-server/src/tools/restart-app/platforms/shared.ts new file mode 100644 index 00000000..ca60f4a8 --- /dev/null +++ b/packages/tool-server/src/tools/restart-app/platforms/shared.ts @@ -0,0 +1,27 @@ +import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; +import type { SimctlBackend } from "../../../utils/simctl-backend"; +import type { RestartAppIosServices, RestartAppParams, RestartAppResult } from "../types"; + +/** + * Shared iOS handler for both local (`xcrun simctl`) and remote + * (`sim-remote simctl`) branches. Termination is best-effort — the app may not + * be running. Only the simctl verbs differ between branches, parametrised via + * `backend`. + */ +export function buildIosRestartHandler(backend: SimctlBackend) { + return async ( + services: RestartAppIosServices, + params: RestartAppParams + ): Promise => { + const { udid, bundleId } = params; + const blocked = await precheckNativeDevtools(services.nativeDevtools, udid); + if (blocked) return blocked; + try { + await backend.terminate(udid, bundleId); + } catch { + // App may not be running — ignore. + } + await backend.launch(udid, bundleId); + return { restarted: true, bundleId }; + }; +} diff --git a/packages/tool-server/src/utils/ax-prefs.ts b/packages/tool-server/src/utils/ax-prefs.ts new file mode 100644 index 00000000..cca13ae3 --- /dev/null +++ b/packages/tool-server/src/utils/ax-prefs.ts @@ -0,0 +1,105 @@ +import * as fsAsync from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { SIMCTL_SPAWN_TIMEOUT_MS } from "./simctl-config"; + +const execFileAsync = promisify(execFile); + +export async function ensureAutomationEnabled(udid: string): Promise { + await execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.Accessibility", + "AutomationEnabled", + "-bool", + "true", + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); +} + +/** + * Check whether `IgnoreAXServerEntitlements` is active on this sim. + * + * iOS 26.5+: SB's AX server rejects unentitled MIG clients with + * kAXError -25215. The pref disables the check, but SB caches it at + * init — writing it post-boot has no effect until the next restart. + * The only effective path is the pre-boot plist write in boot-device. + * + * This read-only probe tells the caller whether the pre-boot write + * happened so describe can surface a degraded-quality hint when it didn't. + */ +export async function isEntitlementBypassActive(udid: string): Promise { + return execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "defaults", + "read", + "com.apple.Accessibility", + "IgnoreAXServerEntitlements", + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ) + .then(({ stdout }) => stdout.trim() === "1") + .catch(() => false); +} + +/** + * Host-side `com.apple.Accessibility` plist inside the sim's data container. + * Writeable while Shutdown; in-sim cfprefsd overwrites it once Booted. + */ +function accessibilityPlistPath(udid: string): string { + return path.join( + os.homedir(), + "Library/Developer/CoreSimulator/Devices", + udid, + "data/Library/Preferences/com.apple.Accessibility.plist" + ); +} + +/** + * Write the four AX prefs to the sim's host plist BEFORE `simctl boot` so SB + * caches them at AX-server init and never needs the disruptive kickstart + * (which kills the foreground app and dismisses in-flight system alerts). + * + * All four are required on a freshly-erased sim: + * - `IgnoreAXServerEntitlements` bypasses the iOS 26.5+ kAXErrorNotEntitled check. + * - `AutomationEnabled` opts the simctl-spawned ax-service in as an AX client. + * - `AccessibilityEnabled` + `ApplicationAccessibilityEnabled` gate the AT + * subsystem bootstrap. Without them SB never spawns `AccessibilityUIServer` + * and describe returns an empty ROOT even though the entitlement check passes + * (reproduced on a wiped iPhone 17e: AccessibilityUIServer active count = 0 + * without these two; auto-spawns at boot with them). + * + * Caller must ensure the sim is Shutdown — in-sim cfprefsd would otherwise + * overwrite this file on flush. + */ +export async function setAccessibilityPrefsPreBoot(udid: string): Promise { + const plistPath = accessibilityPlistPath(udid); + await fsAsync.mkdir(path.dirname(plistPath), { recursive: true }); + const exists = await fsAsync + .access(plistPath) + .then(() => true) + .catch(() => false); + if (!exists) { + await execFileAsync("plutil", ["-create", "binary1", plistPath]); + } + for (const key of [ + "AutomationEnabled", + "IgnoreAXServerEntitlements", + "AccessibilityEnabled", + "ApplicationAccessibilityEnabled", + ]) { + await execFileAsync("plutil", ["-replace", key, "-bool", "true", plistPath]); + } +} diff --git a/packages/tool-server/src/utils/ios-host.ts b/packages/tool-server/src/utils/ios-host.ts new file mode 100644 index 00000000..0f1782de --- /dev/null +++ b/packages/tool-server/src/utils/ios-host.ts @@ -0,0 +1,301 @@ +import { execFile, ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import type { DeviceInfo } from "@argent/registry"; +import { + axServiceBinaryPath, + axServiceBinaryPathTcp, + bootstrapDylibPath, + bootstrapDylibPathTcp, +} from "@argent/native-devtools-ios"; +import { SIMCTL_SPAWN_TIMEOUT_MS } from "./simctl-config"; +import { ensureAutomationEnabled, isEntitlementBypassActive } from "./ax-prefs"; +import { + proxyStart as simRemoteProxyStart, + proxyStop as simRemoteProxyStop, + setupAxService as simRemoteSetupAxService, + setupNativeDevtools as simRemoteSetupNativeDevtools, + setupRunningBundleIds as simRemoteRunningBundleIds, +} from "./sim-remote"; + +const execFileAsync = promisify(execFile); + +export type IosEndpoint = + | { transport: "unix"; socketPath: string } + | { transport: "tcp"; port: number }; + +/** + * Strategy that absorbs the local-vs-remote dichotomy out of the iOS + * blueprints (ax-service, native-devtools). Each iOS service factory threads + * its setup/teardown through one of these implementations and reads as a + * linear pipeline instead of an `if (isRemote)` ladder. + */ +export interface IosHost { + readonly kind: "local" | "remote"; + /** When true, the host can only carry TCP traffic (sim-remote tunnel can't bridge unix sockets). */ + readonly requiresTcp: boolean; + + // ── native-devtools steps ── + setupNativeDevtoolsEnv(udid: string, endpoint: IosEndpoint): Promise; + listRunningBundleIds(udid: string): Promise>; + + // ── ax-service steps ── + /** Local probes via `defaults read`; remote assumes the orchestrator handled it. */ + bootstrapAx(udid: string): Promise<{ entitlementBypassActive: boolean }>; + /** + * Local: real `xcrun simctl spawn` process for the ax-service daemon. + * Remote: fire-and-forget orchestrator setup; returns an `EventEmitter` stub + * so the surrounding factory's exit/error wiring and `kill()` on dispose + * still work. + */ + spawnAxDaemon(udid: string, endpoint: IosEndpoint): ChildProcess; + + // ── reverse tunnel (no-op on local) ── + startProxy(udid: string, port: number): Promise; + stopProxy(udid: string, port: number): Promise; +} + +/** Current bootstrap filename; `libInjectionBootstrap.dylib` is legacy (pre-rename) and still stripped when merging env. */ +const ARGENT_BOOTSTRAP_DYLIB_BASENAMES = new Set([ + "libArgentInjectionBootstrap.dylib", + "libInjectionBootstrap.dylib", +]); + +function splitDyldInsertLibraries(value: string): string[] { + return value + .split(":") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Strips Argent bootstrap dylibs (by basename, including the legacy pre-rename name) + * and entries that don't exist on disk (truncated artifacts from the simctl getenv + * 127-byte bug, stale paths from old installs, etc.). + * Entries starting with '@' (loader-path references) are always preserved. + * Third-party dylibs present on disk (e.g. SimCam) are kept verbatim. + */ +function shouldPreserveDyldInsertLibrariesEntry(entry: string, bootstrapPath: string): boolean { + if (entry === bootstrapPath) { + return false; + } + if (ARGENT_BOOTSTRAP_DYLIB_BASENAMES.has(path.basename(entry))) { + return false; + } + if (entry.startsWith("@")) { + return true; + } + return fs.existsSync(entry); +} + +export function buildDyldInsertLibraries(currentValue: string, bootstrapPath: string): string { + const preserved = splitDyldInsertLibraries(currentValue).filter((entry) => + shouldPreserveDyldInsertLibrariesEntry(entry, bootstrapPath) + ); + return [...preserved, bootstrapPath].join(":"); +} + +async function ensureAccessibilityEnabled(udid: string): Promise { + // iOS 26+ requires AccessibilityEnabled and ApplicationAccessibilityEnabled to be set + // in the simulator's defaults for SwiftUI to populate the accessibility tree. + // Without these flags, all UIAccessibility APIs return nil/0 for SwiftUI views. + const flags = ["AccessibilityEnabled", "ApplicationAccessibilityEnabled"]; + await Promise.all( + flags.map((flag) => + execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.Accessibility", + flag, + "-bool", + "true", + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ) + ) + ); +} + +async function setupNativeDevtoolsEnvLocal(udid: string, endpoint: IosEndpoint): Promise { + const bootstrapPath = + endpoint.transport === "tcp" ? bootstrapDylibPathTcp() : bootstrapDylibPath(); + + // Read from launchctl inside the simulator (via simctl spawn) instead of + // `simctl getenv`. The latter silently truncates values longer than 127 bytes, + // which corrupts the colon-separated path list and causes stale entries to + // accumulate on every ensureEnv() cycle. + const result = await execFileAsync( + "xcrun", + ["simctl", "spawn", udid, "launchctl", "getenv", "DYLD_INSERT_LIBRARIES"], + { encoding: "utf8", timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ).catch((e) => ({ stdout: (e as NodeJS.ErrnoException & { stdout?: string }).stdout ?? "" })); + + const existing = (result.stdout ?? "").trim(); + const updated = buildDyldInsertLibraries(existing, bootstrapPath); + + if (updated !== existing) { + await execFileAsync( + "xcrun", + ["simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", updated], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); + } + + if (endpoint.transport === "tcp") { + await execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "launchctl", + "setenv", + "NATIVE_DEVTOOLS_IOS_CDP_PORT", + String(endpoint.port), + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); + } else { + await execFileAsync( + "xcrun", + [ + "simctl", + "spawn", + udid, + "launchctl", + "setenv", + "NATIVE_DEVTOOLS_IOS_CDP_SOCKET", + endpoint.socketPath, + ], + { timeout: SIMCTL_SPAWN_TIMEOUT_MS } + ); + } + + await ensureAccessibilityEnabled(udid); +} + +/** + * Bare basename of the bootstrap dylib the orchestrator should inject. The + * dylib lives on the orchestrator side (it's the TCP variant of the local + * `libArgentInjectionBootstrap.dylib`) and `sim-remote setup native-devtools` + * resolves it by basename against the orchestrator's own dylib directory — + * we never need a local copy. Hardcoding the basename avoids a + * `bootstrapDylibPathTcp()` lookup that would throw on dev machines that + * don't ship the local TCP variant. + */ +const REMOTE_BOOTSTRAP_DYLIB_BASENAME = "libArgentInjectionBootstrap.dylib"; + +async function setupNativeDevtoolsEnvRemote(udid: string, endpoint: IosEndpoint): Promise { + if (endpoint.transport !== "tcp") { + throw new Error("ios-remote native-devtools requires TCP transport"); + } + await simRemoteSetupNativeDevtools(udid, { + libs: [REMOTE_BOOTSTRAP_DYLIB_BASENAME], + cdpPort: endpoint.port, + }); +} + +async function listRunningUIKitApplicationBundleIds(udid: string): Promise> { + const { stdout } = await execFileAsync("xcrun", ["simctl", "spawn", udid, "launchctl", "list"], { + encoding: "utf8", + }); + + const bundleIds = new Set(); + for (const line of stdout.split("\n")) { + const match = line.match(/UIKitApplication:([^\[]+)/); + if (match) { + bundleIds.add(match[1].trim()); + } + } + return bundleIds; +} + +function spawnAxDaemonLocal(udid: string, endpoint: IosEndpoint): ChildProcess { + const binaryPath = + endpoint.transport === "tcp" ? axServiceBinaryPathTcp() : axServiceBinaryPath(); + + const endpointArgs = + endpoint.transport === "tcp" + ? ["--port", String(endpoint.port)] + : ["--socket", endpoint.socketPath]; + + const proc = execFile( + "xcrun", + ["simctl", "spawn", udid, binaryPath, ...endpointArgs, "--timeout", "3600"], + { encoding: "utf8" } + ) as ChildProcess; + + // Defense-in-depth: a missing udid here would crash the process — + // throwing inside an async listener bypasses promise rejection and + // bubbles up as `uncaughtException`, which the tool-server treats as + // fatal. Tag with "?" instead of dereferencing. + const udidTag = typeof udid === "string" && udid.length > 0 ? udid.slice(0, 8) : "?"; + proc.stderr?.on("data", (data: string) => { + process.stderr.write(`[ax-service ${udidTag}] ${data}`); + }); + + return proc; +} + +function spawnAxDaemonRemote(udid: string, endpoint: IosEndpoint): ChildProcess { + if (endpoint.transport !== "tcp") { + throw new Error("ios-remote ax-service requires TCP transport"); + } + // ios-remote: the orchestrator-supplied daemon is started by + // `sim-remote setup ax-service`. There is no local child process to + // shepherd — return a no-op ChildProcess stub so the surrounding factory + // code (exit/error wiring, kill on dispose) still type-checks. + const noop = new EventEmitter() as unknown as ChildProcess; + (noop as unknown as { kill: () => boolean }).kill = () => true; + void simRemoteSetupAxService(udid, { port: endpoint.port, timeoutSecs: 3600 }).catch( + (err: Error) => { + // Defer the emit so listeners attached after this call still see it. + setImmediate(() => noop.emit("error", err)); + } + ); + return noop; +} + +export const localIosHost: IosHost = { + kind: "local", + requiresTcp: false, + setupNativeDevtoolsEnv: setupNativeDevtoolsEnvLocal, + listRunningBundleIds: listRunningUIKitApplicationBundleIds, + async bootstrapAx(udid) { + await ensureAutomationEnabled(udid); + return { entitlementBypassActive: await isEntitlementBypassActive(udid) }; + }, + spawnAxDaemon: spawnAxDaemonLocal, + async startProxy() {}, + async stopProxy() {}, +}; + +export const remoteIosHost: IosHost = { + kind: "remote", + requiresTcp: true, + setupNativeDevtoolsEnv: setupNativeDevtoolsEnvRemote, + async listRunningBundleIds(udid) { + return new Set(await simRemoteRunningBundleIds(udid)); + }, + // sim-remote applies the accessibility defaults at boot via `setup + // accessibility-defaults`, and the entitlement-bypass plist is managed + // there too. Mark the service as non-degraded on the assumption sim-remote + // did the right thing; if not, describe will still surface useful errors. + async bootstrapAx() { + return { entitlementBypassActive: true }; + }, + spawnAxDaemon: spawnAxDaemonRemote, + startProxy: simRemoteProxyStart, + stopProxy: simRemoteProxyStop, +}; + +export function pickIosHost(device: DeviceInfo): IosHost { + return device.platform === "ios-remote" ? remoteIosHost : localIosHost; +} diff --git a/packages/tool-server/src/utils/simctl-backend.ts b/packages/tool-server/src/utils/simctl-backend.ts new file mode 100644 index 00000000..3972faad --- /dev/null +++ b/packages/tool-server/src/utils/simctl-backend.ts @@ -0,0 +1,29 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { simctlLaunch, simctlTerminate } from "./sim-remote"; + +const execFileAsync = promisify(execFile); + +/** + * Strategy for the simctl verbs that a tool handler shells out to. Lets a + * single iOS handler serve both local sims (`xcrun simctl`) and remote sims + * (`sim-remote simctl`) without an `isRemote` branch inside the handler body. + */ +export interface SimctlBackend { + launch(udid: string, bundleId: string): Promise; + terminate(udid: string, bundleId: string): Promise; +} + +export const localSimctl: SimctlBackend = { + async launch(udid, bundleId) { + await execFileAsync("xcrun", ["simctl", "launch", udid, bundleId]); + }, + async terminate(udid, bundleId) { + await execFileAsync("xcrun", ["simctl", "terminate", udid, bundleId]); + }, +}; + +export const remoteSimctl: SimctlBackend = { + launch: simctlLaunch, + terminate: simctlTerminate, +}; From d3d057db0a6b383c70174bdd4f02a7bd7f524ad8 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Tue, 2 Jun 2026 16:07:15 +0200 Subject: [PATCH 5/9] bump submodule --- packages/argent-private | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/argent-private b/packages/argent-private index 82213764..2a15475f 160000 --- a/packages/argent-private +++ b/packages/argent-private @@ -1 +1 @@ -Subproject commit 82213764cab00cb4c087c91c5282ba0d92fb1cfe +Subproject commit 2a15475f16da013c4248ba99c9c7bd7fe8c21552 From 0faa0ce0b4a6c014b98237bde33dcbdb775cd07e Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Tue, 2 Jun 2026 17:23:41 +0200 Subject: [PATCH 6/9] fix typecheck error --- packages/tool-server/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tool-server/tsconfig.json b/packages/tool-server/tsconfig.json index 3b8ac147..733d152d 100644 --- a/packages/tool-server/tsconfig.json +++ b/packages/tool-server/tsconfig.json @@ -4,7 +4,8 @@ "composite": true, "declaration": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "customConditions": ["node"] }, "references": [ { "path": "../registry" }, From 938855f45687b9ef23c04231f72a67430055586a Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Tue, 2 Jun 2026 17:30:43 +0200 Subject: [PATCH 7/9] fmt --- .../tool-server/src/blueprints/native-devtools.ts | 4 +++- .../tool-server/src/tools/devices/boot-device.ts | 4 +++- .../src/tools/launch-app/platforms/shared.ts | 5 ++++- packages/tool-server/src/utils/check-deps.ts | 7 ++++--- packages/tool-server/src/utils/datachannel-proto.ts | 13 ++----------- packages/tool-server/src/utils/simulator-client.ts | 9 +++++---- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index bc58afeb..b6b8dd99 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -209,7 +209,9 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { +): Promise< + { platform: "ios-remote"; udid: string; booted: true } | NativeDevtoolsInitFailedResult +> { await ensureDep("sim-remote"); const udid = stripRemotePrefix(id); diff --git a/packages/tool-server/src/tools/launch-app/platforms/shared.ts b/packages/tool-server/src/tools/launch-app/platforms/shared.ts index 6320228f..8d5dc75a 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/shared.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/shared.ts @@ -9,7 +9,10 @@ import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../ * verbs differ — parametrised via `backend`. */ export function buildIosLaunchHandler(backend: SimctlBackend) { - return async (services: LaunchAppIosServices, params: LaunchAppParams): Promise => { + return async ( + services: LaunchAppIosServices, + params: LaunchAppParams + ): Promise => { const blocked = await precheckNativeDevtools(services.nativeDevtools, params.udid); if (blocked) return blocked; await backend.launch(params.udid, params.bundleId); diff --git a/packages/tool-server/src/utils/check-deps.ts b/packages/tool-server/src/utils/check-deps.ts index 996e8ea6..4c5e2d06 100644 --- a/packages/tool-server/src/utils/check-deps.ts +++ b/packages/tool-server/src/utils/check-deps.ts @@ -31,10 +31,11 @@ const cache = new Map(); // Short per-dep hints — the message is what the LLM sees on a missing-dep // error, so it should tell it how to unblock the user. const INSTALL_HINTS: Record = { - xcrun: + "xcrun": "Xcode command-line tools are not installed. Run `xcode-select --install` (or install Xcode from the App Store) and retry. Only required for iOS simulators.", - adb: "Android SDK Platform Tools not found. Install with `brew install --cask android-platform-tools` or via Android Studio → SDK Manager. If installed, ensure `adb` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/platform-tools/adb`). Only required for Android devices and emulators.", - emulator: + "adb": + "Android SDK Platform Tools not found. Install with `brew install --cask android-platform-tools` or via Android Studio → SDK Manager. If installed, ensure `adb` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/platform-tools/adb`). Only required for Android devices and emulators.", + "emulator": "Android Emulator not found. Install via Android Studio → SDK Manager → Emulator, or `sdkmanager 'emulator'`. If installed, ensure `emulator` is on PATH or set `$ANDROID_HOME` to the SDK root (the resolver checks `$ANDROID_HOME/emulator/emulator`). Only required to launch new Android emulators via `boot-device`.", "sim-remote": "`sim-remote` CLI not found on PATH. Install via the radon-cloud project (see its README) and run `sim-remote login` before invoking any ios-remote tool. Only required for remote iOS simulators.", diff --git a/packages/tool-server/src/utils/datachannel-proto.ts b/packages/tool-server/src/utils/datachannel-proto.ts index 42429e70..f00a736f 100644 --- a/packages/tool-server/src/utils/datachannel-proto.ts +++ b/packages/tool-server/src/utils/datachannel-proto.ts @@ -115,11 +115,7 @@ export type ButtonName = | "volumeDown" | "appSwitch" | "actionButton"; -export type RotationName = - | "Portrait" - | "PortraitUpsideDown" - | "LandscapeLeft" - | "LandscapeRight"; +export type RotationName = "Portrait" | "PortraitUpsideDown" | "LandscapeLeft" | "LandscapeRight"; const TOUCH_ACTION: Record = { Down: TouchAction.TOUCH_DOWN ?? 0, @@ -199,11 +195,6 @@ export function encodeScreenshot(opts?: { return encode({ screenshot }); } -export function encodeWheel(opts: { - x: number; - y: number; - dx: number; - dy: number; -}): Uint8Array { +export function encodeWheel(opts: { x: number; y: number; dx: number; dy: number }): Uint8Array { return encode({ wheel: { x: opts.x, y: opts.y, dx: opts.dx, dy: opts.dy } }); } diff --git a/packages/tool-server/src/utils/simulator-client.ts b/packages/tool-server/src/utils/simulator-client.ts index f3be7487..a722657b 100644 --- a/packages/tool-server/src/utils/simulator-client.ts +++ b/packages/tool-server/src/utils/simulator-client.ts @@ -181,7 +181,10 @@ export async function httpScreenshot( return { url: resBody.url, path: resBody.path }; } -function routeViaTransport(transport: SimulatorServerTransport, cmd: Record): void { +function routeViaTransport( + transport: SimulatorServerTransport, + cmd: Record +): void { switch (cmd.cmd) { case "touch": { // Local WebSocket protocol uses snake_case second_x/second_y (set to @@ -252,9 +255,7 @@ export function createMoqTransport( ); }, button(opts) { - void moq.sendControl( - encodeButton({ action: opts.direction, button: opts.button }) - ); + void moq.sendControl(encodeButton({ action: opts.direction, button: opts.button })); }, rotate(direction) { void moq.sendControl(encodeRotate(direction)); From e6cf90b72a014a85cc9007b3bb23c85bd84a4f5c Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Wed, 3 Jun 2026 10:55:12 +0200 Subject: [PATCH 8/9] fix deps checking for native ios tools --- .../src/tools/native-devtools/native-describe-screen.ts | 5 ++++- .../src/tools/native-devtools/native-devtools-status.ts | 5 ++++- .../src/tools/native-devtools/native-find-views.ts | 5 ++++- .../src/tools/native-devtools/native-full-hierarchy.ts | 5 ++++- .../src/tools/native-devtools/native-network-logs.ts | 5 ++++- .../native-user-interactable-view-at-point.ts | 5 ++++- .../src/tools/native-devtools/native-view-at-point.ts | 5 ++++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts index 5257fb51..b12d97a3 100644 --- a/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts +++ b/packages/tool-server/src/tools/native-devtools/native-describe-screen.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; import { parseNativeDescribeScreenResult, type NativeDescribeScreenResult, @@ -40,7 +41,6 @@ type Result = export const nativeDescribeScreenTool: ToolDefinition = { id: "native-describe-screen", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Read the running app's native accessibility screen description via injected native devtools. @@ -61,6 +61,9 @@ If status is restart_required: call restart-app then retry.`, nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); diff --git a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts index b9befb02..75cf0347 100644 --- a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts +++ b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -26,7 +27,6 @@ type Result = export const nativeDevtoolsStatusTool: ToolDefinition = { id: "native-devtools-status", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Check whether native devtools are connected to a specific app and whether the next launch is prepared for injection. Use when you need to verify native devtools readiness before calling native-full-hierarchy, native-describe-screen, or native-network-logs. @@ -47,6 +47,9 @@ Fails if the simulator server is not running for the given UDID or the bundleId nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid); diff --git a/packages/tool-server/src/tools/native-devtools/native-find-views.ts b/packages/tool-server/src/tools/native-devtools/native-find-views.ts index e402351b..2e03244b 100644 --- a/packages/tool-server/src/tools/native-devtools/native-find-views.ts +++ b/packages/tool-server/src/tools/native-devtools/native-find-views.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -43,7 +44,6 @@ type Result = export const nativeFindViewsTool: ToolDefinition = { id: "native-find-views", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Search for specific UIViews in the running app by class name, accessibility identifier, label, tag, or React Native nativeID. Use when you need to locate a specific view by its properties without dumping the entire hierarchy. @@ -55,6 +55,9 @@ Fails if native devtools are not connected, the app is not running, or status is nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); diff --git a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts index 9e1769d3..ab85f7f0 100644 --- a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts +++ b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -57,7 +58,6 @@ type Result = export const nativeFullHierarchyTool: ToolDefinition = { id: "native-full-hierarchy", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Get the complete UIKit view tree for the running app. WARNING: Output can be extremely large (100KB–500KB+) for complex apps, especially those built with SwiftUI. Prefer native-find-views for targeted queries. @@ -70,6 +70,9 @@ Fails if native devtools are not connected or the app is not running.`, nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 4b618016..f8cc9fd0 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -8,6 +8,7 @@ import { type NetworkEvent, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -28,7 +29,6 @@ type Result = export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. @@ -40,6 +40,9 @@ Fails if native devtools are not connected or the app is not running.`, nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); diff --git a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts index 9ea5c6d8..f4e9701f 100644 --- a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -62,7 +63,6 @@ type Result = export const nativeUserInteractableViewAtPointTool: ToolDefinition = { id: "native-user-interactable-view-at-point", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Inspect the deepest UIView at a raw native window point that would actually receive touch input. @@ -78,6 +78,9 @@ If status is restart_required: call restart-app then retry.`, nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); diff --git a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts index aa801f1a..f191df76 100644 --- a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts @@ -7,6 +7,7 @@ import { type NativeDevtoolsInitFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; +import { ensureDeps } from "../../utils/check-deps"; const zodSchema = z.object({ udid: z.string().describe("Simulator UDID"), @@ -62,7 +63,6 @@ type Result = export const nativeViewAtPointTool: ToolDefinition = { id: "native-view-at-point", - requires: ["xcrun"], capability: { apple: { simulator: true, device: true }, appleRemote: { simulator: true } }, description: `Inspect the deepest visible UIView at a raw native window point. @@ -78,6 +78,9 @@ If status is restart_required: call restart-app then retry.`, nativeDevtools: nativeDevtoolsRef(resolveDevice(params.udid)), }), async execute(services, params) { + const device = resolveDevice(params.udid); + await ensureDeps(device.platform === "ios-remote" ? ["sim-remote"] : ["xcrun"]); + const api = services.nativeDevtools as NativeDevtoolsApi; const blocked = await precheckNativeDevtools(api, params.udid, params.bundleId); From 0b9f4f3686197c28c6926ff0c8af9dd2e28ed346 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Wed, 3 Jun 2026 12:58:29 +0200 Subject: [PATCH 9/9] use ephemeral ports for native ios devtools --- .../tool-server/src/blueprints/ax-service.ts | 27 +++++++---- .../src/blueprints/native-devtools.ts | 46 +++++++++++-------- packages/tool-server/src/utils/ios-host.ts | 17 ++++++- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index e6f5961d..dd95aa99 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -23,8 +23,6 @@ export const AX_SERVICE_NAMESPACE = "AXService"; export type AXServiceTransport = "unix" | "tcp"; -export const AX_SERVICE_TCP_PORT = Number(process.env.AX_SERVICE_TCP_PORT) || 9231; - // Same DeviceInfo-via-options pattern as the other iOS-only blueprints. type AxServiceFactoryOptions = Record & { device: DeviceInfo; @@ -83,7 +81,9 @@ interface PendingRpc { } // Listen on the chosen transport. Unix: pre-unlink stale socket from previous -// runs so listen() doesn't EADDRINUSE. +// runs so listen() doesn't EADDRINUSE. TCP: when `endpoint.port` is undefined, +// bind on an OS-assigned ephemeral port and write the realized port back into +// `endpoint.port` so each per-device instance gets its own non-colliding port. function startListener( endpoint: IosEndpoint, onConnection: (socket: net.Socket) => void @@ -100,11 +100,20 @@ function startListener( const onListening = () => { server.off("error", reject); + if (endpoint.transport === "tcp") { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + server.close(); + reject(new Error("ax-service server failed to bind a TCP port")); + return; + } + endpoint.port = addr.port; + } resolve(server); }; if (endpoint.transport === "tcp") { - server.listen(endpoint.port, "127.0.0.1", onListening); + server.listen(endpoint.port ?? 0, "127.0.0.1", onListening); } else { server.listen(endpoint.socketPath, onListening); } @@ -201,7 +210,7 @@ export const axServiceBlueprint: ServiceBlueprint = { const transport: AXServiceTransport = host.requiresTcp ? "tcp" : (opts.transport ?? "unix"); const endpoint: IosEndpoint = transport === "tcp" - ? { transport: "tcp", port: AX_SERVICE_TCP_PORT } + ? { transport: "tcp" } : { transport: "unix", socketPath: getSocketPath(udid) }; const events = new TypedEventEmitter(); @@ -271,8 +280,8 @@ export const axServiceBlueprint: ServiceBlueprint = { // Wire the reverse tunnel BEFORE asking the orchestrator to start the // daemon — the daemon will dial 127.0.0.1: inside the simulator // and that dial gets QUIC-forwarded back to our host listener above. - // No-op on local. - await host.startProxy(udid, endpoint.port); + // No-op on local. `port` was populated by startListener. + await host.startProxy(udid, endpoint.port!); } const proc = host.spawnAxDaemon(udid, endpoint); @@ -301,7 +310,7 @@ export const axServiceBlueprint: ServiceBlueprint = { } catch {} } if (endpoint.transport === "tcp") { - await host.stopProxy(udid, endpoint.port); + await host.stopProxy(udid, endpoint.port!); } throw err; } @@ -369,7 +378,7 @@ export const axServiceBlueprint: ServiceBlueprint = { } catch {} } if (endpoint.transport === "tcp") { - await host.stopProxy(udid, endpoint.port); + await host.stopProxy(udid, endpoint.port!); } }, events, diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index b6b8dd99..951efdbd 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -7,15 +7,13 @@ import { type ServiceBlueprint, type ServiceEvents, } from "@argent/registry"; -import { pickIosHost, buildDyldInsertLibraries } from "../utils/ios-host"; +import { pickIosHost, buildDyldInsertLibraries, type IosEndpoint } from "../utils/ios-host"; // Re-exported for the env-merging unit test that imports it from this module. export { buildDyldInsertLibraries }; export type NativeDevtoolsTransport = "unix" | "tcp"; -export const NATIVE_DEVTOOLS_TCP_PORT = Number(process.env.NATIVE_DEVTOOLS_TCP_PORT) || 9230; - export const NATIVE_DEVTOOLS_NAMESPACE = "NativeDevtools"; // Max consecutive init failures per service instance before it stops retrying. @@ -215,11 +213,11 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); const pendingRpc = new Map< @@ -396,17 +394,29 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint((resolve, reject) => { + server.once("error", reject); + server.listen(endpoint.port ?? 0, "127.0.0.1", () => { + server.off("error", reject); + const addr = server.address(); + if (addr === null || typeof addr === "string") { + server.close(); + reject(new Error("native-devtools server failed to bind a TCP port")); + return; + } + endpoint.port = addr.port; + resolve(); + }); + }); // Wire the reverse tunnel (no-op on local) before kicking off ensureEnv // so the dylib's first dial — which can happen as soon as the env is // written — lands on our listener. - await host.startProxy(udid, tcpPort); + await host.startProxy(udid, endpoint.port!); + } else { + server.listen(socketPath); } // Tolerate ensureEnv failure: throwing here would leak `server` — the @@ -528,8 +538,8 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint