mirror of https://github.com/garrytan/gstack.git
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
// 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('<host>.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<Buffer>;
|
|
}
|
|
|
|
export interface ResolveImpl {
|
|
(hostname: string): Promise<string[]>; // 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<Record<string, unknown>>;
|
|
return list.map((d) => {
|
|
const conn = d.connectionProperties as Record<string, unknown> | undefined;
|
|
const props = d.deviceProperties as Record<string, unknown> | undefined;
|
|
const hw = d.hardwareProperties as Record<string, unknown> | 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<string, unknown> | undefined;
|
|
const tunnel = obj?.result?.tunnel as Record<string, unknown> | 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<string | null> {
|
|
// 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<string | null> {
|
|
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] };
|
|
}
|