diff --git a/packages/argent-cli/src/server.ts b/packages/argent-cli/src/server.ts index 16a1db082..2c412b9b3 100644 --- a/packages/argent-cli/src/server.ts +++ b/packages/argent-cli/src/server.ts @@ -16,6 +16,8 @@ import { formatToolsServerUrl, formatLinkUrl, generateAuthToken, + IOS_DEVICE_SET_ENV, + resolveIosDeviceSetPath, type ToolsServerPaths, type ToolsServerState, } from "@argent/tools-client"; @@ -64,6 +66,9 @@ async function statusCmd(json: boolean): Promise { console.log(` startedAt: ${state.startedAt}`); console.log(` process: ${alive ? "alive" : "dead"}`); console.log(` health: ${healthy ? "ok" : "unreachable"}`); + if (state.iosDeviceSetPath) { + console.log(` iOS set: ${state.iosDeviceSetPath}`); + } if (!alive || !healthy) { console.log(`\nState file is stale; next \`argent\` invocation will respawn the server.`); } @@ -102,6 +107,8 @@ export interface StartFlags { force: boolean; /** Disable auth (no token minted). Server accepts unauthenticated requests. */ noAuth: boolean; + /** Optional CoreSimulator device set path for iOS-only operations. */ + iosDeviceSetPath: string | null; help: boolean; } @@ -115,6 +122,7 @@ export function parseStartFlags(argv: string[]): StartFlags { detach: false, force: false, noAuth: false, + iosDeviceSetPath: null, help: false, }; @@ -166,6 +174,14 @@ export function parseStartFlags(argv: string[]): StartFlags { flags.idleTimeoutMinutes = parseIdle(tok.slice("--idle-timeout=".length)); continue; } + if (tok === "--ios-device-set") { + flags.iosDeviceSetPath = parseIosDeviceSetPath(takeValue("--ios-device-set")); + continue; + } + if (tok.startsWith("--ios-device-set=")) { + flags.iosDeviceSetPath = parseIosDeviceSetPath(tok.slice("--ios-device-set=".length)); + continue; + } throw new StartFlagError(`Unknown flag: ${tok}`); } @@ -191,6 +207,14 @@ export function parseIdle(raw: string): number { return Number(raw); } +export function parseIosDeviceSetPath(raw: string): string { + const resolved = resolveIosDeviceSetPath(raw); + if (!resolved) { + throw new StartFlagError(`--ios-device-set requires a non-empty path`); + } + return resolved; +} + function printStartHelp(): void { console.log(`Usage: argent server start [flags] @@ -203,6 +227,8 @@ Flags: Use 0.0.0.0 to expose on every interface. --idle-timeout Auto-shutdown after idle minutes (0 disables). Default: 0 (never auto-shutdown). + --ios-device-set Use this CoreSimulator device set for iOS operations. + Also configurable via ${IOS_DEVICE_SET_ENV}. --detach, -d Run as a detached background process and return. --force If a tool-server is already running, kill it first. --no-auth Disable authentication (no token). Anyone who can @@ -331,6 +357,8 @@ async function startCmd(argv: string[], paths: ToolsServerPaths | undefined): Pr } const port = await resolvePort(flags.port); + const iosDeviceSetPath = + flags.iosDeviceSetPath ?? resolveIosDeviceSetPath(process.env[IOS_DEVICE_SET_ENV]); // Auth on by default; --no-auth opts out (token stays undefined → the // tool-server runs unauthenticated and prints its own warning). @@ -352,11 +380,11 @@ async function startCmd(argv: string[], paths: ToolsServerPaths | undefined): Pr } if (flags.detach) { - await runDetached(paths, port, flags.host, flags.idleTimeoutMinutes, token); + await runDetached(paths, port, flags.host, flags.idleTimeoutMinutes, iosDeviceSetPath, token); return; } - await runForeground(paths, port, flags.host, flags.idleTimeoutMinutes, token); + await runForeground(paths, port, flags.host, flags.idleTimeoutMinutes, iosDeviceSetPath, token); } async function runDetached( @@ -364,11 +392,13 @@ async function runDetached( port: number, host: string, idleTimeoutMinutes: number, + iosDeviceSetPath: string | null, token?: string ): Promise { const { port: actualPort, pid } = await spawnToolsServer(paths, port, { host, idleTimeoutMinutes, + iosDeviceSetPath, token, }); await writeToolsServerState({ @@ -377,6 +407,7 @@ async function runDetached( startedAt: new Date().toISOString(), bundlePath: paths.bundlePath, host, + ...(iosDeviceSetPath ? { iosDeviceSetPath } : {}), ...(token ? { token } : {}), }); const url = formatToolsServerUrl(host, actualPort); @@ -393,11 +424,13 @@ async function runForeground( port: number, host: string, idleTimeoutMinutes: number, + iosDeviceSetPath: string | null, token?: string ): Promise { const env = buildToolsServerEnv(paths, port, process.env, { host, idleTimeoutMinutes, + iosDeviceSetPath, token, }); @@ -432,6 +465,7 @@ async function runForeground( startedAt: new Date().toISOString(), bundlePath: paths.bundlePath, host, + ...(iosDeviceSetPath ? { iosDeviceSetPath } : {}), ...(token ? { token } : {}), }); stateWritten = true; diff --git a/packages/argent-cli/test/parse-start-flags.test.ts b/packages/argent-cli/test/parse-start-flags.test.ts index 759eae221..0b3e93b09 100644 --- a/packages/argent-cli/test/parse-start-flags.test.ts +++ b/packages/argent-cli/test/parse-start-flags.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import * as path from "node:path"; import { parseStartFlags, parsePort, parseIdle, StartFlagError } from "../src/server.js"; describe("parseStartFlags", () => { @@ -10,6 +11,7 @@ describe("parseStartFlags", () => { detach: false, force: false, noAuth: false, + iosDeviceSetPath: null, help: false, }); }); @@ -35,6 +37,15 @@ describe("parseStartFlags", () => { expect(parseStartFlags(["--idle-timeout=0"]).idleTimeoutMinutes).toBe(0); }); + it("parses --ios-device-set in space and equals form", () => { + expect(parseStartFlags(["--ios-device-set", "tmp/device-set"]).iosDeviceSetPath).toBe( + path.resolve("tmp/device-set") + ); + expect(parseStartFlags(["--ios-device-set=/tmp/argent-set"]).iosDeviceSetPath).toBe( + "/tmp/argent-set" + ); + }); + it("parses boolean flags --detach/-d, --force, --no-auth, --help/-h", () => { expect(parseStartFlags(["--detach"]).detach).toBe(true); expect(parseStartFlags(["-d"]).detach).toBe(true); @@ -61,6 +72,7 @@ describe("parseStartFlags", () => { detach: true, force: true, noAuth: false, + iosDeviceSetPath: null, help: false, }); }); @@ -74,6 +86,12 @@ describe("parseStartFlags", () => { expect(() => parseStartFlags(["--port"])).toThrow(/--port requires a value/); expect(() => parseStartFlags(["--host"])).toThrow(/--host requires a value/); expect(() => parseStartFlags(["--idle-timeout"])).toThrow(/--idle-timeout requires a value/); + expect(() => parseStartFlags(["--ios-device-set"])).toThrow( + /--ios-device-set requires a value/ + ); + expect(() => parseStartFlags(["--ios-device-set="])).toThrow( + /--ios-device-set requires a non-empty path/ + ); }); it("does not consume the next token as a value for boolean flags", () => { diff --git a/packages/argent-tools-client/src/index.ts b/packages/argent-tools-client/src/index.ts index d9d542de3..ff290b709 100644 --- a/packages/argent-tools-client/src/index.ts +++ b/packages/argent-tools-client/src/index.ts @@ -13,7 +13,9 @@ export { formatToolsServerUrl, generateAuthToken, AUTH_TOKEN_ENV, + IOS_DEVICE_SET_ENV, STATE_PATHS, + resolveIosDeviceSetPath, type ToolsServerPaths, type ToolsServerState, type ToolsServerHandle, diff --git a/packages/argent-tools-client/src/launcher.ts b/packages/argent-tools-client/src/launcher.ts index 8f1a0335f..0e4476277 100644 --- a/packages/argent-tools-client/src/launcher.ts +++ b/packages/argent-tools-client/src/launcher.ts @@ -2,17 +2,18 @@ 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 { homedir } from "node:os"; +import * as os from "node:os"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdir, writeFile, readFile, unlink, rename, chmod } from "node:fs/promises"; -const STATE_DIR = path.join(homedir(), ".argent"); +const STATE_DIR = path.join(os.homedir(), ".argent"); const STATE_FILE = path.join(STATE_DIR, "tool-server.json"); const LOG_FILE = path.join(STATE_DIR, "tool-server.log"); const AUTH_TOKEN_BYTES = 32; export const AUTH_TOKEN_ENV = "ARGENT_AUTH_TOKEN"; +export const IOS_DEVICE_SET_ENV = "ARGENT_IOS_DEVICE_SET_PATH"; // Idle-shutdown policy for auto-spawned servers (MCP / `argent run` path). The // CLI's `argent server start` overrides this; manual launches default to no @@ -45,6 +46,13 @@ export interface BuildToolsServerEnvOptions { * `argent server start` path, which prints its own no-auth warning. */ token?: string; + /** Optional CoreSimulator device set path for iOS-only operations. */ + iosDeviceSetPath?: string | null; +} + +export function resolveIosDeviceSetPath(raw: string | null | undefined): string | null { + if (!raw?.trim()) return null; + return path.resolve(raw.trim()); } export function buildToolsServerEnv( @@ -53,12 +61,17 @@ export function buildToolsServerEnv( baseEnv: NodeJS.ProcessEnv = process.env, options: BuildToolsServerEnvOptions = {} ): NodeJS.ProcessEnv { + const iosDeviceSetPath = resolveIosDeviceSetPath( + options.iosDeviceSetPath ?? baseEnv[IOS_DEVICE_SET_ENV] + ); const env: NodeJS.ProcessEnv = { ...baseEnv, ARGENT_PORT: String(port), ARGENT_SIMULATOR_SERVER_DIR: paths.simulatorServerDir, ARGENT_NATIVE_DEVTOOLS_DIR: paths.nativeDevtoolsDir, }; + if (iosDeviceSetPath) env[IOS_DEVICE_SET_ENV] = iosDeviceSetPath; + else delete env[IOS_DEVICE_SET_ENV]; if (options.host !== undefined) env.ARGENT_HOST = options.host; if (options.idleTimeoutMinutes !== undefined) { env.ARGENT_IDLE_TIMEOUT_MINUTES = String(options.idleTimeoutMinutes); @@ -81,6 +94,8 @@ export interface ToolsServerState { * `argent server start` writes tokenless (auth-disabled) state. */ token?: string; + /** CoreSimulator device set path this tool-server was started with, if any. */ + iosDeviceSetPath?: string; } /** Handle returned to clients: the base URL plus the matching auth token. */ @@ -358,6 +373,7 @@ export async function killToolServer(): Promise { } export async function ensureToolsServer(paths: ToolsServerPaths): Promise { + const requestedIosDeviceSetPath = resolveIosDeviceSetPath(process.env[IOS_DEVICE_SET_ENV]); const state = await readState(); if (state) { @@ -365,12 +381,17 @@ export async function ensureToolsServer(paths: ToolsServerPaths): Promise { expect(env.FOO).toBe("bar"); expect(env.PATH).toBe("/usr/bin"); }); + + it("normalizes and propagates the iOS device set from options", () => { + const env = buildToolsServerEnv(paths, 3001, {}, { iosDeviceSetPath: "tmp/device-set" }); + expect(env[IOS_DEVICE_SET_ENV]).toBe(path.resolve("tmp/device-set")); + }); + + it("falls back to the iOS device set from the base env", () => { + const env = buildToolsServerEnv(paths, 3001, { [IOS_DEVICE_SET_ENV]: "/tmp/argent-set" }); + expect(env[IOS_DEVICE_SET_ENV]).toBe("/tmp/argent-set"); + }); + + it("removes a blank iOS device set from the spawned env", () => { + const env = buildToolsServerEnv(paths, 3001, { [IOS_DEVICE_SET_ENV]: " " }); + expect(env[IOS_DEVICE_SET_ENV]).toBeUndefined(); + }); +}); + +describe("resolveIosDeviceSetPath", () => { + it("returns null for empty values", () => { + expect(resolveIosDeviceSetPath(undefined)).toBeNull(); + expect(resolveIosDeviceSetPath(" ")).toBeNull(); + }); + + it("resolves relative paths", () => { + expect(resolveIosDeviceSetPath("tmp/device-set")).toBe(path.resolve("tmp/device-set")); + }); }); describe("formatToolsServerUrl", () => { diff --git a/packages/argent/src/cli.ts b/packages/argent/src/cli.ts index ed5ffa721..9d966a7a8 100644 --- a/packages/argent/src/cli.ts +++ b/packages/argent/src/cli.ts @@ -35,6 +35,7 @@ import { BUNDLED_RUNTIME_PATHS } from "./bundled-paths.js"; import { installFatalHandlers } from "./fatal-handlers.js"; const PACKAGE_NAME = "@swmansion/argent"; +const IOS_DEVICE_SET_ENV = "ARGENT_IOS_DEVICE_SET_PATH"; function getInstalledVersion(): string | null { try { @@ -48,7 +49,37 @@ function getInstalledVersion(): string | null { } } -const [, , command, ...rest] = process.argv; +function parseTopLevelArgs(argv: string[]): { command: string | undefined; rest: string[] } { + const pending = [...argv]; + while (pending.length > 0) { + const tok = pending[0]!; + if (tok === "--ios-device-set") { + const value = pending[1]; + if (!value?.trim()) { + console.error("Error: --ios-device-set requires a non-empty path"); + process.exit(2); + } + process.env[IOS_DEVICE_SET_ENV] = path.resolve(value.trim()); + pending.splice(0, 2); + continue; + } + if (tok.startsWith("--ios-device-set=")) { + const value = tok.slice("--ios-device-set=".length); + if (!value.trim()) { + console.error("Error: --ios-device-set requires a non-empty path"); + process.exit(2); + } + process.env[IOS_DEVICE_SET_ENV] = path.resolve(value.trim()); + pending.shift(); + continue; + } + break; + } + const [command, ...rest] = pending; + return { command, rest }; +} + +const { command, rest } = parseTopLevelArgs(process.argv.slice(2)); const isMcpServer = command === "mcp"; installFatalHandlers({ isMcpServer }); @@ -78,8 +109,9 @@ Commands: telemetry Manage anonymous opt-out telemetry (status / enable / disable) Options: - --help, -h Show this help message - --version, -v Show version + --ios-device-set Use this CoreSimulator device set for iOS operations + --help, -h Show this help message + --version, -v Show version Run \`argent --help\` for command-specific help. diff --git a/packages/tool-server/src/blueprints/ax-service.ts b/packages/tool-server/src/blueprints/ax-service.ts index dd16b4ee7..d68fb4768 100644 --- a/packages/tool-server/src/blueprints/ax-service.ts +++ b/packages/tool-server/src/blueprints/ax-service.ts @@ -1,7 +1,6 @@ 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 { promisify } from "node:util"; @@ -17,6 +16,7 @@ import { } from "@argent/registry"; import { axServiceBinaryPath, axServiceBinaryPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; +import { activeIosDeviceSetPath, simctlArgs } from "../utils/simctl"; const execFileAsync = promisify(execFile); @@ -88,8 +88,7 @@ interface PendingRpc { export async function ensureAutomationEnabled(udid: string): Promise { await execFileAsync( "xcrun", - [ - "simctl", + simctlArgs([ "spawn", udid, "defaults", @@ -98,7 +97,7 @@ export async function ensureAutomationEnabled(udid: string): Promise { "AutomationEnabled", "-bool", "true", - ], + ]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ); } @@ -117,15 +116,14 @@ export async function ensureAutomationEnabled(udid: string): Promise { export async function isEntitlementBypassActive(udid: string): Promise { return execFileAsync( "xcrun", - [ - "simctl", + simctlArgs([ "spawn", udid, "defaults", "read", "com.apple.Accessibility", "IgnoreAXServerEntitlements", - ], + ]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ) .then(({ stdout }) => stdout.trim() === "1") @@ -138,8 +136,7 @@ export async function isEntitlementBypassActive(udid: string): Promise */ function accessibilityPlistPath(udid: string): string { return path.join( - os.homedir(), - "Library/Developer/CoreSimulator/Devices", + activeIosDeviceSetPath(), udid, "data/Library/Preferences/com.apple.Accessibility.plist" ); @@ -224,7 +221,7 @@ function spawnDaemon(udid: string, endpoint: AXEndpoint): ChildProcess { const proc = execFile( "xcrun", - ["simctl", "spawn", udid, binaryPath, ...endpointArgs, "--timeout", "3600"], + simctlArgs(["spawn", udid, binaryPath, ...endpointArgs, "--timeout", "3600"]), { encoding: "utf8" } ) as ChildProcess; diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 0ff872679..8db9112b7 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -14,6 +14,7 @@ import { } from "@argent/registry"; import { bootstrapDylibPath, bootstrapDylibPathTcp } from "@argent/native-devtools-ios"; import { SIMCTL_SPAWN_TIMEOUT_MS } from "../utils/simctl-config"; +import { simctlArgs } from "../utils/simctl"; export type NativeDevtoolsTransport = "unix" | "tcp"; @@ -250,8 +251,7 @@ async function ensureAccessibilityEnabled(udid: string): Promise { flags.map((flag) => execFileAsync( "xcrun", - [ - "simctl", + simctlArgs([ "spawn", udid, "defaults", @@ -260,7 +260,7 @@ async function ensureAccessibilityEnabled(udid: string): Promise { flag, "-bool", "true", - ], + ]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ) ) @@ -280,7 +280,7 @@ async function ensureEnv( // accumulate on every ensureEnv() cycle. const result = await execFileAsync( "xcrun", - ["simctl", "spawn", udid, "launchctl", "getenv", "DYLD_INSERT_LIBRARIES"], + simctlArgs(["spawn", udid, "launchctl", "getenv", "DYLD_INSERT_LIBRARIES"]), { encoding: "utf8", timeout: SIMCTL_SPAWN_TIMEOUT_MS } ).catch((e) => ({ stdout: (e as NodeJS.ErrnoException & { stdout?: string }).stdout ?? "" })); @@ -290,7 +290,7 @@ async function ensureEnv( if (updated !== existing) { await execFileAsync( "xcrun", - ["simctl", "spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", updated], + simctlArgs(["spawn", udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", updated]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ); } @@ -300,29 +300,27 @@ async function ensureEnv( if (endpoint.transport === "tcp") { await execFileAsync( "xcrun", - [ - "simctl", + simctlArgs([ "spawn", udid, "launchctl", "setenv", "NATIVE_DEVTOOLS_IOS_CDP_PORT", String(endpoint.port), - ], + ]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ); } else { await execFileAsync( "xcrun", - [ - "simctl", + simctlArgs([ "spawn", udid, "launchctl", "setenv", "NATIVE_DEVTOOLS_IOS_CDP_SOCKET", endpoint.socketPath, - ], + ]), { timeout: SIMCTL_SPAWN_TIMEOUT_MS } ); } @@ -332,9 +330,13 @@ async function ensureEnv( } async function listRunningUIKitApplicationBundleIds(udid: string): Promise> { - const { stdout } = await execFileAsync("xcrun", ["simctl", "spawn", udid, "launchctl", "list"], { - encoding: "utf8", - }); + const { stdout } = await execFileAsync( + "xcrun", + simctlArgs(["spawn", udid, "launchctl", "list"]), + { + encoding: "utf8", + } + ); const bundleIds = new Set(); for (const line of stdout.split("\n")) { diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index a02b6f9cc..1784c7ac6 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -13,6 +13,7 @@ import { import { simulatorServerBinaryPath, simulatorServerBinaryDir } from "@argent/native-devtools-ios"; import { ensureAutomationEnabled } from "./ax-service"; import { ensureDep } from "../utils/check-deps"; +import { iosDeviceSetPath } from "../utils/simctl"; export const SIMULATOR_SERVER_NAMESPACE = "SimulatorServer"; @@ -88,6 +89,8 @@ function spawnSimulatorServerProcess( const { BINARY_PATH, BINARY_DIR } = getPaths(); return new Promise((resolve, reject) => { const args = [subcommand, "--id", udid]; + const deviceSet = subcommand === "ios" ? iosDeviceSetPath() : null; + if (deviceSet) args.push("--device-set", deviceSet); const proc = spawn(BINARY_PATH, args, { cwd: BINARY_DIR, diff --git a/packages/tool-server/src/tools/devices/boot-device.ts b/packages/tool-server/src/tools/devices/boot-device.ts index 1b92c4fa1..579381a3c 100644 --- a/packages/tool-server/src/tools/devices/boot-device.ts +++ b/packages/tool-server/src/tools/devices/boot-device.ts @@ -29,6 +29,7 @@ import { import { ensureDep } from "../../utils/check-deps"; import { linuxBootDiagnostics } from "../../utils/linux-preflight"; import { listIosSimulators } from "../../utils/ios-devices"; +import { iosDeviceSetPath, simctlArgs } from "../../utils/simctl"; import { listVvdImages } from "../../utils/vega-sdk"; import { startVvd, stopVvd, isVvdRunning, waitForVvdRunning } from "../../utils/vega-vvd"; import { resolveRunningVvdSerial, listVegaDevices } from "../../utils/vega-devices"; @@ -455,7 +456,7 @@ async function bootIos( // force=true on a running sim: shut it down so we can pre-write AX prefs. if (force && simState === "Booted") { - await execFileAsync("xcrun", ["simctl", "shutdown", udid]); + await execFileAsync("xcrun", simctlArgs(["shutdown", udid])); } const needsPreBoot = simState === "Shutdown" || (force && simState === "Booted"); @@ -469,13 +470,13 @@ async function bootIos( }); } - await execFileAsync("xcrun", ["simctl", "boot", udid]).catch((err: unknown) => { + await execFileAsync("xcrun", simctlArgs(["boot", udid])).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); if (!message.includes("Unable to boot device in current state: Booted")) { throw err; } }); - await execFileAsync("xcrun", ["simctl", "bootstatus", udid, "-b"]); + await execFileAsync("xcrun", simctlArgs(["bootstatus", udid, "-b"])); // Best-effort fallback: no-op on the happy path (pref already cached from // pre-boot write). When the sim was already Booted without force, writes @@ -496,13 +497,15 @@ async function bootIos( if (initFailure?.givenUp) { return buildInitFailedResult(udid, initFailure); } - await execFileAsync("defaults", [ - "write", - "com.apple.iphonesimulator", - "CurrentDeviceUDID", - udid, - ]); - await execFileAsync("open", ["-a", "Simulator.app"]); + if (!iosDeviceSetPath()) { + await execFileAsync("defaults", [ + "write", + "com.apple.iphonesimulator", + "CurrentDeviceUDID", + udid, + ]); + await execFileAsync("open", ["-a", "Simulator.app"]); + } return { platform: "ios", udid, booted: true }; } @@ -1198,7 +1201,7 @@ function bootVega(params: { // restart, so it must NOT join an in-flight non-force boot (which would skip // the restart and hand back the stale device). Two same-mode boots of the same // image still share one promise. - const key = `${params.vvdImage}${params.force ? "force" : "normal"}`; + const key = `${params.vvdImage}\0${params.force ? "force" : "normal"}`; const existing = inFlightVegaBoots.get(key); if (existing) return existing; const promise = bootVegaImpl(params).finally(() => { 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 aff908df2..177a2bdc2 100644 --- a/packages/tool-server/src/tools/launch-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/launch-app/platforms/ios.ts @@ -3,6 +3,7 @@ import { promisify } from "node:util"; import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import { precheckNativeDevtools } from "../../../blueprints/native-devtools"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlArgs } from "../../../utils/simctl"; import type { LaunchAppIosServices, LaunchAppParams, LaunchAppResult } from "../types"; const execFileAsync = promisify(execFile); @@ -13,7 +14,7 @@ export const iosImpl: PlatformImpl { try { - await execFileAsync("xcrun", ["simctl", "openurl", params.udid, params.url]); + await execFileAsync("xcrun", simctlArgs(["openurl", params.udid, params.url])); } catch (err) { throw new FailureError( `Failed to open URL on iOS simulator ${params.udid}.`, diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index ca635799a..ccd5f6f18 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -1,4 +1,4 @@ -import { spawn, execSync, type ChildProcess } from "child_process"; +import { spawn, execFileSync, type ChildProcess } from "child_process"; import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import { promises as fs } from "fs"; import { existsSync } from "node:fs"; @@ -19,6 +19,7 @@ import type { NativeProfilerAnalyzeResult } from "../../../../utils/ios-profiler import { renderNativeProfilerReport } from "../../../../utils/ios-profiler/render"; import { formatTraceFreshness } from "../../../../utils/profiler-shared/freshness"; import { RECORDING_CAP_MS } from "../../../../utils/profiler-shared/types"; +import { simctlArgs } from "../../../../utils/simctl"; // Two candidates because __dirname differs by runtime: bundled it's argent/dist/ // (template in argent/assets/); in dev it's tool-server/dist/tools/profiler/ @@ -87,7 +88,7 @@ interface DetectedApp { function enumerateRunningUserApps(udid: string): { info: AppInfo; pid: number }[] { let launchctlOutput: string; try { - launchctlOutput = execSync(`xcrun simctl spawn ${udid} launchctl list`, { + launchctlOutput = execFileSync("xcrun", simctlArgs(["spawn", udid, "launchctl", "list"]), { encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, }); @@ -131,7 +132,12 @@ function enumerateRunningUserApps(udid: string): { info: AppInfo; pid: number }[ let listAppsOutput: string; try { - listAppsOutput = execSync(`xcrun simctl listapps ${udid} | plutil -convert json -o - -`, { + const listAppsPlist = execFileSync("xcrun", simctlArgs(["listapps", udid]), { + encoding: "utf-8", + timeout: DETECT_RUNNING_APP_TIMEOUT_MS, + }); + listAppsOutput = execFileSync("plutil", ["-convert", "json", "-o", "-", "-"], { + input: listAppsPlist, encoding: "utf-8", timeout: DETECT_RUNNING_APP_TIMEOUT_MS, }); diff --git a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts index 4d81f91be..79e5a9147 100644 --- a/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts +++ b/packages/tool-server/src/tools/reinstall-app/platforms/ios.ts @@ -3,6 +3,7 @@ import { promisify } from "node:util"; import { resolve as resolvePath } from "node:path"; import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlArgs } from "../../../utils/simctl"; import type { ReinstallAppParams, ReinstallAppResult, ReinstallAppServices } from "../types"; const execFileAsync = promisify(execFile); @@ -13,12 +14,12 @@ export const iosImpl: PlatformImpl { try { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"], { + const { stdout } = await execFileAsync("xcrun", simctlArgs(["list", "devices", "--json"]), { timeout: 10_000, }); const data: SimctlOutput = JSON.parse(stdout); diff --git a/packages/tool-server/src/utils/simctl.ts b/packages/tool-server/src/utils/simctl.ts new file mode 100644 index 000000000..5b6cae639 --- /dev/null +++ b/packages/tool-server/src/utils/simctl.ts @@ -0,0 +1,26 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +export const ARGENT_IOS_DEVICE_SET_ENV = "ARGENT_IOS_DEVICE_SET_PATH"; + +export function iosDeviceSetPath(env: NodeJS.ProcessEnv = process.env): string | null { + const raw = env[ARGENT_IOS_DEVICE_SET_ENV]; + if (!raw?.trim()) return null; + return path.resolve(raw.trim()); +} + +export function defaultIosDeviceSetPath(): string { + return path.join(os.homedir(), "Library/Developer/CoreSimulator/Devices"); +} + +export function activeIosDeviceSetPath(env: NodeJS.ProcessEnv = process.env): string { + return iosDeviceSetPath(env) ?? defaultIosDeviceSetPath(); +} + +export function simctlArgs( + args: readonly string[], + env: NodeJS.ProcessEnv = process.env +): string[] { + const deviceSet = iosDeviceSetPath(env); + return deviceSet ? ["simctl", "--set", deviceSet, ...args] : ["simctl", ...args]; +} diff --git a/packages/tool-server/src/utils/simulator-watcher.ts b/packages/tool-server/src/utils/simulator-watcher.ts index d0da33d6f..5f4bd1c44 100644 --- a/packages/tool-server/src/utils/simulator-watcher.ts +++ b/packages/tool-server/src/utils/simulator-watcher.ts @@ -6,13 +6,14 @@ import { nativeDevtoolsRef, type NativeDevtoolsApi, } from "../blueprints/native-devtools"; +import { simctlArgs } from "./simctl"; const execFileAsync = promisify(execFile); const POLL_INTERVAL_MS = 10_000; async function getBootedUdids(): Promise> { - const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"]); + const { stdout } = await execFileAsync("xcrun", simctlArgs(["list", "devices", "--json"])); const data = JSON.parse(stdout) as { devices: Record>; }; diff --git a/packages/tool-server/test/boot-device.test.ts b/packages/tool-server/test/boot-device.test.ts index 23f944859..769fb3a0c 100644 --- a/packages/tool-server/test/boot-device.test.ts +++ b/packages/tool-server/test/boot-device.test.ts @@ -51,9 +51,11 @@ describe("boot-device — iOS path", () => { // duration of the suite — restored after each test to avoid leaking the // override into other test files run in the same vitest worker. const originalPlatform = process.platform; + const originalIosDeviceSetPath = process.env.ARGENT_IOS_DEVICE_SET_PATH; beforeEach(() => { Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + delete process.env.ARGENT_IOS_DEVICE_SET_PATH; vi.clearAllMocks(); // Pre-warm the dep cache so `ensureDep('xcrun')` doesn't probe PATH and // add an extra first `command -v xcrun` call to mockExecFile. @@ -77,6 +79,8 @@ describe("boot-device — iOS path", () => { afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + if (originalIosDeviceSetPath === undefined) delete process.env.ARGENT_IOS_DEVICE_SET_PATH; + else process.env.ARGENT_IOS_DEVICE_SET_PATH = originalIosDeviceSetPath; }); it("pre-boots AX prefs on a Shutdown sim then waits for boot completion and native-devtools init", async () => { @@ -147,6 +151,42 @@ describe("boot-device — iOS path", () => { expect(reverifyEnv).toHaveBeenCalledOnce(); }); + it("uses a custom CoreSimulator device set for boot commands without touching global Simulator.app defaults", async () => { + process.env.ARGENT_IOS_DEVICE_SET_PATH = "/tmp/argent-ios-set"; + const resolveService = vi.fn(async () => ({ + getInitFailure: () => null, + reverifyEnv: async () => {}, + })); + const registry = { resolveService } as unknown as Registry; + const tool = createBootDeviceTool(registry); + + await expect( + tool.execute!({}, { udid: "11111111-1111-1111-1111-111111111111" }) + ).resolves.toEqual({ + platform: "ios", + udid: "11111111-1111-1111-1111-111111111111", + booted: true, + }); + + expect(mockExecFile.mock.calls.map(([file, args]) => [file, args])).toEqual([ + [ + "xcrun", + ["simctl", "--set", "/tmp/argent-ios-set", "boot", "11111111-1111-1111-1111-111111111111"], + ], + [ + "xcrun", + [ + "simctl", + "--set", + "/tmp/argent-ios-set", + "bootstatus", + "11111111-1111-1111-1111-111111111111", + "-b", + ], + ], + ]); + }); + it("skips pre-boot plist write when the sim is already Booted and falls back to ensureAutomationEnabled", async () => { const resolveService = vi.fn(async () => ({ getInitFailure: () => null, diff --git a/packages/tool-server/test/simctl.test.ts b/packages/tool-server/test/simctl.test.ts new file mode 100644 index 000000000..324736280 --- /dev/null +++ b/packages/tool-server/test/simctl.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import * as os from "node:os"; +import * as path from "node:path"; +import { activeIosDeviceSetPath, defaultIosDeviceSetPath, simctlArgs } from "../src/utils/simctl"; + +describe("simctl device-set helpers", () => { + it("uses plain simctl args when no custom device set is configured", () => { + expect(simctlArgs(["list", "devices"], {})).toEqual(["simctl", "list", "devices"]); + }); + + it("injects --set with a normalized custom device set path", () => { + expect( + simctlArgs(["list", "devices"], { ARGENT_IOS_DEVICE_SET_PATH: "tmp/device-set" }) + ).toEqual(["simctl", "--set", path.resolve("tmp/device-set"), "list", "devices"]); + }); + + it("exposes the default device set path when no custom set is configured", () => { + expect(defaultIosDeviceSetPath()).toBe( + path.join(os.homedir(), "Library/Developer/CoreSimulator/Devices") + ); + expect(activeIosDeviceSetPath({})).toBe(defaultIosDeviceSetPath()); + }); +}); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts index be890ad49..102685deb 100644 --- a/packages/tool-server/test/simulator-server-blueprint.test.ts +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -13,6 +13,7 @@ import type { DeviceInfo } from "@argent/registry"; const spawnMock = vi.fn(); const ensureAutomationEnabledMock = vi.fn(); +const originalIosDeviceSetPath = process.env.ARGENT_IOS_DEVICE_SET_PATH; vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); @@ -75,6 +76,7 @@ function androidDevice(serial: string): DeviceInfo { describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInfo", () => { beforeEach(async () => { + delete process.env.ARGENT_IOS_DEVICE_SET_PATH; spawnMock.mockReset(); ensureAutomationEnabledMock.mockReset().mockResolvedValue(undefined); // Pre-warm the dep cache so the Android branch's `ensureDep('adb')` doesn't @@ -89,6 +91,8 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); afterEach(() => { + if (originalIosDeviceSetPath === undefined) delete process.env.ARGENT_IOS_DEVICE_SET_PATH; + else process.env.ARGENT_IOS_DEVICE_SET_PATH = originalIosDeviceSetPath; vi.clearAllMocks(); }); @@ -122,6 +126,29 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf expect(fakeProc.kill).toHaveBeenCalledTimes(1); }); + it("passes the configured CoreSimulator device set to the iOS simulator-server", async () => { + process.env.ARGENT_IOS_DEVICE_SET_PATH = "/tmp/argent-ios-set"; + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const udid = "11111111-2222-3333-4444-555555555555"; + const device = iosDevice(udid); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + signalReady(fakeProc, 55560); + await factoryPromise; + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0]![1]).toEqual([ + "ios", + "--id", + udid, + "--device-set", + "/tmp/argent-ios-set", + ]); + }); + it("spawns the `android` subcommand for an Android device", async () => { const fakeProc = makeFakeProc(); spawnMock.mockReturnValue(fakeProc);