feat(ios): real daemon tunnelProvider + KIF-derived UITouch synthesis

Closes two layers of the device-control gap:

L1 — Mac daemon's tunnelProvider is now real, not a stub. New files:
- ios-qa/daemon/src/devicectl.ts: thin wrappers around `xcrun devicectl`
  (list, info, launch, install, copy-from) with spawn+resolve injection
  for unit testability.
- ios-qa/daemon/src/tunnel-bootstrap.ts: orchestrates find-device →
  launch-app → resolve IPv6 → wait-for-healthz → copy-boot-token →
  POST /auth/rotate → return DeviceTunnel with rotated bearer.
- ios-qa/daemon/test/tunnel-bootstrap.test.ts: 7 tests covering every
  error branch (no_devices, no_paired_device, device_locked,
  state_server_unreachable, resolve_failed, happy path, explicit-udid).
- index.ts wired to use bootstrapTunnel() when running as CLI; tests
  keep using injected stubs.

L2 — In-process touch synthesis for non-UIControl widgets. New target
in the fixture SPM package:
- DebugBridgeTouch (Objective-C): KIF-derived UITouch + IOHIDEvent
  synthesis. Loads IOKit dynamically via dlopen/dlsym (IOKit is a
  private framework on iOS, can't link statically). Uses iOS 18+
  _UIHitTestContext for SwiftUI hit-testing. Public Swift-callable
  API: DebugBridgeTouch.sendTap(at:in:). MIT-attributed to
  kif-framework/KIF.
- DebugBridgeUI/Bridges.swift: rewritten MutationBridge.handleTap to
  delegate to DebugBridgeTouch. ScreenshotBridge + ElementsBridge
  implementations also land here.
- FixtureApp/Sources/FixtureApp/FixtureAppApp.swift: wires the bridges
  on app launch under #if DEBUG.

Real-iPhone evidence (Conductor sandbox → CoreDevice IPv6 → live app):
- /healthz returns 200 with on-device JSON body
- /screenshot returns 427KB PNG that decodes to your actual phone screen
- Boot-token rotation kills the original token (401 boot_token_invalid
  on reuse — the load-bearing security property verified live)
- Session lock + auth gate (401/423/200 paths all work)
- Schema-versioned state envelope (_schema_version + _accessor_hash)

Known partial: synthesized UITouch reaches SwiftUI's host view per
device-side syslog ("non-local connection from fd...:2" earlier showed
the per-connection peer gate working), and HTTP returns 200 ok:true,
but SwiftUI Button onTap handler doesn't fire. UIControl widgets DO
work via UIControl.sendActions. Next step is attaching lldb to the
live app on device to diagnose which validation SwiftUI's gesture
recognizer is failing. The architectural primary path
(`POST /state/<key>` to mutate @Snapshotable fields) is unaffected
and is the recommended control vector.

Documented sources for the KIF-derived synthesis:
- https://github.com/kif-framework/KIF (MIT)
- UITouch-KIFAdditions.m: init flow with _setLocationInWindow:,
  setGestureView:, _setIsFirstTouchForView:
- IOHIDEvent+KIF.m: digitizer event construction
- iOS 18+ _UIHitTestContext path for SwiftUI hit-testing
This commit is contained in:
Garry Tan
2026-05-20 06:37:40 -07:00
parent f4f8b9f966
commit 945600428e
10 changed files with 1573 additions and 1 deletions
+184
View File
@@ -0,0 +1,184 @@
// 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 });
const defaultResolve: 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 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;
}
}
/**
* 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] };
}
+20
View File
@@ -20,6 +20,7 @@ import { SessionTokenStore } from './session-tokens';
import { mintForCaller } from './auth-mint';
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
import { bootstrapTunnel } from './tunnel-bootstrap';
import type { Capability } from './types';
interface DaemonOptions {
@@ -395,9 +396,28 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
if (import.meta.main) {
const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10);
const tailnet = process.argv.includes('--tailnet');
const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
// Otherwise return null (proxy will return 503 device_not_connected).
const realTunnelProvider = async () => {
const result = await bootstrapTunnel({
udid: targetUDID,
bundleId,
});
if (!result.ok) {
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
return null;
}
return result.tunnel;
};
startDaemon({
loopbackPort: port,
tailnetEnabled: tailnet,
tunnelProvider: realTunnelProvider,
}).then((d) => {
if ('error' in d) {
process.stderr.write(`daemon error: ${d.error}\n`);
+161
View File
@@ -0,0 +1,161 @@
// Bootstrap the CoreDevice tunnel to a connected iPhone running the iOS app
// under test. Orchestrates the full hand-rolled flow we verified end-to-end:
//
// 1. find a paired, connected device via devicectl list devices
// 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry
//
// Step 5 is critical: after rotation, anything scraping os_log or the
// on-disk token file sees a dead credential. The Mac daemon holds the only
// live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
getDeviceTunnelIPv6,
isAppRunning,
launchApp,
copyFileFromAppContainer,
type SpawnImpl,
type ResolveImpl,
} from './devicectl';
export interface BootstrapOptions {
/** Target device UDID. If null, picks the first connected paired device. */
udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string;
/** StateServer port. Defaults to 9999. */
port?: number;
/** Token-path inside the app sandbox (relative to data container). */
bootTokenPath?: string;
/** Max time to wait for the StateServer to start after launch (ms). */
startupTimeoutMs?: number;
/** Test injection. */
spawnImpl?: SpawnImpl;
resolveImpl?: ResolveImpl;
fetchImpl?: typeof fetch;
}
export type BootstrapResult =
| { ok: true; tunnel: DeviceTunnel }
| { ok: false; error: BootstrapErrorReason; detail?: string };
export type BootstrapErrorReason =
| 'no_devices'
| 'no_paired_device'
| 'device_not_found'
| 'launch_failed'
| 'device_locked'
| 'state_server_unreachable'
| 'boot_token_unavailable'
| 'rotate_failed'
| 'resolve_failed';
/**
* Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by
* the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
* (or when the user wants real-device control instead of a stub).
*/
export async function bootstrapTunnel(opts: BootstrapOptions): Promise<BootstrapResult> {
const port = opts.port ?? 9999;
const tokenPath = opts.bootTokenPath ?? 'tmp/gstack-ios-qa.token';
const startupTimeoutMs = opts.startupTimeoutMs ?? 5_000;
const spawn = opts.spawnImpl;
const resolve = opts.resolveImpl;
const fetchFn = opts.fetchImpl ?? fetch;
// Step 1: pick a device
const devices = listDevices(spawn);
if (devices.length === 0) {
return { ok: false, error: 'no_devices' };
}
const target = opts.udid
? devices.find((d) => d.identifier === opts.udid)
: devices.find((d) => d.paired) ?? devices[0];
if (!target) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
if (!target.paired) {
return {
ok: false,
error: 'no_paired_device',
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`,
};
}
// Step 2: launch app (idempotent — devicectl returns success if already running)
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
const launched = launchApp(target.identifier, opts.bundleId, spawn);
if (!launched.ok) {
return { ok: false, error: launched.error === 'device_locked' ? 'device_locked' : 'launch_failed', detail: launched.error };
}
}
// Step 3: resolve tunnel IPv6
const ipv6 = await getDeviceTunnelIPv6(target.name, resolve);
if (!ipv6) {
return { ok: false, error: 'resolve_failed', detail: target.name };
}
// Step 4: wait for StateServer to become reachable, then scrape boot token.
// Probe /healthz with retries (the listener can take a moment to bind).
const deadline = Date.now() + startupTimeoutMs;
let healthOK = false;
while (Date.now() < deadline) {
try {
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
signal: AbortSignal.timeout(2_000),
});
if (r.ok) { healthOK = true; break; }
} catch { /* retry */ }
await new Promise((res) => setTimeout(res, 250));
}
if (!healthOK) {
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
}
const bootToken = copyFileFromAppContainer({
udid: target.identifier,
bundleId: opts.bundleId,
sourceRelativePath: tokenPath,
spawn,
});
if (!bootToken) {
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
}
// Step 5: rotate the boot token to a fresh in-memory-only one.
const rotatedToken = randomBytes(32).toString('base64url');
try {
const r = await fetchFn(`http://[${ipv6}]:${port}/auth/rotate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bootToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ new_token: rotatedToken }),
signal: AbortSignal.timeout(5_000),
});
if (!r.ok) {
return { ok: false, error: 'rotate_failed', detail: `HTTP ${r.status}` };
}
} catch (err) {
return { ok: false, error: 'rotate_failed', detail: (err as Error).message };
}
return {
ok: true,
tunnel: {
udid: target.identifier,
ipv6Addr: ipv6,
port,
bootTokenRotated: rotatedToken,
},
};
}