agent-device 0.4.1 → 0.4.2
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/README.md +18 -12
- package/dist/src/bin.js +32 -32
- package/dist/src/daemon.js +18 -14
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +19 -13
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +3 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/cli.ts +32 -16
- package/src/core/__tests__/capabilities.test.ts +2 -1
- package/src/core/__tests__/dispatch-open.test.ts +25 -0
- package/src/core/__tests__/open-target.test.ts +40 -1
- package/src/core/capabilities.ts +1 -1
- package/src/core/dispatch.ts +22 -0
- package/src/core/open-target.ts +14 -0
- package/src/daemon/__tests__/device-ready.test.ts +52 -0
- package/src/daemon/device-ready.ts +146 -4
- package/src/daemon/handlers/__tests__/session.test.ts +477 -0
- package/src/daemon/handlers/session.ts +196 -91
- package/src/daemon/session-store.ts +0 -2
- package/src/daemon-client.ts +118 -18
- package/src/platforms/android/__tests__/index.test.ts +118 -1
- package/src/platforms/android/index.ts +77 -47
- package/src/platforms/ios/__tests__/index.test.ts +292 -4
- package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
- package/src/platforms/ios/apps.ts +358 -0
- package/src/platforms/ios/config.ts +28 -0
- package/src/platforms/ios/devicectl.ts +134 -0
- package/src/platforms/ios/devices.ts +15 -2
- package/src/platforms/ios/index.ts +20 -455
- package/src/platforms/ios/runner-client.ts +72 -16
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +20 -2
- package/src/utils/__tests__/daemon-client.test.ts +21 -4
- package/src/utils/args.ts +6 -1
- package/src/utils/command-schema.ts +7 -14
- package/src/utils/interactors.ts +2 -2
- package/src/utils/timeouts.ts +9 -0
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- package/src/daemon/app-state.ts +0 -65
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
2
|
+
import { AppError } from '../../utils/errors.ts';
|
|
3
|
+
import { runCmd } from '../../utils/exec.ts';
|
|
4
|
+
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
|
|
5
|
+
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
|
|
6
|
+
|
|
7
|
+
import { IOS_APP_LAUNCH_TIMEOUT_MS } from './config.ts';
|
|
8
|
+
import { listIosDeviceApps, runIosDevicectl, type IosAppInfo } from './devicectl.ts';
|
|
9
|
+
import { ensureBootedSimulator, ensureSimulator, getSimulatorState } from './simulator.ts';
|
|
10
|
+
|
|
11
|
+
const ALIASES: Record<string, string> = {
|
|
12
|
+
settings: 'com.apple.Preferences',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
|
|
16
|
+
const trimmed = app.trim();
|
|
17
|
+
if (trimmed.includes('.')) return trimmed;
|
|
18
|
+
|
|
19
|
+
const alias = ALIASES[trimmed.toLowerCase()];
|
|
20
|
+
if (alias) return alias;
|
|
21
|
+
|
|
22
|
+
const list =
|
|
23
|
+
device.kind === 'simulator'
|
|
24
|
+
? await listSimulatorApps(device)
|
|
25
|
+
: await listIosDeviceApps(device, 'all');
|
|
26
|
+
const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase());
|
|
27
|
+
if (matches.length === 1) return matches[0].bundleId;
|
|
28
|
+
if (matches.length > 1) {
|
|
29
|
+
throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function openIosApp(
|
|
36
|
+
device: DeviceInfo,
|
|
37
|
+
app: string,
|
|
38
|
+
options?: { appBundleId?: string; url?: string },
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const explicitUrl = options?.url?.trim();
|
|
41
|
+
if (explicitUrl) {
|
|
42
|
+
if (!isDeepLinkTarget(explicitUrl)) {
|
|
43
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
|
|
44
|
+
}
|
|
45
|
+
if (device.kind === 'simulator') {
|
|
46
|
+
await ensureBootedSimulator(device);
|
|
47
|
+
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
48
|
+
await runCmd('xcrun', ['simctl', 'openurl', device.id, explicitUrl]);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const appBundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
|
|
52
|
+
const bundleId = resolveIosDeviceDeepLinkBundleId(appBundleId, explicitUrl);
|
|
53
|
+
if (!bundleId) {
|
|
54
|
+
throw new AppError(
|
|
55
|
+
'INVALID_ARGS',
|
|
56
|
+
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const deepLinkTarget = app.trim();
|
|
64
|
+
if (isDeepLinkTarget(deepLinkTarget)) {
|
|
65
|
+
if (device.kind === 'simulator') {
|
|
66
|
+
await ensureBootedSimulator(device);
|
|
67
|
+
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
68
|
+
await runCmd('xcrun', ['simctl', 'openurl', device.id, deepLinkTarget]);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const bundleId = resolveIosDeviceDeepLinkBundleId(options?.appBundleId, deepLinkTarget);
|
|
72
|
+
if (!bundleId) {
|
|
73
|
+
throw new AppError(
|
|
74
|
+
'INVALID_ARGS',
|
|
75
|
+
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
|
|
83
|
+
if (device.kind === 'simulator') {
|
|
84
|
+
await launchIosSimulatorApp(device, bundleId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await launchIosDeviceProcess(device, bundleId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function openIosDevice(device: DeviceInfo): Promise<void> {
|
|
92
|
+
if (device.kind !== 'simulator') return;
|
|
93
|
+
const state = await getSimulatorState(device.id);
|
|
94
|
+
if (state === 'Booted') return;
|
|
95
|
+
|
|
96
|
+
await ensureBootedSimulator(device);
|
|
97
|
+
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function closeIosApp(device: DeviceInfo, app: string): Promise<void> {
|
|
101
|
+
const bundleId = await resolveIosApp(device, app);
|
|
102
|
+
if (device.kind === 'simulator') {
|
|
103
|
+
await ensureBootedSimulator(device);
|
|
104
|
+
const result = await runCmd('xcrun', ['simctl', 'terminate', device.id, bundleId], {
|
|
105
|
+
allowFailure: true,
|
|
106
|
+
});
|
|
107
|
+
if (result.exitCode !== 0) {
|
|
108
|
+
const stderr = result.stderr.toLowerCase();
|
|
109
|
+
if (stderr.includes('found nothing to terminate')) return;
|
|
110
|
+
throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
|
|
111
|
+
cmd: 'xcrun',
|
|
112
|
+
args: ['simctl', 'terminate', device.id, bundleId],
|
|
113
|
+
stdout: result.stdout,
|
|
114
|
+
stderr: result.stderr,
|
|
115
|
+
exitCode: result.exitCode,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await runIosDevicectl(['device', 'process', 'terminate', '--device', device.id, bundleId], {
|
|
122
|
+
action: 'terminate iOS app',
|
|
123
|
+
deviceId: device.id,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
|
|
128
|
+
ensureSimulator(device, 'reinstall');
|
|
129
|
+
const bundleId = await resolveIosApp(device, app);
|
|
130
|
+
await ensureBootedSimulator(device);
|
|
131
|
+
|
|
132
|
+
const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
|
|
133
|
+
allowFailure: true,
|
|
134
|
+
});
|
|
135
|
+
if (result.exitCode !== 0) {
|
|
136
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
137
|
+
if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
|
|
138
|
+
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
|
|
139
|
+
stdout: result.stdout,
|
|
140
|
+
stderr: result.stderr,
|
|
141
|
+
exitCode: result.exitCode,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { bundleId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
150
|
+
ensureSimulator(device, 'reinstall');
|
|
151
|
+
await ensureBootedSimulator(device);
|
|
152
|
+
await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function reinstallIosApp(
|
|
156
|
+
device: DeviceInfo,
|
|
157
|
+
app: string,
|
|
158
|
+
appPath: string,
|
|
159
|
+
): Promise<{ bundleId: string }> {
|
|
160
|
+
const { bundleId } = await uninstallIosApp(device, app);
|
|
161
|
+
await installIosApp(device, appPath);
|
|
162
|
+
return { bundleId };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
|
|
166
|
+
if (device.kind === 'simulator') {
|
|
167
|
+
await ensureBootedSimulator(device);
|
|
168
|
+
await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], {
|
|
173
|
+
action: 'capture iOS screenshot',
|
|
174
|
+
deviceId: device.id,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function setIosSetting(
|
|
179
|
+
device: DeviceInfo,
|
|
180
|
+
setting: string,
|
|
181
|
+
state: string,
|
|
182
|
+
appBundleId?: string,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
ensureSimulator(device, 'settings');
|
|
185
|
+
await ensureBootedSimulator(device);
|
|
186
|
+
const normalized = setting.toLowerCase();
|
|
187
|
+
const enabled = parseSettingState(state);
|
|
188
|
+
|
|
189
|
+
switch (normalized) {
|
|
190
|
+
case 'wifi': {
|
|
191
|
+
const mode = enabled ? 'active' : 'failed';
|
|
192
|
+
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case 'airplane': {
|
|
196
|
+
if (enabled) {
|
|
197
|
+
await runCmd('xcrun', [
|
|
198
|
+
'simctl',
|
|
199
|
+
'status_bar',
|
|
200
|
+
device.id,
|
|
201
|
+
'override',
|
|
202
|
+
'--dataNetwork',
|
|
203
|
+
'hide',
|
|
204
|
+
'--wifiMode',
|
|
205
|
+
'failed',
|
|
206
|
+
'--wifiBars',
|
|
207
|
+
'0',
|
|
208
|
+
'--cellularMode',
|
|
209
|
+
'failed',
|
|
210
|
+
'--cellularBars',
|
|
211
|
+
'0',
|
|
212
|
+
'--operatorName',
|
|
213
|
+
'',
|
|
214
|
+
]);
|
|
215
|
+
} else {
|
|
216
|
+
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'clear']);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'location': {
|
|
221
|
+
if (!appBundleId) {
|
|
222
|
+
throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
|
|
223
|
+
}
|
|
224
|
+
const action = enabled ? 'grant' : 'revoke';
|
|
225
|
+
await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function listIosApps(
|
|
234
|
+
device: DeviceInfo,
|
|
235
|
+
filter: 'user-installed' | 'all' = 'all',
|
|
236
|
+
): Promise<IosAppInfo[]> {
|
|
237
|
+
if (device.kind === 'simulator') {
|
|
238
|
+
const apps = await listSimulatorApps(device);
|
|
239
|
+
return filterIosAppsByBundlePrefix(apps, filter);
|
|
240
|
+
}
|
|
241
|
+
return await listIosDeviceApps(device, filter);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function listSimulatorApps(device: DeviceInfo): Promise<IosAppInfo[]> {
|
|
245
|
+
const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
|
|
246
|
+
const stdout = result.stdout as string;
|
|
247
|
+
const trimmed = stdout.trim();
|
|
248
|
+
if (!trimmed) return [];
|
|
249
|
+
|
|
250
|
+
let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
|
|
251
|
+
if (trimmed.startsWith('{')) {
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(trimmed) as Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }>;
|
|
254
|
+
} catch {
|
|
255
|
+
parsed = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!parsed && trimmed.startsWith('{')) {
|
|
260
|
+
try {
|
|
261
|
+
const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
|
|
262
|
+
allowFailure: true,
|
|
263
|
+
stdin: trimmed,
|
|
264
|
+
});
|
|
265
|
+
if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
|
|
266
|
+
parsed = JSON.parse(converted.stdout) as Record<
|
|
267
|
+
string,
|
|
268
|
+
{ CFBundleDisplayName?: string; CFBundleName?: string }
|
|
269
|
+
>;
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
parsed = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!parsed) return [];
|
|
277
|
+
return Object.entries(parsed).map(([bundleId, info]) => ({
|
|
278
|
+
bundleId,
|
|
279
|
+
name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseSettingState(state: string): boolean {
|
|
284
|
+
const normalized = state.toLowerCase();
|
|
285
|
+
if (normalized === 'on' || normalized === 'true' || normalized === '1') return true;
|
|
286
|
+
if (normalized === 'off' || normalized === 'false' || normalized === '0') return false;
|
|
287
|
+
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isTransientSimulatorLaunchFailure(error: unknown): boolean {
|
|
291
|
+
if (!(error instanceof AppError)) return false;
|
|
292
|
+
if (error.code !== 'COMMAND_FAILED') return false;
|
|
293
|
+
|
|
294
|
+
const details = (error.details ?? {}) as { exitCode?: number; stderr?: unknown };
|
|
295
|
+
if (details.exitCode !== 4) return false;
|
|
296
|
+
const stderr = String(details.stderr ?? '').toLowerCase();
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
stderr.includes('fbsopenapplicationserviceerrordomain') &&
|
|
300
|
+
stderr.includes('the request to open')
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function launchIosSimulatorApp(device: DeviceInfo, bundleId: string): Promise<void> {
|
|
305
|
+
await ensureBootedSimulator(device);
|
|
306
|
+
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
307
|
+
|
|
308
|
+
const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS);
|
|
309
|
+
await retryWithPolicy(
|
|
310
|
+
async ({ deadline: attemptDeadline }) => {
|
|
311
|
+
if (attemptDeadline?.isExpired()) {
|
|
312
|
+
throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', {
|
|
313
|
+
timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const result = await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId], {
|
|
318
|
+
allowFailure: true,
|
|
319
|
+
});
|
|
320
|
+
if (result.exitCode === 0) return;
|
|
321
|
+
|
|
322
|
+
throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
|
|
323
|
+
cmd: 'xcrun',
|
|
324
|
+
args: ['simctl', 'launch', device.id, bundleId],
|
|
325
|
+
stdout: result.stdout,
|
|
326
|
+
stderr: result.stderr,
|
|
327
|
+
exitCode: result.exitCode,
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
maxAttempts: 30,
|
|
332
|
+
baseDelayMs: 1_000,
|
|
333
|
+
maxDelayMs: 5_000,
|
|
334
|
+
jitter: 0.2,
|
|
335
|
+
shouldRetry: isTransientSimulatorLaunchFailure,
|
|
336
|
+
},
|
|
337
|
+
{ deadline: launchDeadline },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function launchIosDeviceProcess(
|
|
342
|
+
device: DeviceInfo,
|
|
343
|
+
bundleId: string,
|
|
344
|
+
options?: { payloadUrl?: string },
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
const args = ['device', 'process', 'launch', '--device', device.id, bundleId];
|
|
347
|
+
if (options?.payloadUrl) {
|
|
348
|
+
args.push('--payload-url', options.payloadUrl);
|
|
349
|
+
}
|
|
350
|
+
await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function filterIosAppsByBundlePrefix(apps: IosAppInfo[], filter: 'user-installed' | 'all'): IosAppInfo[] {
|
|
354
|
+
if (filter === 'user-installed') {
|
|
355
|
+
return apps.filter((app) => !app.bundleId.startsWith('com.apple.'));
|
|
356
|
+
}
|
|
357
|
+
return apps;
|
|
358
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isEnvTruthy, TIMEOUT_PROFILES } from '../../utils/retry.ts';
|
|
2
|
+
import { resolveTimeoutMs } from '../../utils/timeouts.ts';
|
|
3
|
+
|
|
4
|
+
export const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
|
|
5
|
+
process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS,
|
|
6
|
+
TIMEOUT_PROFILES.ios_boot.totalMs,
|
|
7
|
+
5_000,
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const IOS_SIMCTL_LIST_TIMEOUT_MS = resolveTimeoutMs(
|
|
11
|
+
process.env.AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS,
|
|
12
|
+
TIMEOUT_PROFILES.ios_boot.operationMs,
|
|
13
|
+
1_000,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const IOS_APP_LAUNCH_TIMEOUT_MS = resolveTimeoutMs(
|
|
17
|
+
process.env.AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS,
|
|
18
|
+
30_000,
|
|
19
|
+
5_000,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const IOS_DEVICECTL_TIMEOUT_MS = resolveTimeoutMs(
|
|
23
|
+
process.env.AGENT_DEVICE_IOS_DEVICECTL_TIMEOUT_MS,
|
|
24
|
+
20_000,
|
|
25
|
+
1_000,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
+
import { AppError } from '../../utils/errors.ts';
|
|
7
|
+
import { runCmd } from '../../utils/exec.ts';
|
|
8
|
+
|
|
9
|
+
import { IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
|
|
10
|
+
|
|
11
|
+
export type IosAppInfo = {
|
|
12
|
+
bundleId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type IosDeviceAppsPayload = {
|
|
17
|
+
result?: {
|
|
18
|
+
apps?: Array<{
|
|
19
|
+
bundleIdentifier?: unknown;
|
|
20
|
+
name?: unknown;
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function runIosDevicectl(
|
|
26
|
+
args: string[],
|
|
27
|
+
context: { action: string; deviceId: string },
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const fullArgs = ['devicectl', ...args];
|
|
30
|
+
const result = await runCmd('xcrun', fullArgs, {
|
|
31
|
+
allowFailure: true,
|
|
32
|
+
timeoutMs: IOS_DEVICECTL_TIMEOUT_MS,
|
|
33
|
+
});
|
|
34
|
+
if (result.exitCode === 0) return;
|
|
35
|
+
const stdout = String(result.stdout ?? '');
|
|
36
|
+
const stderr = String(result.stderr ?? '');
|
|
37
|
+
throw new AppError('COMMAND_FAILED', `Failed to ${context.action}`, {
|
|
38
|
+
cmd: 'xcrun',
|
|
39
|
+
args: fullArgs,
|
|
40
|
+
exitCode: result.exitCode,
|
|
41
|
+
stdout,
|
|
42
|
+
stderr,
|
|
43
|
+
deviceId: context.deviceId,
|
|
44
|
+
hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listIosDeviceApps(
|
|
49
|
+
device: DeviceInfo,
|
|
50
|
+
filter: 'user-installed' | 'all',
|
|
51
|
+
): Promise<IosAppInfo[]> {
|
|
52
|
+
const jsonPath = path.join(
|
|
53
|
+
os.tmpdir(),
|
|
54
|
+
`agent-device-ios-apps-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
55
|
+
);
|
|
56
|
+
const args = [
|
|
57
|
+
'devicectl',
|
|
58
|
+
'device',
|
|
59
|
+
'info',
|
|
60
|
+
'apps',
|
|
61
|
+
'--device',
|
|
62
|
+
device.id,
|
|
63
|
+
'--include-all-apps',
|
|
64
|
+
'--json-output',
|
|
65
|
+
jsonPath,
|
|
66
|
+
];
|
|
67
|
+
const result = await runCmd('xcrun', args, {
|
|
68
|
+
allowFailure: true,
|
|
69
|
+
timeoutMs: IOS_DEVICECTL_TIMEOUT_MS,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
const stdout = String(result.stdout ?? '');
|
|
75
|
+
const stderr = String(result.stderr ?? '');
|
|
76
|
+
throw new AppError('COMMAND_FAILED', 'Failed to list iOS apps', {
|
|
77
|
+
cmd: 'xcrun',
|
|
78
|
+
args,
|
|
79
|
+
exitCode: result.exitCode,
|
|
80
|
+
stdout,
|
|
81
|
+
stderr,
|
|
82
|
+
deviceId: device.id,
|
|
83
|
+
hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const jsonText = await fs.readFile(jsonPath, 'utf8');
|
|
87
|
+
const apps = parseIosDeviceAppsPayload(JSON.parse(jsonText));
|
|
88
|
+
return filterIosDeviceApps(apps, filter);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof AppError) throw error;
|
|
91
|
+
throw new AppError('COMMAND_FAILED', 'Failed to parse iOS apps list', {
|
|
92
|
+
deviceId: device.id,
|
|
93
|
+
cause: String(error),
|
|
94
|
+
});
|
|
95
|
+
} finally {
|
|
96
|
+
await fs.unlink(jsonPath).catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseIosDeviceAppsPayload(payload: unknown): IosAppInfo[] {
|
|
101
|
+
const apps = (payload as IosDeviceAppsPayload | null | undefined)?.result?.apps;
|
|
102
|
+
if (!Array.isArray(apps)) return [];
|
|
103
|
+
|
|
104
|
+
const parsed: IosAppInfo[] = [];
|
|
105
|
+
for (const entry of apps) {
|
|
106
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
107
|
+
const bundleId = typeof entry.bundleIdentifier === 'string' ? entry.bundleIdentifier.trim() : '';
|
|
108
|
+
if (!bundleId) continue;
|
|
109
|
+
const name = typeof entry.name === 'string' && entry.name.trim().length > 0 ? entry.name.trim() : bundleId;
|
|
110
|
+
parsed.push({ bundleId, name });
|
|
111
|
+
}
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function filterIosDeviceApps(apps: IosAppInfo[], filter: 'user-installed' | 'all'): IosAppInfo[] {
|
|
116
|
+
if (filter === 'user-installed') {
|
|
117
|
+
return apps.filter((app) => !app.bundleId.startsWith('com.apple.'));
|
|
118
|
+
}
|
|
119
|
+
return apps;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const IOS_DEVICECTL_DEFAULT_HINT =
|
|
123
|
+
'Ensure the iOS device is unlocked, trusted, and available in Xcode > Devices, then retry.';
|
|
124
|
+
|
|
125
|
+
export function resolveIosDevicectlHint(stdout: string, stderr: string): string | null {
|
|
126
|
+
const text = `${stdout}\n${stderr}`.toLowerCase();
|
|
127
|
+
if (text.includes('device is busy') && text.includes('connecting')) {
|
|
128
|
+
return 'iOS device is still connecting. Keep it unlocked and connected by cable until it is fully available in Xcode Devices, then retry.';
|
|
129
|
+
}
|
|
130
|
+
if (text.includes('coredeviceservice') && text.includes('timed out')) {
|
|
131
|
+
return 'CoreDevice service timed out. Reconnect the device and retry; if it persists restart Xcode and the iOS device.';
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
@@ -4,6 +4,13 @@ import path from 'node:path';
|
|
|
4
4
|
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
5
5
|
import { AppError } from '../../utils/errors.ts';
|
|
6
6
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
7
|
+
import { resolveTimeoutMs } from '../../utils/timeouts.ts';
|
|
8
|
+
|
|
9
|
+
const IOS_DEVICECTL_LIST_TIMEOUT_MS = resolveTimeoutMs(
|
|
10
|
+
process.env.AGENT_DEVICE_IOS_DEVICECTL_LIST_TIMEOUT_MS,
|
|
11
|
+
8_000,
|
|
12
|
+
500,
|
|
13
|
+
);
|
|
7
14
|
|
|
8
15
|
export async function listIosDevices(): Promise<DeviceInfo[]> {
|
|
9
16
|
if (process.platform !== 'darwin') {
|
|
@@ -45,9 +52,15 @@ export async function listIosDevices(): Promise<DeviceInfo[]> {
|
|
|
45
52
|
try {
|
|
46
53
|
jsonPath = path.join(
|
|
47
54
|
os.tmpdir(),
|
|
48
|
-
`agent-device-devicectl-${process.pid}-${Date.now()}.json`,
|
|
55
|
+
`agent-device-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
49
56
|
);
|
|
50
|
-
await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath]
|
|
57
|
+
const devicectlResult = await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath], {
|
|
58
|
+
allowFailure: true,
|
|
59
|
+
timeoutMs: IOS_DEVICECTL_LIST_TIMEOUT_MS,
|
|
60
|
+
});
|
|
61
|
+
if (devicectlResult.exitCode !== 0) {
|
|
62
|
+
return devices;
|
|
63
|
+
}
|
|
51
64
|
const jsonText = await fs.readFile(jsonPath, 'utf8');
|
|
52
65
|
const payload = JSON.parse(jsonText) as {
|
|
53
66
|
result?: {
|