mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-20 20:00:45 +02:00
fix physical iOS bridge reliability
This commit is contained in:
@@ -10,13 +10,24 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
export interface DeviceEntry {
|
export interface DeviceEntry {
|
||||||
|
/** CoreDevice UUID used by `devicectl --device`. */
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
/** Hardware UDID shown by Xcode and commonly supplied by users/CI. */
|
||||||
|
hardwareUdid: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
model: string;
|
model: string;
|
||||||
state: string; // "connected" | "available" | "available (paired)" | ...
|
state: string; // "connected" | "available" | "available (paired)" | ...
|
||||||
paired: boolean;
|
paired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceListResult =
|
||||||
|
| { ok: true; devices: DeviceEntry[] }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: 'devicectl_unavailable' | 'devicectl_failed' | 'devicectl_bad_response';
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface SpawnImpl {
|
export interface SpawnImpl {
|
||||||
(cmd: string, args: string[]): SpawnSyncReturns<Buffer>;
|
(cmd: string, args: string[]): SpawnSyncReturns<Buffer>;
|
||||||
}
|
}
|
||||||
@@ -66,29 +77,59 @@ const legacyResolve6: ResolveImpl = async (hostname) => {
|
|||||||
* List devices currently known to CoreDevice. Includes connected, paired,
|
* List devices currently known to CoreDevice. Includes connected, paired,
|
||||||
* and pairing-in-progress devices.
|
* 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`);
|
const tmp = join(tmpdir(), `devicectl-list-${process.pid}-${Date.now()}.json`);
|
||||||
try {
|
try {
|
||||||
const r = spawn('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp]);
|
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 raw = readFileSync(tmp, 'utf-8');
|
||||||
const obj = JSON.parse(raw);
|
const obj = JSON.parse(raw);
|
||||||
const list = (obj.result?.devices ?? []) as Array<Record<string, unknown>>;
|
const list = obj?.result?.devices;
|
||||||
return list.map((d) => {
|
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 conn = d.connectionProperties as Record<string, unknown> | undefined;
|
||||||
const props = d.deviceProperties as Record<string, unknown> | undefined;
|
const props = d.deviceProperties as Record<string, unknown> | undefined;
|
||||||
const hw = d.hardwareProperties as Record<string, unknown> | undefined;
|
const hw = d.hardwareProperties as Record<string, unknown> | undefined;
|
||||||
const pairingState = String(conn?.pairingState ?? '');
|
const pairingState = String(conn?.pairingState ?? '');
|
||||||
|
const identifier = String(d.identifier ?? '');
|
||||||
|
if (!identifier) throw new Error('device entry is missing identifier');
|
||||||
return {
|
return {
|
||||||
identifier: String(d.identifier ?? ''),
|
identifier,
|
||||||
|
hardwareUdid: typeof hw?.udid === 'string' && hw.udid ? hw.udid : null,
|
||||||
name: String(props?.name ?? 'unknown'),
|
name: String(props?.name ?? 'unknown'),
|
||||||
model: String(hw?.productType ?? 'unknown'),
|
model: String(hw?.productType ?? 'unknown'),
|
||||||
state: String(conn?.tunnelState ?? 'unknown'),
|
state: String(conn?.tunnelState ?? 'unknown'),
|
||||||
paired: pairingState === 'paired',
|
paired: pairingState === 'paired',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
return { ok: true, devices };
|
||||||
return [];
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'devicectl_bad_response',
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
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 {
|
export interface DeviceTunnel {
|
||||||
udid: string;
|
udid: string;
|
||||||
|
bundleId?: string;
|
||||||
ipv6Addr: string;
|
ipv6Addr: string;
|
||||||
port: number;
|
port: number;
|
||||||
bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer
|
bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer
|
||||||
@@ -33,6 +34,7 @@ export async function proxyToDevice(opts: {
|
|||||||
tunnel: DeviceTunnel;
|
tunnel: DeviceTunnel;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
agentIdentity?: string;
|
agentIdentity?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
}): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
||||||
const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
|
const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
|
||||||
if (body.length > MAX_BODY) {
|
if (body.length > MAX_BODY) {
|
||||||
@@ -46,6 +48,9 @@ export async function proxyToDevice(opts: {
|
|||||||
};
|
};
|
||||||
if (sessionId) headers['x-session-id'] = sessionId;
|
if (sessionId) headers['x-session-id'] = sessionId;
|
||||||
if (agentIdentity) headers['x-agent-identity'] = agentIdentity;
|
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
|
// 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
|
// 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 isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2;
|
||||||
const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr;
|
const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr;
|
||||||
const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`;
|
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, {
|
const req = httpRequest(url, {
|
||||||
method: inbound.method,
|
method: inbound.method,
|
||||||
headers,
|
headers,
|
||||||
timeout: 30_000,
|
timeout: opts.timeoutMs ?? 30_000,
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
res.on('data', (c) => chunks.push(c));
|
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)) {
|
for (const [k, v] of Object.entries(res.headers)) {
|
||||||
if (typeof v === 'string') respHeaders[k] = v;
|
if (typeof v === 'string') respHeaders[k] = v;
|
||||||
}
|
}
|
||||||
resolve({
|
finish({
|
||||||
status: res.statusCode ?? 502,
|
status: res.statusCode ?? 502,
|
||||||
headers: respHeaders,
|
headers: respHeaders,
|
||||||
body: Buffer.concat(chunks),
|
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) => {
|
req.on('error', (err) => {
|
||||||
const e = err as { code?: string };
|
const e = err as { code?: string };
|
||||||
if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') {
|
if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') {
|
||||||
resolve(makeError(503, 'device_disconnected'));
|
finish(makeError(503, 'device_disconnected'));
|
||||||
} else if (e.code === 'ETIMEDOUT') {
|
} else if (e.code === 'ETIMEDOUT') {
|
||||||
resolve(makeError(504, 'upstream_timeout'));
|
finish(makeError(504, 'upstream_timeout'));
|
||||||
} else {
|
} else {
|
||||||
reject(err);
|
finish(makeError(502, 'upstream_error'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.write(body);
|
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 } {
|
function makeError(status: number, error: string): { status: number; headers: Record<string, string>; body: Buffer } {
|
||||||
const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
|
const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export type BootstrapResult =
|
|||||||
| { ok: false; error: BootstrapErrorReason; detail?: string };
|
| { ok: false; error: BootstrapErrorReason; detail?: string };
|
||||||
|
|
||||||
export type BootstrapErrorReason =
|
export type BootstrapErrorReason =
|
||||||
|
| 'device_discovery_unavailable'
|
||||||
|
| 'device_discovery_failed'
|
||||||
|
| 'device_discovery_bad_response'
|
||||||
| 'no_devices'
|
| 'no_devices'
|
||||||
| 'no_paired_device'
|
| 'no_paired_device'
|
||||||
| 'device_not_found'
|
| 'device_not_found'
|
||||||
@@ -71,12 +74,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
|||||||
const fetchFn = opts.fetchImpl ?? fetch;
|
const fetchFn = opts.fetchImpl ?? fetch;
|
||||||
|
|
||||||
// Step 1: pick a device
|
// 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) {
|
if (devices.length === 0) {
|
||||||
return { ok: false, error: 'no_devices' };
|
return { ok: false, error: 'no_devices' };
|
||||||
}
|
}
|
||||||
const target = opts.udid
|
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];
|
: devices.find((d) => d.paired) ?? devices[0];
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||||
@@ -166,6 +178,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
|||||||
ok: true,
|
ok: true,
|
||||||
tunnel: {
|
tunnel: {
|
||||||
udid: target.identifier,
|
udid: target.identifier,
|
||||||
|
bundleId: opts.bundleId,
|
||||||
ipv6Addr: ipv6,
|
ipv6Addr: ipv6,
|
||||||
port,
|
port,
|
||||||
bootTokenRotated: rotatedToken,
|
bootTokenRotated: rotatedToken,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// mutate state, restore state."
|
// mutate state, restore state."
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { classifyRoute } from '../src/proxy';
|
import { classifyRoute, proxyToDevice } from '../src/proxy';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import type { IncomingMessage } from 'http';
|
||||||
|
|
||||||
describe('classifyRoute', () => {
|
describe('classifyRoute', () => {
|
||||||
test('healthz, screenshot, elements, snapshot are observe-tier', () => {
|
test('healthz, screenshot, elements, snapshot are observe-tier', () => {
|
||||||
@@ -45,3 +47,77 @@ describe('classifyRoute', () => {
|
|||||||
expect(classifyRoute('GET', '/auth/sessions').allowed).toBe(false); // loopback-only
|
expect(classifyRoute('GET', '/auth/sessions').allowed).toBe(false); // loopback-only
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('proxyToDevice failure bounds and bundle assertions', () => {
|
||||||
|
test('a suspended/non-responsive app returns a bounded 504', async () => {
|
||||||
|
const server = createServer(() => {
|
||||||
|
// Deliberately keep the connection open without headers or a body. This
|
||||||
|
// is the observable shape of a CoreDevice route to a suspended app.
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await proxyToDevice({
|
||||||
|
inbound: {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/screenshot',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
} as IncomingMessage,
|
||||||
|
body: Buffer.alloc(0),
|
||||||
|
tunnel: {
|
||||||
|
udid: 'CORE-1',
|
||||||
|
ipv6Addr: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
bootTokenRotated: 'rotated-token',
|
||||||
|
},
|
||||||
|
sessionId: null,
|
||||||
|
timeoutMs: 40,
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(504);
|
||||||
|
expect(JSON.parse(result.body.toString())).toEqual({ error: 'upstream_timeout' });
|
||||||
|
expect(Date.now() - started).toBeLessThan(1_000);
|
||||||
|
} finally {
|
||||||
|
server.closeAllConnections?.();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coordinate actions carry the expected active bundle assertion', async () => {
|
||||||
|
let expectedBundleHeader: string | undefined;
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
expectedBundleHeader = req.headers['x-gstack-expected-bundle-id'] as string | undefined;
|
||||||
|
req.resume();
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
try {
|
||||||
|
const result = await proxyToDevice({
|
||||||
|
inbound: {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/tap',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
} as IncomingMessage,
|
||||||
|
body: Buffer.from('{"x":10,"y":20}'),
|
||||||
|
tunnel: {
|
||||||
|
udid: 'CORE-1',
|
||||||
|
bundleId: 'com.gstack.fixture',
|
||||||
|
ipv6Addr: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
bootTokenRotated: 'rotated-token',
|
||||||
|
},
|
||||||
|
sessionId: 'session-1',
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(expectedBundleHeader).toBe('com.gstack.fixture');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -81,6 +81,37 @@ describe('bootstrapTunnel', () => {
|
|||||||
if (!r.ok) expect(r.error).toBe('no_devices');
|
if (!r.ok) expect(r.error).toBe('no_devices');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not misclassify a devicectl failure as no_devices', async () => {
|
||||||
|
const spawn = makeSpawn([
|
||||||
|
{
|
||||||
|
argsMatch: /devicectl list devices/,
|
||||||
|
exitCode: 1,
|
||||||
|
stderr: 'xcrun: error: unable to find utility devicectl',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toBe('device_discovery_failed');
|
||||||
|
expect(r.detail).toContain('devicectl');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not turn malformed devicectl JSON into an empty successful list', async () => {
|
||||||
|
const spawn = makeSpawn([
|
||||||
|
{
|
||||||
|
argsMatch: /devicectl list devices/,
|
||||||
|
jsonOutput: { result: { unexpected: [] } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toBe('device_discovery_bad_response');
|
||||||
|
expect(r.detail).toContain('result.devices');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('returns no_paired_device when device is connected but not paired', async () => {
|
test('returns no_paired_device when device is connected but not paired', async () => {
|
||||||
const spawn = makeSpawn([
|
const spawn = makeSpawn([
|
||||||
{
|
{
|
||||||
@@ -225,6 +256,7 @@ describe('bootstrapTunnel', () => {
|
|||||||
expect(r.tunnel.udid).toBe('TEST-UDID');
|
expect(r.tunnel.udid).toBe('TEST-UDID');
|
||||||
expect(r.tunnel.ipv6Addr).toBe('fd99::beef');
|
expect(r.tunnel.ipv6Addr).toBe('fd99::beef');
|
||||||
expect(r.tunnel.port).toBe(9999);
|
expect(r.tunnel.port).toBe(9999);
|
||||||
|
expect(r.tunnel.bundleId).toBe('com.test');
|
||||||
expect(r.tunnel.bootTokenRotated).toMatch(/^[A-Za-z0-9_-]+$/);
|
expect(r.tunnel.bootTokenRotated).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
expect(r.tunnel.bootTokenRotated).not.toBe('BOOT-TOKEN-XYZ-123');
|
expect(r.tunnel.bootTokenRotated).not.toBe('BOOT-TOKEN-XYZ-123');
|
||||||
expect(r.tunnel.bootTokenRotated.length).toBeGreaterThan(20);
|
expect(r.tunnel.bootTokenRotated.length).toBeGreaterThan(20);
|
||||||
@@ -265,39 +297,39 @@ describe('bootstrapTunnel', () => {
|
|||||||
if (!r.ok) expect(r.error).toBe('resolve_failed');
|
if (!r.ok) expect(r.error).toBe('resolve_failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('respects explicit udid when set', async () => {
|
test('accepts a hardware UDID but uses the matching CoreDevice UUID for commands', async () => {
|
||||||
const spawn = makeSpawn([
|
const spawn = makeSpawn([
|
||||||
{
|
{
|
||||||
argsMatch: /devicectl list devices/,
|
argsMatch: /devicectl list devices/,
|
||||||
jsonOutput: {
|
jsonOutput: {
|
||||||
result: { devices: [
|
result: { devices: [
|
||||||
{ identifier: 'A', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'A' }, hardwareProperties: { productType: 'iPhone18,2' } },
|
{ 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' } },
|
{ identifier: 'COREDEVICE-B', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'B' }, hardwareProperties: { productType: 'iPhone18,2', udid: 'HARDWARE-B' } },
|
||||||
] },
|
] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
argsMatch: /devicectl device info processes -d B/,
|
argsMatch: /devicectl device info processes -d COREDEVICE-B/,
|
||||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
argsMatch: /devicectl device info details --device B/,
|
argsMatch: /devicectl device info details --device COREDEVICE-B/,
|
||||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } },
|
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
argsMatch: /devicectl device copy from --device B/,
|
argsMatch: /devicectl device copy from --device COREDEVICE-B/,
|
||||||
destOutput: 'TOKEN\n',
|
destOutput: 'TOKEN\n',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const r = await bootstrapTunnel({
|
const r = await bootstrapTunnel({
|
||||||
udid: 'B',
|
udid: 'HARDWARE-B',
|
||||||
bundleId: 'com.test',
|
bundleId: 'com.test',
|
||||||
spawnImpl: spawn,
|
spawnImpl: spawn,
|
||||||
resolveImpl: async () => ['fd00::b'],
|
resolveImpl: async () => ['fd00::b'],
|
||||||
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
if (r.ok) expect(r.tunnel.udid).toBe('B');
|
if (r.ok) expect(r.tunnel.udid).toBe('COREDEVICE-B');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ public final class StateServer {
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"build": appBuildId,
|
"build": appBuildId,
|
||||||
"accessor_hash": accessorHash,
|
"accessor_hash": accessorHash,
|
||||||
|
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -511,12 +512,35 @@ public final class StateServer {
|
|||||||
|
|
||||||
private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
|
private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
|
||||||
guard requireSession(in: request, connection: connection) else { return }
|
guard requireSession(in: request, connection: connection) else { return }
|
||||||
|
let bundleBefore = Bundle.main.bundleIdentifier ?? "unknown"
|
||||||
|
if let expected = request.headers["x-gstack-expected-bundle-id"], expected != bundleBefore {
|
||||||
|
send(connection: connection, status: 409, body: [
|
||||||
|
"error": "active_bundle_mismatch",
|
||||||
|
"expected_bundle": expected,
|
||||||
|
"active_bundle": bundleBefore,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
|
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
|
||||||
send(connection: connection, status: 400, body: ["error": "invalid_json"])
|
send(connection: connection, status: 400, body: ["error": "invalid_json"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let ok = MutationBridge.dispatch(op: op, payload: payload)
|
let ok = MutationBridge.dispatch(op: op, payload: payload)
|
||||||
send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok])
|
let bundleAfter = Bundle.main.bundleIdentifier ?? "unknown"
|
||||||
|
guard bundleAfter == bundleBefore else {
|
||||||
|
send(connection: connection, status: 409, body: [
|
||||||
|
"error": "active_bundle_changed",
|
||||||
|
"before_bundle": bundleBefore,
|
||||||
|
"after_bundle": bundleAfter,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(connection: connection, status: ok ? 200 : 400, body: [
|
||||||
|
"op": op,
|
||||||
|
"ok": ok,
|
||||||
|
"active_bundle_before": bundleBefore,
|
||||||
|
"active_bundle_after": bundleAfter,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Response
|
// MARK: Response
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ public final class StateServer {
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"build": appBuildId,
|
"build": appBuildId,
|
||||||
"accessor_hash": accessorHash,
|
"accessor_hash": accessorHash,
|
||||||
|
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -511,12 +512,35 @@ public final class StateServer {
|
|||||||
|
|
||||||
private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
|
private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) {
|
||||||
guard requireSession(in: request, connection: connection) else { return }
|
guard requireSession(in: request, connection: connection) else { return }
|
||||||
|
let bundleBefore = Bundle.main.bundleIdentifier ?? "unknown"
|
||||||
|
if let expected = request.headers["x-gstack-expected-bundle-id"], expected != bundleBefore {
|
||||||
|
send(connection: connection, status: 409, body: [
|
||||||
|
"error": "active_bundle_mismatch",
|
||||||
|
"expected_bundle": expected,
|
||||||
|
"active_bundle": bundleBefore,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
|
guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else {
|
||||||
send(connection: connection, status: 400, body: ["error": "invalid_json"])
|
send(connection: connection, status: 400, body: ["error": "invalid_json"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let ok = MutationBridge.dispatch(op: op, payload: payload)
|
let ok = MutationBridge.dispatch(op: op, payload: payload)
|
||||||
send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok])
|
let bundleAfter = Bundle.main.bundleIdentifier ?? "unknown"
|
||||||
|
guard bundleAfter == bundleBefore else {
|
||||||
|
send(connection: connection, status: 409, body: [
|
||||||
|
"error": "active_bundle_changed",
|
||||||
|
"before_bundle": bundleBefore,
|
||||||
|
"after_bundle": bundleAfter,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(connection: connection, status: ok ? 200 : 400, body: [
|
||||||
|
"op": op,
|
||||||
|
"ok": ok,
|
||||||
|
"active_bundle_before": bundleBefore,
|
||||||
|
"active_bundle_after": bundleAfter,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Response
|
// MARK: Response
|
||||||
|
|||||||
Reference in New Issue
Block a user