diff --git a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts index bd99a2309e..7cf98c4203 100644 --- a/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts +++ b/packages/platform-apple/src/core/__tests__/physical-device-coredevice.test.ts @@ -589,6 +589,22 @@ test('ensureCoreDeviceReady names the connecting tunnel state as the reason it r assert.match(error.message, /not ready for automation/i); }); +test('ensureCoreDeviceReady refuses a device CoreDevice cannot reach', async () => { + const error = await expectReadyFailure(() => readyPayload('unavailable')); + assert.equal(error.details?.kind, 'not_ready'); + assert.equal(error.details?.tunnelState, 'unavailable'); + assert.match(error.message, /not ready for automation/i); +}); + +test('ensureCoreDeviceReady passes a device whose tunnel opens on demand', async () => { + await withCoreDeviceProbe( + () => readyPayload('disconnected'), + async () => { + await ensureCoreDeviceReady(IOS_DEVICE); + }, + ); +}); + test('ensureCoreDeviceReady reports a failed details command with the device hint', async () => { const error = await expectReadyFailure(() => ({ exitCode: 1, diff --git a/packages/platform-apple/src/core/physical-device-constants.ts b/packages/platform-apple/src/core/physical-device-constants.ts index 3a61cce420..f981f0c511 100644 --- a/packages/platform-apple/src/core/physical-device-constants.ts +++ b/packages/platform-apple/src/core/physical-device-constants.ts @@ -1,2 +1,6 @@ export const IOS_DEVICE_READY_TIMEOUT_MS = 15_000; export const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000; + +export function isCoreDeviceReachable(tunnelState: unknown): boolean { + return typeof tunnelState !== 'string' || tunnelState.toLowerCase() !== 'unavailable'; +} diff --git a/packages/platform-apple/src/core/physical-device-coredevice.ts b/packages/platform-apple/src/core/physical-device-coredevice.ts index 7600e7eea1..f7e7551b39 100644 --- a/packages/platform-apple/src/core/physical-device-coredevice.ts +++ b/packages/platform-apple/src/core/physical-device-coredevice.ts @@ -19,6 +19,7 @@ import { import { IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS, IOS_DEVICE_READY_TIMEOUT_MS, + isCoreDeviceReachable, } from './physical-device-constants.ts'; const IOS_RUNNER_DEVICE_INFO_TIMEOUT_MS = 10_000; @@ -72,6 +73,14 @@ export async function ensureCoreDeviceReady( hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.', }); } + if (!isCoreDeviceReachable(tunnelState)) { + throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', { + kind: 'not_ready', + deviceId: device.id, + tunnelState, + hint: 'CoreDevice cannot reach the device. Connect it by cable or to the same network as this Mac, unlock it, and wait until Xcode Devices lists it as available, then retry.', + }); + } return; } throw new AppError( diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index bc72daa436..6c643a6fee 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -63,6 +63,27 @@ test('devicectl records map to normalized physical Apple devices', () => { ); }); +test('devicectl records report a paired device CoreDevice cannot reach as not booted', () => { + const record = (tunnelState?: string) => ({ + hardwareProperties: { platform: 'iOS', productType: 'iPhone18,1', udid: 'phone-1' }, + ...(tunnelState === undefined ? {} : { connectionProperties: { tunnelState } }), + }); + assert.equal(mapDevicectlAppleDevice(record('unavailable'))?.booted, false); + assert.equal(mapDevicectlAppleDevice(record('Unavailable'))?.booted, false); + // CoreDevice opens a tunnel on demand, so an idle device it can reach is still usable. + assert.equal(mapDevicectlAppleDevice(record('disconnected'))?.booted, true); + assert.equal(mapDevicectlAppleDevice(record('connected'))?.booted, true); + assert.equal(mapDevicectlAppleDevice(record())?.booted, true); + // devicectl JSON is not validated at runtime, so a malformed state must not break the listing. + assert.equal( + mapDevicectlAppleDevice({ + hardwareProperties: { platform: 'iOS', productType: 'iPhone18,1', udid: 'phone-1' }, + connectionProperties: { tunnelState: 3 as unknown as string }, + })?.booted, + true, + ); +}); + test('devicectl records report the marketing model and OS version', () => { const devicectlRecord = { identifier: '3F1DAD0D-212F-44F2-B41E-C390F96140BE', diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 77de4771c9..2d1ba61018 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -1,4 +1,5 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; +import { isCoreDeviceReachable } from './core/physical-device-constants.ts'; const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; const APPLE_IPAD_PATTERN = /ipad/i; @@ -128,6 +129,6 @@ export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInf ...(model ? { model } : {}), ...(osVersion ? { osVersion } : {}), iosPhysicalDeviceBackend: 'coredevice', - booted: true, + booted: isCoreDeviceReachable(device.connectionProperties?.tunnelState), }; } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 47b34af2b6..961523746b 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -457,7 +457,7 @@ Discovery: iOS physical-device prerequisites: Xcode, xcrun xcdevice, and xcrun xctrace must be available from the selected Xcode. - The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled. + The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled. A paired device that CoreDevice cannot reach (devicectl tunnelState unavailable: unplugged or off the network) is listed with booted: false, and commands refuse it with details.kind not_ready and details.tunnelState unavailable. Modern devices visible to devicectl use CoreDevice. Older devices visible only to xctrace use the XCTest backend automatically. XCTest-backed devices must already have the target app installed and should be opened by bundle ID; app inventory, install/reinstall, logs, performance sampling, recording, deep links, and launch arguments require CoreDevice. XCTest-backed runner commands travel through macOS usbmuxd; keep the trusted device connected by cable. diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index ae7cc70a55..bd9afb69dc 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -113,6 +113,7 @@ Run `agent-device help physical-device` for the same setup guidance from the CLI - Xcode is installed with `xcrun devicectl` and `xcrun xctrace` available. - The device is paired, trusted, connected, unlocked when needed, and listed by `xcrun devicectl list devices`. + A paired device that CoreDevice cannot reach (`tunnelState: unavailable`, for example unplugged and off the network) is listed by `devices` with `booted: false`. Commands refuse it with `details.kind: not_ready` and `details.tunnelState: unavailable`. - Developer Mode is on in the device's Settings. - The Apple runner must be signed before commands can run on the device. Start with Automatic Signing in Xcode and these environment variables; add the others below only when needed: - `AGENT_DEVICE_IOS_TEAM_ID`