Skip to content

Commit e18e666

Browse files
committed
feat: enable tvOS runner interactions
1 parent d9edf64 commit e18e666

15 files changed

Lines changed: 156 additions & 55 deletions

File tree

README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ Efficient snapshot usage:
196196

197197
Flags:
198198
- `--version, -V` print version and exit
199-
- `--platform ios|android`
199+
- `--platform ios|android|apple` (`apple` aliases the iOS/tvOS backend)
200200
- `--target mobile|tv` select device class within platform (requires `--platform`; for example AndroidTV/tvOS)
201201
- `--device <name>`
202202
- `--udid <udid>` (iOS)
@@ -218,10 +218,10 @@ Flags:
218218
- `--max-steps <n>` batch: max allowed steps per request
219219

220220
TV targets:
221-
- Use `--target tv` together with `--platform ios|android`.
221+
- Use `--target tv` together with `--platform ios|android|apple`.
222222
- AndroidTV app launch/app listing use TV launcher discovery (`LEANBACK_LAUNCHER`) and fallback component resolution when needed.
223-
- tvOS currently supports non-runner flows only: `open`, `close`, `apps`, `screenshot`, `logs`, `reinstall`, `boot`.
224-
- tvOS runner-driven interaction/snapshot commands remain unsupported: `snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`.
223+
- tvOS uses the same runner-driven interaction/snapshot flow as iOS (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`, and related selector flows).
224+
- tvOS back/home/app-switcher use Siri Remote semantics in the runner (`menu`, `home`, double-home).
225225

226226
Examples:
227227
- `agent-device open YouTube --platform android --target tv`
@@ -259,7 +259,7 @@ Sessions:
259259
- On iOS, `appstate` is session-scoped and requires an active session on the target device.
260260

261261
Navigation helpers:
262-
- `boot --platform ios|android` ensures the target is ready without launching an app.
262+
- `boot --platform ios|android|apple` ensures the target is ready without launching an app.
263263
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
264264
- `open [app|url] [url]` already boots/activates the selected target when needed.
265265
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
@@ -367,7 +367,7 @@ Boot diagnostics:
367367
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
368368
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
369369
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
370-
- Use `agent-device boot --platform ios|android` when starting a new session only if `open` cannot find/connect to an available target.
370+
- Use `agent-device boot --platform ios|android|apple` when starting a new session only if `open` cannot find/connect to an available target.
371371
- `--debug` captures retry telemetry in diagnostics logs.
372372
- Set `AGENT_DEVICE_RETRY_LOGS=1` to also print retry telemetry directly to stderr (ad-hoc troubleshooting).
373373

@@ -384,8 +384,7 @@ Diagnostics files:
384384
## iOS notes
385385
- Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `longpress`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`.
386386
- Simulator-only commands: `alert`, `pinch`, `settings`.
387-
- tvOS targets are selectable (`--platform ios --target tv`) for non-runner flows (`open`, `close`, `apps`, `screenshot`, `logs`, `reinstall`, `boot`).
388-
- tvOS runner-driven interaction commands are currently unsupported (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`).
387+
- tvOS targets are selectable (`--platform ios --target tv` or `--platform apple --target tv`) and support runner-driven interaction/snapshot commands.
389388
- `record` supports iOS simulators and physical iOS devices.
390389
- iOS simulator recording uses native `simctl io ... recordVideo`.
391390
- Physical iOS device recording is runner-based and built from repeated `XCUIScreen.main.screenshot()` frames (no native video stream/audio capture).

ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@
258258
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
259259
MTL_FAST_MATH = YES;
260260
ONLY_ACTIVE_ARCH = YES;
261-
SDKROOT = iphoneos;
261+
SDKROOT = auto;
262262
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
263263
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
264264
};
@@ -315,7 +315,7 @@
315315
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
316316
MTL_ENABLE_DEBUG_INFO = NO;
317317
MTL_FAST_MATH = YES;
318-
SDKROOT = iphoneos;
318+
SDKROOT = auto;
319319
SWIFT_COMPILATION_MODE = wholemodule;
320320
VALIDATE_PRODUCT = YES;
321321
};
@@ -350,7 +350,9 @@
350350
SWIFT_EMIT_LOC_STRINGS = YES;
351351
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
352352
SWIFT_VERSION = 5.0;
353-
TARGETED_DEVICE_FAMILY = "1,2";
353+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator appletvos appletvsimulator";
354+
TARGETED_DEVICE_FAMILY = "1,2,3";
355+
TVOS_DEPLOYMENT_TARGET = 15.6;
354356
};
355357
name = Debug;
356358
};
@@ -383,7 +385,9 @@
383385
SWIFT_EMIT_LOC_STRINGS = YES;
384386
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
385387
SWIFT_VERSION = 5.0;
386-
TARGETED_DEVICE_FAMILY = "1,2";
388+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator appletvos appletvsimulator";
389+
TARGETED_DEVICE_FAMILY = "1,2,3";
390+
TVOS_DEPLOYMENT_TARGET = 15.6;
387391
};
388392
name = Release;
389393
};
@@ -404,7 +408,9 @@
404408
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
405409
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
406410
SWIFT_VERSION = 5.0;
407-
TARGETED_DEVICE_FAMILY = "1,2";
411+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator appletvos appletvsimulator";
412+
TARGETED_DEVICE_FAMILY = "1,2,3";
413+
TVOS_DEPLOYMENT_TARGET = 15.6;
408414
TEST_TARGET_NAME = AgentDeviceRunner;
409415
};
410416
name = Debug;
@@ -426,7 +432,9 @@
426432
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
427433
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
428434
SWIFT_VERSION = 5.0;
429-
TARGETED_DEVICE_FAMILY = "1,2";
435+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator appletvos appletvsimulator";
436+
TARGETED_DEVICE_FAMILY = "1,2,3";
437+
TVOS_DEPLOYMENT_TARGET = 15.6;
430438
TEST_TARGET_NAME = AgentDeviceRunner;
431439
};
432440
name = Release;

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ final class RunnerTests: XCTestCase {
790790
performBackGesture(app: activeApp)
791791
return Response(ok: true, data: DataPayload(message: "back"))
792792
case .home:
793-
XCUIDevice.shared.press(.home)
793+
pressHomeButton()
794794
return Response(ok: true, data: DataPayload(message: "home"))
795795
case .appSwitcher:
796796
performAppSwitcherGesture(app: activeApp)
@@ -990,21 +990,43 @@ final class RunnerTests: XCTestCase {
990990
back.tap()
991991
return true
992992
}
993+
#if os(tvOS)
994+
XCUIRemote.shared.press(.menu)
995+
return true
996+
#endif
993997
return false
994998
}
995999

9961000
private func performBackGesture(app: XCUIApplication) {
1001+
#if os(tvOS)
1002+
XCUIRemote.shared.press(.menu)
1003+
#else
9971004
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
9981005
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
9991006
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
10001007
start.press(forDuration: 0.05, thenDragTo: end)
1008+
#endif
10011009
}
10021010

10031011
private func performAppSwitcherGesture(app: XCUIApplication) {
1012+
#if os(tvOS)
1013+
XCUIRemote.shared.press(.home)
1014+
usleep(120_000)
1015+
XCUIRemote.shared.press(.home)
1016+
#else
10041017
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
10051018
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
10061019
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
10071020
start.press(forDuration: 0.6, thenDragTo: end)
1021+
#endif
1022+
}
1023+
1024+
private func pressHomeButton() {
1025+
#if os(tvOS)
1026+
XCUIRemote.shared.press(.home)
1027+
#else
1028+
XCUIDevice.shared.press(.home)
1029+
#endif
10081030
}
10091031

10101032
private func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
@@ -1109,6 +1131,19 @@ final class RunnerTests: XCTestCase {
11091131
}
11101132

11111133
private func swipe(app: XCUIApplication, direction: SwipeDirection) {
1134+
#if os(tvOS)
1135+
switch direction {
1136+
case .up:
1137+
XCUIRemote.shared.press(.up)
1138+
case .down:
1139+
XCUIRemote.shared.press(.down)
1140+
case .left:
1141+
XCUIRemote.shared.press(.left)
1142+
case .right:
1143+
XCUIRemote.shared.press(.right)
1144+
}
1145+
return
1146+
#endif
11121147
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
11131148
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
11141149
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))

skills/agent-device/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ Use `--target mobile|tv` with `--platform` (required) to pick phone/tablet vs TV
7272

7373
TV quick reference:
7474
- AndroidTV: `open`/`apps` use TV launcher discovery automatically.
75-
- tvOS: use non-runner flows only (`open`, `close`, `apps`, `screenshot`, `logs`, `reinstall`, `boot`).
76-
- tvOS runner-driven interactions are unsupported (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`).
75+
- tvOS: runner-driven interactions and snapshots are supported (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record` and related selector flows).
76+
- tvOS `back`/`home`/`app-switcher` map to Siri Remote actions (`menu`, `home`, double-home) in the runner.
7777

7878
### Snapshot and targeting
7979

@@ -115,7 +115,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
115115
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
116116
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
117117
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
118-
- For AndroidTV/tvOS selection, always pair `--target` with `--platform`; target-only selection is invalid.
118+
- For AndroidTV/tvOS selection, always pair `--target` with `--platform` (`ios`, `android`, or `apple` alias); target-only selection is invalid.
119119
- `push` simulates notification delivery:
120120
- iOS simulator uses APNs-style payload JSON.
121121
- Android uses broadcast action + typed extras (string/boolean/number).

src/core/__tests__/capabilities.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,12 @@ test('Android TV uses Android capabilities for core commands', () => {
106106
}
107107
});
108108

109-
test('tvOS allows non-runner commands and blocks runner-driven interactions', () => {
109+
test('tvOS follows iOS capability matrix by device kind', () => {
110110
for (const cmd of ['open', 'close', 'apps', 'screenshot', 'logs', 'reinstall', 'boot']) {
111111
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), true, `${cmd} on tvOS`);
112112
}
113113
for (const cmd of ['snapshot', 'wait', 'press', 'get', 'fill', 'scroll', 'back', 'home', 'app-switcher', 'record']) {
114-
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), false, `${cmd} on tvOS`);
114+
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), true, `${cmd} on tvOS`);
115115
}
116116
});
117117

src/core/capabilities.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,13 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
4646
wait: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
4747
};
4848

49-
const IOS_TV_SUPPORTED_COMMANDS = new Set<string>([
50-
'apps',
51-
'boot',
52-
'close',
53-
'logs',
54-
'open',
55-
'reinstall',
56-
'screenshot',
57-
]);
58-
5949
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
6050
const capability = COMMAND_CAPABILITY_MATRIX[command];
6151
if (!capability) return true;
6252
const byPlatform = capability[device.platform];
6353
if (!byPlatform) return false;
6454
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
65-
if (byPlatform[kind] !== true) return false;
66-
if (device.platform === 'ios' && device.target === 'tv') {
67-
return IOS_TV_SUPPORTED_COMMANDS.has(command);
68-
}
69-
return true;
55+
return byPlatform[kind] === true;
7056
}
7157

7258
export function listCapabilityCommands(): string[] {

src/core/dispatch.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceIn
3636
return await withDiagnosticTimer(
3737
'resolve_target_device',
3838
async () => {
39+
const normalizedPlatform = flags.platform === 'apple' ? 'ios' : flags.platform;
3940
const selector = {
40-
platform: flags.platform,
41+
platform: normalizedPlatform,
4142
target: flags.target,
4243
deviceName: flags.device,
4344
udid: flags.udid,
@@ -46,7 +47,7 @@ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceIn
4647
if (selector.target && !selector.platform) {
4748
throw new AppError(
4849
'INVALID_ARGS',
49-
'Device target selector requires --platform. Use --platform ios|android with --target mobile|tv.',
50+
'Device target selector requires --platform. Use --platform ios|android|apple with --target mobile|tv.',
5051
);
5152
}
5253

@@ -75,7 +76,7 @@ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceIn
7576
return await selectDevice(devices, selector);
7677
},
7778
{
78-
platform: flags.platform,
79+
platform: flags.platform === 'apple' ? 'ios' : flags.platform,
7980
target: flags.target,
8081
},
8182
);

src/daemon/__tests__/session-selector.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ test('rejects mismatched platform selector', () => {
4343
);
4444
});
4545

46+
test('accepts --platform apple alias for ios sessions', () => {
47+
const session = makeSession({
48+
device: {
49+
platform: 'ios',
50+
id: 'tv-sim-1',
51+
name: 'Apple TV',
52+
kind: 'simulator',
53+
target: 'tv',
54+
booted: true,
55+
},
56+
});
57+
assert.doesNotThrow(() => assertSessionSelectorMatches(session, { platform: 'apple', target: 'tv' }));
58+
});
59+
4660
test('rejects mismatched serial selector', () => {
4761
const session = makeSession();
4862
assert.throws(

src/daemon/handlers/session.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ const REPLAY_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'target'
5454
const LOG_ACTIONS = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const;
5555
const LOG_ACTIONS_MESSAGE = `logs requires ${LOG_ACTIONS.slice(0, -1).join(', ')}, or ${LOG_ACTIONS.at(-1)}`;
5656

57+
function normalizePlatformSelector(platform: CommandFlags['platform'] | undefined): DeviceInfo['platform'] | undefined {
58+
if (platform === 'apple') return 'ios';
59+
return platform;
60+
}
61+
5762
function requireSessionOrExplicitSelector(
5863
command: string,
5964
session: SessionState | undefined,
@@ -85,7 +90,8 @@ function selectorTargetsSessionDevice(
8590
): boolean {
8691
if (!session) return false;
8792
if (!hasExplicitDeviceSelector(flags)) return true;
88-
if (flags?.platform && flags.platform !== session.device.platform) return false;
93+
const normalizedPlatform = normalizePlatformSelector(flags?.platform);
94+
if (normalizedPlatform && normalizedPlatform !== session.device.platform) return false;
8995
if (flags?.target && flags.target !== (session.device.target ?? 'mobile')) return false;
9096
if (flags?.udid && flags.udid !== session.device.id) return false;
9197
if (flags?.serial && flags.serial !== session.device.id) return false;
@@ -179,9 +185,10 @@ async function handleAppStateCommand(params: {
179185
const { req, sessionName, sessionStore, ensureReady, resolveDevice } = params;
180186
const session = sessionStore.get(sessionName);
181187
const flags = req.flags ?? {};
188+
const normalizedPlatform = normalizePlatformSelector(flags.platform);
182189
if (!session && hasExplicitSessionFlag(flags)) {
183190
const iOSSessionHint =
184-
flags.platform === 'ios'
191+
normalizedPlatform === 'ios'
185192
? `No active session "${sessionName}". Run open with --session ${sessionName} first.`
186193
: `No active session "${sessionName}". Run open with --session ${sessionName} first, or omit --session to query by device selector.`;
187194
return {
@@ -196,7 +203,7 @@ async function handleAppStateCommand(params: {
196203
if (guard) return guard;
197204

198205
const shouldUseSessionStateForIos = session?.device.platform === 'ios' && selectorTargetsSessionDevice(flags, session);
199-
const targetsIos = flags.platform === 'ios';
206+
const targetsIos = normalizedPlatform === 'ios';
200207
if (targetsIos && !shouldUseSessionStateForIos) {
201208
return {
202209
ok: false,
@@ -315,10 +322,11 @@ export async function handleSessionCommands(params: {
315322
if (command === 'devices') {
316323
try {
317324
const devices: DeviceInfo[] = [];
318-
if (req.flags?.platform === 'android') {
325+
const requestedPlatform = normalizePlatformSelector(req.flags?.platform);
326+
if (requestedPlatform === 'android') {
319327
const { listAndroidDevices } = await import('../../platforms/android/devices.ts');
320328
devices.push(...(await listAndroidDevices()));
321-
} else if (req.flags?.platform === 'ios') {
329+
} else if (requestedPlatform === 'ios') {
322330
const { listIosDevices } = await import('../../platforms/ios/devices.ts');
323331
devices.push(...(await listIosDevices()));
324332
} else {

src/daemon/session-selector.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { AppError } from '../utils/errors.ts';
22
import type { CommandFlags } from '../core/dispatch.ts';
33
import type { SessionState } from './types.ts';
44

5+
function normalizePlatformSelector(platform: CommandFlags['platform'] | undefined): SessionState['device']['platform'] | undefined {
6+
if (platform === 'apple') return 'ios';
7+
return platform;
8+
}
9+
510
export function assertSessionSelectorMatches(
611
session: SessionState,
712
flags?: CommandFlags,
@@ -11,7 +16,8 @@ export function assertSessionSelectorMatches(
1116
const mismatches: string[] = [];
1217
const device = session.device;
1318

14-
if (flags.platform && flags.platform !== device.platform) {
19+
const normalizedPlatform = normalizePlatformSelector(flags.platform);
20+
if (normalizedPlatform && normalizedPlatform !== device.platform) {
1521
mismatches.push(`--platform=${flags.platform}`);
1622
}
1723
if (flags.target && flags.target !== (device.target ?? 'mobile')) {

0 commit comments

Comments
 (0)