diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts new file mode 100644 index 000000000..a3cab48ad --- /dev/null +++ b/ios-qa/daemon/src/devicectl.ts @@ -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('.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 }); + +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>; + 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 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; + } +} + +/** + * 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] }; +} diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index d1461c4be..e89507e1c 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -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 { 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`); diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts new file mode 100644 index 000000000..aeea9532d --- /dev/null +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -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 { + 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, + }, + }; +} diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts new file mode 100644 index 000000000..3e0c8f57c --- /dev/null +++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts @@ -0,0 +1,276 @@ +// Bootstrap unit tests. Injects spawn + resolve + fetch stubs so we exercise +// every branch (no_devices, no_paired_device, device_locked, healthz timeout, +// rotate_failed, success) without needing a real iPhone connected. + +import { describe, test, expect } from 'bun:test'; +import { bootstrapTunnel } from '../src/tunnel-bootstrap'; +import type { SpawnImpl } from '../src/devicectl'; +import { writeFileSync } from 'fs'; + +interface ScriptedCall { + argsMatch: RegExp; + stdout?: string; + stderr?: string; + exitCode?: number; + /** If set, write this content to the JSON output path before returning. */ + jsonOutput?: object; + /** If set, write this content to the file matching `--destination`. */ + destOutput?: string; +} + +/** + * Build a spawnImpl that walks through a scripted sequence of expected calls. + * Each call matches its args against `argsMatch`. Unmatched calls return + * exit-code 1 with an "unexpected call" stderr. + */ +function makeSpawn(scripts: ScriptedCall[]): SpawnImpl { + let idx = 0; + return (cmd: string, args: string[]) => { + const joined = `${cmd} ${args.join(' ')}`; + const script = scripts[idx]; + if (!script) { + return makeReturn(1, '', `unexpected call beyond scripted: ${joined}`); + } + if (!script.argsMatch.test(joined)) { + return makeReturn(1, '', `unexpected call shape: ${joined} (expected ${script.argsMatch})`); + } + idx++; + // Honor --json-output: write to that path BEFORE returning. + if (script.jsonOutput) { + const flagIdx = args.indexOf('--json-output'); + if (flagIdx !== -1 && args[flagIdx + 1]) { + writeFileSync(args[flagIdx + 1]!, JSON.stringify(script.jsonOutput)); + } + } + if (script.destOutput) { + const flagIdx = args.indexOf('--destination'); + if (flagIdx !== -1 && args[flagIdx + 1]) { + writeFileSync(args[flagIdx + 1]!, script.destOutput); + } + } + return makeReturn(script.exitCode ?? 0, script.stdout ?? '', script.stderr ?? ''); + }; +} + +function makeReturn(exit: number, stdout: string, stderr: string) { + return { + pid: 0, + output: [null, Buffer.from(stdout), Buffer.from(stderr)], + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), + status: exit, + signal: null, + } as ReturnType; +} + +describe('bootstrapTunnel', () => { + test('returns no_devices when devicectl list shows zero', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { result: { devices: [] } }, + }, + ]); + const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('no_devices'); + }); + + test('returns no_paired_device when device is connected but not paired', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { + devices: [{ + identifier: 'TEST-UDID', + connectionProperties: { tunnelState: 'available (pairing)', pairingState: 'unpaired' }, + deviceProperties: { name: 'Test iPhone' }, + hardwareProperties: { productType: 'iPhone18,2' }, + }], + }, + }, + }, + ]); + const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe('no_paired_device'); + expect(r.detail).toContain('Trust'); + } + }); + + test('returns device_locked when launchApp errors due to lock', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, + deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' }, + }] }, + }, + }, + { + argsMatch: /devicectl device info processes/, + jsonOutput: { result: { runningProcesses: [] } }, + }, + { + argsMatch: /devicectl device process launch/, + stderr: 'Locked ("Unable to launch com.test because the device was not, or could not be, unlocked").', + exitCode: 1, + }, + ]); + const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('device_locked'); + }); + + test('returns state_server_unreachable when healthz never responds', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, + deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' }, + }] }, + }, + }, + { + argsMatch: /devicectl device info processes/, + jsonOutput: { result: { runningProcesses: [{ executable: 'file:///private/var/containers/Bundle/Application/.../com.test.app/com.test', processIdentifier: 1234 }] } }, + stdout: 'com.test', + }, + ]); + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: async () => ['fd00::1'], + // fetch always fails. + fetchImpl: (async () => { throw new Error('connection refused'); }) as typeof fetch, + startupTimeoutMs: 200, // short, so test runs fast + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('state_server_unreachable'); + }); + + test('happy path: returns DeviceTunnel with rotated token', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'TEST-UDID', + connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, + deviceProperties: { name: 'Test Device' }, + hardwareProperties: { productType: 'iPhone18,2' }, + }] }, + }, + }, + { + argsMatch: /devicectl device info processes/, + jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test', processIdentifier: 5678 }] } }, + stdout: '/com.test.app/', + }, + { + argsMatch: /devicectl device copy from/, + destOutput: 'BOOT-TOKEN-XYZ-123\n', + }, + ]); + const fetchCalls: Array<{ url: string; method: string }> = []; + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: async () => ['fd99::beef'], + fetchImpl: (async (url, init) => { + const u = String(url); + const method = (init?.method ?? 'GET').toUpperCase(); + fetchCalls.push({ url: u, method }); + if (u.endsWith('/healthz')) { + return new Response('{"version":"1.0.0"}', { status: 200 }) as Response; + } + if (u.endsWith('/auth/rotate') && method === 'POST') { + // Verify the boot token is sent (not the rotated one). + const auth = (init?.headers as Record)['Authorization'] ?? ''; + if (auth !== 'Bearer BOOT-TOKEN-XYZ-123') { + return new Response('wrong bearer', { status: 401 }) as Response; + } + return new Response('{"ok":true}', { status: 200 }) as Response; + } + return new Response('not found', { status: 404 }) as Response; + }) as typeof fetch, + startupTimeoutMs: 1_000, + }); + + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.tunnel.udid).toBe('TEST-UDID'); + expect(r.tunnel.ipv6Addr).toBe('fd99::beef'); + expect(r.tunnel.port).toBe(9999); + expect(r.tunnel.bootTokenRotated).toMatch(/^[A-Za-z0-9_-]+$/); + expect(r.tunnel.bootTokenRotated).not.toBe('BOOT-TOKEN-XYZ-123'); + expect(r.tunnel.bootTokenRotated.length).toBeGreaterThan(20); + } + // Verify the bootstrap sequence: /healthz first, /auth/rotate second. + expect(fetchCalls[0]?.url).toContain('/healthz'); + expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate'); + }); + + test('resolve_failed when hostname cant be resolved to an IPv6', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, + deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' }, + }] }, + }, + }, + { + argsMatch: /devicectl device info processes/, + // jsonOutput body contains the bundle id path, so isAppRunning() returns true. + jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, + }, + ]); + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: async () => { throw new Error('ENOTFOUND'); }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('resolve_failed'); + }); + + test('respects explicit udid when set', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [ + { identifier: 'A', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'A' }, hardwareProperties: { productType: 'iPhone18,2' } }, + { identifier: 'B', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'B' }, hardwareProperties: { productType: 'iPhone18,2' } }, + ] }, + }, + }, + { + argsMatch: /devicectl device info processes -d B/, + jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, + }, + { + argsMatch: /devicectl device copy from --device B/, + destOutput: 'TOKEN\n', + }, + ]); + const r = await bootstrapTunnel({ + udid: 'B', + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: async () => ['fd00::b'], + fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch, + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.tunnel.udid).toBe('B'); + }); +}); diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template new file mode 100644 index 000000000..bf7af6e3f --- /dev/null +++ b/ios-qa/templates/Bridges.swift.template @@ -0,0 +1,308 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/Bridges.swift.template +// +// Real UIKit-backed implementations of the three bridges StateServer +// declares: ScreenshotBridge (PNG capture), ElementsBridge (accessibility +// tree), MutationBridge (tap/swipe/type via accessibility actions + hit +// testing). Everything #if DEBUG && canImport(UIKit) so Release builds +// don't link UIKit or carry any of this code. +// +// Wire from the consuming app: +// +// #if DEBUG && canImport(UIKit) +// import DebugBridgeUI +// DebugBridgeUIWiring.installAll() +// #endif + +#if DEBUG && canImport(UIKit) + +import DebugBridgeCore +import DebugBridgeTouch +import Foundation +import SwiftUI +import UIKit + +@MainActor +public enum DebugBridgeUIWiring { + /// Install all three bridge resolvers. Idempotent — calling multiple + /// times reinstalls the same closures. Must be called on @MainActor + /// because every UIKit access requires the main actor. + public static func installAll() { + ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() } + ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } + MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } + } +} + +// MARK: - ScreenshotBridge implementation + +@MainActor +enum ScreenshotBridgeImpl { + /// Capture a PNG of the active window. Uses UIGraphicsImageRenderer + /// (modern API, replaces UIGraphicsBeginImageContext). Returns nil if + /// no key window is available (e.g., app backgrounded). + static func capturePNG() -> Data? { + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } + let bounds = window.bounds + let renderer = UIGraphicsImageRenderer(bounds: bounds) + let image = renderer.image { _ in + // drawHierarchy is the documented way to snapshot real UIKit + // layers including layer-backed views. afterScreenUpdates: false + // because we want the CURRENT visible state, not a forced layout. + window.drawHierarchy(in: bounds, afterScreenUpdates: false) + } + return image.pngData() + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +// MARK: - ElementsBridge implementation + +@MainActor +enum ElementsBridgeImpl { + /// Walk the accessibility hierarchy + emit a flat list of elements. + /// Each entry has frame (in window coords), accessibility label, + /// identifier, traits as a bitmask, and a parent path. Skips + /// non-accessible / hidden views. + static func snapshot() -> [JSONDict] { + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } + var elements: [JSONDict] = [] + collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) + return elements + } + + private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { + // Skip hidden / zero-size / off-screen subtrees early. + if view.isHidden || view.alpha < 0.01 { return } + + let frameInWindow = view.convert(view.bounds, to: nil) + if !windowBounds.intersects(frameInWindow) { return } + + let isAccessible = view.isAccessibilityElement + let label = view.accessibilityLabel ?? "" + let identifier = view.accessibilityIdentifier ?? "" + let traits = Int(view.accessibilityTraits.rawValue) + let value = (view.accessibilityValue ?? "") as String + let className = String(describing: type(of: view)) + let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" + + // Emit if any of: + // - Marked accessible (covers UIKit-native widgets) + // - Has explicit AX label / identifier + // - Is a known interactive type (UIControl, UITextField, UIScrollView) + // - Hosts a SwiftUI view (UIHostingController's view class) + let isInteractive = view is UIControl || view is UIScrollView || view is UITextInput + let isHosting = className.contains("Hosting") || className.contains("SwiftUI") + if isAccessible || !label.isEmpty || !identifier.isEmpty || isInteractive || isHosting { + elements.append([ + "path": path, + "class": className, + "label": label, + "identifier": identifier, + "value": value, + "traits": traits, + "frame": [ + "x": Int(frameInWindow.origin.x), + "y": Int(frameInWindow.origin.y), + "w": Int(frameInWindow.size.width), + "h": Int(frameInWindow.size.height), + ], + "is_user_interaction_enabled": view.isUserInteractionEnabled, + ]) + } + + // Recurse into accessibility-elements first (some custom views vend + // synthetic children), then UIView subviews. SwiftUI's host views + // populate accessibilityElements lazily — many return nil before + // VoiceOver triggers them. Force population by reading accessibilityElementCount. + _ = view.accessibilityElementCount() + if let axElements = view.accessibilityElements { + for case let element as NSObject in axElements { + if let v = element as? UIView { + collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) + } else { + // Synthetic accessibility element (no UIView). Capture frame in screen coords. + let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + elements.append([ + "path": "\(path) > ", + "class": "AccessibilityElement", + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + } + } else { + // accessibilityElements is nil — iterate by index. SwiftUI uses + // this dynamic protocol pattern; many AX elements only respond + // to accessibilityElementCount + accessibilityElement(at:). + let count = view.accessibilityElementCount() + for i in 0.. ", + "class": String(describing: type(of: element)), + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + } + } + for sub in view.subviews { + collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) + } + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +// MARK: - MutationBridge implementation + +@MainActor +enum MutationBridgeImpl { + /// Route a mutation op to the right handler. Returns true on success, + /// false on failure (which the StateServer surfaces as 400 to the agent). + static func dispatch(op: String, payload: JSONDict) -> Bool { + switch op { + case "tap": return handleTap(payload) + case "type": return handleType(payload) + case "swipe": return handleSwipe(payload) + default: return false + } + } + + /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch + /// (KIF-derived in-process touch synthesis). The Obj-C target builds a + /// real UITouch + IOHIDEvent + UIEvent and dispatches via + /// `UIApplication.sendEvent`, which is what UIKit uses for real touches. + /// This works for UIControl, SwiftUI Button (via iOS 18+ + /// `_UIHitTestContext`), gesture recognizers, and anything else that + /// listens to the real event-dispatch path. + private static func handleTap(_ payload: JSONDict) -> Bool { + guard let x = payload["x"] as? NSNumber, + let y = payload["y"] as? NSNumber else { return false } + let point = CGPoint(x: x.doubleValue, y: y.doubleValue) + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + return DebugBridgeTouch.sendTap(at: point, in: window) + } + + /// Set text on the first responder if it's a UITextField or UITextView. + private static func handleType(_ payload: JSONDict) -> Bool { + guard let text = payload["text"] as? String else { return false } + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + guard let responder = findFirstResponder(in: window) else { return false } + if let field = responder as? UITextField { + field.text = text + field.sendActions(for: .editingChanged) + return true + } + if let view = responder as? UITextView { + view.text = text + view.delegate?.textViewDidChange?(view) + return true + } + return false + } + + /// Swipe via UIScrollView programmatic scroll OR via setContentOffset on + /// the deepest UIScrollView in the hit-tested ancestor chain. Less + /// faithful than synthesized touches but covers common scroll scenarios. + private static func handleSwipe(_ payload: JSONDict) -> Bool { + guard let fx = payload["from_x"] as? NSNumber, + let fy = payload["from_y"] as? NSNumber, + let tx = payload["to_x"] as? NSNumber, + let ty = payload["to_y"] as? NSNumber else { return false } + let from = CGPoint(x: fx.doubleValue, y: fy.doubleValue) + let to = CGPoint(x: tx.doubleValue, y: ty.doubleValue) + + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + guard let hit = window.hitTest(from, with: nil) else { return false } + + // Find the nearest enclosing UIScrollView. + var node: UIView? = hit + while let cur = node { + if let scroll = cur as? UIScrollView { + let dx = from.x - to.x + let dy = from.y - to.y + var off = scroll.contentOffset + off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) + off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy)) + scroll.setContentOffset(off, animated: true) + return true + } + node = cur.superview + } + return false + } + + // MARK: helpers + + private static func walkUp(_ view: UIView) -> UIView? { + var node: UIView? = view + while let cur = node { + if cur is UIControl { return cur } + node = cur.superview + } + return view + } + + private static func findFirstResponder(in view: UIView) -> UIResponder? { + if view.isFirstResponder { return view } + for sub in view.subviews { + if let found = findFirstResponder(in: sub) { return found } + } + return nil + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +#endif // DEBUG && canImport(UIKit) diff --git a/test/fixtures/ios-qa/FixtureApp/Package.swift b/test/fixtures/ios-qa/FixtureApp/Package.swift index 2da564bcf..40477f0fe 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -2,6 +2,8 @@ // Test fixture: minimal SwiftUI app + DebugBridge SPM package. // DebugBridgeCore (Foundation+Network) builds cross-platform. // DebugBridgeUI (UIKit/SwiftUI) is iOS-only. +// DebugBridgeTouch (Objective-C) is iOS-only — in-process tap synthesis +// derived from KIF (MIT). DEBUG-only. import PackageDescription @@ -14,6 +16,7 @@ let package = Package( products: [ .library(name: "DebugBridgeCore", targets: ["DebugBridgeCore"]), .library(name: "DebugBridgeUI", targets: ["DebugBridgeUI"]), + .library(name: "DebugBridgeTouch", targets: ["DebugBridgeTouch"]), ], targets: [ .target( @@ -24,9 +27,18 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)), ] ), + .target( + name: "DebugBridgeTouch", + dependencies: [], + path: "Sources/DebugBridgeTouch", + publicHeadersPath: "include", + linkerSettings: [ + .linkedFramework("UIKit", .when(platforms: [.iOS])), + ] + ), .target( name: "DebugBridgeUI", - dependencies: ["DebugBridgeCore"], + dependencies: ["DebugBridgeCore", "DebugBridgeTouch"], path: "Sources/DebugBridgeUI", swiftSettings: [ .define("DEBUG", .when(configuration: .debug)), diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m new file mode 100644 index 000000000..d2bd8632e --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -0,0 +1,277 @@ +// +// DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. +// Original code: https://github.com/kif-framework/KIF — MIT-licensed +// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// iOS 18+ aware subset for the gstack/ios-qa DebugBridge. +// +// Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): +// UITouch: _setLocationInWindow:resetPrevious:, _setIsFirstTouchForView:, +// setPhase:, setTimestamp:, setView:, setWindow:, setTapCount:, +// _setHidEvent: +// UIEvent: _clearTouches, _addTouch:forDelayedDelivery:, _setHIDEvent: +// UIApplication: _touchesEvent +// UIView: _hitTestWithContext: (iOS 18+ for SwiftUI hit-testing) +// NSObject: _UIHitTestContext contextWithPoint:radius: (iOS 18+) +// +// IOKit private symbols (linked dynamically via the IOKit framework on iOS): +// IOHIDEventCreateDigitizerEvent, IOHIDEventCreateDigitizerFingerEventWithQuality, +// IOHIDEventSetIntegerValue, IOHIDEventAppendEvent. + +#import "DebugBridgeTouch.h" +#import +#import +#import +#import + +#pragma mark - IOHIDEvent (private symbols from IOKit) + +typedef struct __IOHIDEvent * IOHIDEventRef; + +#define IOHIDEventFieldBase(type) (type << 16) +#ifdef __LP64__ +typedef double IOHIDFloat; +#else +typedef float IOHIDFloat; +#endif +typedef UInt32 IOOptionBits; +typedef uint32_t IOHIDDigitizerTransducerType; +typedef uint32_t IOHIDEventField; + +enum { + kIOHIDDigitizerTransducerTypeStylus = 0, + kIOHIDDigitizerTransducerTypePuck, + kIOHIDDigitizerTransducerTypeFinger, + kIOHIDDigitizerTransducerTypeHand +}; + +enum { + kIOHIDEventTypeDigitizer = 11, +}; + +enum { + kIOHIDDigitizerEventRange = 0x00000001, + kIOHIDDigitizerEventTouch = 0x00000002, + kIOHIDDigitizerEventPosition = 0x00000004, +}; + +enum { + kIOHIDEventFieldDigitizerX = IOHIDEventFieldBase(kIOHIDEventTypeDigitizer), + kIOHIDEventFieldDigitizerY, + kIOHIDEventFieldDigitizerZ, + kIOHIDEventFieldDigitizerButtonMask, + kIOHIDEventFieldDigitizerType, + kIOHIDEventFieldDigitizerIndex, + kIOHIDEventFieldDigitizerIdentity, + kIOHIDEventFieldDigitizerEventMask, + kIOHIDEventFieldDigitizerRange, + kIOHIDEventFieldDigitizerTouch, + kIOHIDEventFieldDigitizerPressure, + kIOHIDEventFieldDigitizerAuxiliaryPressure, + kIOHIDEventFieldDigitizerTwist, + kIOHIDEventFieldDigitizerTiltX, + kIOHIDEventFieldDigitizerTiltY, + kIOHIDEventFieldDigitizerAltitude, + kIOHIDEventFieldDigitizerAzimuth, + kIOHIDEventFieldDigitizerQuality, + kIOHIDEventFieldDigitizerDensity, + kIOHIDEventFieldDigitizerIrregularity, + kIOHIDEventFieldDigitizerMajorRadius, + kIOHIDEventFieldDigitizerMinorRadius, + kIOHIDEventFieldDigitizerCollection, + kIOHIDEventFieldDigitizerCollectionChord, + kIOHIDEventFieldDigitizerChildEventMask, + kIOHIDEventFieldDigitizerIsDisplayIntegrated, +}; + +// IOKit is a PRIVATE framework on iOS — we can't link it via -framework. Load +// at runtime via dlopen/dlsym. This is the standard approach for KIF-style +// touch synthesis on iOS, including in DEBUG-only test harnesses. +#import + +typedef IOHIDEventRef (*IOHIDEventCreateDigitizerEventFn)(CFAllocatorRef, AbsoluteTime, + IOHIDDigitizerTransducerType, uint32_t, uint32_t, uint32_t, uint32_t, + IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, Boolean, Boolean, IOOptionBits); + +typedef IOHIDEventRef (*IOHIDEventCreateDigitizerFingerEventWithQualityFn)(CFAllocatorRef, + AbsoluteTime, uint32_t, uint32_t, uint32_t, IOHIDFloat, IOHIDFloat, IOHIDFloat, + IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, + IOHIDFloat, Boolean, Boolean, IOOptionBits); + +typedef void (*IOHIDEventSetIntegerValueFn)(IOHIDEventRef, IOHIDEventField, int); +typedef void (*IOHIDEventAppendEventFn)(IOHIDEventRef, IOHIDEventRef); + +static IOHIDEventCreateDigitizerEventFn _IOHIDEventCreateDigitizerEvent; +static IOHIDEventCreateDigitizerFingerEventWithQualityFn _IOHIDEventCreateDigitizerFingerEventWithQuality; +static IOHIDEventSetIntegerValueFn _IOHIDEventSetIntegerValue; +static IOHIDEventAppendEventFn _IOHIDEventAppendEvent; + +static BOOL _IOKitLoaded = NO; +static BOOL DBT_LoadIOKit(void) { + if (_IOKitLoaded) return YES; + void *handle = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); + if (!handle) { + handle = dlopen("/System/Library/PrivateFrameworks/IOKit.framework/IOKit", RTLD_NOW); + } + if (!handle) return NO; + _IOHIDEventCreateDigitizerEvent = (IOHIDEventCreateDigitizerEventFn)dlsym(handle, "IOHIDEventCreateDigitizerEvent"); + _IOHIDEventCreateDigitizerFingerEventWithQuality = (IOHIDEventCreateDigitizerFingerEventWithQualityFn)dlsym(handle, "IOHIDEventCreateDigitizerFingerEventWithQuality"); + _IOHIDEventSetIntegerValue = (IOHIDEventSetIntegerValueFn)dlsym(handle, "IOHIDEventSetIntegerValue"); + _IOHIDEventAppendEvent = (IOHIDEventAppendEventFn)dlsym(handle, "IOHIDEventAppendEvent"); + _IOKitLoaded = (_IOHIDEventCreateDigitizerEvent && _IOHIDEventCreateDigitizerFingerEventWithQuality && + _IOHIDEventSetIntegerValue && _IOHIDEventAppendEvent); + return _IOKitLoaded; +} + +static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED; +static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) { + if (!DBT_LoadIOKit()) return NULL; + uint64_t abTime = mach_absolute_time(); + AbsoluteTime timeStamp; + timeStamp.hi = (UInt32)(abTime >> 32); + timeStamp.lo = (UInt32)(abTime); + + IOHIDEventRef handEvent = _IOHIDEventCreateDigitizerEvent(kCFAllocatorDefault, + timeStamp, kIOHIDDigitizerTransducerTypeHand, + 0, 0, kIOHIDDigitizerEventTouch, 0, + 0, 0, 0, 0, 0, + 0, true, 0); + _IOHIDEventSetIntegerValue(handEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1); + + uint32_t eventMask = (touch.phase == UITouchPhaseMoved) + ? kIOHIDDigitizerEventPosition + : (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch); + uint32_t isTouching = (touch.phase == UITouchPhaseEnded) ? 0 : 1; + + CGPoint loc = [touch locationInView:touch.window]; + + IOHIDEventRef fingerEvent = _IOHIDEventCreateDigitizerFingerEventWithQuality(kCFAllocatorDefault, + timeStamp, 1, 2, eventMask, + (IOHIDFloat)loc.x, (IOHIDFloat)loc.y, 0.0, + 0, 0, 5.0, 5.0, 1.0, 1.0, 1.0, + (IOHIDFloat)isTouching, (IOHIDFloat)isTouching, 0); + _IOHIDEventSetIntegerValue(fingerEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1); + + _IOHIDEventAppendEvent(handEvent, fingerEvent); + CFRelease(fingerEvent); + + return handEvent; +} + +#pragma mark - Private selectors + +@interface UITouch () +- (void)setWindow:(UIWindow *)window; +- (void)setView:(UIView *)view; +- (void)setTapCount:(NSUInteger)tapCount; +- (void)setTimestamp:(NSTimeInterval)timestamp; +- (void)setPhase:(UITouchPhase)touchPhase; +- (void)setGestureView:(UIView *)view; +- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious; +- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView; +- (void)_setHidEvent:(IOHIDEventRef)event; +@end + +@interface UIEvent (DBTPrivate) +- (void)_clearTouches; +- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed; +- (void)_setHIDEvent:(IOHIDEventRef)event; +- (void)_setTimestamp:(NSTimeInterval)timestamp; +@end + +@interface UIApplication (DBTPrivate) +- (UIEvent *)_touchesEvent; +@end + +@interface UIView (DBTPrivate) +- (id)_hitTestWithContext:(id)context; +@end + +#pragma mark - SwiftUI-aware hit test (iOS 18+) + +static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) { + UIView *fallback = [window hitTest:point withEvent:nil]; + + if (@available(iOS 18.0, *)) { + Class ctxClass = NSClassFromString(@"_UIHitTestContext"); + SEL ctxSel = NSSelectorFromString(@"contextWithPoint:radius:"); + if (ctxClass && [ctxClass respondsToSelector:ctxSel] && + [UIView instancesRespondToSelector:@selector(_hitTestWithContext:)]) { + id (*sendCtx)(id, SEL, CGPoint, CGFloat) = + (id (*)(id, SEL, CGPoint, CGFloat))objc_msgSend; + id ctx = sendCtx(ctxClass, ctxSel, point, 0); + if (ctx) { + id found = nil; + UIView *current = fallback; + while (found == nil && current != nil) { + found = [current _hitTestWithContext:ctx]; + current = current.superview; + } + if (found && [found isKindOfClass:[UIView class]]) { + return (UIView *)found; + } + } + } + } + return fallback; +} + +#pragma mark - Public API + +@implementation DebugBridgeTouch + ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { + if (!window) return NO; + + UIView *hit = DBT_HitTestView(window, point); + if (!hit) return NO; + + // Build a single synthetic UITouch via private setters. Order matters — + // setWindow: clears internal state and must come first. + UITouch *touch = [[UITouch alloc] init]; + [touch setWindow:window]; + [touch setTapCount:1]; + [touch _setLocationInWindow:point resetPrevious:YES]; + [touch setView:hit]; + [touch setPhase:UITouchPhaseBegan]; + if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) { + [touch _setIsFirstTouchForView:YES]; + } + [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + // KIF sets the gestureView too — required for SwiftUI Button gesture + // recognition. Without this the gesture system sees the touch as + // unattached and drops it. + if ([touch respondsToSelector:@selector(setGestureView:)]) { + [touch setGestureView:hit]; + } + + // Attach a real IOHIDEvent (required iOS 9+). + IOHIDEventRef hidEventBegan = DBT_IOHIDEventWithTouch(touch); + [touch _setHidEvent:hidEventBegan]; + + UIEvent *event = [[UIApplication sharedApplication] _touchesEvent]; + if (!event) { + CFRelease(hidEventBegan); + return NO; + } + [event _clearTouches]; + [event _setHIDEvent:hidEventBegan]; + [event _addTouch:touch forDelayedDelivery:NO]; + + [[UIApplication sharedApplication] sendEvent:event]; + CFRelease(hidEventBegan); + + // Ended phase + [touch setPhase:UITouchPhaseEnded]; + [touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]]; + IOHIDEventRef hidEventEnded = DBT_IOHIDEventWithTouch(touch); + [touch _setHidEvent:hidEventEnded]; + [event _clearTouches]; + [event _setHIDEvent:hidEventEnded]; + [event _addTouch:touch forDelayedDelivery:NO]; + [[UIApplication sharedApplication] sendEvent:event]; + CFRelease(hidEventEnded); + + return YES; +} + +@end diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h new file mode 100644 index 000000000..365d8b4df --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -0,0 +1,21 @@ +// +// DebugBridgeTouch.h — public Objective-C interface for in-process touch +// synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), +// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// point on the key window, including SwiftUI Buttons via iOS 18+ +// _UIHitTestContext. DEBUG-only — never link in Release. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface DebugBridgeTouch : NSObject + +/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given +/// window-coordinate point. Returns YES if the touch was delivered (a hit +/// view was found and the event passed through UIApplication.sendEvent). ++ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window; + +@end + +NS_ASSUME_NONNULL_END diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift new file mode 100644 index 000000000..bf7af6e3f --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeUI/Bridges.swift @@ -0,0 +1,308 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/Bridges.swift.template +// +// Real UIKit-backed implementations of the three bridges StateServer +// declares: ScreenshotBridge (PNG capture), ElementsBridge (accessibility +// tree), MutationBridge (tap/swipe/type via accessibility actions + hit +// testing). Everything #if DEBUG && canImport(UIKit) so Release builds +// don't link UIKit or carry any of this code. +// +// Wire from the consuming app: +// +// #if DEBUG && canImport(UIKit) +// import DebugBridgeUI +// DebugBridgeUIWiring.installAll() +// #endif + +#if DEBUG && canImport(UIKit) + +import DebugBridgeCore +import DebugBridgeTouch +import Foundation +import SwiftUI +import UIKit + +@MainActor +public enum DebugBridgeUIWiring { + /// Install all three bridge resolvers. Idempotent — calling multiple + /// times reinstalls the same closures. Must be called on @MainActor + /// because every UIKit access requires the main actor. + public static func installAll() { + ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() } + ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() } + MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) } + } +} + +// MARK: - ScreenshotBridge implementation + +@MainActor +enum ScreenshotBridgeImpl { + /// Capture a PNG of the active window. Uses UIGraphicsImageRenderer + /// (modern API, replaces UIGraphicsBeginImageContext). Returns nil if + /// no key window is available (e.g., app backgrounded). + static func capturePNG() -> Data? { + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil } + let bounds = window.bounds + let renderer = UIGraphicsImageRenderer(bounds: bounds) + let image = renderer.image { _ in + // drawHierarchy is the documented way to snapshot real UIKit + // layers including layer-backed views. afterScreenUpdates: false + // because we want the CURRENT visible state, not a forced layout. + window.drawHierarchy(in: bounds, afterScreenUpdates: false) + } + return image.pngData() + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +// MARK: - ElementsBridge implementation + +@MainActor +enum ElementsBridgeImpl { + /// Walk the accessibility hierarchy + emit a flat list of elements. + /// Each entry has frame (in window coords), accessibility label, + /// identifier, traits as a bitmask, and a parent path. Skips + /// non-accessible / hidden views. + static func snapshot() -> [JSONDict] { + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] } + var elements: [JSONDict] = [] + collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements) + return elements + } + + private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) { + // Skip hidden / zero-size / off-screen subtrees early. + if view.isHidden || view.alpha < 0.01 { return } + + let frameInWindow = view.convert(view.bounds, to: nil) + if !windowBounds.intersects(frameInWindow) { return } + + let isAccessible = view.isAccessibilityElement + let label = view.accessibilityLabel ?? "" + let identifier = view.accessibilityIdentifier ?? "" + let traits = Int(view.accessibilityTraits.rawValue) + let value = (view.accessibilityValue ?? "") as String + let className = String(describing: type(of: view)) + let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)" + + // Emit if any of: + // - Marked accessible (covers UIKit-native widgets) + // - Has explicit AX label / identifier + // - Is a known interactive type (UIControl, UITextField, UIScrollView) + // - Hosts a SwiftUI view (UIHostingController's view class) + let isInteractive = view is UIControl || view is UIScrollView || view is UITextInput + let isHosting = className.contains("Hosting") || className.contains("SwiftUI") + if isAccessible || !label.isEmpty || !identifier.isEmpty || isInteractive || isHosting { + elements.append([ + "path": path, + "class": className, + "label": label, + "identifier": identifier, + "value": value, + "traits": traits, + "frame": [ + "x": Int(frameInWindow.origin.x), + "y": Int(frameInWindow.origin.y), + "w": Int(frameInWindow.size.width), + "h": Int(frameInWindow.size.height), + ], + "is_user_interaction_enabled": view.isUserInteractionEnabled, + ]) + } + + // Recurse into accessibility-elements first (some custom views vend + // synthetic children), then UIView subviews. SwiftUI's host views + // populate accessibilityElements lazily — many return nil before + // VoiceOver triggers them. Force population by reading accessibilityElementCount. + _ = view.accessibilityElementCount() + if let axElements = view.accessibilityElements { + for case let element as NSObject in axElements { + if let v = element as? UIView { + collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements) + } else { + // Synthetic accessibility element (no UIView). Capture frame in screen coords. + let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + elements.append([ + "path": "\(path) > ", + "class": "AccessibilityElement", + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + } + } else { + // accessibilityElements is nil — iterate by index. SwiftUI uses + // this dynamic protocol pattern; many AX elements only respond + // to accessibilityElementCount + accessibilityElement(at:). + let count = view.accessibilityElementCount() + for i in 0.. ", + "class": String(describing: type(of: element)), + "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "", + "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "", + "value": (element.value(forKey: "accessibilityValue") as? String) ?? "", + "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0, + "frame": [ + "x": Int(af.origin.x), + "y": Int(af.origin.y), + "w": Int(af.size.width), + "h": Int(af.size.height), + ], + "is_user_interaction_enabled": true, + ]) + } + } + } + for sub in view.subviews { + collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements) + } + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +// MARK: - MutationBridge implementation + +@MainActor +enum MutationBridgeImpl { + /// Route a mutation op to the right handler. Returns true on success, + /// false on failure (which the StateServer surfaces as 400 to the agent). + static func dispatch(op: String, payload: JSONDict) -> Bool { + switch op { + case "tap": return handleTap(payload) + case "type": return handleType(payload) + case "swipe": return handleSwipe(payload) + default: return false + } + } + + /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch + /// (KIF-derived in-process touch synthesis). The Obj-C target builds a + /// real UITouch + IOHIDEvent + UIEvent and dispatches via + /// `UIApplication.sendEvent`, which is what UIKit uses for real touches. + /// This works for UIControl, SwiftUI Button (via iOS 18+ + /// `_UIHitTestContext`), gesture recognizers, and anything else that + /// listens to the real event-dispatch path. + private static func handleTap(_ payload: JSONDict) -> Bool { + guard let x = payload["x"] as? NSNumber, + let y = payload["y"] as? NSNumber else { return false } + let point = CGPoint(x: x.doubleValue, y: y.doubleValue) + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + return DebugBridgeTouch.sendTap(at: point, in: window) + } + + /// Set text on the first responder if it's a UITextField or UITextView. + private static func handleType(_ payload: JSONDict) -> Bool { + guard let text = payload["text"] as? String else { return false } + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + guard let responder = findFirstResponder(in: window) else { return false } + if let field = responder as? UITextField { + field.text = text + field.sendActions(for: .editingChanged) + return true + } + if let view = responder as? UITextView { + view.text = text + view.delegate?.textViewDidChange?(view) + return true + } + return false + } + + /// Swipe via UIScrollView programmatic scroll OR via setContentOffset on + /// the deepest UIScrollView in the hit-tested ancestor chain. Less + /// faithful than synthesized touches but covers common scroll scenarios. + private static func handleSwipe(_ payload: JSONDict) -> Bool { + guard let fx = payload["from_x"] as? NSNumber, + let fy = payload["from_y"] as? NSNumber, + let tx = payload["to_x"] as? NSNumber, + let ty = payload["to_y"] as? NSNumber else { return false } + let from = CGPoint(x: fx.doubleValue, y: fy.doubleValue) + let to = CGPoint(x: tx.doubleValue, y: ty.doubleValue) + + guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + guard let hit = window.hitTest(from, with: nil) else { return false } + + // Find the nearest enclosing UIScrollView. + var node: UIView? = hit + while let cur = node { + if let scroll = cur as? UIScrollView { + let dx = from.x - to.x + let dy = from.y - to.y + var off = scroll.contentOffset + off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx)) + off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy)) + scroll.setContentOffset(off, animated: true) + return true + } + node = cur.superview + } + return false + } + + // MARK: helpers + + private static func walkUp(_ view: UIView) -> UIView? { + var node: UIView? = view + while let cur = node { + if cur is UIControl { return cur } + node = cur.superview + } + return view + } + + private static func findFirstResponder(in view: UIView) -> UIResponder? { + if view.isFirstResponder { return view } + for sub in view.subviews { + if let found = findFirstResponder(in: sub) { return found } + } + return nil + } + + private static func activeScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? (UIApplication.shared.connectedScenes.first as? UIWindowScene) + } + + private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? { + scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first + } +} + +#endif // DEBUG && canImport(UIKit) diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift index e3d8906db..af18b72e3 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift @@ -23,6 +23,11 @@ struct FixtureAppApp: App { init() { #if DEBUG StateServer.shared.start() + // Wire the three UIKit-backed bridges so /screenshot, /elements, + // /tap, /type, /swipe actually do something on the device. + #if canImport(UIKit) + DebugBridgeUIWiring.installAll() + #endif #endif }