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
package/src/daemon-client.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { AppError } from './utils/errors.ts';
|
|
|
6
6
|
import type { CommandFlags } from './core/dispatch.ts';
|
|
7
7
|
import { runCmdDetached } from './utils/exec.ts';
|
|
8
8
|
import { findProjectRoot, readVersion } from './utils/version.ts';
|
|
9
|
+
import {
|
|
10
|
+
isAgentDeviceDaemonProcess,
|
|
11
|
+
stopProcessForTakeover,
|
|
12
|
+
} from './utils/process-identity.ts';
|
|
9
13
|
|
|
10
14
|
export type DaemonRequest = {
|
|
11
15
|
token: string;
|
|
@@ -19,12 +23,32 @@ export type DaemonResponse =
|
|
|
19
23
|
| { ok: true; data?: Record<string, unknown> }
|
|
20
24
|
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };
|
|
21
25
|
|
|
22
|
-
type DaemonInfo = {
|
|
26
|
+
type DaemonInfo = {
|
|
27
|
+
port: number;
|
|
28
|
+
token: string;
|
|
29
|
+
pid: number;
|
|
30
|
+
version?: string;
|
|
31
|
+
processStartTime?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type DaemonLockInfo = {
|
|
35
|
+
pid: number;
|
|
36
|
+
processStartTime?: string;
|
|
37
|
+
startedAt?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type DaemonMetadataState = {
|
|
41
|
+
hasInfo: boolean;
|
|
42
|
+
hasLock: boolean;
|
|
43
|
+
};
|
|
23
44
|
|
|
24
45
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
25
46
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
26
|
-
const
|
|
47
|
+
const lockPath = path.join(baseDir, 'daemon.lock');
|
|
48
|
+
const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
|
|
27
49
|
const DAEMON_STARTUP_TIMEOUT_MS = 5000;
|
|
50
|
+
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
|
|
51
|
+
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;
|
|
28
52
|
|
|
29
53
|
export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
|
|
30
54
|
const info = await ensureDaemon();
|
|
@@ -38,40 +62,128 @@ async function ensureDaemon(): Promise<DaemonInfo> {
|
|
|
38
62
|
const existingReachable = existing ? await canConnect(existing) : false;
|
|
39
63
|
if (existing && existing.version === localVersion && existingReachable) return existing;
|
|
40
64
|
if (existing && (existing.version !== localVersion || !existingReachable)) {
|
|
65
|
+
await stopDaemonProcessForTakeover(existing);
|
|
41
66
|
removeDaemonInfo();
|
|
42
67
|
}
|
|
43
68
|
|
|
69
|
+
cleanupStaleDaemonLockIfSafe();
|
|
44
70
|
await startDaemon();
|
|
71
|
+
const started = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS);
|
|
72
|
+
if (started) return started;
|
|
73
|
+
|
|
74
|
+
if (await recoverDaemonLockHolder()) {
|
|
75
|
+
await startDaemon();
|
|
76
|
+
const recovered = await waitForDaemonInfo(DAEMON_STARTUP_TIMEOUT_MS);
|
|
77
|
+
if (recovered) return recovered;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
|
|
81
|
+
kind: 'daemon_startup_failed',
|
|
82
|
+
infoPath,
|
|
83
|
+
lockPath,
|
|
84
|
+
hint: resolveDaemonStartupHint(getDaemonMetadataState()),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
45
87
|
|
|
88
|
+
async function waitForDaemonInfo(timeoutMs: number): Promise<DaemonInfo | null> {
|
|
46
89
|
const start = Date.now();
|
|
47
|
-
while (Date.now() - start <
|
|
90
|
+
while (Date.now() - start < timeoutMs) {
|
|
48
91
|
const info = readDaemonInfo();
|
|
49
92
|
if (info && (await canConnect(info))) return info;
|
|
50
93
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
51
94
|
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
98
|
+
async function recoverDaemonLockHolder(): Promise<boolean> {
|
|
99
|
+
const state = getDaemonMetadataState();
|
|
100
|
+
if (!state.hasLock || state.hasInfo) return false;
|
|
101
|
+
const lockInfo = readDaemonLockInfo();
|
|
102
|
+
if (!lockInfo) {
|
|
103
|
+
removeDaemonLock();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (!isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
|
|
107
|
+
removeDaemonLock();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
await stopProcessForTakeover(lockInfo.pid, {
|
|
111
|
+
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
|
|
112
|
+
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
|
|
113
|
+
expectedStartTime: lockInfo.processStartTime,
|
|
114
|
+
});
|
|
115
|
+
removeDaemonLock();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
|
|
120
|
+
await stopProcessForTakeover(info.pid, {
|
|
121
|
+
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
|
|
122
|
+
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
|
|
123
|
+
expectedStartTime: info.processStartTime,
|
|
56
124
|
});
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
function readDaemonInfo(): DaemonInfo | null {
|
|
60
|
-
|
|
128
|
+
const data = readJsonFile(infoPath) as DaemonInfo | null;
|
|
129
|
+
if (!data || !data.port || !data.token) return null;
|
|
130
|
+
return {
|
|
131
|
+
...data,
|
|
132
|
+
pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readDaemonLockInfo(): DaemonLockInfo | null {
|
|
137
|
+
const data = readJsonFile(lockPath) as DaemonLockInfo | null;
|
|
138
|
+
if (!data || !Number.isInteger(data.pid) || data.pid <= 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return data;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function removeDaemonInfo(): void {
|
|
145
|
+
removeFileIfExists(infoPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function removeDaemonLock(): void {
|
|
149
|
+
removeFileIfExists(lockPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cleanupStaleDaemonLockIfSafe(): void {
|
|
153
|
+
const state = getDaemonMetadataState();
|
|
154
|
+
if (!state.hasLock || state.hasInfo) return;
|
|
155
|
+
const lockInfo = readDaemonLockInfo();
|
|
156
|
+
if (!lockInfo) {
|
|
157
|
+
removeDaemonLock();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
removeDaemonLock();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getDaemonMetadataState(): DaemonMetadataState {
|
|
167
|
+
return {
|
|
168
|
+
hasInfo: fs.existsSync(infoPath),
|
|
169
|
+
hasLock: fs.existsSync(lockPath),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readJsonFile(filePath: string): unknown | null {
|
|
174
|
+
if (!fs.existsSync(filePath)) return null;
|
|
61
175
|
try {
|
|
62
|
-
|
|
63
|
-
if (!data.port || !data.token) return null;
|
|
64
|
-
return data;
|
|
176
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
65
177
|
} catch {
|
|
66
178
|
return null;
|
|
67
179
|
}
|
|
68
180
|
}
|
|
69
181
|
|
|
70
|
-
function
|
|
182
|
+
function removeFileIfExists(filePath: string): void {
|
|
71
183
|
try {
|
|
72
|
-
if (fs.existsSync(
|
|
184
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
73
185
|
} catch {
|
|
74
|
-
// Best-effort cleanup only
|
|
186
|
+
// Best-effort cleanup only.
|
|
75
187
|
}
|
|
76
188
|
}
|
|
77
189
|
|
|
@@ -142,10 +254,19 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
|
|
|
142
254
|
});
|
|
143
255
|
}
|
|
144
256
|
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
if (!raw) return 60000;
|
|
257
|
+
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
|
|
258
|
+
if (!raw) return 90000;
|
|
148
259
|
const parsed = Number(raw);
|
|
149
|
-
if (!Number.isFinite(parsed)) return
|
|
260
|
+
if (!Number.isFinite(parsed)) return 90000;
|
|
150
261
|
return Math.max(1000, Math.floor(parsed));
|
|
151
262
|
}
|
|
263
|
+
|
|
264
|
+
export function resolveDaemonStartupHint(state: { hasInfo: boolean; hasLock: boolean }): string {
|
|
265
|
+
if (state.hasLock && !state.hasInfo) {
|
|
266
|
+
return 'Detected ~/.agent-device/daemon.lock without daemon.json. If no agent-device daemon process is running, delete ~/.agent-device/daemon.lock and retry.';
|
|
267
|
+
}
|
|
268
|
+
if (state.hasLock && state.hasInfo) {
|
|
269
|
+
return 'Daemon metadata may be stale. If no agent-device daemon process is running, delete ~/.agent-device/daemon.json and ~/.agent-device/daemon.lock, then retry.';
|
|
270
|
+
}
|
|
271
|
+
return 'Daemon metadata is missing or stale. Delete ~/.agent-device/daemon.json if present and retry.';
|
|
272
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
|
|
|
7
7
|
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
|
|
8
8
|
import { asAppError, AppError } from './utils/errors.ts';
|
|
9
9
|
import { readVersion } from './utils/version.ts';
|
|
10
|
-
import {
|
|
10
|
+
import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
|
|
11
11
|
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
|
|
12
12
|
import { SessionStore } from './daemon/session-store.ts';
|
|
13
13
|
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
|
|
@@ -18,9 +18,14 @@ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
|
|
|
18
18
|
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
|
|
19
19
|
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
|
|
20
20
|
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
|
|
21
|
+
import {
|
|
22
|
+
isAgentDeviceDaemonProcess,
|
|
23
|
+
readProcessStartTime,
|
|
24
|
+
} from './utils/process-identity.ts';
|
|
21
25
|
|
|
22
26
|
const baseDir = path.join(os.homedir(), '.agent-device');
|
|
23
27
|
const infoPath = path.join(baseDir, 'daemon.json');
|
|
28
|
+
const lockPath = path.join(baseDir, 'daemon.lock');
|
|
24
29
|
const logPath = path.join(baseDir, 'daemon.log');
|
|
25
30
|
const sessionsDir = path.join(baseDir, 'sessions');
|
|
26
31
|
const sessionStore = new SessionStore(sessionsDir);
|
|
@@ -28,6 +33,15 @@ const version = readVersion();
|
|
|
28
33
|
const token = crypto.randomBytes(24).toString('hex');
|
|
29
34
|
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
|
|
30
35
|
|
|
36
|
+
type DaemonLockInfo = {
|
|
37
|
+
pid: number;
|
|
38
|
+
version: string;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
processStartTime?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;
|
|
44
|
+
|
|
31
45
|
function contextFromFlags(
|
|
32
46
|
flags: CommandFlags | undefined,
|
|
33
47
|
appBundleId?: string,
|
|
@@ -122,7 +136,7 @@ function writeInfo(port: number): void {
|
|
|
122
136
|
fs.writeFileSync(logPath, '');
|
|
123
137
|
fs.writeFileSync(
|
|
124
138
|
infoPath,
|
|
125
|
-
JSON.stringify({ port, token, pid: process.pid, version }, null, 2),
|
|
139
|
+
JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
|
|
126
140
|
{
|
|
127
141
|
mode: 0o600,
|
|
128
142
|
},
|
|
@@ -133,7 +147,73 @@ function removeInfo(): void {
|
|
|
133
147
|
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
function readLockInfo(): DaemonLockInfo | null {
|
|
151
|
+
if (!fs.existsSync(lockPath)) return null;
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as DaemonLockInfo;
|
|
154
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
155
|
+
return parsed;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function acquireDaemonLock(): boolean {
|
|
162
|
+
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
|
|
163
|
+
const lockData: DaemonLockInfo = {
|
|
164
|
+
pid: process.pid,
|
|
165
|
+
version,
|
|
166
|
+
startedAt: Date.now(),
|
|
167
|
+
processStartTime: daemonProcessStartTime,
|
|
168
|
+
};
|
|
169
|
+
const payload = JSON.stringify(lockData, null, 2);
|
|
170
|
+
|
|
171
|
+
const tryWriteLock = (): boolean => {
|
|
172
|
+
try {
|
|
173
|
+
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
|
|
174
|
+
return true;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (tryWriteLock()) return true;
|
|
182
|
+
const existing = readLockInfo();
|
|
183
|
+
if (
|
|
184
|
+
existing?.pid
|
|
185
|
+
&& existing.pid !== process.pid
|
|
186
|
+
&& isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)
|
|
187
|
+
) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// Best-effort stale-lock cleanup: another process may win the race between unlink and re-create.
|
|
191
|
+
// We rely on the subsequent write with `wx` to enforce single-writer semantics.
|
|
192
|
+
try {
|
|
193
|
+
fs.unlinkSync(lockPath);
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
return tryWriteLock();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function releaseDaemonLock(): void {
|
|
201
|
+
const existing = readLockInfo();
|
|
202
|
+
if (existing && existing.pid !== process.pid) return;
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
136
210
|
function start(): void {
|
|
211
|
+
if (!acquireDaemonLock()) {
|
|
212
|
+
process.stderr.write('Daemon lock is held by another process; exiting.\n');
|
|
213
|
+
process.exit(0);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
137
217
|
const server = net.createServer((socket) => {
|
|
138
218
|
let buffer = '';
|
|
139
219
|
socket.setEncoding('utf8');
|
|
@@ -172,18 +252,28 @@ function start(): void {
|
|
|
172
252
|
}
|
|
173
253
|
});
|
|
174
254
|
|
|
255
|
+
let shuttingDown = false;
|
|
256
|
+
const closeServer = async (): Promise<void> => {
|
|
257
|
+
await new Promise<void>((resolve) => {
|
|
258
|
+
try {
|
|
259
|
+
server.close(() => resolve());
|
|
260
|
+
} catch {
|
|
261
|
+
resolve();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
};
|
|
175
265
|
const shutdown = async () => {
|
|
266
|
+
if (shuttingDown) return;
|
|
267
|
+
shuttingDown = true;
|
|
268
|
+
await closeServer();
|
|
176
269
|
const sessionsToStop = sessionStore.toArray();
|
|
177
270
|
for (const session of sessionsToStop) {
|
|
178
|
-
if (session.device.platform === 'ios') {
|
|
179
|
-
await stopIosRunnerSession(session.device.id);
|
|
180
|
-
}
|
|
181
271
|
sessionStore.writeSessionLog(session);
|
|
182
272
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
273
|
+
await stopAllIosRunnerSessions();
|
|
274
|
+
removeInfo();
|
|
275
|
+
releaseDaemonLock();
|
|
276
|
+
process.exit(0);
|
|
187
277
|
};
|
|
188
278
|
|
|
189
279
|
process.on('SIGINT', () => {
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
|
|
6
|
+
import { inferAndroidAppName, listAndroidApps, openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
|
|
7
7
|
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
8
8
|
import { AppError } from '../../../utils/errors.ts';
|
|
9
9
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
@@ -95,6 +95,123 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
|
|
|
95
95
|
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test('inferAndroidAppName derives readable names from package ids', () => {
|
|
99
|
+
assert.equal(inferAndroidAppName('com.android.settings'), 'Settings');
|
|
100
|
+
assert.equal(inferAndroidAppName('com.google.android.apps.maps'), 'Maps');
|
|
101
|
+
assert.equal(inferAndroidAppName('org.mozilla.firefox'), 'Firefox');
|
|
102
|
+
assert.equal(inferAndroidAppName('com.facebook.katana'), 'Katana');
|
|
103
|
+
assert.equal(inferAndroidAppName('single'), 'Single');
|
|
104
|
+
assert.equal(inferAndroidAppName('com.android.app.services'), 'Services');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('listAndroidApps returns launchable apps with inferred names', async () => {
|
|
108
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-apps-all-'));
|
|
109
|
+
const adbPath = path.join(tmpDir, 'adb');
|
|
110
|
+
await fs.writeFile(
|
|
111
|
+
adbPath,
|
|
112
|
+
[
|
|
113
|
+
'#!/bin/sh',
|
|
114
|
+
'if [ "$1" = "-s" ]; then',
|
|
115
|
+
' shift',
|
|
116
|
+
' shift',
|
|
117
|
+
'fi',
|
|
118
|
+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "query-activities" ]; then',
|
|
119
|
+
' echo "com.google.android.apps.maps/.MainActivity"',
|
|
120
|
+
' echo "org.mozilla.firefox/.App"',
|
|
121
|
+
' echo "com.android.settings/.Settings"',
|
|
122
|
+
' exit 0',
|
|
123
|
+
'fi',
|
|
124
|
+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ] && [ "$5" = "-3" ]; then',
|
|
125
|
+
' echo "package:com.google.android.apps.maps"',
|
|
126
|
+
' echo "package:com.example.serviceonly"',
|
|
127
|
+
' echo "package:org.mozilla.firefox"',
|
|
128
|
+
' exit 0',
|
|
129
|
+
'fi',
|
|
130
|
+
'echo "unexpected args: $@" >&2',
|
|
131
|
+
'exit 1',
|
|
132
|
+
'',
|
|
133
|
+
].join('\n'),
|
|
134
|
+
'utf8',
|
|
135
|
+
);
|
|
136
|
+
await fs.chmod(adbPath, 0o755);
|
|
137
|
+
|
|
138
|
+
const previousPath = process.env.PATH;
|
|
139
|
+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
140
|
+
|
|
141
|
+
const device: DeviceInfo = {
|
|
142
|
+
platform: 'android',
|
|
143
|
+
id: 'emulator-5554',
|
|
144
|
+
name: 'Pixel',
|
|
145
|
+
kind: 'emulator',
|
|
146
|
+
booted: true,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const apps = await listAndroidApps(device, 'all');
|
|
151
|
+
assert.deepEqual(apps, [
|
|
152
|
+
{ package: 'com.android.settings', name: 'Settings' },
|
|
153
|
+
{ package: 'com.google.android.apps.maps', name: 'Maps' },
|
|
154
|
+
{ package: 'org.mozilla.firefox', name: 'Firefox' },
|
|
155
|
+
]);
|
|
156
|
+
} finally {
|
|
157
|
+
process.env.PATH = previousPath;
|
|
158
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('listAndroidApps user-installed excludes non-launchable packages', async () => {
|
|
163
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-apps-user-'));
|
|
164
|
+
const adbPath = path.join(tmpDir, 'adb');
|
|
165
|
+
await fs.writeFile(
|
|
166
|
+
adbPath,
|
|
167
|
+
[
|
|
168
|
+
'#!/bin/sh',
|
|
169
|
+
'if [ "$1" = "-s" ]; then',
|
|
170
|
+
' shift',
|
|
171
|
+
' shift',
|
|
172
|
+
'fi',
|
|
173
|
+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "query-activities" ]; then',
|
|
174
|
+
' echo "com.google.android.apps.maps/.MainActivity"',
|
|
175
|
+
' echo "org.mozilla.firefox/.App"',
|
|
176
|
+
' exit 0',
|
|
177
|
+
'fi',
|
|
178
|
+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ] && [ "$5" = "-3" ]; then',
|
|
179
|
+
' echo "package:com.google.android.apps.maps"',
|
|
180
|
+
' echo "package:com.example.serviceonly"',
|
|
181
|
+
' echo "package:org.mozilla.firefox"',
|
|
182
|
+
' exit 0',
|
|
183
|
+
'fi',
|
|
184
|
+
'echo "unexpected args: $@" >&2',
|
|
185
|
+
'exit 1',
|
|
186
|
+
'',
|
|
187
|
+
].join('\n'),
|
|
188
|
+
'utf8',
|
|
189
|
+
);
|
|
190
|
+
await fs.chmod(adbPath, 0o755);
|
|
191
|
+
|
|
192
|
+
const previousPath = process.env.PATH;
|
|
193
|
+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
194
|
+
|
|
195
|
+
const device: DeviceInfo = {
|
|
196
|
+
platform: 'android',
|
|
197
|
+
id: 'emulator-5554',
|
|
198
|
+
name: 'Pixel',
|
|
199
|
+
kind: 'emulator',
|
|
200
|
+
booted: true,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const apps = await listAndroidApps(device, 'user-installed');
|
|
205
|
+
assert.deepEqual(apps, [
|
|
206
|
+
{ package: 'com.google.android.apps.maps', name: 'Maps' },
|
|
207
|
+
{ package: 'org.mozilla.firefox', name: 'Firefox' },
|
|
208
|
+
]);
|
|
209
|
+
} finally {
|
|
210
|
+
process.env.PATH = previousPath;
|
|
211
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
98
215
|
test('openAndroidApp rejects activity override for deep link URLs', async () => {
|
|
99
216
|
const device: DeviceInfo = {
|
|
100
217
|
platform: 'android',
|
|
@@ -48,60 +48,87 @@ export async function resolveAndroidApp(
|
|
|
48
48
|
|
|
49
49
|
export async function listAndroidApps(
|
|
50
50
|
device: DeviceInfo,
|
|
51
|
-
filter: '
|
|
52
|
-
): Promise<string
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
51
|
+
filter: 'user-installed' | 'all' = 'all',
|
|
52
|
+
): Promise<Array<{ package: string; name: string }>> {
|
|
53
|
+
const launchable = await listAndroidLaunchablePackages(device);
|
|
54
|
+
const packageIds =
|
|
55
|
+
filter === 'user-installed'
|
|
56
|
+
? (await listAndroidUserInstalledPackages(device)).filter((pkg) => launchable.has(pkg))
|
|
57
|
+
: Array.from(launchable);
|
|
58
|
+
return packageIds
|
|
59
|
+
.sort((a, b) => a.localeCompare(b))
|
|
60
|
+
.map((pkg) => ({ package: pkg, name: inferAndroidAppName(pkg) }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function listAndroidLaunchablePackages(device: DeviceInfo): Promise<Set<string>> {
|
|
64
|
+
const result = await runCmd(
|
|
65
|
+
'adb',
|
|
66
|
+
adbArgs(device, [
|
|
67
|
+
'shell',
|
|
68
|
+
'cmd',
|
|
69
|
+
'package',
|
|
70
|
+
'query-activities',
|
|
71
|
+
'--brief',
|
|
72
|
+
'-a',
|
|
73
|
+
'android.intent.action.MAIN',
|
|
74
|
+
'-c',
|
|
75
|
+
'android.intent.category.LAUNCHER',
|
|
76
|
+
]),
|
|
77
|
+
{ allowFailure: true },
|
|
78
|
+
);
|
|
79
|
+
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
|
|
80
|
+
return new Set<string>();
|
|
81
|
+
}
|
|
82
|
+
const packages = new Set<string>();
|
|
83
|
+
for (const line of result.stdout.split('\n')) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed) continue;
|
|
86
|
+
const firstToken = trimmed.split(/\s+/)[0];
|
|
87
|
+
const pkg = firstToken.includes('/') ? firstToken.split('/')[0] : firstToken;
|
|
88
|
+
if (pkg) packages.add(pkg);
|
|
83
89
|
}
|
|
90
|
+
return packages;
|
|
91
|
+
}
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
? ['shell', 'pm', 'list', 'packages', '-3']
|
|
88
|
-
: ['shell', 'pm', 'list', 'packages'];
|
|
89
|
-
const result = await runCmd('adb', adbArgs(device, args));
|
|
93
|
+
async function listAndroidUserInstalledPackages(device: DeviceInfo): Promise<string[]> {
|
|
94
|
+
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages', '-3']));
|
|
90
95
|
return result.stdout
|
|
91
96
|
.split('\n')
|
|
92
97
|
.map((line: string) => line.replace('package:', '').trim())
|
|
93
98
|
.filter(Boolean);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
export
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
export function inferAndroidAppName(packageName: string): string {
|
|
102
|
+
const ignoredTokens = new Set([
|
|
103
|
+
'com',
|
|
104
|
+
'android',
|
|
105
|
+
'google',
|
|
106
|
+
'app',
|
|
107
|
+
'apps',
|
|
108
|
+
'service',
|
|
109
|
+
'services',
|
|
110
|
+
'mobile',
|
|
111
|
+
'client',
|
|
112
|
+
]);
|
|
113
|
+
const tokens = packageName
|
|
114
|
+
.split('.')
|
|
115
|
+
.flatMap((segment) => segment.split(/[_-]+/))
|
|
116
|
+
.map((token) => token.trim().toLowerCase())
|
|
117
|
+
.filter((token) => token.length > 0);
|
|
118
|
+
// Fallback to last token if every token is ignored (e.g. "com.android.app.services" → "Services").
|
|
119
|
+
let chosen = tokens[tokens.length - 1] ?? packageName;
|
|
120
|
+
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const token = tokens[index];
|
|
122
|
+
if (!ignoredTokens.has(token)) {
|
|
123
|
+
chosen = token;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return chosen
|
|
128
|
+
.split(/[^a-z0-9]+/i)
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
131
|
+
.join(' ');
|
|
105
132
|
}
|
|
106
133
|
|
|
107
134
|
export async function getAndroidAppState(
|
|
@@ -180,7 +207,7 @@ export async function openAndroidApp(
|
|
|
180
207
|
if (activity) {
|
|
181
208
|
throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent');
|
|
182
209
|
}
|
|
183
|
-
await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-a', resolved.value]));
|
|
210
|
+
await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-W', '-a', resolved.value]));
|
|
184
211
|
return;
|
|
185
212
|
}
|
|
186
213
|
if (activity) {
|
|
@@ -193,6 +220,7 @@ export async function openAndroidApp(
|
|
|
193
220
|
'shell',
|
|
194
221
|
'am',
|
|
195
222
|
'start',
|
|
223
|
+
'-W',
|
|
196
224
|
'-a',
|
|
197
225
|
'android.intent.action.MAIN',
|
|
198
226
|
'-c',
|
|
@@ -212,6 +240,7 @@ export async function openAndroidApp(
|
|
|
212
240
|
'shell',
|
|
213
241
|
'am',
|
|
214
242
|
'start',
|
|
243
|
+
'-W',
|
|
215
244
|
'-a',
|
|
216
245
|
'android.intent.action.MAIN',
|
|
217
246
|
'-c',
|
|
@@ -232,6 +261,7 @@ export async function openAndroidApp(
|
|
|
232
261
|
'shell',
|
|
233
262
|
'am',
|
|
234
263
|
'start',
|
|
264
|
+
'-W',
|
|
235
265
|
'-a',
|
|
236
266
|
'android.intent.action.MAIN',
|
|
237
267
|
'-c',
|