// Thin wrappers around `xcrun devicectl` and DNS resolution. Every function // here is unit-testable in isolation by injecting a spawnImpl + resolveImpl. // // Production code uses the defaults: spawnSync('xcrun', [...]) and // dns.lookup('.coredevice.local'). Tests inject stubs. import { spawnSync, type SpawnSyncReturns } from 'child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; export interface DeviceEntry { identifier: string; name: string; model: string; state: string; // "connected" | "available" | "available (paired)" | ... paired: boolean; } export interface SpawnImpl { (cmd: string, args: string[]): SpawnSyncReturns; } export interface ResolveImpl { (hostname: string): Promise; // returns IPv6 addresses } const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 }); /** * Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder * on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on * recent macOS — returns ESERVFAIL for `*.coredevice.local`). * * Prefer the IPv6 record but fall back to whatever getaddrinfo returns. */ const defaultResolve: ResolveImpl = async (hostname) => { const dns = await import('dns'); return new Promise((resolve, reject) => { dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => { if (err) { reject(err); return; } const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address); if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; } resolve(ipv6); }); }); }; /** * Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility * and for environments where mDNSResponder is not in the resolver chain. On * macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment * on `defaultResolve` above. */ const legacyResolve6: ResolveImpl = async (hostname) => { const dns = await import('dns'); return new Promise((resolve, reject) => { dns.resolve6(hostname, (err, addrs) => { if (err) reject(err); else resolve(addrs); }); }); }; /** * List devices currently known to CoreDevice. Includes connected, paired, * and pairing-in-progress devices. */ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] { const tmp = join(tmpdir(), `devicectl-list-${process.pid}-${Date.now()}.json`); try { const r = spawn('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp]); if (r.status !== 0) return []; const raw = readFileSync(tmp, 'utf-8'); const obj = JSON.parse(raw); const list = (obj.result?.devices ?? []) as Array>; return list.map((d) => { const conn = d.connectionProperties as Record | undefined; const props = d.deviceProperties as Record | undefined; const hw = d.hardwareProperties as Record | undefined; const pairingState = String(conn?.pairingState ?? ''); return { identifier: String(d.identifier ?? ''), name: String(props?.name ?? 'unknown'), model: String(hw?.productType ?? 'unknown'), state: String(conn?.tunnelState ?? 'unknown'), paired: pairingState === 'paired', }; }); } catch { return []; } finally { try { rmSync(tmp, { force: true }); } catch { /* ignore */ } } } /** * Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info * details --json-output`. This is the most reliable path on macOS 26.x: the * tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is * authoritative (it's what CoreDevice itself uses to route). * * A side effect of running `devicectl device info details` is that it forces * CoreDevice to bring up / refresh the tunnel session, which is why we prefer * this over mDNS even on machines where mDNS works. * * Returns null when the device isn't found, isn't tunneled, or devicectl * fails — callers should fall through to mDNS resolution. */ export function getDeviceTunnelIPv6FromDevicectl( udid: string, spawn: SpawnImpl = defaultSpawn, ): string | null { const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`); try { const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]); if (r.status !== 0) return null; const raw = readFileSync(tmp, 'utf-8'); const obj = JSON.parse(raw); // `result.connectionProperties.tunnelIPAddress` is the canonical location. // Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress` // — accept either. const conn = obj?.result?.connectionProperties as Record | undefined; const tunnel = obj?.result?.tunnel as Record | undefined; const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined; if (typeof addr === 'string' && addr.includes(':')) return addr; return null; } catch { return null; } finally { try { rmSync(tmp, { force: true }); } catch { /* ignore */ } } } /** * Start a periodic devicectl `info details` poll that keeps the CoreDevice * tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while * a devicectl command is in-flight or Xcode itself is debugging. Without * something poking it, the tunnel IPv6 becomes unroutable within seconds — * `curl` to the address times out even though the address looks valid. * * Implementation note: we chose `device info details` (cheap, ~10ms of CPU * per tick, no persistent child process) over `device console` (which would * keep the tunnel up continuously but spams stdout, can wedge on backpressure, * and is harder to kill cleanly). The 5-second interval is comfortably under * the empirically-observed tunnel teardown timeout (~10-15s of idle). * * Returns a `stop()` function that cancels the timer. Safe to call multiple * times. */ export function startTunnelKeepalive( udid: string, opts: { intervalMs?: number; spawn?: SpawnImpl } = {}, ): { stop: () => void } { const intervalMs = opts.intervalMs ?? 5_000; const spawn = opts.spawn ?? defaultSpawn; let stopped = false; const tick = () => { if (stopped) return; // Fire-and-forget: ignore result, the side-effect of the spawn is what // keeps the tunnel up. We deliberately do not use the JSON output here. try { const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`); spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]); try { rmSync(tmp, { force: true }); } catch { /* ignore */ } } catch { /* ignore — next tick will retry */ } }; const handle = setInterval(tick, intervalMs); // Don't keep the event loop alive just for this — daemon owns the lifecycle. if (typeof handle.unref === 'function') handle.unref(); return { stop: () => { if (stopped) return; stopped = true; clearInterval(handle); }, }; } /** * Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is * derived from the device name as printed by `devicectl list devices`. The * resolved address looks like `fd72:8347:2ead::1` — RFC 4193 ULA, regenerated * per session. */ export async function getDeviceTunnelIPv6( deviceName: string, resolve: ResolveImpl = defaultResolve, ): Promise { // CoreDevice mDNS host: lowercase, spaces and apostrophes → hyphens, plus // ".coredevice.local" suffix. Apple normalizes "Garry's Durendal" to // "Garrys-Durendal.coredevice.local". const slug = deviceName .replace(/['']/g, '') // strip apostrophes .replace(/[\s_]+/g, '-') // spaces/underscores → hyphens .replace(/[^a-zA-Z0-9-]/g, '') // anything else not URL-safe → drop + '.coredevice.local'; try { const addrs = await resolve(slug); return addrs[0] ?? null; } catch { return null; } } /** * Resolve a device's tunnel IPv6 using every strategy we know, in order of * decreasing reliability: * * 1. `devicectl device info details --json-output` (most reliable on * macOS 26.x; also has the useful side-effect of bumping the tunnel). * 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder * on macOS, unlike `dns.resolve6`). * 3. mDNS via `dns.resolve6` (legacy path — kept for backwards * compatibility; will ESERVFAIL on recent macOS). * * Returns the first address that any strategy yields, or null. */ export async function resolveTunnelIPv6(opts: { udid: string; deviceName: string; spawn?: SpawnImpl; resolve?: ResolveImpl; legacyResolve?: ResolveImpl; }): Promise { const spawn = opts.spawn ?? defaultSpawn; const resolveLookup = opts.resolve ?? defaultResolve; const resolveLegacy = opts.legacyResolve ?? legacyResolve6; // 1. devicectl-based const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn); if (fromDevicectl) return fromDevicectl; // 2. mDNS via dns.lookup const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup); if (fromLookup) return fromLookup; // 3. last-resort: legacy dns.resolve6 const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy); return fromLegacy; } /** * Check whether a specific bundle ID has a running process on the device. */ export function isAppRunning( udid: string, bundleId: string, spawn: SpawnImpl = defaultSpawn, ): boolean { const tmp = join(tmpdir(), `devicectl-procs-${process.pid}-${Date.now()}.json`); try { const r = spawn('xcrun', ['devicectl', 'device', 'info', 'processes', '-d', udid, '--json-output', tmp]); if (r.status !== 0) return false; const raw = readFileSync(tmp, 'utf-8'); return raw.includes(`/${bundleId}/`) || raw.includes(`/${bundleId}.app/`); } catch { return false; } finally { try { rmSync(tmp, { force: true }); } catch { /* ignore */ } } } /** * Launch an app on the device. Returns true on success, false otherwise. * Locked-device errors (the iPhone needs to be unlocked first) are surfaced * through the error string. */ export function launchApp( udid: string, bundleId: string, spawn: SpawnImpl = defaultSpawn, ): { ok: boolean; error?: string } { const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]); if (r.status === 0) return { ok: true }; const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? ''); if (err.includes('was not, or could not be, unlocked')) { return { ok: false, error: 'device_locked' }; } if (err.includes('FBSOpenApplicationServiceErrorDomain')) { return { ok: false, error: 'launch_failed' }; } return { ok: false, error: err.split('\n')[0] ?? 'unknown' }; } /** * Copy a file out of an app's data container. Used to scrape the boot token * from `tmp/gstack-ios-qa.token` after the StateServer starts. */ export function copyFileFromAppContainer(opts: { udid: string; bundleId: string; sourceRelativePath: string; spawn?: SpawnImpl; }): string | null { const spawn = opts.spawn ?? defaultSpawn; const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-copy-')); const dest = join(dir, 'fetched'); try { const r = spawn('xcrun', [ 'devicectl', 'device', 'copy', 'from', '--device', opts.udid, '--domain-type', 'appDataContainer', '--domain-identifier', opts.bundleId, '--source', opts.sourceRelativePath, '--destination', dest, ]); if (r.status !== 0) return null; return readFileSync(dest, 'utf-8').replace(/[\r\n]+$/, ''); } catch { return null; } finally { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } } } /** * Install an .app bundle on the device. The bundle must be signed with a * dev/distribution profile that includes the device. */ export function installApp( udid: string, appBundlePath: string, spawn: SpawnImpl = defaultSpawn, ): { ok: boolean; error?: string } { const r = spawn('xcrun', ['devicectl', 'device', 'install', 'app', '--device', udid, appBundlePath]); if (r.status === 0) return { ok: true }; return { ok: false, error: (r.stderr?.toString() ?? r.stdout?.toString() ?? 'unknown').split('\n')[0] }; }