mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-18 15:50:11 +02:00
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:
@@ -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,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`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<SpawnImpl>;
|
||||
}
|
||||
|
||||
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<string, string>)['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');
|
||||
});
|
||||
});
|
||||
@@ -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) > <synthetic>",
|
||||
"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..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"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)
|
||||
+13
-1
@@ -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)),
|
||||
|
||||
@@ -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 <UIKit/UIKit.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <mach/mach_time.h>
|
||||
|
||||
#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 <dlfcn.h>
|
||||
|
||||
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
|
||||
+21
@@ -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 <UIKit/UIKit.h>
|
||||
|
||||
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
|
||||
@@ -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) > <synthetic>",
|
||||
"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..<count {
|
||||
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||
if let v = element as? UIView {
|
||||
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
||||
} else {
|
||||
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
||||
elements.append([
|
||||
"path": "\(path) > <ax\(i)>",
|
||||
"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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user