agent-device 0.4.0 → 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 +20 -12
- package/dist/src/797.js +1 -0
- package/dist/src/bin.js +40 -29
- package/dist/src/daemon.js +21 -17
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +23 -14
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +5 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/__tests__/cli-help.test.ts +102 -0
- package/src/cli.ts +68 -22
- 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/__tests__/session-store.test.ts +23 -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 +198 -93
- package/src/daemon/handlers/snapshot.ts +210 -185
- package/src/daemon/session-store.ts +16 -6
- package/src/daemon/types.ts +2 -1
- package/src/daemon-client.ts +138 -17
- package/src/daemon.ts +99 -9
- 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 +171 -69
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +66 -2
- package/src/utils/__tests__/daemon-client.test.ts +95 -0
- package/src/utils/__tests__/keyed-lock.test.ts +55 -0
- package/src/utils/__tests__/process-identity.test.ts +33 -0
- package/src/utils/args.ts +37 -1
- package/src/utils/command-schema.ts +58 -27
- package/src/utils/interactors.ts +2 -2
- package/src/utils/keyed-lock.ts +14 -0
- package/src/utils/process-identity.ts +100 -0
- package/src/utils/timeouts.ts +9 -0
- package/dist/src/274.js +0 -1
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- package/src/daemon/app-state.ts +0 -65
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
IOS_SAFARI_BUNDLE_ID,
|
|
5
|
+
isDeepLinkTarget,
|
|
6
|
+
isWebUrl,
|
|
7
|
+
resolveIosDeviceDeepLinkBundleId,
|
|
8
|
+
} from '../open-target.ts';
|
|
4
9
|
|
|
5
10
|
test('isDeepLinkTarget accepts URL-style deep links', () => {
|
|
6
11
|
assert.equal(isDeepLinkTarget('myapp://home'), true);
|
|
@@ -14,3 +19,37 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
|
|
|
14
19
|
assert.equal(isDeepLinkTarget('settings'), false);
|
|
15
20
|
assert.equal(isDeepLinkTarget('http:/x'), false);
|
|
16
21
|
});
|
|
22
|
+
|
|
23
|
+
test('isWebUrl accepts http and https URLs', () => {
|
|
24
|
+
assert.equal(isWebUrl('https://example.com'), true);
|
|
25
|
+
assert.equal(isWebUrl('http://example.com/path'), true);
|
|
26
|
+
assert.equal(isWebUrl('https://example.com/path?q=1'), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('isWebUrl rejects custom schemes and non-URLs', () => {
|
|
30
|
+
assert.equal(isWebUrl('myapp://home'), false);
|
|
31
|
+
assert.equal(isWebUrl('tel:123456789'), false);
|
|
32
|
+
assert.equal(isWebUrl('com.example.app'), false);
|
|
33
|
+
assert.equal(isWebUrl('settings'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('resolveIosDeviceDeepLinkBundleId prefers active app context', () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
resolveIosDeviceDeepLinkBundleId('com.example.app', 'myapp://home'),
|
|
39
|
+
'com.example.app',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveIosDeviceDeepLinkBundleId falls back to Safari for web URLs', () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveIosDeviceDeepLinkBundleId(undefined, 'https://example.com/path'),
|
|
46
|
+
IOS_SAFARI_BUNDLE_ID,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('resolveIosDeviceDeepLinkBundleId returns undefined for custom scheme without app context', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveIosDeviceDeepLinkBundleId(undefined, 'myapp://home'),
|
|
53
|
+
undefined,
|
|
54
|
+
);
|
|
55
|
+
});
|
package/src/core/capabilities.ts
CHANGED
|
@@ -17,7 +17,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
|
|
|
17
17
|
alert: { ios: { simulator: true }, android: {} },
|
|
18
18
|
pinch: { ios: { simulator: true }, android: {} },
|
|
19
19
|
'app-switcher': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
20
|
-
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
20
|
+
apps: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
21
21
|
back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
22
22
|
boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
|
23
23
|
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
|
package/src/core/dispatch.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
|
|
|
16
16
|
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
|
|
17
17
|
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
|
|
18
18
|
import { setIosSetting } from '../platforms/ios/index.ts';
|
|
19
|
+
import { isDeepLinkTarget } from './open-target.ts';
|
|
19
20
|
import type { RawSnapshotNode } from '../utils/snapshot.ts';
|
|
20
21
|
import type { CliFlags } from '../utils/command-schema.ts';
|
|
21
22
|
|
|
@@ -89,10 +90,31 @@ export async function dispatchCommand(
|
|
|
89
90
|
switch (command) {
|
|
90
91
|
case 'open': {
|
|
91
92
|
const app = positionals[0];
|
|
93
|
+
const url = positionals[1];
|
|
94
|
+
if (positionals.length > 2) {
|
|
95
|
+
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
|
|
96
|
+
}
|
|
92
97
|
if (!app) {
|
|
93
98
|
await interactor.openDevice();
|
|
94
99
|
return { app: null };
|
|
95
100
|
}
|
|
101
|
+
if (url !== undefined) {
|
|
102
|
+
if (device.platform !== 'ios') {
|
|
103
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> is supported only on iOS');
|
|
104
|
+
}
|
|
105
|
+
if (isDeepLinkTarget(app)) {
|
|
106
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires an app target as the first argument');
|
|
107
|
+
}
|
|
108
|
+
if (!isDeepLinkTarget(url)) {
|
|
109
|
+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
|
|
110
|
+
}
|
|
111
|
+
await interactor.open(app, {
|
|
112
|
+
activity: context?.activity,
|
|
113
|
+
appBundleId: context?.appBundleId,
|
|
114
|
+
url,
|
|
115
|
+
});
|
|
116
|
+
return { app, url };
|
|
117
|
+
}
|
|
96
118
|
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
|
|
97
119
|
return { app };
|
|
98
120
|
}
|
package/src/core/open-target.ts
CHANGED
|
@@ -11,3 +11,17 @@ export function isDeepLinkTarget(input: string): boolean {
|
|
|
11
11
|
}
|
|
12
12
|
return true;
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
export function isWebUrl(input: string): boolean {
|
|
16
|
+
const scheme = input.trim().split(':')[0]?.toLowerCase();
|
|
17
|
+
return scheme === 'http' || scheme === 'https';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const IOS_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
|
|
21
|
+
|
|
22
|
+
export function resolveIosDeviceDeepLinkBundleId(appBundleId: string | undefined, url: string): string | undefined {
|
|
23
|
+
const bundleId = appBundleId?.trim();
|
|
24
|
+
if (bundleId) return bundleId;
|
|
25
|
+
if (isWebUrl(url)) return IOS_SAFARI_BUNDLE_ID;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseIosReadyPayload, resolveIosReadyHint } from '../device-ready.ts';
|
|
4
|
+
|
|
5
|
+
test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => {
|
|
6
|
+
const parsed = parseIosReadyPayload({
|
|
7
|
+
result: {
|
|
8
|
+
connectionProperties: {
|
|
9
|
+
tunnelState: 'connected',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
assert.equal(parsed.tunnelState, 'connected');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parseIosReadyPayload reads tunnelState from nested device connectionProperties', () => {
|
|
17
|
+
const parsed = parseIosReadyPayload({
|
|
18
|
+
result: {
|
|
19
|
+
device: {
|
|
20
|
+
connectionProperties: {
|
|
21
|
+
tunnelState: 'connecting',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
assert.equal(parsed.tunnelState, 'connecting');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseIosReadyPayload returns empty payload for malformed input', () => {
|
|
30
|
+
assert.deepEqual(parseIosReadyPayload(null), {});
|
|
31
|
+
assert.deepEqual(parseIosReadyPayload({}), {});
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
parseIosReadyPayload({
|
|
34
|
+
result: { connectionProperties: { tunnelState: 123 } },
|
|
35
|
+
}),
|
|
36
|
+
{},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('resolveIosReadyHint maps known connection errors', () => {
|
|
41
|
+
const connecting = resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)');
|
|
42
|
+
assert.match(connecting, /still connecting/i);
|
|
43
|
+
|
|
44
|
+
const coreDeviceTimeout = resolveIosReadyHint('CoreDeviceService timed out', '');
|
|
45
|
+
assert.match(coreDeviceTimeout, /coredevice service/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('resolveIosReadyHint falls back to generic guidance', () => {
|
|
49
|
+
const hint = resolveIosReadyHint('unexpected failure', '');
|
|
50
|
+
assert.match(hint, /unlocked/i);
|
|
51
|
+
assert.match(hint, /xcode/i);
|
|
52
|
+
});
|
|
@@ -94,6 +94,29 @@ test('saveScript flag enables .ad session log writing', () => {
|
|
|
94
94
|
assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
test('saveScript path writes session log to custom location', () => {
|
|
98
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-custom-path-'));
|
|
99
|
+
const store = new SessionStore(path.join(root, 'sessions'));
|
|
100
|
+
const session = makeSession('default');
|
|
101
|
+
const customPath = path.join(root, 'workflows', 'my-flow.ad');
|
|
102
|
+
store.recordAction(session, {
|
|
103
|
+
command: 'open',
|
|
104
|
+
positionals: ['Settings'],
|
|
105
|
+
flags: { platform: 'ios', saveScript: customPath },
|
|
106
|
+
result: {},
|
|
107
|
+
});
|
|
108
|
+
store.recordAction(session, {
|
|
109
|
+
command: 'close',
|
|
110
|
+
positionals: [],
|
|
111
|
+
flags: { platform: 'ios' },
|
|
112
|
+
result: {},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
store.writeSessionLog(session);
|
|
116
|
+
assert.equal(fs.existsSync(customPath), true);
|
|
117
|
+
assert.equal(fs.existsSync(path.join(root, 'sessions')), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
97
120
|
test('writeSessionLog persists open --relaunch in script output', () => {
|
|
98
121
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
|
|
99
122
|
const store = new SessionStore(root);
|
|
@@ -1,13 +1,155 @@
|
|
|
1
1
|
import type { DeviceInfo } from '../utils/device.ts';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { runCmd } from '../utils/exec.ts';
|
|
6
|
+
import { AppError } from '../utils/errors.ts';
|
|
7
|
+
import { resolveTimeoutMs } from '../utils/timeouts.ts';
|
|
8
|
+
import { resolveIosDevicectlHint, IOS_DEVICECTL_DEFAULT_HINT } from '../platforms/ios/devicectl.ts';
|
|
9
|
+
|
|
10
|
+
const IOS_DEVICE_READY_TIMEOUT_MS = resolveTimeoutMs(
|
|
11
|
+
process.env.AGENT_DEVICE_IOS_DEVICE_READY_TIMEOUT_MS,
|
|
12
|
+
15_000,
|
|
13
|
+
1_000,
|
|
14
|
+
);
|
|
15
|
+
const IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS = 3_000;
|
|
2
16
|
|
|
3
17
|
export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
|
|
4
|
-
if (device.platform === 'ios'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
18
|
+
if (device.platform === 'ios') {
|
|
19
|
+
if (device.kind === 'simulator') {
|
|
20
|
+
const { ensureBootedSimulator } = await import('../platforms/ios/index.ts');
|
|
21
|
+
await ensureBootedSimulator(device);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (device.kind === 'device') {
|
|
25
|
+
await ensureIosDeviceReady(device.id);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
8
28
|
}
|
|
9
29
|
if (device.platform === 'android') {
|
|
10
30
|
const { waitForAndroidBoot } = await import('../platforms/android/devices.ts');
|
|
11
31
|
await waitForAndroidBoot(device.id);
|
|
12
32
|
}
|
|
13
33
|
}
|
|
34
|
+
|
|
35
|
+
async function ensureIosDeviceReady(deviceId: string): Promise<void> {
|
|
36
|
+
const jsonPath = path.join(
|
|
37
|
+
os.tmpdir(),
|
|
38
|
+
`agent-device-ready-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
39
|
+
);
|
|
40
|
+
const timeoutSeconds = Math.max(1, Math.ceil(IOS_DEVICE_READY_TIMEOUT_MS / 1000));
|
|
41
|
+
try {
|
|
42
|
+
const result = await runCmd(
|
|
43
|
+
'xcrun',
|
|
44
|
+
[
|
|
45
|
+
'devicectl',
|
|
46
|
+
'device',
|
|
47
|
+
'info',
|
|
48
|
+
'details',
|
|
49
|
+
'--device',
|
|
50
|
+
deviceId,
|
|
51
|
+
'--json-output',
|
|
52
|
+
jsonPath,
|
|
53
|
+
'--timeout',
|
|
54
|
+
String(timeoutSeconds),
|
|
55
|
+
],
|
|
56
|
+
{
|
|
57
|
+
allowFailure: true,
|
|
58
|
+
timeoutMs: IOS_DEVICE_READY_TIMEOUT_MS + IOS_DEVICE_READY_COMMAND_TIMEOUT_BUFFER_MS,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const stdout = String(result.stdout ?? '');
|
|
62
|
+
const stderr = String(result.stderr ?? '');
|
|
63
|
+
const parsed = await readIosReadyPayload(jsonPath);
|
|
64
|
+
if (result.exitCode === 0) {
|
|
65
|
+
if (!parsed.parsed) {
|
|
66
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
67
|
+
kind: 'probe_inconclusive',
|
|
68
|
+
deviceId,
|
|
69
|
+
stdout,
|
|
70
|
+
stderr,
|
|
71
|
+
hint: 'CoreDevice returned success but readiness JSON output was missing or invalid. Retry; if it persists restart Xcode and the iOS device.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const tunnelState = parsed?.tunnelState?.toLowerCase();
|
|
75
|
+
if (tunnelState === 'connecting') {
|
|
76
|
+
throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', {
|
|
77
|
+
kind: 'not_ready',
|
|
78
|
+
deviceId,
|
|
79
|
+
tunnelState,
|
|
80
|
+
hint: 'Device tunnel is still connecting. Keep the device unlocked and connected by cable until it is fully available in Xcode Devices, then retry.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw new AppError('COMMAND_FAILED', 'iOS device is not ready for automation', {
|
|
86
|
+
kind: 'not_ready',
|
|
87
|
+
deviceId,
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
exitCode: result.exitCode,
|
|
91
|
+
tunnelState: parsed?.tunnelState,
|
|
92
|
+
hint: resolveIosReadyHint(stdout, stderr),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof AppError && error.code === 'COMMAND_FAILED') {
|
|
96
|
+
const kind = typeof error.details?.kind === 'string' ? error.details.kind : '';
|
|
97
|
+
if (kind === 'not_ready') {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const details = (error.details ?? {}) as {
|
|
101
|
+
stdout?: string;
|
|
102
|
+
stderr?: string;
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
};
|
|
105
|
+
const stdout = String(details.stdout ?? '');
|
|
106
|
+
const stderr = String(details.stderr ?? '');
|
|
107
|
+
const timeoutMs = Number(details.timeoutMs ?? IOS_DEVICE_READY_TIMEOUT_MS);
|
|
108
|
+
const timeoutHint = `CoreDevice did not respond within ${timeoutMs}ms. Keep the device unlocked and trusted, then retry; if it persists restart Xcode and the iOS device.`;
|
|
109
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
110
|
+
deviceId,
|
|
111
|
+
cause: error.message,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
stdout,
|
|
114
|
+
stderr,
|
|
115
|
+
hint: stdout || stderr ? resolveIosReadyHint(stdout, stderr) : timeoutHint,
|
|
116
|
+
}, error);
|
|
117
|
+
}
|
|
118
|
+
throw new AppError('COMMAND_FAILED', 'iOS device readiness probe failed', {
|
|
119
|
+
deviceId,
|
|
120
|
+
hint: 'Reconnect the device, keep it unlocked, and retry.',
|
|
121
|
+
}, error instanceof Error ? error : undefined);
|
|
122
|
+
} finally {
|
|
123
|
+
await fs.rm(jsonPath, { force: true }).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseIosReadyPayload(payload: unknown): { tunnelState?: string } {
|
|
128
|
+
const result = (payload as { result?: unknown } | null | undefined)?.result;
|
|
129
|
+
if (!result || typeof result !== 'object') return {};
|
|
130
|
+
const direct = (result as { connectionProperties?: { tunnelState?: unknown } }).connectionProperties?.tunnelState;
|
|
131
|
+
const nested = (result as { device?: { connectionProperties?: { tunnelState?: unknown } } }).device?.connectionProperties?.tunnelState;
|
|
132
|
+
const tunnelState = typeof direct === 'string' ? direct : typeof nested === 'string' ? nested : undefined;
|
|
133
|
+
return tunnelState ? { tunnelState } : {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readIosReadyPayload(jsonPath: string): Promise<{ parsed: boolean; tunnelState?: string }> {
|
|
137
|
+
try {
|
|
138
|
+
const payloadText = await fs.readFile(jsonPath, 'utf8');
|
|
139
|
+
const payload = JSON.parse(payloadText) as unknown;
|
|
140
|
+
const parsed = parseIosReadyPayload(payload);
|
|
141
|
+
return { parsed: true, tunnelState: parsed.tunnelState };
|
|
142
|
+
} catch {
|
|
143
|
+
return { parsed: false };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveIosReadyHint(stdout: string, stderr: string): string {
|
|
148
|
+
const devicectlHint = resolveIosDevicectlHint(stdout, stderr);
|
|
149
|
+
if (devicectlHint) return devicectlHint;
|
|
150
|
+
const text = `${stdout}\n${stderr}`.toLowerCase();
|
|
151
|
+
if (text.includes('timed out waiting for all destinations')) {
|
|
152
|
+
return 'Xcode destination did not become available in time. Keep device unlocked and retry.';
|
|
153
|
+
}
|
|
154
|
+
return IOS_DEVICECTL_DEFAULT_HINT;
|
|
155
|
+
}
|