devicectl.ts
372 lines · ios-qa/daemon/src/devicectl.ts at 3f79d81
1// Thin wrappers around `xcrun devicectl` and DNS resolution. Every function
2// here is unit-testable in isolation by injecting a spawnImpl + resolveImpl.
3//
4// Production code uses the defaults: spawnSync('xcrun', [...]) and
5// dns.lookup('<host>.coredevice.local'). Tests inject stubs.
7import { spawnSync, type SpawnSyncReturns } from 'child_process';
8import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
9import { tmpdir } from 'os';
10import { join } from 'path';
11import { resultLine } from '../../../lib/result-codes';
13export interface DeviceEntry {
14 identifier: string;
15 name: string;
16 model: string;
17 platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
18 deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
19 state: string; // "connected" | "available" | "available (paired)" | ...
20 transport: string; // "wired" on USB devices; empty for stale/unavailable entries
21 paired: boolean;
22}
24export interface SpawnImpl {
25 (cmd: string, args: string[]): SpawnSyncReturns<Buffer>;
26}
28export interface ResolveImpl {
29 (hostname: string): Promise<string[]>; // returns IPv6 addresses
30}
32const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 });
34/**
35 * Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder
36 * on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on
37 * recent macOS — returns ESERVFAIL for `*.coredevice.local`).
38 *
39 * Prefer the IPv6 record but fall back to whatever getaddrinfo returns.
40 */
41const defaultResolve: ResolveImpl = async (hostname) => {
42 const dns = await import('dns');
43 return new Promise((resolve, reject) => {
44 dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => {
45 if (err) { reject(err); return; }
46 const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address);
47 if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; }
48 resolve(ipv6);
49 });
50 });
51};
53/**
54 * Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility
55 * and for environments where mDNSResponder is not in the resolver chain. On
56 * macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment
57 * on `defaultResolve` above.
58 */
59const legacyResolve6: ResolveImpl = async (hostname) => {
60 const dns = await import('dns');
61 return new Promise((resolve, reject) => {
62 dns.resolve6(hostname, (err, addrs) => {
63 if (err) reject(err);
64 else resolve(addrs);
65 });
66 });
67};
69/**
70 * List devices currently known to CoreDevice. Includes connected, paired,
71 * and pairing-in-progress devices.
72 */
73export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
74 const tmp = join(tmpdir(), `devicectl-list-${process.pid}-${Date.now()}.json`);
75 try {
76 const r = spawn('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp]);
77 if (r.status !== 0) return [];
78 const raw = readFileSync(tmp, 'utf-8');
79 const obj = JSON.parse(raw);
80 const list = (obj.result?.devices ?? []) as Array<Record<string, unknown>>;
81 return list.map((d) => {
82 const conn = d.connectionProperties as Record<string, unknown> | undefined;
83 const props = d.deviceProperties as Record<string, unknown> | undefined;
84 const hw = d.hardwareProperties as Record<string, unknown> | undefined;
85 const pairingState = String(conn?.pairingState ?? '');
86 return {
87 identifier: String(d.identifier ?? ''),
88 name: String(props?.name ?? 'unknown'),
89 model: String(hw?.productType ?? 'unknown'),
90 platform: String(hw?.platform ?? ''),
91 deviceType: String(hw?.deviceType ?? ''),
92 state: String(conn?.tunnelState ?? 'unknown'),
93 transport: String(conn?.transportType ?? ''),
94 paired: pairingState === 'paired',
95 };
96 });
97 } catch {
98 return [];
99 } finally {
100 try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
101 }
102}
104/**
105 * Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info
106 * details --json-output`. This is the most reliable path on macOS 26.x: the
107 * tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is
108 * authoritative (it's what CoreDevice itself uses to route).
109 *
110 * A side effect of running `devicectl device info details` is that it forces
111 * CoreDevice to bring up / refresh the tunnel session, which is why we prefer
112 * this over mDNS even on machines where mDNS works.
113 *
114 * Returns null when the device isn't found, isn't tunneled, or devicectl
115 * fails — callers should fall through to mDNS resolution.
116 */
117export function getDeviceTunnelIPv6FromDevicectl(
118 udid: string,
119 spawn: SpawnImpl = defaultSpawn,
120): string | null {
121 const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`);
122 try {
123 const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
124 if (r.status !== 0) return null;
125 const raw = readFileSync(tmp, 'utf-8');
126 const obj = JSON.parse(raw);
127 // `result.connectionProperties.tunnelIPAddress` is the canonical location.
128 // Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress`
129 // — accept either.
130 const conn = obj?.result?.connectionProperties as Record<string, unknown> | undefined;
131 const tunnel = obj?.result?.tunnel as Record<string, unknown> | undefined;
132 const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined;
133 if (typeof addr === 'string' && addr.includes(':')) return addr;
134 return null;
135 } catch {
136 return null;
137 } finally {
138 try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
139 }
140}
142/**
143 * Start a periodic devicectl `info details` poll that keeps the CoreDevice
144 * tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while
145 * a devicectl command is in-flight or Xcode itself is debugging. Without
146 * something poking it, the tunnel IPv6 becomes unroutable within seconds —
147 * `curl` to the address times out even though the address looks valid.
148 *
149 * Implementation note: we chose `device info details` (cheap, ~10ms of CPU
150 * per tick, no persistent child process) over `device console` (which would
151 * keep the tunnel up continuously but spams stdout, can wedge on backpressure,
152 * and is harder to kill cleanly). The 5-second interval is comfortably under
153 * the empirically-observed tunnel teardown timeout (~10-15s of idle).
154 *
155 * Returns a `stop()` function that cancels the timer. Safe to call multiple
156 * times.
157 */
158export function startTunnelKeepalive(
159 udid: string,
160 opts: { intervalMs?: number; spawn?: SpawnImpl } = {},
161): { stop: () => void } {
162 const intervalMs = opts.intervalMs ?? 5_000;
163 const spawn = opts.spawn ?? defaultSpawn;
164 let stopped = false;
165 const tick = () => {
166 if (stopped) return;
167 // Fire-and-forget: ignore result, the side-effect of the spawn is what
168 // keeps the tunnel up. We deliberately do not use the JSON output here.
169 try {
170 const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`);
171 spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
172 try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
173 } catch { /* ignore — next tick will retry */ }
174 };
175 const handle = setInterval(tick, intervalMs);
176 // Don't keep the event loop alive just for this — daemon owns the lifecycle.
177 if (typeof handle.unref === 'function') handle.unref();
178 return {
179 stop: () => {
180 if (stopped) return;
181 stopped = true;
182 clearInterval(handle);
183 },
184 };
185}
187/**
188 * Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is
189 * derived from the device name as printed by `devicectl list devices`. The
190 * resolved address looks like `fd72:8347:2ead::1` — RFC 4193 ULA, regenerated
191 * per session.
192 */
193export async function getDeviceTunnelIPv6(
194 deviceName: string,
195 resolve: ResolveImpl = defaultResolve,
196): Promise<string | null> {
197 // CoreDevice mDNS host: lowercase, spaces and apostrophes → hyphens, plus
198 // ".coredevice.local" suffix. Apple normalizes "Garry's Durendal" to
199 // "Garrys-Durendal.coredevice.local".
200 const slug = deviceName
201 .replace(/['']/g, '') // strip apostrophes
202 .replace(/[\s_]+/g, '-') // spaces/underscores → hyphens
203 .replace(/[^a-zA-Z0-9-]/g, '') // anything else not URL-safe → drop
204 + '.coredevice.local';
205 try {
206 const addrs = await resolve(slug);
207 return addrs[0] ?? null;
208 } catch {
209 return null;
210 }
211}
213/**
214 * Resolve a device's tunnel IPv6 using every strategy we know, in order of
215 * decreasing reliability:
216 *
217 * 1. `devicectl device info details --json-output` (most reliable on
218 * macOS 26.x; also has the useful side-effect of bumping the tunnel).
219 * 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder
220 * on macOS, unlike `dns.resolve6`).
221 * 3. mDNS via `dns.resolve6` (legacy path — kept for backwards
222 * compatibility; will ESERVFAIL on recent macOS).
223 *
224 * Returns the first address that any strategy yields, or null.
225 */
226export async function resolveTunnelIPv6(opts: {
227 udid: string;
228 deviceName: string;
229 spawn?: SpawnImpl;
230 resolve?: ResolveImpl;
231 legacyResolve?: ResolveImpl;
232}): Promise<string | null> {
233 const spawn = opts.spawn ?? defaultSpawn;
234 const resolveLookup = opts.resolve ?? defaultResolve;
235 const resolveLegacy = opts.legacyResolve ?? legacyResolve6;
237 // 1. devicectl-based
238 const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn);
239 if (fromDevicectl) return fromDevicectl;
241 // 2. mDNS via dns.lookup
242 const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup);
243 if (fromLookup) return fromLookup;
245 // 3. last-resort: legacy dns.resolve6
246 const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy);
247 return fromLegacy;
248}
250/**
251 * Check whether a specific bundle ID has a running process on the device.
252 */
253export function isAppRunning(
254 udid: string,
255 bundleId: string,
256 spawn: SpawnImpl = defaultSpawn,
257): boolean {
258 const tmp = join(tmpdir(), `devicectl-procs-${process.pid}-${Date.now()}.json`);
259 try {
260 const r = spawn('xcrun', ['devicectl', 'device', 'info', 'processes', '-d', udid, '--json-output', tmp]);
261 if (r.status !== 0) return false;
262 const raw = readFileSync(tmp, 'utf-8');
263 return raw.includes(`/${bundleId}/`) || raw.includes(`/${bundleId}.app/`);
264 } catch {
265 return false;
266 } finally {
267 try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
268 }
269}
271/** The debug bridge's own variable namespace; a launch environment may not set it. */
272const BRIDGE_ENV_PREFIX = 'GSTACK_';
274/**
275 * Parse GSTACK_IOS_LAUNCH_ENV: a JSON object of string to string (for
276 * example `{"DEBUG_BRIDGE":"1"}`), or unset. A refusal names keys and types,
277 * never values, which may be credentials.
278 */
279export function parseLaunchEnv(raw: string | undefined): { ok: true; env: Record<string, string> | null } | { ok: false; detail: string } {
280 if (raw === undefined || raw.trim() === '') return { ok: true, env: null };
281 let value: unknown;
282 try { value = JSON.parse(raw); } catch { return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV is not valid JSON' }; }
283 if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV must be a JSON object of string to string' };
284 for (const [key, v] of Object.entries(value)) {
285 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return { ok: false, detail: `${JSON.stringify(key)} is not an environment variable name` };
286 if (typeof v !== 'string') return { ok: false, detail: `${key} is a ${v === null ? 'null' : typeof v}, not a string` };
287 if (key.startsWith(BRIDGE_ENV_PREFIX)) return { ok: false, detail: `${key} collides with the debug bridge's reserved ${BRIDGE_ENV_PREFIX} variables` };
288 }
289 return { ok: true, env: Object.keys(value).length ? value as Record<string, string> : null };
290}
292/**
293 * The `--environment-variables` argv for a devicectl launch, from
294 * GSTACK_IOS_LAUNCH_ENV (empty when unset), or the IOS_LAUNCH_ENV_INVALID
295 * line that must stop the launch. Every launch path uses it, so the app is
296 * never started without the variables its debug bridge may be gated on.
297 */
298export function launchEnvArgs(raw = process.env.GSTACK_IOS_LAUNCH_ENV): { ok: true; args: string[] } | { ok: false; error: string } {
299 const launchEnv = parseLaunchEnv(raw);
300 if (!launchEnv.ok) return { ok: false, error: resultLine('ios-qa launch', 'IOS_LAUNCH_ENV_INVALID', launchEnv.detail) };
301 return { ok: true, args: launchEnv.env ? ['--environment-variables', JSON.stringify(launchEnv.env)] : [] };
302}
304/**
305 * Launch an app on the device. Returns true on success, false otherwise.
306 * Locked-device errors (the iPhone needs to be unlocked first) are surfaced
307 * through the error string. GSTACK_IOS_LAUNCH_ENV is forwarded (see
308 * launchEnvArgs); an invalid value refuses before any launch.
309 */
310export function launchApp(
311 udid: string,
312 bundleId: string,
313 spawn: SpawnImpl = defaultSpawn,
314): { ok: boolean; error?: string } {
315 const env = launchEnvArgs();
316 if (!env.ok) return { ok: false, error: env.error };
317 const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, ...env.args, bundleId]);
318 if (r.status === 0) return { ok: true };
319 const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
320 if (err.includes('was not, or could not be, unlocked')) {
321 return { ok: false, error: 'device_locked' };
322 }
323 if (err.includes('FBSOpenApplicationServiceErrorDomain')) {
324 return { ok: false, error: 'launch_failed' };
325 }
326 return { ok: false, error: err.split('\n')[0] ?? 'unknown' };
327}
329/**
330 * Copy a file out of an app's data container. Used to scrape the boot token
331 * from `tmp/gstack-ios-qa.token` after the StateServer starts.
332 */
333export function copyFileFromAppContainer(opts: {
334 udid: string;
335 bundleId: string;
336 sourceRelativePath: string;
337 spawn?: SpawnImpl;
338}): string | null {
339 const spawn = opts.spawn ?? defaultSpawn;
340 const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-copy-'));
341 const dest = join(dir, 'fetched');
342 try {
343 const r = spawn('xcrun', [
344 'devicectl', 'device', 'copy', 'from',
345 '--device', opts.udid,
346 '--domain-type', 'appDataContainer',
347 '--domain-identifier', opts.bundleId,
348 '--source', opts.sourceRelativePath,
349 '--destination', dest,
350 ]);
351 if (r.status !== 0) return null;
352 return readFileSync(dest, 'utf-8').replace(/[\r\n]+$/, '');
353 } catch {
354 return null;
355 } finally {
356 try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
357 }
358}
360/**
361 * Install an .app bundle on the device. The bundle must be signed with a
362 * dev/distribution profile that includes the device.
363 */
364export function installApp(
365 udid: string,
366 appBundlePath: string,
367 spawn: SpawnImpl = defaultSpawn,
368): { ok: boolean; error?: string } {
369 const r = spawn('xcrun', ['devicectl', 'device', 'install', 'app', '--device', udid, appBundlePath]);
370 if (r.status === 0) return { ok: true };
371 return { ok: false, error: (r.stderr?.toString() ?? r.stdout?.toString() ?? 'unknown').split('\n')[0] };
372}