mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
fix physical iOS bridge reliability
This commit is contained in:
@@ -10,13 +10,24 @@ import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface DeviceEntry {
|
||||
/** CoreDevice UUID used by `devicectl --device`. */
|
||||
identifier: string;
|
||||
/** Hardware UDID shown by Xcode and commonly supplied by users/CI. */
|
||||
hardwareUdid: string | null;
|
||||
name: string;
|
||||
model: string;
|
||||
state: string; // "connected" | "available" | "available (paired)" | ...
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
export type DeviceListResult =
|
||||
| { ok: true; devices: DeviceEntry[] }
|
||||
| {
|
||||
ok: false;
|
||||
error: 'devicectl_unavailable' | 'devicectl_failed' | 'devicectl_bad_response';
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export interface SpawnImpl {
|
||||
(cmd: string, args: string[]): SpawnSyncReturns<Buffer>;
|
||||
}
|
||||
@@ -66,29 +77,59 @@ const legacyResolve6: ResolveImpl = async (hostname) => {
|
||||
* List devices currently known to CoreDevice. Includes connected, paired,
|
||||
* and pairing-in-progress devices.
|
||||
*/
|
||||
export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
|
||||
export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceListResult {
|
||||
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 [];
|
||||
if (r.error) {
|
||||
const code = (r.error as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
ok: false,
|
||||
error: code === 'ENOENT' ? 'devicectl_unavailable' : 'devicectl_failed',
|
||||
detail: code ? `${code}: ${r.error.message}` : r.error.message,
|
||||
};
|
||||
}
|
||||
if (r.status !== 0) {
|
||||
const stderr = r.stderr?.toString().trim();
|
||||
return {
|
||||
ok: false,
|
||||
error: 'devicectl_failed',
|
||||
detail: stderr || `devicectl exited ${r.status ?? 'without a status'}`,
|
||||
};
|
||||
}
|
||||
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 list = obj?.result?.devices;
|
||||
if (!Array.isArray(list)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'devicectl_bad_response',
|
||||
detail: 'JSON response is missing result.devices[]',
|
||||
};
|
||||
}
|
||||
const devices = (list as Array<Record<string, unknown>>).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 ?? '');
|
||||
const identifier = String(d.identifier ?? '');
|
||||
if (!identifier) throw new Error('device entry is missing identifier');
|
||||
return {
|
||||
identifier: String(d.identifier ?? ''),
|
||||
identifier,
|
||||
hardwareUdid: typeof hw?.udid === 'string' && hw.udid ? hw.udid : null,
|
||||
name: String(props?.name ?? 'unknown'),
|
||||
model: String(hw?.productType ?? 'unknown'),
|
||||
state: String(conn?.tunnelState ?? 'unknown'),
|
||||
paired: pairingState === 'paired',
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
return { ok: true, devices };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'devicectl_bad_response',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_BODY = 1_048_576; // 1MB hard cap on tailnet ingress
|
||||
|
||||
export interface DeviceTunnel {
|
||||
udid: string;
|
||||
bundleId?: string;
|
||||
ipv6Addr: string;
|
||||
port: number;
|
||||
bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer
|
||||
@@ -33,6 +34,7 @@ export async function proxyToDevice(opts: {
|
||||
tunnel: DeviceTunnel;
|
||||
sessionId: string | null;
|
||||
agentIdentity?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
||||
const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
|
||||
if (body.length > MAX_BODY) {
|
||||
@@ -46,6 +48,9 @@ export async function proxyToDevice(opts: {
|
||||
};
|
||||
if (sessionId) headers['x-session-id'] = sessionId;
|
||||
if (agentIdentity) headers['x-agent-identity'] = agentIdentity;
|
||||
if (tunnel.bundleId && isCoordinateMutation(inbound.method, inbound.url)) {
|
||||
headers['x-gstack-expected-bundle-id'] = tunnel.bundleId;
|
||||
}
|
||||
|
||||
// Bracket IPv6 literals; pass IPv4 + hostnames bare. The CoreDevice tunnel
|
||||
// is always IPv6 in production, but tests inject 127.0.0.1 to talk to a
|
||||
@@ -54,11 +59,17 @@ export async function proxyToDevice(opts: {
|
||||
const isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2;
|
||||
const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr;
|
||||
const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: { status: number; headers: Record<string, string>; body: Buffer }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const req = httpRequest(url, {
|
||||
method: inbound.method,
|
||||
headers,
|
||||
timeout: 30_000,
|
||||
timeout: opts.timeoutMs ?? 30_000,
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
@@ -67,21 +78,30 @@ export async function proxyToDevice(opts: {
|
||||
for (const [k, v] of Object.entries(res.headers)) {
|
||||
if (typeof v === 'string') respHeaders[k] = v;
|
||||
}
|
||||
resolve({
|
||||
finish({
|
||||
status: res.statusCode ?? 502,
|
||||
headers: respHeaders,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
res.on('aborted', () => finish(makeError(503, 'device_disconnected')));
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
// Node's request timeout is advisory: without destroying the socket it
|
||||
// can hang forever while a suspended app keeps the CoreDevice route but
|
||||
// stops servicing HTTP. Resolve first, then destroy; the resulting error
|
||||
// event is ignored by the settled guard.
|
||||
finish(makeError(504, 'upstream_timeout'));
|
||||
req.destroy();
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') {
|
||||
resolve(makeError(503, 'device_disconnected'));
|
||||
finish(makeError(503, 'device_disconnected'));
|
||||
} else if (e.code === 'ETIMEDOUT') {
|
||||
resolve(makeError(504, 'upstream_timeout'));
|
||||
finish(makeError(504, 'upstream_timeout'));
|
||||
} else {
|
||||
reject(err);
|
||||
finish(makeError(502, 'upstream_error'));
|
||||
}
|
||||
});
|
||||
req.write(body);
|
||||
@@ -89,6 +109,10 @@ export async function proxyToDevice(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
function isCoordinateMutation(method: string | undefined, path: string | undefined): boolean {
|
||||
return method === 'POST' && path !== undefined && ['/tap', '/swipe', '/type'].includes(path.split('?')[0]!);
|
||||
}
|
||||
|
||||
function makeError(status: number, error: string): { status: number; headers: Record<string, string>; body: Buffer } {
|
||||
const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
|
||||
return {
|
||||
|
||||
@@ -47,6 +47,9 @@ export type BootstrapResult =
|
||||
| { ok: false; error: BootstrapErrorReason; detail?: string };
|
||||
|
||||
export type BootstrapErrorReason =
|
||||
| 'device_discovery_unavailable'
|
||||
| 'device_discovery_failed'
|
||||
| 'device_discovery_bad_response'
|
||||
| 'no_devices'
|
||||
| 'no_paired_device'
|
||||
| 'device_not_found'
|
||||
@@ -71,12 +74,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
|
||||
// Step 1: pick a device
|
||||
const devices = listDevices(spawn);
|
||||
const listed = listDevices(spawn);
|
||||
if (!listed.ok) {
|
||||
const error: BootstrapErrorReason = listed.error === 'devicectl_unavailable'
|
||||
? 'device_discovery_unavailable'
|
||||
: listed.error === 'devicectl_bad_response'
|
||||
? 'device_discovery_bad_response'
|
||||
: 'device_discovery_failed';
|
||||
return { ok: false, error, detail: listed.detail };
|
||||
}
|
||||
const devices = listed.devices;
|
||||
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.identifier === opts.udid || d.hardwareUdid === opts.udid)
|
||||
: devices.find((d) => d.paired) ?? devices[0];
|
||||
if (!target) {
|
||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||
@@ -166,6 +178,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
ok: true,
|
||||
tunnel: {
|
||||
udid: target.identifier,
|
||||
bundleId: opts.bundleId,
|
||||
ipv6Addr: ipv6,
|
||||
port,
|
||||
bootTokenRotated: rotatedToken,
|
||||
|
||||
Reference in New Issue
Block a user