agent-device 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  type RawSnapshotNode,
17
17
  } from './utils/snapshot.ts';
18
18
  import { runIosRunnerCommand, stopIosRunnerSession } from './platforms/ios/runner-client.ts';
19
+ import { runCmd, runCmdBackground } from './utils/exec.ts';
20
+ import { snapshotAndroid } from './platforms/android/index.ts';
19
21
 
20
22
  type DaemonRequest = {
21
23
  token: string;
@@ -37,6 +39,13 @@ type SessionState = {
37
39
  appName?: string;
38
40
  snapshot?: SnapshotState;
39
41
  actions: SessionAction[];
42
+ recording?: {
43
+ platform: 'ios' | 'android';
44
+ outPath: string;
45
+ remotePath?: string;
46
+ child: ReturnType<typeof import('node:child_process').spawn>;
47
+ wait: Promise<import('./utils/exec.ts').ExecResult>;
48
+ };
40
49
  };
41
50
 
42
51
  type SessionAction = {
@@ -112,6 +121,71 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
112
121
  return { ok: true, data };
113
122
  }
114
123
 
124
+ if (command === 'devices') {
125
+ try {
126
+ const devices: DeviceInfo[] = [];
127
+ if (req.flags?.platform === 'android') {
128
+ const { listAndroidDevices } = await import('./platforms/android/devices.ts');
129
+ devices.push(...(await listAndroidDevices()));
130
+ } else if (req.flags?.platform === 'ios') {
131
+ const { listIosDevices } = await import('./platforms/ios/devices.ts');
132
+ devices.push(...(await listIosDevices()));
133
+ } else {
134
+ const { listAndroidDevices } = await import('./platforms/android/devices.ts');
135
+ const { listIosDevices } = await import('./platforms/ios/devices.ts');
136
+ try {
137
+ devices.push(...(await listAndroidDevices()));
138
+ } catch {
139
+ // ignore
140
+ }
141
+ try {
142
+ devices.push(...(await listIosDevices()));
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+ return { ok: true, data: { devices } };
148
+ } catch (err) {
149
+ const appErr = asAppError(err);
150
+ return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
151
+ }
152
+ }
153
+
154
+ if (command === 'apps') {
155
+ const session = sessions.get(sessionName);
156
+ const flags = req.flags ?? {};
157
+ if (
158
+ !session &&
159
+ !flags.platform &&
160
+ !flags.device &&
161
+ !flags.udid &&
162
+ !flags.serial
163
+ ) {
164
+ return {
165
+ ok: false,
166
+ error: {
167
+ code: 'INVALID_ARGS',
168
+ message: 'apps requires an active session or an explicit device selector (e.g. --platform ios).',
169
+ },
170
+ };
171
+ }
172
+ const device = session?.device ?? (await resolveTargetDevice(flags));
173
+ await ensureDeviceReady(device);
174
+ if (device.platform === 'ios') {
175
+ if (device.kind !== 'simulator') {
176
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps list is only supported on iOS simulators' } };
177
+ }
178
+ const { listSimulatorApps } = await import('./platforms/ios/index.ts');
179
+ const apps = (await listSimulatorApps(device)).map((app) =>
180
+ app.name && app.name !== app.bundleId ? `${app.name} (${app.bundleId})` : app.bundleId,
181
+ );
182
+ return { ok: true, data: { apps } };
183
+ }
184
+ const { listAndroidApps } = await import('./platforms/android/index.ts');
185
+ const apps = await listAndroidApps(device, req.flags?.appsFilter);
186
+ return { ok: true, data: { apps } };
187
+ }
188
+
115
189
  if (command === 'open') {
116
190
  if (sessions.has(sessionName)) {
117
191
  return {
@@ -226,15 +300,31 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
226
300
  await ensureDeviceReady(device);
227
301
  }
228
302
  const appBundleId = session?.appBundleId;
303
+ let snapshotScope = req.flags?.snapshotScope;
304
+ if (snapshotScope && snapshotScope.trim().startsWith('@')) {
305
+ if (!session?.snapshot) {
306
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref scope requires an existing snapshot in session.' } };
307
+ }
308
+ const ref = normalizeRef(snapshotScope.trim());
309
+ if (!ref) {
310
+ return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref scope: ${snapshotScope}` } };
311
+ }
312
+ const node = findNodeByRef(session.snapshot.nodes, ref);
313
+ const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
314
+ if (!resolved) {
315
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${snapshotScope} not found or has no label` } };
316
+ }
317
+ snapshotScope = resolved;
318
+ }
229
319
  const data = (await dispatchCommand(device, 'snapshot', [], req.flags?.out, {
230
- ...contextFromFlags(req.flags, appBundleId),
320
+ ...contextFromFlags({ ...req.flags, snapshotScope }, appBundleId),
231
321
  })) as {
232
322
  nodes?: RawSnapshotNode[];
233
323
  truncated?: boolean;
234
324
  backend?: 'ax' | 'xctest' | 'android';
235
325
  };
236
- const pruned = pruneGroupNodes(data?.nodes ?? []);
237
- const nodes = attachRefs(pruned);
326
+ const rawNodes = data?.nodes ?? [];
327
+ const nodes = attachRefs(req.flags?.snapshotRaw ? rawNodes : pruneGroupNodes(rawNodes));
238
328
  const snapshot: SnapshotState = {
239
329
  nodes,
240
330
  truncated: data?.truncated,
@@ -267,6 +357,229 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
267
357
  };
268
358
  }
269
359
 
360
+ if (command === 'wait') {
361
+ const session = sessions.get(sessionName);
362
+ const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
363
+ if (!session) {
364
+ await ensureDeviceReady(device);
365
+ }
366
+ const args = req.positionals ?? [];
367
+ if (args.length === 0) {
368
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires a duration or text' } };
369
+ }
370
+ const parseTimeout = (value: string | undefined): number | null => {
371
+ if (!value) return null;
372
+ const parsed = Number(value);
373
+ return Number.isFinite(parsed) ? parsed : null;
374
+ };
375
+ const sleepMs = parseTimeout(args[0]);
376
+ if (sleepMs !== null) {
377
+ await new Promise((resolve) => setTimeout(resolve, sleepMs));
378
+ if (session) {
379
+ recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { waitedMs: sleepMs } });
380
+ }
381
+ return { ok: true, data: { waitedMs: sleepMs } };
382
+ }
383
+ let text = '';
384
+ let timeoutMs: number | null = null;
385
+ if (args[0] === 'text') {
386
+ timeoutMs = parseTimeout(args[args.length - 1]);
387
+ text = timeoutMs !== null ? args.slice(1, -1).join(' ') : args.slice(1).join(' ');
388
+ } else if (args[0].startsWith('@')) {
389
+ if (!session?.snapshot) {
390
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'Ref wait requires an existing snapshot in session.' } };
391
+ }
392
+ const ref = normalizeRef(args[0]);
393
+ if (!ref) {
394
+ return { ok: false, error: { code: 'INVALID_ARGS', message: `Invalid ref: ${args[0]}` } };
395
+ }
396
+ const node = findNodeByRef(session.snapshot.nodes, ref);
397
+ const resolved = node ? resolveRefLabel(node, session.snapshot.nodes) : undefined;
398
+ if (!resolved) {
399
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: `Ref ${args[0]} not found or has no label` } };
400
+ }
401
+ timeoutMs = parseTimeout(args[args.length - 1]);
402
+ text = resolved;
403
+ } else {
404
+ timeoutMs = parseTimeout(args[args.length - 1]);
405
+ text = timeoutMs !== null ? args.slice(0, -1).join(' ') : args.join(' ');
406
+ }
407
+ text = text.trim();
408
+ if (!text) {
409
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'wait requires text' } };
410
+ }
411
+ const timeout = timeoutMs ?? 10000;
412
+ const start = Date.now();
413
+ while (Date.now() - start < timeout) {
414
+ if (device.platform === 'ios' && device.kind === 'simulator') {
415
+ const result = (await runIosRunnerCommand(
416
+ device,
417
+ { command: 'findText', text, appBundleId: session?.appBundleId },
418
+ { verbose: req.flags?.verbose, logPath },
419
+ )) as { found?: boolean };
420
+ if (result?.found) {
421
+ if (session) {
422
+ recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
423
+ }
424
+ return { ok: true, data: { text, waitedMs: Date.now() - start } };
425
+ }
426
+ } else if (device.platform === 'android') {
427
+ const androidResult = await snapshotAndroid(device, { scope: text });
428
+ if (findNodeByLabel(attachRefs(androidResult.nodes ?? []), text)) {
429
+ if (session) {
430
+ recordAction(session, { command, positionals: req.positionals ?? [], flags: req.flags ?? {}, result: { text, waitedMs: Date.now() - start } });
431
+ }
432
+ return { ok: true, data: { text, waitedMs: Date.now() - start } };
433
+ }
434
+ } else {
435
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'wait is not supported on this device' } };
436
+ }
437
+ await new Promise((resolve) => setTimeout(resolve, 300));
438
+ }
439
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: `wait timed out for text: ${text}` } };
440
+ }
441
+
442
+ if (command === 'alert') {
443
+ const session = sessions.get(sessionName);
444
+ const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
445
+ if (!session) {
446
+ await ensureDeviceReady(device);
447
+ }
448
+ const action = (req.positionals?.[0] ?? 'get').toLowerCase();
449
+ const parseTimeout = (value: string | undefined): number | null => {
450
+ if (!value) return null;
451
+ const parsed = Number(value);
452
+ return Number.isFinite(parsed) ? parsed : null;
453
+ };
454
+ if (device.platform !== 'ios' || device.kind !== 'simulator') {
455
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'alert is only supported on iOS simulators in v1' } };
456
+ }
457
+ if (action === 'wait') {
458
+ const timeout = parseTimeout(req.positionals?.[1]) ?? 10000;
459
+ const start = Date.now();
460
+ while (Date.now() - start < timeout) {
461
+ try {
462
+ const data = await runIosRunnerCommand(
463
+ device,
464
+ { command: 'alert', action: 'get', appBundleId: session?.appBundleId },
465
+ { verbose: req.flags?.verbose, logPath },
466
+ );
467
+ if (session) {
468
+ recordAction(session, {
469
+ command,
470
+ positionals: req.positionals ?? [],
471
+ flags: req.flags ?? {},
472
+ result: data,
473
+ });
474
+ }
475
+ return { ok: true, data };
476
+ } catch {
477
+ // keep waiting
478
+ }
479
+ await new Promise((resolve) => setTimeout(resolve, 300));
480
+ }
481
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'alert wait timed out' } };
482
+ }
483
+ const data = await runIosRunnerCommand(
484
+ device,
485
+ {
486
+ command: 'alert',
487
+ action: action === 'accept' || action === 'dismiss' ? (action as 'accept' | 'dismiss') : 'get',
488
+ appBundleId: session?.appBundleId,
489
+ },
490
+ { verbose: req.flags?.verbose, logPath },
491
+ );
492
+ if (session) {
493
+ recordAction(session, {
494
+ command,
495
+ positionals: req.positionals ?? [],
496
+ flags: req.flags ?? {},
497
+ result: data,
498
+ });
499
+ }
500
+ return { ok: true, data };
501
+ }
502
+
503
+ if (command === 'record') {
504
+ const action = (req.positionals?.[0] ?? '').toLowerCase();
505
+ if (!['start', 'stop'].includes(action)) {
506
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'record requires start|stop' } };
507
+ }
508
+ const session = sessions.get(sessionName);
509
+ const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {}));
510
+ if (!session) {
511
+ await ensureDeviceReady(device);
512
+ }
513
+ const activeSession = session ?? {
514
+ name: sessionName,
515
+ device,
516
+ createdAt: Date.now(),
517
+ actions: [],
518
+ };
519
+
520
+ if (action === 'start') {
521
+ if (activeSession.recording) {
522
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } };
523
+ }
524
+ const outPath = req.positionals?.[1] ?? `./recording-${Date.now()}.mp4`;
525
+ const resolvedOut = path.resolve(outPath);
526
+ const outDir = path.dirname(resolvedOut);
527
+ if (!fs.existsSync(outDir)) {
528
+ fs.mkdirSync(outDir, { recursive: true });
529
+ }
530
+ if (device.platform === 'ios') {
531
+ if (device.kind !== 'simulator') {
532
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'record is only supported on iOS simulators in v1' } };
533
+ }
534
+ const { child, wait } = runCmdBackground('xcrun', ['simctl', 'io', device.id, 'recordVideo', resolvedOut], {
535
+ allowFailure: true,
536
+ });
537
+ activeSession.recording = { platform: 'ios', outPath: resolvedOut, child, wait };
538
+ } else {
539
+ const remotePath = `/sdcard/agent-device-recording-${Date.now()}.mp4`;
540
+ const { child, wait } = runCmdBackground('adb', ['-s', device.id, 'shell', 'screenrecord', remotePath], {
541
+ allowFailure: true,
542
+ });
543
+ activeSession.recording = { platform: 'android', outPath: resolvedOut, remotePath, child, wait };
544
+ }
545
+ sessions.set(sessionName, activeSession);
546
+ recordAction(activeSession, {
547
+ command,
548
+ positionals: req.positionals ?? [],
549
+ flags: req.flags ?? {},
550
+ result: { action: 'start' },
551
+ });
552
+ return { ok: true, data: { recording: 'started', outPath } };
553
+ }
554
+
555
+ if (!activeSession.recording) {
556
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'no active recording' } };
557
+ }
558
+ const recording = activeSession.recording;
559
+ recording.child.kill('SIGINT');
560
+ try {
561
+ await recording.wait;
562
+ } catch {
563
+ // ignore
564
+ }
565
+ if (recording.platform === 'android' && recording.remotePath) {
566
+ try {
567
+ await runCmd('adb', ['-s', device.id, 'pull', recording.remotePath, recording.outPath], { allowFailure: true });
568
+ await runCmd('adb', ['-s', device.id, 'shell', 'rm', '-f', recording.remotePath], { allowFailure: true });
569
+ } catch {
570
+ // ignore
571
+ }
572
+ }
573
+ activeSession.recording = undefined;
574
+ recordAction(activeSession, {
575
+ command,
576
+ positionals: req.positionals ?? [],
577
+ flags: req.flags ?? {},
578
+ result: { action: 'stop', outPath: recording.outPath },
579
+ });
580
+ return { ok: true, data: { recording: 'stopped', outPath: recording.outPath } };
581
+ }
582
+
270
583
  if (command === 'click') {
271
584
  const session = sessions.get(sessionName);
272
585
  if (!session?.snapshot) {
@@ -410,39 +723,6 @@ async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
410
723
  return { ok: true, data: { ref, text, node } };
411
724
  }
412
725
 
413
- if (command === 'rect') {
414
- const session = sessions.get(sessionName);
415
- if (!session?.snapshot) {
416
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'No snapshot in session. Run snapshot first.' } };
417
- }
418
- const target = req.positionals?.[0] ?? '';
419
- const ref = normalizeRef(target);
420
- let label = '';
421
- if (ref) {
422
- const node = findNodeByRef(session.snapshot.nodes, ref);
423
- label = node?.label?.trim() ?? '';
424
- } else {
425
- label = req.positionals.join(' ').trim();
426
- }
427
- if (!label) {
428
- return { ok: false, error: { code: 'INVALID_ARGS', message: 'rect requires a label or ref with label' } };
429
- }
430
- if (session.device.platform !== 'ios' || session.device.kind !== 'simulator') {
431
- return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'rect is only supported on iOS simulators' } };
432
- }
433
- const data = await runIosRunnerCommand(
434
- session.device,
435
- { command: 'rect', text: label, appBundleId: session.appBundleId },
436
- { verbose: req.flags?.verbose, logPath },
437
- );
438
- recordAction(session, {
439
- command,
440
- positionals: req.positionals ?? [],
441
- flags: req.flags ?? {},
442
- result: { label, rect: data?.rect },
443
- });
444
- return { ok: true, data: { label, rect: data?.rect } };
445
- }
446
726
 
447
727
  const session = sessions.get(sessionName);
448
728
  if (!session) {
@@ -836,7 +1116,11 @@ function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
836
1116
  skippedDepths.pop();
837
1117
  }
838
1118
  const type = normalizeType(node.type ?? '');
839
- if (type === 'group' || type === 'ioscontentgroup') {
1119
+ const labelCandidate = [node.label, node.value, node.identifier]
1120
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
1121
+ .find((value) => value && value.length > 0);
1122
+ const hasMeaningfulLabel = labelCandidate ? isMeaningfulLabel(labelCandidate) : false;
1123
+ if ((type === 'group' || type === 'ioscontentgroup') && !hasMeaningfulLabel) {
840
1124
  skippedDepths.push(depth);
841
1125
  continue;
842
1126
  }
@@ -43,6 +43,53 @@ export async function resolveAndroidApp(
43
43
  throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`);
44
44
  }
45
45
 
46
+ export async function listAndroidApps(
47
+ device: DeviceInfo,
48
+ filter: 'launchable' | 'user-installed' | 'all' = 'launchable',
49
+ ): Promise<string[]> {
50
+ if (filter === 'launchable') {
51
+ const result = await runCmd(
52
+ 'adb',
53
+ adbArgs(device, [
54
+ 'shell',
55
+ 'cmd',
56
+ 'package',
57
+ 'query-activities',
58
+ '--brief',
59
+ '-a',
60
+ 'android.intent.action.MAIN',
61
+ '-c',
62
+ 'android.intent.category.LAUNCHER',
63
+ ]),
64
+ { allowFailure: true },
65
+ );
66
+ if (result.exitCode === 0 && result.stdout.trim().length > 0) {
67
+ const packages = new Set<string>();
68
+ for (const line of result.stdout.split('\n')) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed) continue;
71
+ const firstToken = trimmed.split(/\s+/)[0];
72
+ const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
73
+ if (pkg) packages.add(pkg);
74
+ }
75
+ if (packages.size > 0) {
76
+ return Array.from(packages);
77
+ }
78
+ }
79
+ // fallback: list all if query-activities not available
80
+ }
81
+
82
+ const args =
83
+ filter === 'user-installed'
84
+ ? ['shell', 'pm', 'list', 'packages', '-3']
85
+ : ['shell', 'pm', 'list', 'packages'];
86
+ const result = await runCmd('adb', adbArgs(device, args));
87
+ return result.stdout
88
+ .split('\n')
89
+ .map((line: string) => line.replace('package:', '').trim())
90
+ .filter(Boolean);
91
+ }
92
+
46
93
  export async function openAndroidApp(device: DeviceInfo, app: string): Promise<void> {
47
94
  if (!device.booted) {
48
95
  await waitForAndroidBoot(device.id);
@@ -89,6 +136,18 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr
89
136
  await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
90
137
  }
91
138
 
139
+ export async function backAndroid(device: DeviceInfo): Promise<void> {
140
+ await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4']));
141
+ }
142
+
143
+ export async function homeAndroid(device: DeviceInfo): Promise<void> {
144
+ await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '3']));
145
+ }
146
+
147
+ export async function appSwitcherAndroid(device: DeviceInfo): Promise<void> {
148
+ await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '187']));
149
+ }
150
+
92
151
  export async function longPressAndroid(
93
152
  device: DeviceInfo,
94
153
  x: number,
@@ -32,6 +32,10 @@ export async function snapshotAx(
32
32
  ' Enable Accessibility for your terminal in System Settings > Privacy & Security > Accessibility, ' +
33
33
  'or use --backend xctest (slower snapshots via XCTest).';
34
34
  }
35
+ if (stderrText.toLowerCase().includes('could not find ios app content')) {
36
+ hint =
37
+ ' AX snapshot sometimes caches empty content. Try restarting the Simulator app.';
38
+ }
35
39
  throw new AppError('COMMAND_FAILED', 'AX snapshot failed', {
36
40
  stderr: `${stderrText}${hint}`,
37
41
  stdout: result.stdout,
@@ -127,14 +131,8 @@ async function ensureAxSnapshotBinary(): Promise<string> {
127
131
  const packageDir = path.join(projectRoot, 'ios-runner', 'AXSnapshot');
128
132
  const envPath = process.env.AGENT_DEVICE_AX_BINARY;
129
133
  if (envPath && fs.existsSync(envPath)) return envPath;
130
- const packagedCandidates = [
131
- path.join(projectRoot, 'bin', 'axsnapshot'),
132
- path.join(projectRoot, 'dist', 'bin', 'axsnapshot'),
133
- path.join(projectRoot, 'dist', 'axsnapshot'),
134
- ];
135
- for (const candidate of packagedCandidates) {
136
- if (fs.existsSync(candidate)) return candidate;
137
- }
134
+ const packagedPath = path.join(projectRoot, 'dist', 'bin', 'axsnapshot');
135
+ if (fs.existsSync(packagedPath)) return packagedPath;
138
136
  const binaryPath = path.join(packageDir, '.build', 'release', 'axsnapshot');
139
137
  if (fs.existsSync(binaryPath)) return binaryPath;
140
138
  const result = await runCmd('swift', ['build', '-c', 'release'], {
@@ -163,24 +163,45 @@ function ensureSimulator(device: DeviceInfo, command: string): void {
163
163
  }
164
164
  }
165
165
 
166
- async function listSimulatorApps(
166
+ export async function listSimulatorApps(
167
167
  device: DeviceInfo,
168
168
  ): Promise<{ bundleId: string; name: string }[]> {
169
169
  const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
170
170
  const stdout = result.stdout as string;
171
- if (!stdout.trim().startsWith('{')) return [];
172
- try {
173
- const payload = JSON.parse(stdout) as Record<
174
- string,
175
- { CFBundleDisplayName?: string; CFBundleName?: string }
176
- >;
177
- return Object.entries(payload).map(([bundleId, info]) => ({
178
- bundleId,
179
- name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
180
- }));
181
- } catch {
182
- return [];
171
+ const trimmed = stdout.trim();
172
+ if (!trimmed) return [];
173
+ let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
174
+ if (trimmed.startsWith('{')) {
175
+ try {
176
+ parsed = JSON.parse(trimmed) as Record<
177
+ string,
178
+ { CFBundleDisplayName?: string; CFBundleName?: string }
179
+ >;
180
+ } catch {
181
+ parsed = null;
182
+ }
183
183
  }
184
+ if (!parsed && trimmed.startsWith('{')) {
185
+ try {
186
+ const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
187
+ allowFailure: true,
188
+ stdin: trimmed,
189
+ });
190
+ if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
191
+ parsed = JSON.parse(converted.stdout) as Record<
192
+ string,
193
+ { CFBundleDisplayName?: string; CFBundleName?: string }
194
+ >;
195
+ }
196
+ } catch {
197
+ parsed = null;
198
+ }
199
+ }
200
+ if (!parsed) return [];
201
+ return Object.entries(parsed).map(([bundleId, info]) => ({
202
+ bundleId,
203
+ name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
204
+ }));
184
205
  }
185
206
 
186
207
  export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
@@ -8,9 +8,21 @@ import type { DeviceInfo } from '../../utils/device.ts';
8
8
  import net from 'node:net';
9
9
 
10
10
  export type RunnerCommand = {
11
- command: 'tap' | 'type' | 'swipe' | 'findText' | 'listTappables' | 'snapshot' | 'rect' | 'shutdown';
11
+ command:
12
+ | 'tap'
13
+ | 'type'
14
+ | 'swipe'
15
+ | 'findText'
16
+ | 'listTappables'
17
+ | 'snapshot'
18
+ | 'back'
19
+ | 'home'
20
+ | 'appSwitcher'
21
+ | 'alert'
22
+ | 'shutdown';
12
23
  appBundleId?: string;
13
24
  text?: string;
25
+ action?: 'get' | 'accept' | 'dismiss';
14
26
  x?: number;
15
27
  y?: number;
16
28
  direction?: 'up' | 'down' | 'left' | 'right';
package/src/utils/args.ts CHANGED
@@ -18,6 +18,7 @@ export type ParsedArgs = {
18
18
  snapshotScope?: string;
19
19
  snapshotRaw?: boolean;
20
20
  snapshotBackend?: 'ax' | 'xctest';
21
+ appsFilter?: 'launchable' | 'user-installed' | 'all';
21
22
  noRecord?: boolean;
22
23
  recordJson?: boolean;
23
24
  help: boolean;
@@ -62,6 +63,14 @@ export function parseArgs(argv: string[]): ParsedArgs {
62
63
  flags.recordJson = true;
63
64
  continue;
64
65
  }
66
+ if (arg === '--user-installed') {
67
+ flags.appsFilter = 'user-installed';
68
+ continue;
69
+ }
70
+ if (arg === '--all') {
71
+ flags.appsFilter = 'all';
72
+ continue;
73
+ }
65
74
  if (arg.startsWith('--backend')) {
66
75
  const value = arg.includes('=')
67
76
  ? arg.split('=')[1]
@@ -156,8 +165,14 @@ Commands:
156
165
  --raw Raw node output
157
166
  --backend ax|xctest ax: macOS Accessibility tree (fast, recommended, needs permissions)
158
167
  xctest: XCTest snapshot (slower, no permissions)
168
+ devices List available devices
169
+ apps [--user-installed|--all] List installed apps (Android launchable by default, iOS simulator)
170
+ back Navigate back (where supported)
171
+ home Go to home screen (where supported)
172
+ app-switcher Open app switcher (where supported)
173
+ wait <ms>|text <text>|@ref [timeoutMs] Wait for duration or text to appear
174
+ alert [get|accept|dismiss|wait] [timeout] Inspect or handle alert (iOS simulator)
159
175
  click <@ref> Click element by snapshot ref
160
- rect <label|@ref> Fetch element frame by label or ref (iOS sim)
161
176
  get text <@ref> Return element text by ref
162
177
  get attrs <@ref> Return element attributes by ref
163
178
  replay <path> Replay a recorded session
@@ -169,6 +184,8 @@ Commands:
169
184
  scroll <direction> [amount] Scroll in direction (0-1 amount)
170
185
  scrollintoview <text> Scroll until text appears (Android only)
171
186
  screenshot [--out path] Capture screenshot
187
+ record start [path] Start screen recording
188
+ record stop Stop screen recording
172
189
  session list List active sessions
173
190
 
174
191
  Flags:
@@ -182,5 +199,7 @@ Flags:
182
199
  --json JSON output
183
200
  --no-record Do not record this action
184
201
  --record-json Record JSON session log
202
+ --user-installed Apps: list user-installed packages (Android only)
203
+ --all Apps: list all packages (Android only)
185
204
  `;
186
205
  }