@mobileaidev/ai-app-bridge 0.2.0

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.
@@ -0,0 +1,4164 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execFile, spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const net = require('net');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const generatedArtifactRetention = 20;
11
+
12
+ const defaults = {
13
+ adb: process.env.ADB || 'adb',
14
+ adbTimeoutMs: Number(process.env.AI_APP_BRIDGE_ADB_TIMEOUT_MS || 15000),
15
+ serial: '',
16
+ port: 18080,
17
+ packageName: 'io.github.mobileaidev.aiappbridge.sample',
18
+ nativeActivity: '.debugbridge.DebugBridgeNativeTestActivity',
19
+ flutterActivity: '.MainActivity',
20
+ };
21
+
22
+ const helpText = `Usage: ai-app-bridge <command> [options]
23
+
24
+ Commands:
25
+ status Read bridge status and app/device metadata.
26
+ tree Read the Android View tree from the in-app bridge.
27
+ uia-tree Read UIAutomator XML for the current foreground window.
28
+ screenshot Capture an ADB screenshot.
29
+ logs Read generic in-app log records.
30
+ logcat Read Android logcat through ADB.
31
+ network Read generic in-app network records.
32
+ state Read generic in-app state records.
33
+ events Read generic in-app event records.
34
+
35
+ H5/WebView commands:
36
+ h5-dom Read native Android WebView DOM.
37
+ h5-eval Execute JavaScript in the current native WebView.
38
+ h5-click Click a native WebView element.
39
+ h5-input Set text in a native WebView input.
40
+ h5-wait Wait for native WebView text or selector.
41
+ h5-scroll Scroll native WebView content.
42
+ webview-pages List attachable Android WebView DevTools/CDP pages.
43
+ webview-network Capture WebView Network events through CDP.
44
+ webview-console Capture WebView console/log events through CDP.
45
+
46
+ Flutter commands:
47
+ flutter-tree Read the latest Flutter layout snapshot.
48
+ flutter-nodes Read Flutter operable nodes.
49
+ flutter-action Dispatch a raw Flutter action payload.
50
+ tap-flutter-text Tap a Flutter node by text.
51
+ input-flutter-text Set Flutter text through the Flutter action bridge.
52
+ scroll-flutter Scroll Flutter content.
53
+ flutter-h5-dom Read DOM through a Flutter H5 adapter.
54
+ flutter-h5-eval Execute JavaScript through a Flutter H5 adapter.
55
+ flutter-h5-click Click a Flutter H5 element.
56
+ flutter-h5-input Set text in a Flutter H5 input.
57
+ flutter-h5-wait Wait for Flutter H5 text or selector.
58
+ flutter-h5-scroll Scroll Flutter H5 content.
59
+
60
+ Device/action commands:
61
+ tap Tap device coordinates through ADB.
62
+ tap-text Tap a visible node by exact text or content description.
63
+ tap-uia-text Tap a UIAutomator node by text.
64
+ wait-text Wait until text appears in bridge or UIAutomator output.
65
+ input-text Set native Android text through the in-app bridge; ASCII can fall back to ADB.
66
+ swipe Swipe device coordinates through ADB.
67
+ keyevent Send an Android keyevent through ADB.
68
+ keyboard-state Read Android soft keyboard visibility from dumpsys.
69
+ hide-keyboard Hide the Android soft keyboard when it is visible.
70
+
71
+ App/permission commands:
72
+ install-apk Install an APK and assist device-side installer screens.
73
+ launch-native-test Launch the debug native Android bridge test Activity.
74
+ launch-flutter Launch the Flutter Activity.
75
+ permission-state Read Android runtime permission state.
76
+ permission-grant Grant an Android runtime permission.
77
+ permission-revoke Revoke an Android runtime permission.
78
+ permission-dialog Tap a visible Android permission dialog allow button.
79
+ appops-set Set an Android app-op mode.
80
+
81
+ Advanced commands:
82
+ forward Create the ADB port forward for the bridge.
83
+ remove-forward Remove the ADB port forward for the bridge.
84
+ smoke Run the native sample smoke test.
85
+ help Show this help.
86
+
87
+ Options:
88
+ --package-name <name> Target Android package; discovers its bridge port via run-as.
89
+ --port <port> Override bridge local/device port.
90
+ --serial <serial> Target a specific ADB device.
91
+ --adb <path> ADB executable path.
92
+ --adb-timeout-ms <ms> Timeout for ADB subprocesses.
93
+ --out-file <path> Screenshot output path.
94
+ --artifact-dir <path> Directory for generated screenshot/artifact defaults.
95
+ --apk-path <path> APK path used by install-apk.
96
+ --text <text> Text used by Unicode-safe bridge input commands.
97
+ --value <text> Text value used by h5-input or flutter-h5-input.
98
+ --selector <css> CSS selector used by H5 commands.
99
+ --target-text <text> Text used by tap-text or wait-text.
100
+ --tap-x <n> X coordinate used by tap or Flutter input.
101
+ --tap-y <n> Y coordinate used by tap or Flutter input.
102
+ --start-x <n> Swipe start X coordinate.
103
+ --start-y <n> Swipe start Y coordinate.
104
+ --end-x <n> Swipe end X coordinate.
105
+ --end-y <n> Swipe end Y coordinate.
106
+ --key-code <n> Android key code used by keyevent.
107
+ --require-text <text> Extra text that must be present for wait-text.
108
+ --absent-text <text> Text that must be absent for wait-text.
109
+ --require-activity <s> Activity substring that must match for wait-text.
110
+ --timeout-sec <n> Wait timeout for wait-text or H5 waits.
111
+ --interval-ms <ms> Polling interval for wait/dialog/keyboard helpers.
112
+ --hide-keyboard Hide the soft keyboard after input-text.
113
+ --no-auto-hide-keyboard Disable keyboard-risk guard before lower-screen taps.
114
+ --allow-downgrade Pass -d to adb install.
115
+ --install-timeout-ms <ms> Timeout for the adb install subprocess.
116
+ --installer-timeout-ms <ms> Timeout for installer UI confirmation handling.
117
+ --webview-port <port> Local port used for WebView DevTools forwarding.
118
+ --socket-name <name> Explicit webview_devtools_remote socket name.
119
+ --target-id <id> Explicit CDP target/page id.
120
+ --page-url-filter <s> Prefer a WebView page whose URL contains this string.
121
+ --duration-ms <ms> CDP capture duration. Defaults to 3000 ms.
122
+ --script <js> JavaScript expression to evaluate after CDP attach.
123
+ --include-response-body Include response bodies when CDP exposes them.
124
+ --keep-forward Keep WebView DevTools ADB forward after command exits.
125
+ --url-filter <s> Keep network records whose URL contains this string.
126
+ --method <s> Keep network records with this HTTP method.
127
+ --status-code <n> Keep network records with this HTTP status.
128
+ --no-bodies Omit requestBody/responseBody from network output.
129
+ --body-max-bytes <n> Truncate network request/response bodies.
130
+ --since-id <n> Incremental capture cursor for logs/network/state/events.
131
+ --since-ms <n> Incremental capture timestamp for logs/network/state/events.
132
+ --limit <n> Limit capture records.
133
+ --full Return full raw status output, including Flutter layout dumps.
134
+ --compact Return compact tree output for tree/uia-tree.
135
+ --text-filter <s> Keep compact tree nodes whose text/description contains this.
136
+ --resource-id-filter <s> Keep compact tree nodes whose resource id/name contains this.
137
+ --class-filter <s> Keep compact tree nodes whose class contains this.
138
+ --visible-only Keep only visible/in-viewport compact tree nodes.
139
+ --max-nodes <n> Maximum compact tree nodes to return.
140
+ --max-depth <n> Maximum compact tree depth to scan.
141
+ --payload <json> Raw Flutter action payload for flutter-action.
142
+ --initial-route <path> Initial route for launch-flutter.
143
+ --delta <n> Scroll delta for Flutter/H5 scroll helpers.
144
+ --delta-x <n> Horizontal scroll delta.
145
+ --delta-y <n> Vertical scroll delta.
146
+ --max-swipes <n> Maximum swipes for scroll-flutter by text.
147
+ --permission <name> Android permission for permission-* commands.
148
+ --op <name> App-op name for appops-set.
149
+ --mode <mode> App-op mode for appops-set.
150
+ --pid <pid|current> PID filter for logcat.
151
+ --app-pid Filter logcat by current app pid.
152
+ --tag <tag[,tag]> Logcat tag filter.
153
+ --level <level> Minimum logcat level.
154
+ --grep <text> Logcat substring filter.
155
+ --lines <n> Logcat tail line count.
156
+ --since <time> Passed to adb logcat -T.
157
+ --follow Follow live logcat for a bounded duration.
158
+ --clear Clear logcat before reading.
159
+ --force Force hide-keyboard dismiss attempts.
160
+ --exact Use exact text matching where supported.
161
+ --resource-id <id> Permission dialog resource id.
162
+ --button-text <text> Permission dialog button text.
163
+ --attempts <n> Permission dialog tap attempts.
164
+ --streaming Use streaming APK install.
165
+ --skip-flutter-launch Skip Flutter launch during smoke.
166
+ --help Show this help without touching ADB or the device.`;
167
+
168
+ if (require.main === module) {
169
+ main().catch((error) => {
170
+ console.error(error.stack || error.message || String(error));
171
+ process.exitCode = 1;
172
+ });
173
+ }
174
+
175
+ async function main() {
176
+ const { command, options } = parseArgs(process.argv.slice(2));
177
+ if (options.help || command === 'help') {
178
+ process.stdout.write(`${helpText}\n`);
179
+ return;
180
+ }
181
+
182
+ const ctx = {
183
+ adb: options.adb || defaults.adb,
184
+ adbTimeoutMs: Number(options.adbTimeoutMs || defaults.adbTimeoutMs),
185
+ serial: options.serial || defaults.serial,
186
+ port: Number(options.port || defaults.port),
187
+ explicitPort: options.port !== undefined,
188
+ packageName: options.packageName || defaults.packageName,
189
+ explicitPackageName: options.packageName !== undefined,
190
+ nativeActivity: options.nativeActivity || defaults.nativeActivity,
191
+ flutterActivity: options.flutterActivity || defaults.flutterActivity,
192
+ };
193
+
194
+ const result = await runCommand(command || options.command || 'status', options, ctx);
195
+ if (Buffer.isBuffer(result)) {
196
+ process.stdout.write(result);
197
+ return;
198
+ }
199
+ if (typeof result === 'string') {
200
+ process.stdout.write(result);
201
+ if (!result.endsWith('\n')) process.stdout.write('\n');
202
+ return;
203
+ }
204
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
205
+ }
206
+
207
+ async function runCommand(command, options, ctx) {
208
+ switch (command) {
209
+ case 'forward':
210
+ return ensureForward(ctx);
211
+ case 'remove-forward':
212
+ await adb(ctx, ['forward', '--remove', `tcp:${ctx.port}`]);
213
+ return { ok: true, removed: `tcp:${ctx.port}` };
214
+ case 'status':
215
+ return bridgeStatus(ctx, options);
216
+ case 'tree':
217
+ return bridgeTree(ctx, options);
218
+ case 'flutter-tree': {
219
+ const status = await bridgeGet(ctx, '/v1/status');
220
+ return status.flutter?.layout || null;
221
+ }
222
+ case 'flutter-nodes':
223
+ return flutterNodes(ctx);
224
+ case 'tap-flutter-text':
225
+ return flutterAction(ctx, { action: 'tapText', text: requiredString(options.targetText, 'targetText') });
226
+ case 'input-flutter-text': {
227
+ const result = await flutterAction(ctx, {
228
+ action: 'inputText',
229
+ text: requiredString(options.text, 'text'),
230
+ ...(options.tapX !== undefined && options.tapY !== undefined ? { x: Number(options.tapX), y: Number(options.tapY) } : {}),
231
+ });
232
+ if (booleanOption(options.hideKeyboard)) {
233
+ result.keyboard = await hideKeyboard(ctx, options);
234
+ }
235
+ return result;
236
+ }
237
+ case 'scroll-flutter':
238
+ return options.targetText
239
+ ? flutterAction(ctx, { action: 'scrollUntilText', text: options.targetText, maxSwipes: Number(options.maxSwipes || 12) })
240
+ : flutterAction(ctx, { action: 'scrollBy', delta: Number(options.delta || 420) });
241
+ case 'flutter-action':
242
+ return flutterAction(ctx, JSON.parse(requiredString(options.payload, 'payload')));
243
+ case 'logs':
244
+ return bridgeGet(ctx, withQuery('/v1/logs', captureQuery(options)));
245
+ case 'network':
246
+ return networkRecords(ctx, options);
247
+ case 'state':
248
+ return bridgeGet(ctx, withQuery('/v1/state', captureQuery(options)));
249
+ case 'events':
250
+ return bridgeGet(ctx, withQuery('/v1/events', captureQuery(options)));
251
+ case 'h5-dom':
252
+ return bridgeGet(ctx, '/v1/h5/dom');
253
+ case 'h5-eval':
254
+ return bridgePost(ctx, '/v1/h5/eval', { script: requiredString(options.script, 'script') });
255
+ case 'h5-click':
256
+ return h5Click(ctx, options);
257
+ case 'h5-input':
258
+ return h5Input(ctx, options);
259
+ case 'h5-wait':
260
+ return h5Wait(ctx, options);
261
+ case 'h5-scroll':
262
+ return h5Scroll(ctx, options);
263
+ case 'flutter-h5-dom':
264
+ return flutterH5Dom(ctx);
265
+ case 'flutter-h5-eval':
266
+ return flutterH5Eval(ctx, { script: requiredString(options.script, 'script') });
267
+ case 'flutter-h5-click':
268
+ return flutterH5Click(ctx, options);
269
+ case 'flutter-h5-input':
270
+ return flutterH5Input(ctx, options);
271
+ case 'flutter-h5-wait':
272
+ return flutterH5Wait(ctx, options);
273
+ case 'flutter-h5-scroll':
274
+ return flutterH5Scroll(ctx, options);
275
+ case 'uia-tree':
276
+ return uiaTreeCommand(ctx, options);
277
+ case 'screenshot':
278
+ return screenshot(ctx, screenshotOutputPath(options, 'ai_app_bridge_screenshot'), options);
279
+ case 'tap':
280
+ return tap(ctx, requiredNumber(options.tapX, 'tapX'), requiredNumber(options.tapY, 'tapY'));
281
+ case 'tap-text':
282
+ return tapText(ctx, requiredString(options.targetText, 'targetText'), options);
283
+ case 'wait-text':
284
+ return waitText(ctx, requiredString(options.targetText, 'targetText'), options);
285
+ case 'input-text':
286
+ return inputText(ctx, requiredString(options.text, 'text'), options);
287
+ case 'keyboard-state':
288
+ return keyboardState(ctx);
289
+ case 'hide-keyboard':
290
+ return hideKeyboard(ctx, options);
291
+ case 'install-apk':
292
+ return installApk(ctx, options);
293
+ case 'webview-pages':
294
+ return webviewPages(ctx, options);
295
+ case 'webview-network':
296
+ return webviewCdpCapture(ctx, { ...options, captureNetwork: true, captureConsole: true });
297
+ case 'webview-console':
298
+ return webviewCdpCapture(ctx, { ...options, captureNetwork: false, captureConsole: true });
299
+ case 'swipe':
300
+ return swipe(
301
+ ctx,
302
+ requiredNumber(options.startX, 'startX'),
303
+ requiredNumber(options.startY, 'startY'),
304
+ requiredNumber(options.endX, 'endX'),
305
+ requiredNumber(options.endY, 'endY'),
306
+ Number(options.durationMs || 300),
307
+ );
308
+ case 'keyevent':
309
+ return keyevent(ctx, Number(options.keyCode || 4));
310
+ case 'logcat':
311
+ return logcat(ctx, options);
312
+ case 'permission-state':
313
+ return permissionState(ctx, requiredString(options.permission, 'permission'));
314
+ case 'permission-grant':
315
+ return permissionGrant(ctx, requiredString(options.permission, 'permission'));
316
+ case 'permission-revoke':
317
+ return permissionRevoke(ctx, requiredString(options.permission, 'permission'));
318
+ case 'appops-set':
319
+ return appopsSet(ctx, requiredString(options.op, 'op'), requiredString(options.mode, 'mode'));
320
+ case 'tap-uia-text':
321
+ return tapUiaText(ctx, requiredString(options.targetText, 'targetText'), options);
322
+ case 'permission-dialog':
323
+ return permissionDialog(ctx, options);
324
+ case 'launch-native-test':
325
+ return launchNativeTest(ctx);
326
+ case 'launch-flutter':
327
+ return launchFlutter(ctx, options.initialRoute || '');
328
+ case 'smoke':
329
+ return smoke(ctx, options);
330
+ default:
331
+ throw new Error(`unknown command: ${command}`);
332
+ }
333
+ }
334
+
335
+ function parseArgs(argv) {
336
+ const options = {};
337
+ let command = '';
338
+ for (let index = 0; index < argv.length; index += 1) {
339
+ const arg = argv[index];
340
+ if (!arg.startsWith('--') && !command) {
341
+ command = arg;
342
+ continue;
343
+ }
344
+ if (!arg.startsWith('--')) {
345
+ continue;
346
+ }
347
+ const rawName = arg.slice(2);
348
+ const name = rawName.replace(/-([a-z])/g, (_, value) => value.toUpperCase());
349
+ const next = argv[index + 1];
350
+ if (next === undefined || next.startsWith('--')) {
351
+ options[name] = true;
352
+ continue;
353
+ }
354
+ options[name] = next;
355
+ index += 1;
356
+ }
357
+ return { command, options };
358
+ }
359
+
360
+ async function adb(ctx, args, { binary = false } = {}) {
361
+ const allArgs = adbArgs(ctx, args);
362
+ return new Promise((resolve, reject) => {
363
+ execFile(ctx.adb, allArgs, {
364
+ encoding: binary ? 'buffer' : 'utf8',
365
+ maxBuffer: 64 * 1024 * 1024,
366
+ timeout: ctx.adbTimeoutMs || defaults.adbTimeoutMs,
367
+ windowsHide: true,
368
+ }, (error, stdout, stderr) => {
369
+ if (error) {
370
+ if (error.killed || error.signal) {
371
+ error.message = `adb timed out after ${ctx.adbTimeoutMs || defaults.adbTimeoutMs}ms: ${ctx.adb} ${allArgs.join(' ')}`;
372
+ }
373
+ error.message = `${error.message}\n${stderr || ''}`;
374
+ reject(error);
375
+ return;
376
+ }
377
+ resolve({ stdout, stderr });
378
+ });
379
+ });
380
+ }
381
+
382
+ async function adbBinaryToFile(ctx, args, outFile) {
383
+ const allArgs = adbArgs(ctx, args);
384
+ await fs.promises.mkdir(path.dirname(path.resolve(outFile)), { recursive: true });
385
+ return new Promise((resolve, reject) => {
386
+ const child = spawn(ctx.adb, allArgs, { windowsHide: true });
387
+ const output = fs.createWriteStream(outFile);
388
+ let stderr = '';
389
+ const timeout = setTimeout(() => {
390
+ child.kill();
391
+ }, ctx.adbTimeoutMs || defaults.adbTimeoutMs);
392
+ child.stdout.pipe(output);
393
+ child.stderr.on('data', (chunk) => {
394
+ stderr += chunk.toString();
395
+ });
396
+ child.on('error', reject);
397
+ child.on('close', (code) => {
398
+ clearTimeout(timeout);
399
+ output.close(() => {
400
+ if (code !== 0) {
401
+ reject(new Error(`adb failed with exit code ${code}: ${stderr}`));
402
+ return;
403
+ }
404
+ resolve(path.resolve(outFile));
405
+ });
406
+ });
407
+ });
408
+ }
409
+
410
+ function adbArgs(ctx, args) {
411
+ const allArgs = [];
412
+ if (ctx.serial) allArgs.push('-s', ctx.serial);
413
+ allArgs.push(...args);
414
+ return allArgs;
415
+ }
416
+
417
+ async function installApk(ctx, options) {
418
+ const apkPath = path.resolve(requiredString(options.apkPath || options.apk, 'apkPath'));
419
+ if (!fs.existsSync(apkPath)) {
420
+ throw new Error(`apkPath does not exist: ${apkPath}`);
421
+ }
422
+
423
+ const packageBefore = await safePackageInstallState(ctx);
424
+ const installMode = packageBefore.known
425
+ ? (packageBefore.installed ? 'reinstall' : 'new_install')
426
+ : 'unknown_without_package_name';
427
+ const installArgs = ['install'];
428
+ if (!booleanOption(options.streaming)) {
429
+ installArgs.push('--no-streaming');
430
+ }
431
+ installArgs.push('-r');
432
+ if (booleanOption(options.allowDowngrade)) {
433
+ installArgs.push('-d');
434
+ }
435
+ installArgs.push(apkPath);
436
+
437
+ const installTimeoutMs = Number(options.installTimeoutMs || 180000);
438
+ const installerTimeoutMs = Number(options.installerTimeoutMs || 90000);
439
+ const intervalMs = Number(options.intervalMs || 700);
440
+ const child = spawn(ctx.adb, adbArgs(ctx, installArgs), { windowsHide: true });
441
+ let stdout = '';
442
+ let stderr = '';
443
+ let processDone = false;
444
+ let processResult = null;
445
+
446
+ const processPromise = new Promise((resolve) => {
447
+ child.stdout.on('data', (chunk) => {
448
+ stdout += chunk.toString();
449
+ });
450
+ child.stderr.on('data', (chunk) => {
451
+ stderr += chunk.toString();
452
+ });
453
+ child.on('error', (error) => {
454
+ processDone = true;
455
+ processResult = { code: null, error: firstErrorLine(error) };
456
+ resolve(processResult);
457
+ });
458
+ child.on('close', (code, signal) => {
459
+ processDone = true;
460
+ processResult = { code, signal, error: null };
461
+ resolve(processResult);
462
+ });
463
+ });
464
+
465
+ const installerActions = [];
466
+ const installDeadline = Date.now() + installTimeoutMs;
467
+ while (!processDone && Date.now() < installDeadline) {
468
+ const action = await installerAssistOnce(ctx, { phase: 'install-pending' });
469
+ if (action.action === 'tap') {
470
+ installerActions.push(action);
471
+ }
472
+ await sleep(intervalMs);
473
+ }
474
+
475
+ let timedOut = false;
476
+ if (!processDone) {
477
+ timedOut = true;
478
+ child.kill();
479
+ }
480
+
481
+ processResult = processResult || (await processPromise);
482
+ const postInstallActions = await assistInstallerScreens(ctx, {
483
+ phase: 'post-install',
484
+ timeoutMs: installerTimeoutMs,
485
+ intervalMs,
486
+ });
487
+ const packageAfter = await safePackageInstallState(ctx);
488
+ const output = `${stdout}\n${stderr}`.trim();
489
+ const adbSuccess = processResult.code === 0 && /Success/i.test(output);
490
+ const packageVerified = !packageAfter.known || packageAfter.installed;
491
+
492
+ return {
493
+ ok: !timedOut && adbSuccess && packageVerified,
494
+ action: 'install-apk',
495
+ transport: 'adb',
496
+ apkPath,
497
+ packageName: ctx.explicitPackageName ? ctx.packageName : null,
498
+ installMode,
499
+ installedBefore: packageBefore,
500
+ installedAfter: packageAfter,
501
+ process: {
502
+ ...processResult,
503
+ timedOut,
504
+ stdout: stdout.trim(),
505
+ stderr: stderr.trim(),
506
+ },
507
+ installerActions,
508
+ postInstallActions,
509
+ error: timedOut ? 'install_timeout' : (adbSuccess ? null : 'adb_install_failed'),
510
+ };
511
+ }
512
+
513
+ async function safePackageInstallState(ctx) {
514
+ if (!ctx.explicitPackageName) {
515
+ return { known: false, reason: 'package_name_not_provided' };
516
+ }
517
+ try {
518
+ const result = await adb(ctx, ['shell', 'pm', 'path', ctx.packageName]);
519
+ const paths = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
520
+ return {
521
+ known: true,
522
+ installed: paths.some((line) => line.startsWith('package:')),
523
+ paths,
524
+ };
525
+ } catch (error) {
526
+ return {
527
+ known: true,
528
+ installed: false,
529
+ error: firstErrorLine(error),
530
+ };
531
+ }
532
+ }
533
+
534
+ async function assistInstallerScreens(ctx, options) {
535
+ const timeoutMs = Number(options.timeoutMs || 90000);
536
+ const intervalMs = Number(options.intervalMs || 700);
537
+ const deadline = Date.now() + timeoutMs;
538
+ const actions = [];
539
+ let quietPolls = 0;
540
+ while (Date.now() < deadline) {
541
+ const action = await installerAssistOnce(ctx, { phase: options.phase || 'installer' });
542
+ if (action.action === 'tap') {
543
+ actions.push(action);
544
+ quietPolls = 0;
545
+ await sleep(intervalMs);
546
+ continue;
547
+ }
548
+ if (action.reason === 'not_installer_surface' || action.reason === 'installer_finish_surface_no_action') {
549
+ quietPolls += 1;
550
+ if (quietPolls >= 2) break;
551
+ }
552
+ await sleep(intervalMs);
553
+ }
554
+ return actions;
555
+ }
556
+
557
+ async function installerAssistOnce(ctx, options = {}) {
558
+ let foreground;
559
+ let xml;
560
+ try {
561
+ foreground = await foregroundWindow(ctx);
562
+ xml = await uiaTree(ctx);
563
+ } catch (error) {
564
+ return {
565
+ ok: false,
566
+ action: 'none',
567
+ phase: options.phase || 'installer',
568
+ reason: 'probe_failed',
569
+ message: firstErrorLine(error),
570
+ };
571
+ }
572
+
573
+ const surface = classifyInstallerSurface(foreground, xml);
574
+ if (!surface.installer) {
575
+ return {
576
+ ok: true,
577
+ action: 'none',
578
+ phase: options.phase || 'installer',
579
+ reason: 'not_installer_surface',
580
+ foreground,
581
+ surface,
582
+ };
583
+ }
584
+
585
+ if (ctx.explicitPackageName && !surface.finish) {
586
+ const packageState = await safePackageInstallState(ctx);
587
+ if (shouldSkipInstallerTapForInstalledPackage({
588
+ phase: options.phase || 'installer',
589
+ packageState,
590
+ })) {
591
+ return {
592
+ ok: true,
593
+ action: 'none',
594
+ phase: options.phase || 'installer',
595
+ reason: 'target_package_already_installed',
596
+ foreground,
597
+ surface,
598
+ packageState,
599
+ };
600
+ }
601
+ }
602
+
603
+ const node = findUiaNodeByAny(xml, {
604
+ texts: installerButtonTextsForSurface(surface),
605
+ exact: true,
606
+ requireClickable: true,
607
+ });
608
+ if (!node) {
609
+ return {
610
+ ok: surface.finish,
611
+ action: 'none',
612
+ phase: options.phase || 'installer',
613
+ reason: surface.finish ? 'installer_finish_surface_no_action' : 'installer_button_not_found',
614
+ foreground,
615
+ surface,
616
+ };
617
+ }
618
+
619
+ const x = Math.round((node.left + node.right) / 2);
620
+ const y = Math.round((node.top + node.bottom) / 2);
621
+ await tap(ctx, x, y);
622
+ return {
623
+ ok: true,
624
+ action: 'tap',
625
+ phase: options.phase || 'installer',
626
+ source: 'uiautomator',
627
+ x,
628
+ y,
629
+ foreground,
630
+ surface,
631
+ matched: node.matched,
632
+ };
633
+ }
634
+
635
+ function isLikelyInstallerSurface(foreground, xml) {
636
+ return classifyInstallerSurface(foreground, xml).installer;
637
+ }
638
+
639
+ function classifyInstallerSurface(foreground, xml) {
640
+ const text = String(xml || '');
641
+ const foregroundPackage = String(foreground?.packageName || '');
642
+ const foregroundActivity = String(foreground?.activity || '');
643
+ const marketPackages = [
644
+ 'com.heytap.market',
645
+ 'com.oppo.market',
646
+ 'com.android.vending',
647
+ ];
648
+ const knownInstallerPackages = [
649
+ 'com.android.packageinstaller',
650
+ 'com.google.android.packageinstaller',
651
+ 'com.miui.packageinstaller',
652
+ 'com.samsung.android.packageinstaller',
653
+ 'com.oplus.appdetail',
654
+ 'com.coloros.securitypermission',
655
+ 'packageinstaller',
656
+ ];
657
+ const hasInstallerPackage = knownInstallerPackages.some((value) => {
658
+ return foregroundPackage.includes(value) || text.includes(`package="${value}`);
659
+ });
660
+ const hasMarketPackage = marketPackages.some((value) => foregroundPackage.includes(value) || text.includes(`package="${value}`));
661
+ const finish = /finish/i.test(foregroundActivity) ||
662
+ text.includes('安装完成') ||
663
+ text.includes('已安装') ||
664
+ text.includes('Install complete') ||
665
+ text.includes('App installed');
666
+ if (hasInstallerPackage) {
667
+ return {
668
+ installer: true,
669
+ finish,
670
+ market: false,
671
+ source: 'installer_package',
672
+ foregroundPackage,
673
+ foregroundActivity,
674
+ };
675
+ }
676
+ if (hasMarketPackage) {
677
+ return {
678
+ installer: false,
679
+ finish: false,
680
+ market: true,
681
+ source: 'market_package',
682
+ foregroundPackage,
683
+ foregroundActivity,
684
+ };
685
+ }
686
+ const hasRiskKeyword = [
687
+ '检测结果',
688
+ '未知来源',
689
+ '未知应用',
690
+ '敏感权限',
691
+ '安全扫描',
692
+ 'Install unknown apps',
693
+ 'Package installer',
694
+ ].some((value) => text.includes(value));
695
+ return {
696
+ installer: hasRiskKeyword,
697
+ finish,
698
+ market: false,
699
+ source: hasRiskKeyword ? 'risk_keyword' : 'not_installer',
700
+ foregroundPackage,
701
+ foregroundActivity,
702
+ };
703
+ }
704
+
705
+ function installerButtonTextsForSurface(surface) {
706
+ if (surface?.finish) {
707
+ return [
708
+ '完成',
709
+ '确定',
710
+ 'Done',
711
+ 'OK',
712
+ ];
713
+ }
714
+ const base = [
715
+ '继续安装',
716
+ '仍然安装',
717
+ '允许',
718
+ '确定',
719
+ '继续',
720
+ '下一步',
721
+ 'Continue install',
722
+ 'Install anyway',
723
+ 'Allow',
724
+ 'OK',
725
+ 'Continue',
726
+ 'Next',
727
+ ];
728
+ if (!surface?.market) {
729
+ base.push('安装', 'Install');
730
+ }
731
+ return base;
732
+ }
733
+
734
+ function defaultInstallerButtonTexts() {
735
+ return installerButtonTextsForSurface({ finish: false, market: false });
736
+ }
737
+
738
+ function shouldSkipInstallerTapForInstalledPackage({ phase, packageState } = {}) {
739
+ if (!packageState?.installed) {
740
+ return false;
741
+ }
742
+ return phase !== 'install-pending';
743
+ }
744
+
745
+ async function ensureForward(ctx) {
746
+ const resolvedPort = await resolveDevicePort(ctx);
747
+ const devicePort = resolvedPort.port;
748
+ const hostPort = ctx.explicitPort ? ctx.port : devicePort;
749
+ ctx.devicePort = devicePort;
750
+ ctx.hostPort = hostPort;
751
+ ctx.devicePortSource = resolvedPort.source;
752
+ ctx.devicePortState = resolvedPort.state;
753
+ ctx.devicePortError = resolvedPort.error;
754
+ await adb(ctx, ['forward', `tcp:${hostPort}`, `tcp:${devicePort}`]);
755
+ return {
756
+ ok: true,
757
+ forward: `tcp:${hostPort} -> device tcp:${devicePort}`,
758
+ hostPort,
759
+ devicePort,
760
+ devicePortSource: resolvedPort.source,
761
+ };
762
+ }
763
+
764
+ async function resolveDevicePort(ctx) {
765
+ if (ctx.explicitPort) return { port: ctx.port, source: 'explicit-port' };
766
+ try {
767
+ const result = await adb(ctx, ['shell', 'run-as', ctx.packageName, 'cat', 'files/ai_app_bridge_port.json']);
768
+ const state = JSON.parse(result.stdout.trim());
769
+ const discoveredPort = Number(state.port);
770
+ if (state.ok === true && Number.isInteger(discoveredPort) && discoveredPort > 0) {
771
+ return { port: discoveredPort, source: 'package-port-file', state };
772
+ }
773
+ } catch (error) {
774
+ // Fall back to the historical default port for older bridge versions.
775
+ return {
776
+ port: ctx.port,
777
+ source: 'default-port',
778
+ error: firstErrorLine(error),
779
+ };
780
+ }
781
+ return { port: ctx.port, source: 'default-port' };
782
+ }
783
+
784
+ async function bridgeStatus(ctx, options = {}) {
785
+ try {
786
+ const status = await bridgeGet(ctx, '/v1/status');
787
+ return booleanOption(options.full) ? status : compactStatus(status);
788
+ } catch (error) {
789
+ return buildBridgeFailureResult(ctx, 'status', '/v1/status', error);
790
+ }
791
+ }
792
+
793
+ function compactStatus(status) {
794
+ if (!status || typeof status !== 'object') return status;
795
+ const next = { ...status };
796
+ if (status.flutter && typeof status.flutter === 'object') {
797
+ next.flutter = compactFlutterStatus(status.flutter);
798
+ }
799
+ return next;
800
+ }
801
+
802
+ function compactFlutterStatus(flutterStatus) {
803
+ const next = { ...flutterStatus };
804
+ if (flutterStatus.layout && typeof flutterStatus.layout === 'object') {
805
+ next.layout = compactFlutterLayout(flutterStatus.layout);
806
+ }
807
+ return next;
808
+ }
809
+
810
+ function compactFlutterLayout(layout) {
811
+ return {
812
+ updatedAtMs: layout.updatedAtMs,
813
+ widgetInspector: layout.widgetInspector ? compactDiagnosticTreeNode(layout.widgetInspector) : undefined,
814
+ widgetDump: compactWidgetDump(layout.widgetDump),
815
+ semantics: layout.semantics ? compactSemantics(layout.semantics) : undefined,
816
+ operable: layout.operable ? compactFlutterOperable(layout.operable) : undefined,
817
+ };
818
+ }
819
+
820
+ function compactDiagnosticTreeNode(node) {
821
+ if (!node || typeof node !== 'object') return node;
822
+ return {
823
+ description: shortStatusString(node.description),
824
+ type: shortStatusString(node.type),
825
+ hasChildren: Boolean(node.hasChildren),
826
+ childCount: Array.isArray(node.children) ? node.children.length : undefined,
827
+ createdByLocalProject: Boolean(node.createdByLocalProject),
828
+ };
829
+ }
830
+
831
+ function compactWidgetDump(widgetDump) {
832
+ if (!widgetDump || typeof widgetDump !== 'object') return widgetDump;
833
+ return {
834
+ ok: widgetDump.ok,
835
+ error: widgetDump.error,
836
+ truncated: widgetDump.truncated,
837
+ length: widgetDump.length,
838
+ };
839
+ }
840
+
841
+ function compactSemantics(semantics) {
842
+ if (!semantics || typeof semantics !== 'object') return semantics;
843
+ return {
844
+ ok: semantics.ok,
845
+ error: semantics.error,
846
+ semanticsEnabled: semantics.semanticsEnabled,
847
+ nodeCount: semantics.nodeCount,
848
+ };
849
+ }
850
+
851
+ function compactFlutterOperable(operable) {
852
+ if (!operable || typeof operable !== 'object') return operable;
853
+ return {
854
+ ok: operable.ok,
855
+ error: operable.error,
856
+ count: operable.count,
857
+ visitedCount: operable.visitedCount,
858
+ textCount: operable.textCount,
859
+ actionCount: operable.actionCount,
860
+ sampleWidgetTypes: Array.isArray(operable.sampleWidgetTypes)
861
+ ? operable.sampleWidgetTypes.slice(0, 20)
862
+ : operable.sampleWidgetTypes,
863
+ nodes: Array.isArray(operable.nodes)
864
+ ? operable.nodes.slice(0, 12).map(compactFlutterOperableNode)
865
+ : operable.nodes,
866
+ truncated: operable.truncated,
867
+ viewport: operable.viewport,
868
+ updatedAtMs: operable.updatedAtMs,
869
+ };
870
+ }
871
+
872
+ function compactFlutterOperableNode(node) {
873
+ if (!node || typeof node !== 'object') return node;
874
+ return {
875
+ id: node.id,
876
+ widgetType: shortStatusString(node.widgetType),
877
+ text: shortStatusString(node.text),
878
+ bounds: node.bounds,
879
+ actions: node.actions,
880
+ depth: node.depth,
881
+ };
882
+ }
883
+
884
+ function shortStatusString(value, maxLength = 160) {
885
+ if (value === undefined || value === null) return value;
886
+ const text = String(value);
887
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...[truncated]` : text;
888
+ }
889
+
890
+ function buildBridgeFailureResult(ctx, command, requestPath, error) {
891
+ const normalized = normalizeBridgeError(error);
892
+ return {
893
+ ok: false,
894
+ command,
895
+ requestPath,
896
+ error: normalized.code,
897
+ message: normalized.message,
898
+ packageName: ctx.packageName,
899
+ attempted: {
900
+ host: '127.0.0.1',
901
+ localPort: ctx.hostPort || ctx.port,
902
+ devicePort: ctx.devicePort || ctx.port,
903
+ devicePortSource: ctx.devicePortSource || (ctx.explicitPort ? 'explicit-port' : 'unknown'),
904
+ portState: ctx.devicePortState || null,
905
+ portDiscoveryError: ctx.devicePortError || null,
906
+ url: bridgeUrl(ctx, requestPath),
907
+ },
908
+ suggestion: normalized.suggestion,
909
+ };
910
+ }
911
+
912
+ function normalizeBridgeError(error) {
913
+ const message = firstErrorLine(error);
914
+ const code = error?.code || '';
915
+ const lower = message.toLowerCase();
916
+ if (
917
+ code === 'ECONNRESET' ||
918
+ lower.includes('socket hang up') ||
919
+ lower.includes('connection reset') ||
920
+ lower.includes('http timeout')
921
+ ) {
922
+ return {
923
+ code: 'bridge_not_ready',
924
+ message,
925
+ suggestion: 'Launch the target app, wait for the debug bridge to start, then retry status.',
926
+ };
927
+ }
928
+ if (code === 'ECONNREFUSED' || lower.includes('econnrefused') || lower.includes('connection refused')) {
929
+ return {
930
+ code: 'bridge_connection_refused',
931
+ message,
932
+ suggestion: 'Confirm the target app is running and that the resolved bridge port belongs to this package.',
933
+ };
934
+ }
935
+ if (lower.includes('adb timed out')) {
936
+ return {
937
+ code: 'adb_timeout',
938
+ message,
939
+ suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
940
+ };
941
+ }
942
+ if (lower.includes('run-as') || lower.includes('package not found')) {
943
+ return {
944
+ code: 'bridge_port_discovery_failed',
945
+ message,
946
+ suggestion: 'Install a debuggable build for the requested package or pass --port explicitly.',
947
+ };
948
+ }
949
+ if (lower.includes('adb forward')) {
950
+ return {
951
+ code: 'bridge_forward_failed',
952
+ message,
953
+ suggestion: 'Remove stale adb forwards or pass a different --port for this target package.',
954
+ };
955
+ }
956
+ return {
957
+ code: 'bridge_request_failed',
958
+ message,
959
+ suggestion: 'Check that the device is connected, the target package is installed, and the app is foreground or recently launched.',
960
+ };
961
+ }
962
+
963
+ function firstErrorLine(error) {
964
+ return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
965
+ }
966
+
967
+ async function bridgeGet(ctx, requestPath) {
968
+ await ensureForward(ctx);
969
+ const body = await httpGet(bridgeUrl(ctx, requestPath));
970
+ return JSON.parse(body);
971
+ }
972
+
973
+ async function bridgePost(ctx, requestPath, payload) {
974
+ await ensureForward(ctx);
975
+ const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
976
+ return JSON.parse(body);
977
+ }
978
+
979
+ function bridgeUrl(ctx, requestPath) {
980
+ return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
981
+ }
982
+
983
+ function httpGet(url) {
984
+ return new Promise((resolve, reject) => {
985
+ const request = http.get(url, { timeout: 10000 }, (response) => {
986
+ let data = '';
987
+ response.setEncoding('utf8');
988
+ response.on('data', (chunk) => {
989
+ data += chunk;
990
+ });
991
+ response.on('end', () => {
992
+ if (response.statusCode < 200 || response.statusCode >= 300) {
993
+ reject(new Error(`HTTP ${response.statusCode}: ${data}`));
994
+ return;
995
+ }
996
+ resolve(data);
997
+ });
998
+ });
999
+ request.on('timeout', () => {
1000
+ request.destroy(new Error(`HTTP timeout: ${url}`));
1001
+ });
1002
+ request.on('error', (error) => {
1003
+ error.url = url;
1004
+ reject(error);
1005
+ });
1006
+ });
1007
+ }
1008
+
1009
+ function httpPost(url, payload) {
1010
+ const body = JSON.stringify(payload || {});
1011
+ return new Promise((resolve, reject) => {
1012
+ const request = http.request(url, {
1013
+ method: 'POST',
1014
+ timeout: 20000,
1015
+ headers: {
1016
+ 'Content-Type': 'application/json; charset=utf-8',
1017
+ 'Content-Length': Buffer.byteLength(body),
1018
+ },
1019
+ }, (response) => {
1020
+ let data = '';
1021
+ response.setEncoding('utf8');
1022
+ response.on('data', (chunk) => {
1023
+ data += chunk;
1024
+ });
1025
+ response.on('end', () => {
1026
+ if (response.statusCode < 200 || response.statusCode >= 300) {
1027
+ reject(new Error(`HTTP ${response.statusCode}: ${data}`));
1028
+ return;
1029
+ }
1030
+ resolve(data);
1031
+ });
1032
+ });
1033
+ request.on('timeout', () => {
1034
+ request.destroy(new Error(`HTTP timeout: ${url}`));
1035
+ });
1036
+ request.on('error', (error) => {
1037
+ error.url = url;
1038
+ reject(error);
1039
+ });
1040
+ request.write(body);
1041
+ request.end();
1042
+ });
1043
+ }
1044
+
1045
+ function captureQuery(options) {
1046
+ return {
1047
+ sinceId: options.sinceId,
1048
+ sinceMs: options.sinceMs,
1049
+ limit: options.limit,
1050
+ };
1051
+ }
1052
+
1053
+ async function networkRecords(ctx, options = {}) {
1054
+ const capture = await bridgeGet(ctx, withQuery('/v1/network', captureQuery(options)));
1055
+ return shapeNetworkCapture(capture, options);
1056
+ }
1057
+
1058
+ function shapeNetworkCapture(capture, options = {}) {
1059
+ const sourceItems = Array.isArray(capture?.items) ? capture.items : [];
1060
+ const filteredItems = sourceItems.filter((item) => networkRecordMatches(item, options));
1061
+ const compact = booleanOption(options.compact);
1062
+ const shapedItems = filteredItems.map((item) => (
1063
+ compact ? compactNetworkRecord(item) : shapeNetworkRecord(item, options)
1064
+ ));
1065
+ const result = {
1066
+ ...capture,
1067
+ count: shapedItems.length,
1068
+ items: shapedItems,
1069
+ };
1070
+ if (filteredItems.length !== sourceItems.length) {
1071
+ result.sourceCount = sourceItems.length;
1072
+ }
1073
+ const filters = networkResultOptions(options);
1074
+ if (Object.keys(filters).length > 0) {
1075
+ result.options = filters;
1076
+ }
1077
+ return result;
1078
+ }
1079
+
1080
+ function networkRecordMatches(record, options = {}) {
1081
+ const urlFilter = options.urlFilter ? String(options.urlFilter) : '';
1082
+ if (urlFilter && !networkRecordUrl(record).includes(urlFilter)) {
1083
+ return false;
1084
+ }
1085
+
1086
+ const method = options.method ? String(options.method).toUpperCase() : '';
1087
+ if (method && networkRecordMethod(record).toUpperCase() !== method) {
1088
+ return false;
1089
+ }
1090
+
1091
+ if (options.statusCode !== undefined && options.statusCode !== null && options.statusCode !== '') {
1092
+ const expectedStatus = Number(options.statusCode);
1093
+ if (!Number.isFinite(expectedStatus) || Number(networkRecordStatus(record)) !== expectedStatus) {
1094
+ return false;
1095
+ }
1096
+ }
1097
+
1098
+ return true;
1099
+ }
1100
+
1101
+ function shapeNetworkRecord(record, options = {}) {
1102
+ const shaped = { ...record };
1103
+ const noBodies = booleanOption(options.noBodies);
1104
+ for (const key of ['requestBody', 'responseBody']) {
1105
+ if (!Object.prototype.hasOwnProperty.call(shaped, key)) {
1106
+ continue;
1107
+ }
1108
+ if (noBodies) {
1109
+ shaped[`${key}Omitted`] = true;
1110
+ delete shaped[key];
1111
+ continue;
1112
+ }
1113
+ const bodyMaxBytes = positiveNumber(options.bodyMaxBytes);
1114
+ if (bodyMaxBytes !== null) {
1115
+ shaped[key] = truncateString(shaped[key], bodyMaxBytes);
1116
+ }
1117
+ }
1118
+ return shaped;
1119
+ }
1120
+
1121
+ function compactNetworkRecord(record) {
1122
+ const result = {
1123
+ id: record?.id,
1124
+ timestampMs: record?.timestampMs,
1125
+ source: record?.source,
1126
+ method: networkRecordMethod(record),
1127
+ url: networkRecordUrl(record),
1128
+ statusCode: networkRecordStatus(record),
1129
+ durationMs: record?.durationMs,
1130
+ error: record?.error || undefined,
1131
+ redacted: record?.redacted,
1132
+ };
1133
+ const contentType = networkRecordContentType(record);
1134
+ if (contentType) {
1135
+ result.contentType = contentType;
1136
+ }
1137
+ if (Object.prototype.hasOwnProperty.call(record || {}, 'requestBody')) {
1138
+ result.requestBodyBytes = bodyByteLength(record.requestBody);
1139
+ }
1140
+ if (Object.prototype.hasOwnProperty.call(record || {}, 'responseBody')) {
1141
+ result.responseBodyBytes = bodyByteLength(record.responseBody);
1142
+ }
1143
+ return pruneUndefined(result);
1144
+ }
1145
+
1146
+ function networkResultOptions(options = {}) {
1147
+ const result = {};
1148
+ if (booleanOption(options.compact)) result.compact = true;
1149
+ if (options.urlFilter) result.urlFilter = String(options.urlFilter);
1150
+ if (options.method) result.method = String(options.method).toUpperCase();
1151
+ if (options.statusCode !== undefined && options.statusCode !== null && options.statusCode !== '') {
1152
+ result.statusCode = Number(options.statusCode);
1153
+ }
1154
+ if (booleanOption(options.noBodies)) result.noBodies = true;
1155
+ if (positiveNumber(options.bodyMaxBytes) !== null) result.bodyMaxBytes = positiveNumber(options.bodyMaxBytes);
1156
+ return result;
1157
+ }
1158
+
1159
+ function networkRecordUrl(record) {
1160
+ return String(record?.url || record?.requestUrl || record?.responseUrl || record?.response?.url || '');
1161
+ }
1162
+
1163
+ function networkRecordMethod(record) {
1164
+ return String(record?.method || record?.requestMethod || record?.request?.method || '');
1165
+ }
1166
+
1167
+ function networkRecordStatus(record) {
1168
+ return record?.statusCode ?? record?.responseStatusCode ?? record?.status ?? record?.response?.statusCode;
1169
+ }
1170
+
1171
+ function networkRecordContentType(record) {
1172
+ return headerValue(record?.responseHeaders, 'content-type') ||
1173
+ headerValue(record?.requestHeaders, 'content-type') ||
1174
+ '';
1175
+ }
1176
+
1177
+ function headerValue(headers, key) {
1178
+ if (!headers || typeof headers !== 'object') {
1179
+ return '';
1180
+ }
1181
+ const expected = key.toLowerCase();
1182
+ for (const [name, value] of Object.entries(headers)) {
1183
+ if (String(name).toLowerCase() === expected) {
1184
+ return String(value);
1185
+ }
1186
+ }
1187
+ return '';
1188
+ }
1189
+
1190
+ function bodyByteLength(value) {
1191
+ if (value === undefined || value === null) {
1192
+ return 0;
1193
+ }
1194
+ return Buffer.byteLength(String(value), 'utf8');
1195
+ }
1196
+
1197
+ function positiveNumber(value) {
1198
+ const number = Number(value);
1199
+ return Number.isFinite(number) && number >= 0 ? number : null;
1200
+ }
1201
+
1202
+ function pruneUndefined(value) {
1203
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1204
+ }
1205
+
1206
+ function withQuery(requestPath, query) {
1207
+ const params = new URLSearchParams();
1208
+ for (const [key, value] of Object.entries(query || {})) {
1209
+ if (value === undefined || value === null || value === '' || value === false) continue;
1210
+ params.set(key, String(value));
1211
+ }
1212
+ const queryString = params.toString();
1213
+ return queryString ? `${requestPath}?${queryString}` : requestPath;
1214
+ }
1215
+
1216
+ async function webviewPages(ctx, options) {
1217
+ const setup = await setupWebViewDevTools(ctx, options, { selectPage: false });
1218
+ const keepForward = booleanOption(options.keepForward);
1219
+ if (!keepForward) {
1220
+ await setup.cleanup();
1221
+ }
1222
+ return {
1223
+ ok: true,
1224
+ transport: 'webview-devtools-cdp',
1225
+ packageName: ctx.packageName,
1226
+ sockets: setup.sockets,
1227
+ selectedSocket: setup.socket,
1228
+ packagePids: setup.packagePids,
1229
+ forward: {
1230
+ localPort: setup.localPort,
1231
+ socketName: setup.socket.name,
1232
+ active: keepForward,
1233
+ },
1234
+ pages: setup.pages,
1235
+ pageCount: setup.pages.length,
1236
+ selectionWarning: setup.selectionWarning || null,
1237
+ };
1238
+ }
1239
+
1240
+ async function webviewCdpCapture(ctx, options) {
1241
+ const setup = await setupWebViewDevTools(ctx, options, { selectPage: true });
1242
+ const durationMs = Math.max(0, Number(options.durationMs || 3000));
1243
+ const captureNetwork = options.captureNetwork !== false;
1244
+ const captureConsole = options.captureConsole !== false;
1245
+ const includeResponseBody = booleanOption(options.includeResponseBody);
1246
+ const maxEvents = Number(options.maxEvents || 200);
1247
+ const bodyMaxBytes = Number(options.bodyMaxBytes || 64 * 1024);
1248
+ const urlFilter = options.urlFilter || '';
1249
+ const events = [];
1250
+ const requests = new Map();
1251
+ const consoleEvents = [];
1252
+ let scriptResult = null;
1253
+ let cdp = null;
1254
+
1255
+ const handleEvent = (event) => {
1256
+ if (events.length < maxEvents) {
1257
+ events.push({
1258
+ method: event.method,
1259
+ params: summarizeCdpParams(event.method, event.params),
1260
+ });
1261
+ }
1262
+ if (captureNetwork && event.method === 'Network.requestWillBeSent') {
1263
+ const request = event.params?.request || {};
1264
+ if (urlFilter && !String(request.url || '').includes(urlFilter)) return;
1265
+ const entry = requests.get(event.params.requestId) || {};
1266
+ requests.set(event.params.requestId, {
1267
+ ...entry,
1268
+ requestId: event.params.requestId,
1269
+ loaderId: event.params.loaderId,
1270
+ documentURL: event.params.documentURL,
1271
+ type: event.params.type,
1272
+ timestamp: event.params.timestamp,
1273
+ wallTime: event.params.wallTime,
1274
+ method: request.method,
1275
+ url: request.url,
1276
+ requestHeaders: request.headers || {},
1277
+ requestPostData: truncateString(request.postData || '', bodyMaxBytes),
1278
+ });
1279
+ return;
1280
+ }
1281
+ if (captureNetwork && event.method === 'Network.responseReceived') {
1282
+ const response = event.params?.response || {};
1283
+ if (urlFilter && !String(response.url || '').includes(urlFilter)) return;
1284
+ const entry = requests.get(event.params.requestId) || {};
1285
+ requests.set(event.params.requestId, {
1286
+ ...entry,
1287
+ requestId: event.params.requestId,
1288
+ type: event.params.type || entry.type,
1289
+ status: response.status,
1290
+ statusText: response.statusText,
1291
+ responseUrl: response.url,
1292
+ mimeType: response.mimeType,
1293
+ protocol: response.protocol,
1294
+ remoteIPAddress: response.remoteIPAddress,
1295
+ remotePort: response.remotePort,
1296
+ responseHeaders: response.headers || {},
1297
+ });
1298
+ return;
1299
+ }
1300
+ if (captureNetwork && event.method === 'Network.responseReceivedExtraInfo') {
1301
+ const entry = requests.get(event.params.requestId) || {};
1302
+ if (urlFilter && !String(entry.url || entry.responseUrl || '').includes(urlFilter)) return;
1303
+ requests.set(event.params.requestId, {
1304
+ ...entry,
1305
+ requestId: event.params.requestId,
1306
+ status: entry.status ?? event.params.statusCode,
1307
+ responseHeaders: {
1308
+ ...(entry.responseHeaders || {}),
1309
+ ...(event.params.headers || {}),
1310
+ },
1311
+ responseHeadersText: event.params.headersText,
1312
+ resourceIPAddressSpace: event.params.resourceIPAddressSpace,
1313
+ });
1314
+ return;
1315
+ }
1316
+ if (captureNetwork && event.method === 'Network.loadingFinished') {
1317
+ const entry = requests.get(event.params.requestId) || {};
1318
+ requests.set(event.params.requestId, {
1319
+ ...entry,
1320
+ requestId: event.params.requestId,
1321
+ encodedDataLength: event.params.encodedDataLength,
1322
+ finished: true,
1323
+ });
1324
+ return;
1325
+ }
1326
+ if (captureNetwork && event.method === 'Network.loadingFailed') {
1327
+ const entry = requests.get(event.params.requestId) || {};
1328
+ requests.set(event.params.requestId, {
1329
+ ...entry,
1330
+ requestId: event.params.requestId,
1331
+ failed: true,
1332
+ errorText: event.params.errorText,
1333
+ canceled: event.params.canceled,
1334
+ blockedReason: event.params.blockedReason,
1335
+ corsErrorStatus: event.params.corsErrorStatus,
1336
+ });
1337
+ return;
1338
+ }
1339
+ if (captureConsole && event.method === 'Runtime.consoleAPICalled') {
1340
+ consoleEvents.push({
1341
+ type: event.params?.type,
1342
+ timestamp: event.params?.timestamp,
1343
+ args: (event.params?.args || []).map(cdpRemoteValue),
1344
+ stackTrace: event.params?.stackTrace || null,
1345
+ });
1346
+ return;
1347
+ }
1348
+ if (captureConsole && event.method === 'Log.entryAdded') {
1349
+ const entry = event.params?.entry || {};
1350
+ consoleEvents.push({
1351
+ source: entry.source,
1352
+ level: entry.level,
1353
+ text: entry.text,
1354
+ url: entry.url,
1355
+ lineNumber: entry.lineNumber,
1356
+ timestamp: entry.timestamp,
1357
+ });
1358
+ }
1359
+ };
1360
+
1361
+ try {
1362
+ cdp = await CdpSession.open(setup.page.webSocketDebuggerUrl);
1363
+ cdp.onEvent(handleEvent);
1364
+ if (captureNetwork) {
1365
+ await cdp.send('Network.enable');
1366
+ }
1367
+ if (captureConsole) {
1368
+ await cdp.send('Runtime.enable');
1369
+ await cdp.send('Log.enable').catch(() => null);
1370
+ }
1371
+ if (options.script) {
1372
+ scriptResult = await cdp.send('Runtime.evaluate', {
1373
+ expression: String(options.script),
1374
+ awaitPromise: true,
1375
+ returnByValue: true,
1376
+ });
1377
+ }
1378
+ await sleep(durationMs);
1379
+ if (includeResponseBody && captureNetwork) {
1380
+ for (const entry of requests.values()) {
1381
+ if (entry.status === undefined || entry.failed) continue;
1382
+ try {
1383
+ const body = await cdp.send('Network.getResponseBody', { requestId: entry.requestId }, 2500);
1384
+ entry.responseBody = truncateString(body.body || '', bodyMaxBytes);
1385
+ entry.base64Encoded = Boolean(body.base64Encoded);
1386
+ } catch (error) {
1387
+ entry.responseBodyError = firstErrorLine(error);
1388
+ }
1389
+ }
1390
+ }
1391
+ } finally {
1392
+ if (cdp) cdp.close();
1393
+ await setup.cleanup();
1394
+ }
1395
+
1396
+ const requestItems = Array.from(requests.values());
1397
+ return {
1398
+ ok: true,
1399
+ transport: 'webview-devtools-cdp',
1400
+ packageName: ctx.packageName,
1401
+ socket: setup.socket,
1402
+ packagePids: setup.packagePids,
1403
+ page: setup.page,
1404
+ durationMs,
1405
+ captureNetwork,
1406
+ captureConsole,
1407
+ scriptResult: normalizeRuntimeEvaluateResult(scriptResult),
1408
+ counts: {
1409
+ events: events.length,
1410
+ requests: requestItems.length,
1411
+ console: consoleEvents.length,
1412
+ },
1413
+ requests: requestItems,
1414
+ console: consoleEvents,
1415
+ events,
1416
+ selectionWarning: setup.selectionWarning || null,
1417
+ };
1418
+ }
1419
+
1420
+ async function setupWebViewDevTools(ctx, options, behavior = {}) {
1421
+ const packagePids = await packagePidsFor(ctx);
1422
+ const procNetUnix = (await adb(ctx, ['shell', 'cat', '/proc/net/unix'])).stdout;
1423
+ const sockets = parseWebViewDevToolsSockets(procNetUnix, packagePids);
1424
+ if (sockets.length === 0) {
1425
+ throw new Error('no WebView DevTools socket found; make sure the app is running and WebView debugging is enabled');
1426
+ }
1427
+ const choice = chooseWebViewDevToolsSocket(sockets, options, packagePids);
1428
+ if (!choice.socket) {
1429
+ throw new Error(choice.error || 'no matching WebView DevTools socket found');
1430
+ }
1431
+ const localPort = await resolveWebViewDevToolsPort(ctx, options);
1432
+ await removeAdbForwardIfPresent(ctx, localPort);
1433
+ await adb(ctx, ['forward', `tcp:${localPort}`, `localabstract:${choice.socket.name}`]);
1434
+ let pages = [];
1435
+ try {
1436
+ pages = JSON.parse(await httpGet(`http://127.0.0.1:${localPort}/json`));
1437
+ if (!Array.isArray(pages)) pages = [];
1438
+ pages = pages.map((page) => normalizeCdpPage(page, localPort));
1439
+ } catch (error) {
1440
+ await removeAdbForwardIfPresent(ctx, localPort);
1441
+ throw error;
1442
+ }
1443
+ const page = behavior.selectPage === false ? null : chooseWebViewPage(pages, options);
1444
+ if (behavior.selectPage !== false && !page) {
1445
+ await removeAdbForwardIfPresent(ctx, localPort);
1446
+ throw new Error('no attachable WebView CDP page found');
1447
+ }
1448
+ return {
1449
+ sockets,
1450
+ socket: choice.socket,
1451
+ packagePids,
1452
+ selectionWarning: choice.warning,
1453
+ localPort,
1454
+ pages,
1455
+ page,
1456
+ cleanup: () => removeAdbForwardIfPresent(ctx, localPort),
1457
+ };
1458
+ }
1459
+
1460
+ async function packagePidsFor(ctx) {
1461
+ if (!ctx.packageName) return [];
1462
+ try {
1463
+ const result = await adb(ctx, ['shell', 'pidof', ctx.packageName]);
1464
+ return result.stdout.split(/\s+/).map((value) => value.trim()).filter((value) => /^\d+$/.test(value));
1465
+ } catch (_) {
1466
+ return [];
1467
+ }
1468
+ }
1469
+
1470
+ function parseWebViewDevToolsSockets(procNetUnix, packagePids = []) {
1471
+ const packagePidSet = new Set(packagePids.map(String));
1472
+ const sockets = [];
1473
+ const seen = new Set();
1474
+ const lines = String(procNetUnix || '').split(/\r?\n/);
1475
+ for (const line of lines) {
1476
+ const matches = line.matchAll(/(?:^|\s|@)(webview_devtools_remote(?:_[^\s@]+)?)/g);
1477
+ for (const match of matches) {
1478
+ const name = match[1];
1479
+ if (!name || seen.has(name)) continue;
1480
+ seen.add(name);
1481
+ const pidMatch = /_(\d+)$/.exec(name);
1482
+ const pid = pidMatch ? pidMatch[1] : null;
1483
+ sockets.push({
1484
+ name,
1485
+ rawName: match[0].trim(),
1486
+ pid,
1487
+ packageMatch: Boolean(pid && packagePidSet.has(pid)),
1488
+ line: line.trim(),
1489
+ });
1490
+ }
1491
+ }
1492
+ return sockets;
1493
+ }
1494
+
1495
+ function chooseWebViewDevToolsSocket(sockets, options = {}, packagePids = []) {
1496
+ if (options.socketName) {
1497
+ const requested = String(options.socketName).replace(/^@/, '');
1498
+ const socket = sockets.find((item) => item.name === requested);
1499
+ return socket ? { socket } : { error: `requested WebView DevTools socket not found: ${requested}` };
1500
+ }
1501
+ for (const pid of packagePids.map(String)) {
1502
+ const socket = sockets.find((item) => item.pid === pid);
1503
+ if (socket) return { socket };
1504
+ }
1505
+ const matched = sockets.filter((item) => item.packageMatch);
1506
+ if (matched.length > 0) return { socket: matched[0] };
1507
+ if (sockets.length === 1) return { socket: sockets[0] };
1508
+ return {
1509
+ socket: sockets[0],
1510
+ warning: `multiple WebView DevTools sockets found and none matched ${packagePids.join(',') || 'the target package pid'}; selected ${sockets[0].name}`,
1511
+ };
1512
+ }
1513
+
1514
+ function chooseWebViewPage(pages, options = {}) {
1515
+ if (!Array.isArray(pages) || pages.length === 0) return null;
1516
+ if (options.targetId) {
1517
+ const targetId = String(options.targetId);
1518
+ const byId = pages.find((page) => page.id === targetId);
1519
+ if (byId) return byId;
1520
+ }
1521
+ if (options.pageUrlFilter) {
1522
+ const filter = String(options.pageUrlFilter);
1523
+ const byUrl = pages.find((page) => String(page.url || '').includes(filter));
1524
+ if (byUrl) return byUrl;
1525
+ }
1526
+ return pages.find((page) => page.webSocketDebuggerUrl && page.type === 'page') ||
1527
+ pages.find((page) => page.webSocketDebuggerUrl) ||
1528
+ null;
1529
+ }
1530
+
1531
+ function normalizeCdpPage(page, localPort) {
1532
+ const normalized = {
1533
+ id: page.id,
1534
+ type: page.type,
1535
+ title: page.title,
1536
+ url: page.url,
1537
+ description: page.description,
1538
+ webSocketDebuggerUrl: page.webSocketDebuggerUrl,
1539
+ };
1540
+ if (normalized.webSocketDebuggerUrl) {
1541
+ normalized.webSocketDebuggerUrl = normalized.webSocketDebuggerUrl.replace(
1542
+ /^ws:\/\/(?:\[::\]|localhost|127\.0\.0\.1):\d+/,
1543
+ `ws://127.0.0.1:${localPort}`,
1544
+ );
1545
+ }
1546
+ return normalized;
1547
+ }
1548
+
1549
+ async function resolveWebViewDevToolsPort(ctx, options) {
1550
+ const explicit = Number(options.webviewPort || options.devtoolsPort || options.cdpPort || 0);
1551
+ if (Number.isInteger(explicit) && explicit > 0) return explicit;
1552
+ const start = 9222;
1553
+ for (let port = start; port < start + 100; port += 1) {
1554
+ await removeAdbForwardIfPresent(ctx, port);
1555
+ if (await isLocalPortAvailable(port)) return port;
1556
+ }
1557
+ throw new Error('no available local port for WebView DevTools forwarding');
1558
+ }
1559
+
1560
+ function isLocalPortAvailable(port) {
1561
+ return new Promise((resolve) => {
1562
+ const server = net.createServer();
1563
+ server.once('error', () => resolve(false));
1564
+ server.once('listening', () => {
1565
+ server.close(() => resolve(true));
1566
+ });
1567
+ server.listen(port, '127.0.0.1');
1568
+ });
1569
+ }
1570
+
1571
+ async function removeAdbForwardIfPresent(ctx, port) {
1572
+ try {
1573
+ await adb(ctx, ['forward', '--remove', `tcp:${port}`]);
1574
+ } catch (_) {
1575
+ // A missing forward is the common case.
1576
+ }
1577
+ }
1578
+
1579
+ class CdpSession {
1580
+ constructor(socket) {
1581
+ this.socket = socket;
1582
+ this.nextId = 1;
1583
+ this.pending = new Map();
1584
+ this.eventHandlers = [];
1585
+ }
1586
+
1587
+ static async open(url) {
1588
+ const WebSocketCtor = webSocketConstructor();
1589
+ const socket = new WebSocketCtor(url);
1590
+ await waitForWebSocketOpen(socket, url);
1591
+ const session = new CdpSession(socket);
1592
+ addWebSocketMessageHandler(socket, (data) => {
1593
+ webSocketDataToText(data)
1594
+ .then((text) => session.handleMessage(text))
1595
+ .catch(() => null);
1596
+ });
1597
+ addWebSocketCloseHandler(socket, () => session.rejectAll(new Error('CDP WebSocket closed')));
1598
+ return session;
1599
+ }
1600
+
1601
+ onEvent(handler) {
1602
+ this.eventHandlers.push(handler);
1603
+ }
1604
+
1605
+ send(method, params = {}, timeoutMs = 5000) {
1606
+ const id = this.nextId;
1607
+ this.nextId += 1;
1608
+ const payload = JSON.stringify({ id, method, params });
1609
+ return new Promise((resolve, reject) => {
1610
+ const timer = setTimeout(() => {
1611
+ this.pending.delete(id);
1612
+ reject(new Error(`CDP timeout: ${method}`));
1613
+ }, timeoutMs);
1614
+ this.pending.set(id, { resolve, reject, timer, method });
1615
+ this.socket.send(payload);
1616
+ });
1617
+ }
1618
+
1619
+ handleMessage(text) {
1620
+ const message = JSON.parse(text);
1621
+ if (message.id !== undefined) {
1622
+ const pending = this.pending.get(message.id);
1623
+ if (!pending) return;
1624
+ clearTimeout(pending.timer);
1625
+ this.pending.delete(message.id);
1626
+ if (message.error) {
1627
+ pending.reject(new Error(`CDP ${pending.method} failed: ${message.error.message || JSON.stringify(message.error)}`));
1628
+ } else {
1629
+ pending.resolve(message.result || {});
1630
+ }
1631
+ return;
1632
+ }
1633
+ if (message.method) {
1634
+ for (const handler of this.eventHandlers) {
1635
+ handler(message);
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ rejectAll(error) {
1641
+ for (const [id, pending] of this.pending.entries()) {
1642
+ clearTimeout(pending.timer);
1643
+ pending.reject(error);
1644
+ this.pending.delete(id);
1645
+ }
1646
+ }
1647
+
1648
+ close() {
1649
+ try {
1650
+ this.socket.close();
1651
+ } catch (_) {
1652
+ // Ignore close races.
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ function webSocketConstructor() {
1658
+ if (typeof globalThis.WebSocket === 'function') return globalThis.WebSocket;
1659
+ try {
1660
+ return require('ws');
1661
+ } catch (_) {
1662
+ throw new Error('WebView CDP capture requires Node.js with WebSocket support or the ws package');
1663
+ }
1664
+ }
1665
+
1666
+ function waitForWebSocketOpen(socket, url) {
1667
+ return new Promise((resolve, reject) => {
1668
+ const timer = setTimeout(() => reject(new Error(`CDP WebSocket open timeout: ${url}`)), 10000);
1669
+ const done = (error) => {
1670
+ clearTimeout(timer);
1671
+ if (error) reject(error);
1672
+ else resolve();
1673
+ };
1674
+ if (typeof socket.addEventListener === 'function') {
1675
+ socket.addEventListener('open', () => done(), { once: true });
1676
+ socket.addEventListener('error', () => done(new Error(`CDP WebSocket error: ${url}`)), { once: true });
1677
+ return;
1678
+ }
1679
+ socket.once('open', () => done());
1680
+ socket.once('error', (error) => done(error));
1681
+ });
1682
+ }
1683
+
1684
+ function addWebSocketMessageHandler(socket, handler) {
1685
+ if (typeof socket.addEventListener === 'function') {
1686
+ socket.addEventListener('message', (event) => handler(event.data));
1687
+ return;
1688
+ }
1689
+ socket.on('message', handler);
1690
+ }
1691
+
1692
+ function addWebSocketCloseHandler(socket, handler) {
1693
+ if (typeof socket.addEventListener === 'function') {
1694
+ socket.addEventListener('close', handler);
1695
+ return;
1696
+ }
1697
+ socket.on('close', handler);
1698
+ }
1699
+
1700
+ async function webSocketDataToText(data) {
1701
+ if (typeof data === 'string') return data;
1702
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
1703
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
1704
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
1705
+ if (data && typeof data.text === 'function') return data.text();
1706
+ return String(data);
1707
+ }
1708
+
1709
+ function cdpRemoteValue(value) {
1710
+ if (!value || typeof value !== 'object') return value;
1711
+ if (Object.prototype.hasOwnProperty.call(value, 'value')) return value.value;
1712
+ if (Object.prototype.hasOwnProperty.call(value, 'unserializableValue')) return value.unserializableValue;
1713
+ return value.description || value.type || null;
1714
+ }
1715
+
1716
+ function normalizeRuntimeEvaluateResult(result) {
1717
+ if (!result) return null;
1718
+ return {
1719
+ result: cdpRemoteValue(result.result),
1720
+ exceptionDetails: result.exceptionDetails || null,
1721
+ };
1722
+ }
1723
+
1724
+ function summarizeCdpParams(method, params) {
1725
+ if (!params || typeof params !== 'object') return params;
1726
+ if (method === 'Network.requestWillBeSent') {
1727
+ return {
1728
+ requestId: params.requestId,
1729
+ type: params.type,
1730
+ documentURL: params.documentURL,
1731
+ request: {
1732
+ method: params.request?.method,
1733
+ url: params.request?.url,
1734
+ },
1735
+ };
1736
+ }
1737
+ if (method === 'Network.responseReceived') {
1738
+ return {
1739
+ requestId: params.requestId,
1740
+ type: params.type,
1741
+ response: {
1742
+ url: params.response?.url,
1743
+ status: params.response?.status,
1744
+ mimeType: params.response?.mimeType,
1745
+ },
1746
+ };
1747
+ }
1748
+ if (method === 'Network.responseReceivedExtraInfo') {
1749
+ return {
1750
+ requestId: params.requestId,
1751
+ statusCode: params.statusCode,
1752
+ resourceIPAddressSpace: params.resourceIPAddressSpace,
1753
+ };
1754
+ }
1755
+ if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
1756
+ return {
1757
+ requestId: params.requestId,
1758
+ encodedDataLength: params.encodedDataLength,
1759
+ errorText: params.errorText,
1760
+ blockedReason: params.blockedReason,
1761
+ };
1762
+ }
1763
+ if (method === 'Runtime.consoleAPICalled') {
1764
+ return {
1765
+ type: params.type,
1766
+ args: (params.args || []).map(cdpRemoteValue),
1767
+ };
1768
+ }
1769
+ if (method === 'Log.entryAdded') {
1770
+ return params.entry || {};
1771
+ }
1772
+ return params;
1773
+ }
1774
+
1775
+ function truncateString(value, maxBytes) {
1776
+ const text = String(value || '');
1777
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text;
1778
+ return `${Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8')}...[truncated]`;
1779
+ }
1780
+
1781
+ async function uiaTree(ctx) {
1782
+ const remotePath = '/sdcard/ai_app_window.xml';
1783
+ return withFileLock(uiautomatorLockPath(ctx), async () => retry(async () => {
1784
+ await adb(ctx, ['shell', 'uiautomator', 'dump', remotePath]);
1785
+ return (await adb(ctx, ['exec-out', 'cat', remotePath])).stdout;
1786
+ }, 4, 500), {
1787
+ timeoutMs: Number(process.env.AI_APP_BRIDGE_UIA_LOCK_TIMEOUT_MS || 30000),
1788
+ staleMs: Number(process.env.AI_APP_BRIDGE_UIA_LOCK_STALE_MS || 120000),
1789
+ });
1790
+ }
1791
+
1792
+ async function bridgeTree(ctx, options = {}) {
1793
+ const tree = await bridgeGet(ctx, '/v1/view/tree');
1794
+ return wantsCompactTree(options) ? compactBridgeTree(tree, options) : tree;
1795
+ }
1796
+
1797
+ async function uiaTreeCommand(ctx, options = {}) {
1798
+ const xml = await uiaTree(ctx);
1799
+ return wantsCompactTree(options) ? compactUiaTree(xml, options) : xml;
1800
+ }
1801
+
1802
+ function wantsCompactTree(options = {}) {
1803
+ return Boolean(
1804
+ booleanOption(options.compact) ||
1805
+ options.textFilter ||
1806
+ options.resourceIdFilter ||
1807
+ options.classFilter ||
1808
+ options.visibleOnly ||
1809
+ options.maxNodes ||
1810
+ options.maxDepth,
1811
+ );
1812
+ }
1813
+
1814
+ function compactTreeOptions(options = {}) {
1815
+ return {
1816
+ textFilter: normalizeFilter(options.textFilter || options.targetText),
1817
+ resourceIdFilter: normalizeFilter(options.resourceIdFilter || options.resourceFilter),
1818
+ classFilter: normalizeFilter(options.classFilter),
1819
+ visibleOnly: booleanOption(options.visibleOnly),
1820
+ maxNodes: boundedInteger(options.maxNodes, 80, 1, 1000),
1821
+ maxDepth: boundedInteger(options.maxDepth, Number.POSITIVE_INFINITY, 0, 200),
1822
+ };
1823
+ }
1824
+
1825
+ function compactBridgeTree(tree, options = {}) {
1826
+ const compactOptions = compactTreeOptions(options);
1827
+ const result = {
1828
+ ok: true,
1829
+ source: 'bridge-tree',
1830
+ compact: true,
1831
+ activity: tree?.activity || null,
1832
+ windowCount: Array.isArray(tree?.windows) ? tree.windows.length : undefined,
1833
+ originalNodeCount: Number.isFinite(Number(tree?.nodeCount)) ? Number(tree.nodeCount) : undefined,
1834
+ options: compactTreeResultOptions(compactOptions),
1835
+ nodes: [],
1836
+ scannedNodes: 0,
1837
+ matchedNodes: 0,
1838
+ truncated: false,
1839
+ updatedAtMs: tree?.updatedAtMs || Date.now(),
1840
+ };
1841
+
1842
+ const roots = [];
1843
+ if (Array.isArray(tree?.windows)) {
1844
+ tree.windows.forEach((windowInfo, index) => {
1845
+ if (windowInfo?.root) {
1846
+ roots.push({
1847
+ root: windowInfo.root,
1848
+ windowType: windowInfo.type || 'window',
1849
+ windowIndex: index,
1850
+ viewport: windowInfo.bounds || windowInfo.root.bounds || null,
1851
+ });
1852
+ }
1853
+ });
1854
+ }
1855
+ if (tree?.root) {
1856
+ roots.push({
1857
+ root: tree.root,
1858
+ windowType: 'root',
1859
+ windowIndex: null,
1860
+ viewport: tree.root.bounds || null,
1861
+ });
1862
+ }
1863
+
1864
+ for (const rootInfo of roots) {
1865
+ visitBridgeTreeNode(rootInfo.root, {
1866
+ result,
1867
+ options: compactOptions,
1868
+ depth: 0,
1869
+ parentPath: '',
1870
+ rootInfo,
1871
+ });
1872
+ if (result.truncated) break;
1873
+ }
1874
+
1875
+ return result;
1876
+ }
1877
+
1878
+ function visitBridgeTreeNode(node, state) {
1879
+ if (!node || state.result.truncated) return;
1880
+ state.result.scannedNodes += 1;
1881
+
1882
+ const children = Array.isArray(node.children) ? node.children : [];
1883
+ const path = state.parentPath ? `${state.parentPath}.${state.result.scannedNodes}` : String(state.result.scannedNodes);
1884
+ if (state.depth <= state.options.maxDepth && bridgeNodeMatchesCompactFilters(node, state.options, state.rootInfo.viewport)) {
1885
+ state.result.matchedNodes += 1;
1886
+ if (state.result.nodes.length >= state.options.maxNodes) {
1887
+ state.result.truncated = true;
1888
+ return;
1889
+ }
1890
+ state.result.nodes.push(compactBridgeNode(node, {
1891
+ depth: state.depth,
1892
+ path,
1893
+ childCount: children.length,
1894
+ windowType: state.rootInfo.windowType,
1895
+ windowIndex: state.rootInfo.windowIndex,
1896
+ }));
1897
+ }
1898
+
1899
+ if (state.depth >= state.options.maxDepth) return;
1900
+ for (const child of children) {
1901
+ visitBridgeTreeNode(child, {
1902
+ ...state,
1903
+ depth: state.depth + 1,
1904
+ parentPath: path,
1905
+ });
1906
+ if (state.result.truncated) return;
1907
+ }
1908
+ }
1909
+
1910
+ function bridgeNodeMatchesCompactFilters(node, options, viewport) {
1911
+ if (options.visibleOnly && nodeTapState(node, viewport).ok !== true) return false;
1912
+ const text = compactString(node.text || node.contentDescription || '');
1913
+ const resource = compactString(node.resourceName || node.id || '');
1914
+ const className = compactString(node.className || node.simpleClassName || '');
1915
+ return compactFiltersMatch({ text, resource, className }, options);
1916
+ }
1917
+
1918
+ function compactBridgeNode(node, extra) {
1919
+ return {
1920
+ depth: extra.depth,
1921
+ path: extra.path,
1922
+ windowType: extra.windowType,
1923
+ windowIndex: extra.windowIndex,
1924
+ className: node.simpleClassName || node.className || '',
1925
+ resourceName: node.resourceName || null,
1926
+ text: node.text || null,
1927
+ contentDescription: node.contentDescription || null,
1928
+ visible: node.visible !== false && node.effectiveVisible !== false,
1929
+ enabled: node.enabled !== false,
1930
+ clickable: Boolean(node.clickable),
1931
+ focusable: Boolean(node.focusable),
1932
+ focused: Boolean(node.focused),
1933
+ selected: Boolean(node.selected),
1934
+ bounds: compactBounds(node.bounds),
1935
+ childCount: extra.childCount,
1936
+ };
1937
+ }
1938
+
1939
+ function compactUiaTree(xml, options = {}) {
1940
+ const compactOptions = compactTreeOptions(options);
1941
+ const text = String(xml || '');
1942
+ const viewport = parseUiaViewport(text);
1943
+ const result = {
1944
+ ok: true,
1945
+ source: 'uiautomator',
1946
+ compact: true,
1947
+ rawBytes: Buffer.byteLength(text, 'utf8'),
1948
+ viewport,
1949
+ options: compactTreeResultOptions(compactOptions),
1950
+ nodes: [],
1951
+ scannedNodes: 0,
1952
+ matchedNodes: 0,
1953
+ truncated: false,
1954
+ updatedAtMs: Date.now(),
1955
+ };
1956
+
1957
+ const tokenRegex = /<\/node>|<node\b[^>]*\/?>/g;
1958
+ let depth = -1;
1959
+ let token;
1960
+ while ((token = tokenRegex.exec(text)) !== null) {
1961
+ const tag = token[0];
1962
+ if (tag.startsWith('</node')) {
1963
+ depth = Math.max(-1, depth - 1);
1964
+ continue;
1965
+ }
1966
+
1967
+ depth += 1;
1968
+ result.scannedNodes += 1;
1969
+ const attrs = parseXmlAttributes(tag);
1970
+ const node = compactUiaNode(attrs, depth, result.scannedNodes);
1971
+ if (depth <= compactOptions.maxDepth && uiaNodeMatchesCompactFilters(node, compactOptions, viewport)) {
1972
+ result.matchedNodes += 1;
1973
+ if (result.nodes.length >= compactOptions.maxNodes) {
1974
+ result.truncated = true;
1975
+ break;
1976
+ }
1977
+ result.nodes.push(node);
1978
+ }
1979
+ if (tag.endsWith('/>')) {
1980
+ depth = Math.max(-1, depth - 1);
1981
+ }
1982
+ }
1983
+
1984
+ return result;
1985
+ }
1986
+
1987
+ function compactUiaNode(attrs, depth, sequence) {
1988
+ return {
1989
+ depth,
1990
+ sequence,
1991
+ className: attrs.class || '',
1992
+ resourceId: attrs['resource-id'] || null,
1993
+ text: attrs.text || null,
1994
+ contentDescription: attrs['content-desc'] || null,
1995
+ packageName: attrs.package || null,
1996
+ enabled: attrs.enabled !== 'false',
1997
+ clickable: attrs.clickable === 'true',
1998
+ focusable: attrs.focusable === 'true',
1999
+ focused: attrs.focused === 'true',
2000
+ selected: attrs.selected === 'true',
2001
+ scrollable: attrs.scrollable === 'true',
2002
+ checked: attrs.checked === 'true',
2003
+ bounds: compactBounds(parseUiaBounds(attrs.bounds)),
2004
+ };
2005
+ }
2006
+
2007
+ function uiaNodeMatchesCompactFilters(node, options, viewport) {
2008
+ if (options.visibleOnly && !boundsInViewport(node.bounds, viewport)) return false;
2009
+ return compactFiltersMatch({
2010
+ text: compactString(node.text || node.contentDescription || ''),
2011
+ resource: compactString(node.resourceId || ''),
2012
+ className: compactString(node.className || ''),
2013
+ }, options);
2014
+ }
2015
+
2016
+ function compactFiltersMatch(values, options) {
2017
+ if (options.textFilter && !values.text.includes(options.textFilter)) return false;
2018
+ if (options.resourceIdFilter && !values.resource.includes(options.resourceIdFilter)) return false;
2019
+ if (options.classFilter && !values.className.includes(options.classFilter)) return false;
2020
+ return true;
2021
+ }
2022
+
2023
+ function compactTreeResultOptions(options) {
2024
+ return {
2025
+ textFilter: options.textFilter || undefined,
2026
+ resourceIdFilter: options.resourceIdFilter || undefined,
2027
+ classFilter: options.classFilter || undefined,
2028
+ visibleOnly: options.visibleOnly || undefined,
2029
+ maxNodes: options.maxNodes,
2030
+ maxDepth: Number.isFinite(options.maxDepth) ? options.maxDepth : undefined,
2031
+ };
2032
+ }
2033
+
2034
+ function parseXmlAttributes(tag) {
2035
+ const attrs = {};
2036
+ const attrRegex = /([A-Za-z0-9_:-]+)="([^"]*)"/g;
2037
+ let match;
2038
+ while ((match = attrRegex.exec(String(tag || ''))) !== null) {
2039
+ attrs[match[1]] = decodeXmlAttribute(match[2]);
2040
+ }
2041
+ return attrs;
2042
+ }
2043
+
2044
+ function decodeXmlAttribute(value) {
2045
+ return String(value || '')
2046
+ .replace(/&quot;/g, '"')
2047
+ .replace(/&apos;/g, "'")
2048
+ .replace(/&lt;/g, '<')
2049
+ .replace(/&gt;/g, '>')
2050
+ .replace(/&amp;/g, '&');
2051
+ }
2052
+
2053
+ function parseUiaBounds(value) {
2054
+ const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(String(value || ''));
2055
+ if (!match) return null;
2056
+ const left = Number(match[1]);
2057
+ const top = Number(match[2]);
2058
+ const right = Number(match[3]);
2059
+ const bottom = Number(match[4]);
2060
+ return {
2061
+ left,
2062
+ top,
2063
+ right,
2064
+ bottom,
2065
+ width: right - left,
2066
+ height: bottom - top,
2067
+ };
2068
+ }
2069
+
2070
+ function boundsInViewport(bounds, viewport) {
2071
+ if (!bounds) return false;
2072
+ const width = Number(bounds.width ?? bounds.right - bounds.left);
2073
+ const height = Number(bounds.height ?? bounds.bottom - bounds.top);
2074
+ if (width <= 0 || height <= 0) return false;
2075
+ if (!viewport) return true;
2076
+ const centerX = (Number(bounds.left) + Number(bounds.right)) / 2;
2077
+ const centerY = (Number(bounds.top) + Number(bounds.bottom)) / 2;
2078
+ return centerX >= Number(viewport.left) &&
2079
+ centerX <= Number(viewport.right) &&
2080
+ centerY >= Number(viewport.top) &&
2081
+ centerY <= Number(viewport.bottom);
2082
+ }
2083
+
2084
+ function compactBounds(bounds) {
2085
+ if (!bounds) return null;
2086
+ return {
2087
+ left: Number(bounds.left),
2088
+ top: Number(bounds.top),
2089
+ right: Number(bounds.right),
2090
+ bottom: Number(bounds.bottom),
2091
+ width: Number(bounds.width ?? bounds.right - bounds.left),
2092
+ height: Number(bounds.height ?? bounds.bottom - bounds.top),
2093
+ };
2094
+ }
2095
+
2096
+ function normalizeFilter(value) {
2097
+ return String(value || '').trim().toLowerCase();
2098
+ }
2099
+
2100
+ function compactString(value) {
2101
+ return String(value || '').toLowerCase();
2102
+ }
2103
+
2104
+ function boundedInteger(value, fallback, min, max) {
2105
+ if (value === undefined || value === null || value === '') return fallback;
2106
+ const number = Number(value);
2107
+ if (!Number.isFinite(number)) return fallback;
2108
+ return Math.max(min, Math.min(Math.floor(number), max));
2109
+ }
2110
+
2111
+ async function screenshot(ctx, outFile, options = {}) {
2112
+ const foreground = await foregroundWindow(ctx);
2113
+ const resolvedPath = await adbBinaryToFile(ctx, ['exec-out', 'screencap', '-p'], outFile);
2114
+ const size = pngSize(resolvedPath);
2115
+ const result = {
2116
+ ok: true,
2117
+ transport: 'adb',
2118
+ mimeType: 'image/png',
2119
+ path: resolvedPath,
2120
+ width: size.width,
2121
+ height: size.height,
2122
+ artifact: {
2123
+ path: resolvedPath,
2124
+ generatedDefault: !options.outFile,
2125
+ directory: path.dirname(resolvedPath),
2126
+ },
2127
+ foreground,
2128
+ };
2129
+ if (result.artifact.generatedDefault) {
2130
+ result.artifact.retention = await pruneGeneratedArtifacts({
2131
+ directory: result.artifact.directory,
2132
+ prefix: options.artifactPrefix || 'ai_app_bridge_screenshot',
2133
+ extension: 'png',
2134
+ currentPath: resolvedPath,
2135
+ });
2136
+ }
2137
+ if (ctx.packageName) {
2138
+ result.targetPackageName = ctx.packageName;
2139
+ }
2140
+ if (ctx.explicitPackageName && foreground.packageName) {
2141
+ result.foregroundMatchesPackage = foreground.packageName === ctx.packageName;
2142
+ if (!result.foregroundMatchesPackage) {
2143
+ result.ok = false;
2144
+ result.error = 'foreground_package_mismatch';
2145
+ result.warning = `screenshot captured foreground package ${foreground.packageName}, not requested package ${ctx.packageName}`;
2146
+ }
2147
+ }
2148
+ return result;
2149
+ }
2150
+
2151
+ function screenshotOutputPath(options = {}, prefix = 'ai_app_bridge_screenshot') {
2152
+ if (options.outFile) return options.outFile;
2153
+ return defaultArtifactPath(prefix, 'png', { artifactDir: options.artifactDir });
2154
+ }
2155
+
2156
+ function defaultArtifactPath(prefix, extension, options = {}) {
2157
+ const directory = path.resolve(options.artifactDir || defaultArtifactDirectory());
2158
+ const suffix = [
2159
+ artifactTimestamp(options.now || new Date()),
2160
+ String(options.pid || process.pid),
2161
+ options.randomSuffix || Math.random().toString(36).slice(2, 8),
2162
+ ].join('-');
2163
+ const name = `${sanitizeArtifactName(prefix)}-${suffix}.${sanitizeArtifactExtension(extension)}`;
2164
+ return path.join(directory, name);
2165
+ }
2166
+
2167
+ async function pruneGeneratedArtifacts(options = {}) {
2168
+ const keep = generatedArtifactRetention;
2169
+ const result = {
2170
+ keep,
2171
+ matched: 0,
2172
+ deleted: 0,
2173
+ };
2174
+ const directory = path.resolve(options.directory || process.cwd());
2175
+ const prefix = sanitizeArtifactName(options.prefix || 'artifact');
2176
+ const extension = sanitizeArtifactExtension(options.extension || 'bin');
2177
+ const currentPath = options.currentPath ? path.resolve(options.currentPath) : '';
2178
+ const pattern = new RegExp(`^${escapeRegExp(prefix)}-\\d{8}-\\d{6}-\\d{3}-\\d+-[a-z0-9]+\\.${escapeRegExp(extension)}$`);
2179
+
2180
+ let entries;
2181
+ try {
2182
+ entries = await fs.promises.readdir(directory, { withFileTypes: true });
2183
+ } catch (error) {
2184
+ return {
2185
+ ...result,
2186
+ error: firstErrorLine(error),
2187
+ };
2188
+ }
2189
+
2190
+ const files = [];
2191
+ for (const entry of entries) {
2192
+ if (!entry.isFile() || !pattern.test(entry.name)) {
2193
+ continue;
2194
+ }
2195
+ const filePath = path.join(directory, entry.name);
2196
+ try {
2197
+ const stat = await fs.promises.stat(filePath);
2198
+ files.push({
2199
+ name: entry.name,
2200
+ path: path.resolve(filePath),
2201
+ mtimeMs: stat.mtimeMs,
2202
+ });
2203
+ } catch (_) {
2204
+ // Ignore files that disappear while pruning.
2205
+ }
2206
+ }
2207
+
2208
+ result.matched = files.length;
2209
+ if (files.length <= keep) {
2210
+ return result;
2211
+ }
2212
+
2213
+ files.sort((left, right) => (right.mtimeMs - left.mtimeMs) || right.name.localeCompare(left.name));
2214
+ const keepSet = new Set();
2215
+ if (currentPath) {
2216
+ keepSet.add(currentPath);
2217
+ }
2218
+ for (const file of files) {
2219
+ if (keepSet.size >= keep) {
2220
+ break;
2221
+ }
2222
+ keepSet.add(file.path);
2223
+ }
2224
+
2225
+ for (const file of files) {
2226
+ if (keepSet.has(file.path)) {
2227
+ continue;
2228
+ }
2229
+ try {
2230
+ await fs.promises.rm(file.path, { force: true });
2231
+ result.deleted += 1;
2232
+ } catch (_) {
2233
+ // Pruning is best-effort and should not make screenshot capture fail.
2234
+ }
2235
+ }
2236
+ return result;
2237
+ }
2238
+
2239
+ function artifactTimestamp(date) {
2240
+ const value = date instanceof Date ? date : new Date(date);
2241
+ const pad = (number, size = 2) => String(number).padStart(size, '0');
2242
+ return [
2243
+ value.getUTCFullYear(),
2244
+ pad(value.getUTCMonth() + 1),
2245
+ pad(value.getUTCDate()),
2246
+ '-',
2247
+ pad(value.getUTCHours()),
2248
+ pad(value.getUTCMinutes()),
2249
+ pad(value.getUTCSeconds()),
2250
+ '-',
2251
+ pad(value.getUTCMilliseconds(), 3),
2252
+ ].join('');
2253
+ }
2254
+
2255
+ function sanitizeArtifactName(value) {
2256
+ return String(value || 'artifact').replace(/[^a-zA-Z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '') || 'artifact';
2257
+ }
2258
+
2259
+ function sanitizeArtifactExtension(value) {
2260
+ return sanitizeArtifactName(String(value || 'bin').replace(/^\.+/, '')) || 'bin';
2261
+ }
2262
+
2263
+ function defaultArtifactDirectory() {
2264
+ return path.join(process.cwd(), 'build', 'ai_app_bridge_artifacts');
2265
+ }
2266
+
2267
+ function escapeRegExp(value) {
2268
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2269
+ }
2270
+
2271
+ function pngSize(filePath) {
2272
+ const bytes = fs.readFileSync(filePath);
2273
+ if (bytes.length < 24) return { width: 0, height: 0 };
2274
+ return {
2275
+ width: bytes.readUInt32BE(16),
2276
+ height: bytes.readUInt32BE(20),
2277
+ };
2278
+ }
2279
+
2280
+ async function foregroundWindow(ctx) {
2281
+ try {
2282
+ const result = await adb(ctx, ['shell', 'dumpsys', 'window']);
2283
+ return parseForegroundWindow(result.stdout);
2284
+ } catch (error) {
2285
+ return {
2286
+ ok: false,
2287
+ error: 'foreground_probe_failed',
2288
+ message: firstErrorLine(error),
2289
+ };
2290
+ }
2291
+ }
2292
+
2293
+ function parseForegroundWindow(raw) {
2294
+ const lines = String(raw || '').split(/\r?\n/);
2295
+ const markers = [
2296
+ 'mCurrentFocus',
2297
+ 'mTopResumedActivity',
2298
+ 'mResumedActivity',
2299
+ 'mFocusedApp',
2300
+ ];
2301
+ for (const marker of markers) {
2302
+ const line = lines.find((item) => item.includes(marker));
2303
+ if (!line) continue;
2304
+ const component = parseComponentFromWindowLine(line);
2305
+ if (!component) continue;
2306
+ return {
2307
+ ok: true,
2308
+ source: marker,
2309
+ packageName: component.packageName,
2310
+ activity: component.activity,
2311
+ component: component.component,
2312
+ raw: line.trim(),
2313
+ };
2314
+ }
2315
+ return {
2316
+ ok: false,
2317
+ error: 'foreground_not_found',
2318
+ };
2319
+ }
2320
+
2321
+ function parseComponentFromWindowLine(line) {
2322
+ const componentRegex = /([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/(\.?[A-Za-z0-9_.$]+(?:\.[A-Za-z0-9_.$]+)*)/g;
2323
+ let match;
2324
+ let lastMatch = null;
2325
+ while ((match = componentRegex.exec(line)) !== null) {
2326
+ lastMatch = match;
2327
+ }
2328
+ if (!lastMatch) return null;
2329
+ const packageName = lastMatch[1];
2330
+ const rawActivity = lastMatch[2];
2331
+ const activity = rawActivity.startsWith('.') ? `${packageName}${rawActivity}` : rawActivity;
2332
+ return {
2333
+ packageName,
2334
+ activity,
2335
+ component: `${packageName}/${rawActivity}`,
2336
+ };
2337
+ }
2338
+
2339
+ async function tap(ctx, x, y) {
2340
+ await adb(ctx, ['shell', 'input', 'tap', String(x), String(y)]);
2341
+ return { ok: true, transport: 'adb', x, y };
2342
+ }
2343
+
2344
+ async function tapText(ctx, targetText, options = {}) {
2345
+ const tree = await bridgeGet(ctx, '/v1/view/tree');
2346
+ const bridgeMatch = findTappableNodeByText(tree, targetText);
2347
+ const node = bridgeMatch.node;
2348
+ if (node?.bounds) {
2349
+ let x = Math.round((node.bounds.left + node.bounds.right) / 2);
2350
+ let y = Math.round((node.bounds.top + node.bounds.bottom) / 2);
2351
+ let keyboard = null;
2352
+ if (!booleanOption(options.noAutoHideKeyboard)) {
2353
+ keyboard = await maybeHideKeyboardForPoint(ctx, { x, y }, bridgeMatch.viewport);
2354
+ if (keyboard.decision?.dismiss && !keyboard.dismissed) {
2355
+ return {
2356
+ ok: false,
2357
+ error: 'keyboard_obscures_target',
2358
+ targetText,
2359
+ source: 'bridge-tree',
2360
+ windowType: bridgeMatch.windowType,
2361
+ x,
2362
+ y,
2363
+ keyboard,
2364
+ };
2365
+ }
2366
+ if (keyboard.dismissed) {
2367
+ const refreshedMatch = findTappableNodeByText(await bridgeGet(ctx, '/v1/view/tree'), targetText);
2368
+ if (refreshedMatch.node?.bounds) {
2369
+ x = Math.round((refreshedMatch.node.bounds.left + refreshedMatch.node.bounds.right) / 2);
2370
+ y = Math.round((refreshedMatch.node.bounds.top + refreshedMatch.node.bounds.bottom) / 2);
2371
+ }
2372
+ }
2373
+ }
2374
+ await tap(ctx, x, y);
2375
+ return {
2376
+ ok: true,
2377
+ transport: 'adb',
2378
+ targetText,
2379
+ source: 'bridge-tree',
2380
+ windowType: bridgeMatch.windowType,
2381
+ x,
2382
+ y,
2383
+ keyboard,
2384
+ };
2385
+ }
2386
+
2387
+ let xml = await uiaTree(ctx);
2388
+ let uiaNode = findUiaNodeByText(xml, targetText);
2389
+ if (!uiaNode) {
2390
+ const flutterTap = await tryTapFlutterText(ctx, targetText, options);
2391
+ if (flutterTap) {
2392
+ return flutterTap;
2393
+ }
2394
+ if (bridgeMatch.rejected) {
2395
+ return {
2396
+ ok: false,
2397
+ error: 'bridge_tree_node_not_tappable',
2398
+ targetText,
2399
+ source: 'bridge-tree',
2400
+ reason: bridgeMatch.rejected.reason,
2401
+ bounds: bridgeMatch.rejected.node.bounds || null,
2402
+ viewport: bridgeMatch.rejected.viewport || null,
2403
+ };
2404
+ }
2405
+ throw new Error(`text not found in Android bridge tree, UIAutomator tree, or Flutter operable tree: ${targetText}`);
2406
+ }
2407
+ let x = Math.round((uiaNode.left + uiaNode.right) / 2);
2408
+ let y = Math.round((uiaNode.top + uiaNode.bottom) / 2);
2409
+ let keyboard = null;
2410
+ if (!booleanOption(options.noAutoHideKeyboard)) {
2411
+ keyboard = await maybeHideKeyboardForPoint(ctx, { x, y }, parseUiaViewport(xml));
2412
+ if (keyboard.decision?.dismiss && !keyboard.dismissed) {
2413
+ return {
2414
+ ok: false,
2415
+ error: 'keyboard_obscures_target',
2416
+ targetText,
2417
+ source: 'uiautomator',
2418
+ x,
2419
+ y,
2420
+ keyboard,
2421
+ };
2422
+ }
2423
+ if (keyboard.dismissed) {
2424
+ xml = await uiaTree(ctx);
2425
+ const refreshedNode = findUiaNodeByText(xml, targetText);
2426
+ if (refreshedNode) {
2427
+ uiaNode = refreshedNode;
2428
+ x = Math.round((uiaNode.left + uiaNode.right) / 2);
2429
+ y = Math.round((uiaNode.top + uiaNode.bottom) / 2);
2430
+ }
2431
+ }
2432
+ }
2433
+ await tap(ctx, x, y);
2434
+ return { ok: true, transport: 'adb', targetText, source: 'uiautomator', x, y, keyboard };
2435
+ }
2436
+
2437
+ function findTappableNodeByText(tree, targetText) {
2438
+ const roots = [];
2439
+ const windows = Array.isArray(tree?.windows) ? tree.windows : [];
2440
+ for (const windowInfo of windows.slice().reverse()) {
2441
+ if (windowInfo?.root) {
2442
+ roots.push({
2443
+ root: windowInfo.root,
2444
+ viewport: windowInfo.bounds || windowInfo.root.bounds || null,
2445
+ windowType: windowInfo.type || 'window',
2446
+ });
2447
+ }
2448
+ }
2449
+ if (tree?.root) {
2450
+ roots.push({
2451
+ root: tree.root,
2452
+ viewport: tree.root.bounds || null,
2453
+ windowType: 'activity',
2454
+ });
2455
+ }
2456
+
2457
+ let rejected = null;
2458
+ for (const rootInfo of roots) {
2459
+ const result = findNodeByText(rootInfo.root, targetText, rootInfo.viewport);
2460
+ if (result.node) {
2461
+ return {
2462
+ node: result.node,
2463
+ windowType: rootInfo.windowType,
2464
+ viewport: rootInfo.viewport,
2465
+ rejected,
2466
+ };
2467
+ }
2468
+ rejected = rejected || result.rejected;
2469
+ }
2470
+ return { node: null, rejected };
2471
+ }
2472
+
2473
+ function findNodeByText(node, targetText, viewport = null) {
2474
+ if (!node) return { node: null, rejected: null };
2475
+ if (node.text === targetText || node.contentDescription === targetText) {
2476
+ const state = nodeTapState(node, viewport);
2477
+ if (state.ok) {
2478
+ return { node, rejected: null };
2479
+ }
2480
+ return {
2481
+ node: null,
2482
+ rejected: {
2483
+ node,
2484
+ viewport,
2485
+ reason: state.reason,
2486
+ },
2487
+ };
2488
+ }
2489
+ let rejected = null;
2490
+ for (const child of node.children || []) {
2491
+ const found = findNodeByText(child, targetText, viewport);
2492
+ if (found.node) return found;
2493
+ rejected = rejected || found.rejected;
2494
+ }
2495
+ return { node: null, rejected };
2496
+ }
2497
+
2498
+ function nodeTapState(node, viewport) {
2499
+ const bounds = node?.bounds;
2500
+ if (!bounds) return { ok: false, reason: 'missing_bounds' };
2501
+ if (node.visible === false || node.effectiveVisible === false) {
2502
+ return { ok: false, reason: 'not_effectively_visible' };
2503
+ }
2504
+ const width = Number(bounds.width ?? bounds.right - bounds.left);
2505
+ const height = Number(bounds.height ?? bounds.bottom - bounds.top);
2506
+ if (width <= 0 || height <= 0) {
2507
+ return { ok: false, reason: 'empty_bounds' };
2508
+ }
2509
+ if (!viewport) return { ok: true };
2510
+ const centerX = (Number(bounds.left) + Number(bounds.right)) / 2;
2511
+ const centerY = (Number(bounds.top) + Number(bounds.bottom)) / 2;
2512
+ if (
2513
+ centerX < Number(viewport.left) ||
2514
+ centerX > Number(viewport.right) ||
2515
+ centerY < Number(viewport.top) ||
2516
+ centerY > Number(viewport.bottom)
2517
+ ) {
2518
+ return { ok: false, reason: 'center_outside_viewport' };
2519
+ }
2520
+ return { ok: true };
2521
+ }
2522
+
2523
+ function findUiaNodeByText(xml, targetText) {
2524
+ const escaped = escapeRegExp(targetText);
2525
+ const nodeRegex = /<node\b[^>]*>/g;
2526
+ let match;
2527
+ while ((match = nodeRegex.exec(xml)) !== null) {
2528
+ const nodeXml = match[0];
2529
+ const textMatch = new RegExp(`\\btext="${escaped}"`).test(nodeXml);
2530
+ const descMatch = new RegExp(`\\bcontent-desc="${escaped}"`).test(nodeXml);
2531
+ if (!textMatch && !descMatch) continue;
2532
+ const boundsMatch = /\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(nodeXml);
2533
+ if (!boundsMatch) continue;
2534
+ return {
2535
+ left: Number(boundsMatch[1]),
2536
+ top: Number(boundsMatch[2]),
2537
+ right: Number(boundsMatch[3]),
2538
+ bottom: Number(boundsMatch[4]),
2539
+ };
2540
+ }
2541
+ return null;
2542
+ }
2543
+
2544
+ function parseUiaViewport(xml) {
2545
+ const match = /<node\b[^>]*\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(String(xml || ''));
2546
+ if (!match) return null;
2547
+ const left = Number(match[1]);
2548
+ const top = Number(match[2]);
2549
+ const right = Number(match[3]);
2550
+ const bottom = Number(match[4]);
2551
+ return {
2552
+ left,
2553
+ top,
2554
+ right,
2555
+ bottom,
2556
+ width: right - left,
2557
+ height: bottom - top,
2558
+ };
2559
+ }
2560
+
2561
+ async function tapUiaText(ctx, targetText, options = {}) {
2562
+ const node = findUiaNodeByAny(await uiaTree(ctx), {
2563
+ texts: [targetText],
2564
+ exact: booleanOption(options.exact),
2565
+ });
2566
+ if (!node) {
2567
+ throw new Error(`text not found in UIAutomator tree: ${targetText}`);
2568
+ }
2569
+ const x = Math.round((node.left + node.right) / 2);
2570
+ const y = Math.round((node.top + node.bottom) / 2);
2571
+ await tap(ctx, x, y);
2572
+ return { ok: true, transport: 'adb', source: 'uiautomator', targetText, x, y, matched: node.matched };
2573
+ }
2574
+
2575
+ async function permissionDialog(ctx, options) {
2576
+ const texts = splitCsv(options.targetText || options.buttonText || options.allowText);
2577
+ const resourceIds = splitCsv(options.resourceId || options.resourceIds);
2578
+ const candidates = texts.length ? texts : defaultPermissionAllowTexts();
2579
+ const ids = resourceIds.length ? resourceIds : defaultPermissionAllowResourceIds();
2580
+ const attempts = Number(options.attempts || 8);
2581
+ const intervalMs = Number(options.intervalMs || 500);
2582
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
2583
+ const xml = await uiaTree(ctx);
2584
+ const node = findUiaNodeByAny(xml, {
2585
+ resourceIds: ids,
2586
+ }) || findUiaNodeByAny(xml, {
2587
+ texts: candidates,
2588
+ exact: booleanOption(options.exact),
2589
+ requireClickable: true,
2590
+ });
2591
+ if (node) {
2592
+ const x = Math.round((node.left + node.right) / 2);
2593
+ const y = Math.round((node.top + node.bottom) / 2);
2594
+ await tap(ctx, x, y);
2595
+ return {
2596
+ ok: true,
2597
+ transport: 'adb',
2598
+ source: 'uiautomator',
2599
+ action: 'permission-dialog',
2600
+ attempt,
2601
+ x,
2602
+ y,
2603
+ matched: node.matched,
2604
+ };
2605
+ }
2606
+ await sleep(intervalMs);
2607
+ }
2608
+ return {
2609
+ ok: false,
2610
+ error: 'permission_dialog_allow_button_not_found',
2611
+ texts: candidates,
2612
+ resourceIds: ids,
2613
+ attempts,
2614
+ };
2615
+ }
2616
+
2617
+ function findUiaNodeByAny(xml, options) {
2618
+ const texts = (options.texts || []).map(String).filter(Boolean);
2619
+ const resourceIds = (options.resourceIds || []).map(String).filter(Boolean);
2620
+ const exact = Boolean(options.exact);
2621
+ const nodeRegex = /<node\b[^>]*>/g;
2622
+ let match;
2623
+ while ((match = nodeRegex.exec(xml)) !== null) {
2624
+ const nodeXml = match[0];
2625
+ const attrs = {
2626
+ text: xmlUnescape(readXmlAttribute(nodeXml, 'text')),
2627
+ contentDescription: xmlUnescape(readXmlAttribute(nodeXml, 'content-desc')),
2628
+ resourceId: xmlUnescape(readXmlAttribute(nodeXml, 'resource-id')),
2629
+ className: xmlUnescape(readXmlAttribute(nodeXml, 'class')),
2630
+ clickable: readXmlAttribute(nodeXml, 'clickable') === 'true',
2631
+ enabled: readXmlAttribute(nodeXml, 'enabled') !== 'false',
2632
+ };
2633
+ if (options.requireClickable && (!attrs.clickable || !attrs.enabled)) continue;
2634
+ const textMatch = texts.find((target) => {
2635
+ return [attrs.text, attrs.contentDescription].some((value) => {
2636
+ return exact ? value === target : value.includes(target);
2637
+ });
2638
+ });
2639
+ const resourceIdMatch = resourceIds.find((target) => {
2640
+ return exact ? attrs.resourceId === target : attrs.resourceId.includes(target);
2641
+ });
2642
+ if (!textMatch && !resourceIdMatch) continue;
2643
+ const boundsMatch = /\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(nodeXml);
2644
+ if (!boundsMatch) continue;
2645
+ return {
2646
+ left: Number(boundsMatch[1]),
2647
+ top: Number(boundsMatch[2]),
2648
+ right: Number(boundsMatch[3]),
2649
+ bottom: Number(boundsMatch[4]),
2650
+ matched: {
2651
+ text: attrs.text,
2652
+ contentDescription: attrs.contentDescription,
2653
+ resourceId: attrs.resourceId,
2654
+ className: attrs.className,
2655
+ clickable: attrs.clickable,
2656
+ target: textMatch || resourceIdMatch,
2657
+ },
2658
+ };
2659
+ }
2660
+ return null;
2661
+ }
2662
+
2663
+ function readXmlAttribute(xml, name) {
2664
+ const match = new RegExp(`\\b${escapeRegExp(name)}="([^"]*)"`).exec(xml);
2665
+ return match ? match[1] : '';
2666
+ }
2667
+
2668
+ function xmlUnescape(value) {
2669
+ return String(value || '')
2670
+ .replace(/&quot;/g, '"')
2671
+ .replace(/&apos;/g, "'")
2672
+ .replace(/&lt;/g, '<')
2673
+ .replace(/&gt;/g, '>')
2674
+ .replace(/&amp;/g, '&');
2675
+ }
2676
+
2677
+ function defaultPermissionAllowTexts() {
2678
+ return [
2679
+ 'Allow',
2680
+ 'While using the app',
2681
+ 'Only this time',
2682
+ '仅在使用该应用时允许',
2683
+ '使用应用时允许',
2684
+ '使用时允许',
2685
+ '仅本次允许',
2686
+ '仅本次使用时允许',
2687
+ '始终允许',
2688
+ ];
2689
+ }
2690
+
2691
+ function defaultPermissionAllowResourceIds() {
2692
+ return [
2693
+ 'permission_allow_button',
2694
+ 'permission_allow_foreground_only_button',
2695
+ 'permission_allow_one_time_button',
2696
+ 'android:id/button1',
2697
+ ];
2698
+ }
2699
+
2700
+ function escapeRegExp(value) {
2701
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2702
+ }
2703
+
2704
+ async function waitText(ctx, targetText, options = {}) {
2705
+ const timeoutSec = Number(options.timeoutSec || 10);
2706
+ const conditions = {
2707
+ requireTexts: splitCsv(options.requireText || options.requireTexts),
2708
+ absentTexts: splitCsv(options.absentText || options.absentTexts),
2709
+ requireActivity: options.requireActivity || options.activity || '',
2710
+ };
2711
+ const deadline = Date.now() + timeoutSec * 1000;
2712
+ let lastCheck = null;
2713
+ while (Date.now() < deadline) {
2714
+ const snapshot = await textSnapshot(ctx);
2715
+ lastCheck = waitTextConditionsMet(snapshot, targetText, conditions);
2716
+ if (lastCheck.ok) {
2717
+ return { ok: true, targetText, timeoutSec, conditions, matched: lastCheck };
2718
+ }
2719
+ await sleep(500);
2720
+ }
2721
+ return { ok: false, error: 'text_not_found', targetText, timeoutSec, conditions, lastCheck };
2722
+ }
2723
+
2724
+ async function flutterNodes(ctx) {
2725
+ const status = await bridgeGet(ctx, '/v1/status');
2726
+ return status.flutter?.layout?.operable || { ok: false, error: 'no_flutter_operable_tree' };
2727
+ }
2728
+
2729
+ async function flutterAction(ctx, payload) {
2730
+ const result = await bridgePost(ctx, '/v1/flutter/action', payload);
2731
+ return { ...result, transport: 'bridge', source: 'flutter-runtime-action', request: payload };
2732
+ }
2733
+
2734
+ async function tryTapFlutterText(ctx, targetText, options = {}) {
2735
+ try {
2736
+ return await tapFlutterText(ctx, targetText, options);
2737
+ } catch (_) {
2738
+ return null;
2739
+ }
2740
+ }
2741
+
2742
+ async function tapFlutterText(ctx, targetText, options = {}) {
2743
+ let operable = await flutterNodes(ctx);
2744
+ let node = findFlutterNode(operable, targetText, 'tap');
2745
+ if (!node) {
2746
+ throw new Error(`flutter tap node not found: ${targetText}`);
2747
+ }
2748
+ let point = flutterNodePoint(node.tap?.bounds || node.bounds, operable.viewport);
2749
+ let keyboard = null;
2750
+ if (!booleanOption(options.noAutoHideKeyboard)) {
2751
+ keyboard = await maybeHideKeyboardForPoint(ctx, point, flutterPhysicalViewport(operable.viewport));
2752
+ if (keyboard.decision?.dismiss && !keyboard.dismissed) {
2753
+ return {
2754
+ ok: false,
2755
+ error: 'keyboard_obscures_target',
2756
+ targetText,
2757
+ source: 'flutter-operable-tree',
2758
+ x: point.x,
2759
+ y: point.y,
2760
+ keyboard,
2761
+ };
2762
+ }
2763
+ if (keyboard.dismissed) {
2764
+ operable = await flutterNodes(ctx);
2765
+ const refreshedNode = findFlutterNode(operable, targetText, 'tap');
2766
+ if (refreshedNode) {
2767
+ node = refreshedNode;
2768
+ point = flutterNodePoint(node.tap?.bounds || node.bounds, operable.viewport);
2769
+ }
2770
+ }
2771
+ }
2772
+ await tap(ctx, point.x, point.y);
2773
+ return {
2774
+ ok: true,
2775
+ transport: 'adb',
2776
+ source: 'flutter-operable-tree',
2777
+ targetText,
2778
+ node,
2779
+ x: point.x,
2780
+ y: point.y,
2781
+ keyboard,
2782
+ };
2783
+ }
2784
+
2785
+ async function inputFlutterText(ctx, targetText, text) {
2786
+ const operable = await flutterNodes(ctx);
2787
+ const node = findFlutterNode(operable, targetText, 'input');
2788
+ if (!node) {
2789
+ throw new Error(`flutter input node not found: ${targetText}`);
2790
+ }
2791
+ const point = flutterNodePoint(node.input?.bounds || node.bounds, operable.viewport);
2792
+ const result = await flutterAction(ctx, { action: 'inputText', text, x: point.x, y: point.y });
2793
+ return {
2794
+ ...result,
2795
+ source: 'flutter-operable-tree',
2796
+ targetText,
2797
+ text,
2798
+ node,
2799
+ x: point.x,
2800
+ y: point.y,
2801
+ };
2802
+ }
2803
+
2804
+ async function scrollFlutter(ctx, targetText) {
2805
+ const operable = await flutterNodes(ctx);
2806
+ const node = targetText
2807
+ ? findFlutterNode(operable, targetText, 'scroll')
2808
+ : (operable.nodes || []).find((item) => (item.actions || []).includes('scroll') && item.scroll?.bounds);
2809
+ if (!node) {
2810
+ throw new Error(targetText ? `flutter scroll node not found: ${targetText}` : 'flutter scroll node not found');
2811
+ }
2812
+ const bounds = node.scroll?.bounds || node.bounds;
2813
+ const dpr = Number(operable.viewport?.devicePixelRatio || 1);
2814
+ const x = Math.round(((bounds.left + bounds.right) / 2) * dpr);
2815
+ const startY = Math.round((bounds.bottom - bounds.height * 0.2) * dpr);
2816
+ const endY = Math.round((bounds.top + bounds.height * 0.2) * dpr);
2817
+ await swipe(ctx, x, startY, x, endY, 600);
2818
+ return {
2819
+ ok: true,
2820
+ transport: 'adb',
2821
+ source: 'flutter-operable-tree',
2822
+ targetText,
2823
+ node,
2824
+ startX: x,
2825
+ startY,
2826
+ endX: x,
2827
+ endY,
2828
+ };
2829
+ }
2830
+
2831
+ function findFlutterNode(operable, targetText, action) {
2832
+ const nodes = Array.isArray(operable?.nodes) ? operable.nodes : [];
2833
+ const candidates = nodes.filter((node) => {
2834
+ const actions = Array.isArray(node.actions) ? node.actions : [];
2835
+ return actions.includes(action) && flutterNodeMatches(node, targetText);
2836
+ });
2837
+ return candidates.find((node) => node.text === targetText || node.value === targetText) || candidates[0] || null;
2838
+ }
2839
+
2840
+ function flutterNodeMatches(node, targetText) {
2841
+ const text = String(node.text || '');
2842
+ const value = String(node.value || '');
2843
+ return text === targetText || value === targetText || text.includes(targetText) || value.includes(targetText);
2844
+ }
2845
+
2846
+ function flutterNodePoint(bounds, viewport) {
2847
+ if (!bounds) throw new Error('flutter node has no bounds');
2848
+ const dpr = Number(viewport?.devicePixelRatio || 1);
2849
+ return {
2850
+ x: Math.round(Number(bounds.centerX ?? ((bounds.left + bounds.right) / 2)) * dpr),
2851
+ y: Math.round(Number(bounds.centerY ?? ((bounds.top + bounds.bottom) / 2)) * dpr),
2852
+ };
2853
+ }
2854
+
2855
+ function flutterPhysicalViewport(viewport) {
2856
+ const width = Number(viewport?.physicalWidth || viewport?.width || viewport?.logicalWidth || 0);
2857
+ const height = Number(viewport?.physicalHeight || viewport?.height || viewport?.logicalHeight || 0);
2858
+ if (!width || !height) return null;
2859
+ return {
2860
+ left: 0,
2861
+ top: 0,
2862
+ right: width,
2863
+ bottom: height,
2864
+ width,
2865
+ height,
2866
+ };
2867
+ }
2868
+
2869
+ async function h5Click(ctx, options) {
2870
+ const result = await h5Operation(ctx, 'click', h5TargetOptions(options));
2871
+ assertH5OperationOk(result, 'h5_click_failed');
2872
+ return result;
2873
+ }
2874
+
2875
+ async function h5Input(ctx, options) {
2876
+ const value = options.value ?? options.inputValue ?? options.text;
2877
+ if (value === undefined || value === null) {
2878
+ throw new Error('missing required option: value');
2879
+ }
2880
+ const result = await h5Operation(ctx, 'input', {
2881
+ ...h5TargetOptions(options),
2882
+ value: String(value),
2883
+ });
2884
+ assertH5OperationOk(result, 'h5_input_failed');
2885
+ return result;
2886
+ }
2887
+
2888
+ async function h5Scroll(ctx, options) {
2889
+ const result = await h5Operation(ctx, 'scroll', {
2890
+ ...h5TargetOptions(options),
2891
+ deltaX: Number(options.deltaX || 0),
2892
+ deltaY: Number(options.deltaY || options.delta || 480),
2893
+ });
2894
+ assertH5OperationOk(result, 'h5_scroll_failed');
2895
+ return result;
2896
+ }
2897
+
2898
+ async function h5Wait(ctx, options) {
2899
+ const timeoutSec = Number(options.timeoutSec || 10);
2900
+ const intervalMs = Number(options.intervalMs || 500);
2901
+ const deadline = Date.now() + timeoutSec * 1000;
2902
+ let lastResult = null;
2903
+ while (Date.now() <= deadline) {
2904
+ lastResult = await h5Operation(ctx, 'find', h5TargetOptions(options));
2905
+ if (lastResult.result?.ok) {
2906
+ return { ...lastResult, timeoutSec, intervalMs };
2907
+ }
2908
+ await sleep(intervalMs);
2909
+ }
2910
+ return {
2911
+ ok: false,
2912
+ error: 'h5_target_not_found',
2913
+ timeoutSec,
2914
+ intervalMs,
2915
+ lastResult,
2916
+ };
2917
+ }
2918
+
2919
+ async function h5Operation(ctx, action, params) {
2920
+ const response = await bridgePost(ctx, '/v1/h5/eval', {
2921
+ script: h5OperationScript(action, params),
2922
+ });
2923
+ return {
2924
+ ...response,
2925
+ transport: 'bridge',
2926
+ source: 'native-webview-eval',
2927
+ action,
2928
+ request: params,
2929
+ result: normalizeH5EvalResult(response.result),
2930
+ };
2931
+ }
2932
+
2933
+ async function flutterH5Dom(ctx) {
2934
+ const response = await flutterAction(ctx, { action: 'h5Dom' });
2935
+ return { ...response, source: 'flutter-h5-adapter' };
2936
+ }
2937
+
2938
+ async function flutterH5Eval(ctx, params) {
2939
+ const response = await flutterAction(ctx, { action: 'h5Eval', script: params.script });
2940
+ return { ...response, source: 'flutter-h5-adapter' };
2941
+ }
2942
+
2943
+ async function flutterH5Click(ctx, options) {
2944
+ const result = await flutterH5Operation(ctx, 'click', h5TargetOptions(options));
2945
+ assertH5OperationOk(result, 'flutter_h5_click_failed');
2946
+ return result;
2947
+ }
2948
+
2949
+ async function flutterH5Input(ctx, options) {
2950
+ const value = options.value ?? options.inputValue ?? options.text;
2951
+ if (value === undefined || value === null) {
2952
+ throw new Error('missing required option: value');
2953
+ }
2954
+ const result = await flutterH5Operation(ctx, 'input', {
2955
+ ...h5TargetOptions(options),
2956
+ value: String(value),
2957
+ });
2958
+ assertH5OperationOk(result, 'flutter_h5_input_failed');
2959
+ return result;
2960
+ }
2961
+
2962
+ async function flutterH5Scroll(ctx, options) {
2963
+ const result = await flutterH5Operation(ctx, 'scroll', {
2964
+ ...h5TargetOptions(options),
2965
+ deltaX: Number(options.deltaX || 0),
2966
+ deltaY: Number(options.deltaY || options.delta || 480),
2967
+ });
2968
+ assertH5OperationOk(result, 'flutter_h5_scroll_failed');
2969
+ return result;
2970
+ }
2971
+
2972
+ async function flutterH5Wait(ctx, options) {
2973
+ const timeoutSec = Number(options.timeoutSec || 10);
2974
+ const intervalMs = Number(options.intervalMs || 500);
2975
+ const deadline = Date.now() + timeoutSec * 1000;
2976
+ let lastResult = null;
2977
+ while (Date.now() <= deadline) {
2978
+ lastResult = await flutterH5Operation(ctx, 'find', h5TargetOptions(options));
2979
+ if (lastResult.result?.ok) {
2980
+ return { ...lastResult, timeoutSec, intervalMs };
2981
+ }
2982
+ await sleep(intervalMs);
2983
+ }
2984
+ return {
2985
+ ok: false,
2986
+ error: 'flutter_h5_target_not_found',
2987
+ timeoutSec,
2988
+ intervalMs,
2989
+ lastResult,
2990
+ };
2991
+ }
2992
+
2993
+ async function flutterH5Operation(ctx, action, params) {
2994
+ const response = await flutterH5Eval(ctx, {
2995
+ script: h5OperationScript(action, params),
2996
+ });
2997
+ return {
2998
+ ...response,
2999
+ source: 'flutter-h5-adapter',
3000
+ action,
3001
+ request: params,
3002
+ result: normalizeH5EvalResult(response.result),
3003
+ };
3004
+ }
3005
+
3006
+ function h5TargetOptions(options) {
3007
+ return {
3008
+ selector: options.selector || '',
3009
+ targetText: options.targetText || options.textContains || '',
3010
+ exact: booleanOption(options.exact),
3011
+ };
3012
+ }
3013
+
3014
+ function booleanOption(value) {
3015
+ if (typeof value === 'boolean') return value;
3016
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
3017
+ }
3018
+
3019
+ function assertH5OperationOk(response, errorName) {
3020
+ if (!response.ok) {
3021
+ throw new Error(`${errorName}: ${response.error || 'bridge_error'}`);
3022
+ }
3023
+ if (!response.result?.ok) {
3024
+ throw new Error(`${errorName}: ${response.result?.error || 'target_not_found'}`);
3025
+ }
3026
+ }
3027
+
3028
+ function normalizeH5EvalResult(value) {
3029
+ if (typeof value === 'string') {
3030
+ try {
3031
+ return JSON.parse(value);
3032
+ } catch (_) {
3033
+ return { ok: true, value };
3034
+ }
3035
+ }
3036
+ return value || null;
3037
+ }
3038
+
3039
+ function h5OperationScript(action, params) {
3040
+ const payload = JSON.stringify({ action, ...params });
3041
+ return `
3042
+ (function() {
3043
+ var params = ${payload};
3044
+ function text(value) {
3045
+ return value == null ? '' : String(value);
3046
+ }
3047
+ function cut(value, max) {
3048
+ var raw = text(value);
3049
+ return raw.length > max ? raw.slice(0, max) : raw;
3050
+ }
3051
+ function visible(element) {
3052
+ if (!element) return false;
3053
+ var style = window.getComputedStyle ? window.getComputedStyle(element) : null;
3054
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
3055
+ var rect = element.getBoundingClientRect();
3056
+ return rect.width > 0 && rect.height > 0;
3057
+ }
3058
+ function bounds(element) {
3059
+ var rect = element.getBoundingClientRect();
3060
+ return {
3061
+ left: rect.left,
3062
+ top: rect.top,
3063
+ right: rect.right,
3064
+ bottom: rect.bottom,
3065
+ width: rect.width,
3066
+ height: rect.height
3067
+ };
3068
+ }
3069
+ function label(element) {
3070
+ return [
3071
+ element.innerText,
3072
+ element.value,
3073
+ element.title,
3074
+ element.id,
3075
+ element.name,
3076
+ element.getAttribute('aria-label'),
3077
+ element.getAttribute('placeholder'),
3078
+ element.getAttribute('role')
3079
+ ].map(text).filter(Boolean).join('\\n');
3080
+ }
3081
+ function matchesText(element, targetText) {
3082
+ if (!targetText) return true;
3083
+ var source = label(element);
3084
+ return params.exact ? source === targetText : source.indexOf(targetText) >= 0;
3085
+ }
3086
+ function describe(element) {
3087
+ if (!element) return null;
3088
+ return {
3089
+ tag: text(element.tagName).toLowerCase(),
3090
+ id: text(element.id),
3091
+ name: text(element.getAttribute('name')),
3092
+ type: text(element.getAttribute('type')),
3093
+ role: text(element.getAttribute('role')),
3094
+ ariaLabel: text(element.getAttribute('aria-label')),
3095
+ placeholder: text(element.getAttribute('placeholder')),
3096
+ text: cut(element.innerText || element.value || element.title || element.getAttribute('aria-label'), 500),
3097
+ value: cut(element.value, 500),
3098
+ disabled: !!element.disabled,
3099
+ bounds: bounds(element)
3100
+ };
3101
+ }
3102
+ function findElement() {
3103
+ var selector = text(params.selector);
3104
+ var targetText = text(params.targetText);
3105
+ var candidates = [];
3106
+ if (selector) {
3107
+ candidates = Array.prototype.slice.call(document.querySelectorAll(selector), 0, 200);
3108
+ } else if (targetText) {
3109
+ candidates = Array.prototype.slice.call(document.querySelectorAll('a,button,input,textarea,select,[role],[onclick],[aria-label],[contenteditable="true"]'), 0, 500);
3110
+ if (!candidates.length) {
3111
+ candidates = Array.prototype.slice.call(document.body ? document.body.querySelectorAll('*') : [], 0, 1000);
3112
+ }
3113
+ } else if (document.activeElement) {
3114
+ candidates = [document.activeElement];
3115
+ }
3116
+ var matched = candidates.filter(function(element) {
3117
+ return matchesText(element, targetText);
3118
+ });
3119
+ return matched.find(visible) || matched[0] || null;
3120
+ }
3121
+ function dispatch(element, name) {
3122
+ var event = new Event(name, { bubbles: true, cancelable: true });
3123
+ element.dispatchEvent(event);
3124
+ }
3125
+ function pointer(element, name) {
3126
+ try {
3127
+ element.dispatchEvent(new MouseEvent(name, { bubbles: true, cancelable: true, view: window }));
3128
+ } catch (_) {
3129
+ dispatch(element, name);
3130
+ }
3131
+ }
3132
+ function bodyText() {
3133
+ return cut(document.body && document.body.innerText, 2000);
3134
+ }
3135
+
3136
+ var targetText = text(params.targetText);
3137
+ if (params.action === 'find' && targetText && bodyText().indexOf(targetText) >= 0) {
3138
+ return JSON.stringify({
3139
+ ok: true,
3140
+ action: params.action,
3141
+ matchSource: 'bodyText',
3142
+ bodyText: bodyText(),
3143
+ updatedAtMs: Date.now()
3144
+ });
3145
+ }
3146
+
3147
+ var element = findElement();
3148
+ if (!element) {
3149
+ return JSON.stringify({
3150
+ ok: false,
3151
+ action: params.action,
3152
+ error: 'target_not_found',
3153
+ selector: text(params.selector),
3154
+ targetText: targetText,
3155
+ bodyText: bodyText(),
3156
+ updatedAtMs: Date.now()
3157
+ });
3158
+ }
3159
+
3160
+ if (params.action === 'click') {
3161
+ element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
3162
+ if (element.focus) element.focus();
3163
+ pointer(element, 'mousedown');
3164
+ pointer(element, 'mouseup');
3165
+ if (element.click) {
3166
+ element.click();
3167
+ } else {
3168
+ pointer(element, 'click');
3169
+ }
3170
+ } else if (params.action === 'input') {
3171
+ element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
3172
+ if (element.focus) element.focus();
3173
+ if ('value' in element) {
3174
+ element.value = text(params.value);
3175
+ } else if (element.isContentEditable) {
3176
+ element.innerText = text(params.value);
3177
+ } else {
3178
+ return JSON.stringify({
3179
+ ok: false,
3180
+ action: params.action,
3181
+ error: 'target_not_editable',
3182
+ matched: describe(element),
3183
+ updatedAtMs: Date.now()
3184
+ });
3185
+ }
3186
+ dispatch(element, 'input');
3187
+ dispatch(element, 'change');
3188
+ } else if (params.action === 'scroll') {
3189
+ if (params.selector || targetText) {
3190
+ element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
3191
+ } else {
3192
+ window.scrollBy(Number(params.deltaX || 0), Number(params.deltaY || 0));
3193
+ }
3194
+ }
3195
+
3196
+ return JSON.stringify({
3197
+ ok: true,
3198
+ action: params.action,
3199
+ matched: describe(element),
3200
+ value: cut(element.value, 500),
3201
+ bodyText: bodyText(),
3202
+ scroll: {
3203
+ x: window.scrollX,
3204
+ y: window.scrollY
3205
+ },
3206
+ updatedAtMs: Date.now()
3207
+ });
3208
+ })()
3209
+ `;
3210
+ }
3211
+
3212
+ async function textPresent(ctx, targetText) {
3213
+ return waitTextConditionsMet(await textSnapshot(ctx), targetText).ok;
3214
+ }
3215
+
3216
+ async function textSnapshot(ctx) {
3217
+ const parts = [];
3218
+ let activity = '';
3219
+ for (const loader of [
3220
+ async () => {
3221
+ const status = await bridgeGet(ctx, '/v1/status');
3222
+ activity = String(status?.activity?.current || '');
3223
+ return statusSearchText(status);
3224
+ },
3225
+ async () => JSON.stringify(await bridgeGet(ctx, '/v1/view/tree')),
3226
+ async () => await uiaTree(ctx),
3227
+ ]) {
3228
+ try {
3229
+ const text = await loader();
3230
+ parts.push(text);
3231
+ } catch (_) {}
3232
+ }
3233
+ return {
3234
+ text: parts.join('\n'),
3235
+ activity,
3236
+ };
3237
+ }
3238
+
3239
+ function statusSearchText(status) {
3240
+ if (!status || typeof status !== 'object') return '';
3241
+ const parts = [];
3242
+ parts.push(JSON.stringify({
3243
+ debugBridge: status.debugBridge,
3244
+ app: status.app,
3245
+ android: status.android,
3246
+ activity: status.activity,
3247
+ capture: status.capture,
3248
+ }));
3249
+ const flutter = status.flutter;
3250
+ if (flutter && typeof flutter === 'object') {
3251
+ parts.push(JSON.stringify({
3252
+ app: flutter.app,
3253
+ route: flutter.route,
3254
+ h5: flutter.h5 ? flutterH5SearchObject(flutter.h5) : undefined,
3255
+ }));
3256
+ const nodes = Array.isArray(flutter.layout?.operable?.nodes) ? flutter.layout.operable.nodes : [];
3257
+ for (const node of nodes) {
3258
+ parts.push(JSON.stringify({
3259
+ widgetType: node.widgetType,
3260
+ text: node.text,
3261
+ value: node.value,
3262
+ actions: node.actions,
3263
+ }));
3264
+ }
3265
+ }
3266
+ return parts.join('\n');
3267
+ }
3268
+
3269
+ function flutterH5SearchObject(h5) {
3270
+ if (!h5 || typeof h5 !== 'object') return h5;
3271
+ return {
3272
+ active: h5.active,
3273
+ adapterId: h5.adapterId,
3274
+ source: h5.source,
3275
+ currentUrl: h5.currentUrl,
3276
+ progress: h5.progress,
3277
+ title: h5.title,
3278
+ dom: h5.dom ? {
3279
+ ok: h5.dom.ok,
3280
+ title: h5.dom.title,
3281
+ url: h5.dom.url,
3282
+ readyState: h5.dom.readyState,
3283
+ bodyText: h5.dom.bodyText,
3284
+ controls: Array.isArray(h5.dom.controls)
3285
+ ? h5.dom.controls.map((control) => ({
3286
+ tag: control.tag,
3287
+ id: control.id,
3288
+ name: control.name,
3289
+ type: control.type,
3290
+ role: control.role,
3291
+ ariaLabel: control.ariaLabel,
3292
+ placeholder: control.placeholder,
3293
+ text: control.text,
3294
+ href: control.href,
3295
+ disabled: control.disabled,
3296
+ }))
3297
+ : h5.dom.controls,
3298
+ controlCount: h5.dom.controlCount,
3299
+ } : undefined,
3300
+ };
3301
+ }
3302
+
3303
+ function waitTextConditionsMet(snapshot, targetText, options = {}) {
3304
+ const text = String(snapshot?.text || '');
3305
+ const activity = String(snapshot?.activity || '');
3306
+ if (!text.includes(targetText)) {
3307
+ return { ok: false, reason: 'target_text_missing', targetText, activity };
3308
+ }
3309
+ const requireActivity = String(options.requireActivity || '');
3310
+ if (requireActivity && !activity.includes(requireActivity)) {
3311
+ return {
3312
+ ok: false,
3313
+ reason: 'activity_mismatch',
3314
+ targetText,
3315
+ activity,
3316
+ requireActivity,
3317
+ };
3318
+ }
3319
+ const missingTexts = (options.requireTexts || []).filter((value) => !text.includes(value));
3320
+ if (missingTexts.length) {
3321
+ return {
3322
+ ok: false,
3323
+ reason: 'required_text_missing',
3324
+ targetText,
3325
+ activity,
3326
+ missingTexts,
3327
+ };
3328
+ }
3329
+ const presentAbsentTexts = (options.absentTexts || []).filter((value) => text.includes(value));
3330
+ if (presentAbsentTexts.length) {
3331
+ return {
3332
+ ok: false,
3333
+ reason: 'absent_text_present',
3334
+ targetText,
3335
+ activity,
3336
+ presentAbsentTexts,
3337
+ };
3338
+ }
3339
+ return { ok: true, targetText, activity };
3340
+ }
3341
+
3342
+ async function keyboardState(ctx) {
3343
+ try {
3344
+ const result = await adb(ctx, ['shell', 'dumpsys', 'input_method']);
3345
+ return parseKeyboardState(result.stdout);
3346
+ } catch (error) {
3347
+ return {
3348
+ ok: false,
3349
+ error: 'keyboard_state_probe_failed',
3350
+ message: firstErrorLine(error),
3351
+ };
3352
+ }
3353
+ }
3354
+
3355
+ function parseKeyboardState(raw) {
3356
+ const text = String(raw || '');
3357
+ const inputShown = text.includes('mInputShown=true') || text.includes('inputShown=true');
3358
+ const windowVisible = text.includes('mWindowVisible=true');
3359
+ const inputViewShown = text.includes('mIsInputViewShown=true') || text.includes('mInputViewStarted=true');
3360
+ const imeWindowVisible = /\bmImeWindowVis=0x[13]\b/i.test(text) || /\bmImeWindowVisibility=0x[13]\b/i.test(text);
3361
+ const markers = [];
3362
+ if (inputShown) markers.push('mInputShown=true');
3363
+ if (windowVisible) markers.push('mWindowVisible=true');
3364
+ if (inputViewShown) markers.push('mIsInputViewShown=true');
3365
+ if (imeWindowVisible) markers.push('mImeWindowVis');
3366
+ const hiddenMarkers = [
3367
+ 'mInputShown=false',
3368
+ 'mWindowVisible=false',
3369
+ 'mImeWindowVis=0',
3370
+ ].filter((marker) => text.includes(marker));
3371
+ return {
3372
+ ok: true,
3373
+ source: 'dumpsys input_method',
3374
+ visible: inputShown || imeWindowVisible || (windowVisible && inputViewShown),
3375
+ markers,
3376
+ hiddenMarkers,
3377
+ };
3378
+ }
3379
+
3380
+ async function hideKeyboard(ctx, options = {}) {
3381
+ const before = await keyboardState(ctx);
3382
+ const force = booleanOption(options.force);
3383
+ if (!force && before.ok && !before.visible) {
3384
+ return {
3385
+ ok: true,
3386
+ action: 'hide-keyboard',
3387
+ dismissed: false,
3388
+ reason: 'keyboard_not_visible',
3389
+ before,
3390
+ after: before,
3391
+ attempts: [],
3392
+ };
3393
+ }
3394
+
3395
+ const attempts = [];
3396
+ for (const keyCode of [111, 4]) {
3397
+ await keyevent(ctx, keyCode);
3398
+ await sleep(Number(options.intervalMs || 500));
3399
+ const after = await keyboardState(ctx);
3400
+ attempts.push({ keyCode, visible: after.visible, ok: after.ok });
3401
+ if (after.ok && !after.visible) {
3402
+ return {
3403
+ ok: true,
3404
+ action: 'hide-keyboard',
3405
+ dismissed: true,
3406
+ before,
3407
+ after,
3408
+ attempts,
3409
+ };
3410
+ }
3411
+ }
3412
+
3413
+ const after = await keyboardState(ctx);
3414
+ return {
3415
+ ok: false,
3416
+ action: 'hide-keyboard',
3417
+ error: 'keyboard_still_visible',
3418
+ dismissed: false,
3419
+ before,
3420
+ after,
3421
+ attempts,
3422
+ };
3423
+ }
3424
+
3425
+ async function maybeHideKeyboardForPoint(ctx, point, viewport) {
3426
+ const state = await keyboardState(ctx);
3427
+ const decision = shouldDismissKeyboardForPoint({ point, viewport, keyboardVisible: state.visible });
3428
+ if (!decision.dismiss) {
3429
+ return {
3430
+ dismissed: false,
3431
+ state,
3432
+ decision,
3433
+ };
3434
+ }
3435
+ const hide = await hideKeyboard(ctx, { reason: decision.reason });
3436
+ return {
3437
+ dismissed: hide.dismissed,
3438
+ state,
3439
+ decision,
3440
+ hide,
3441
+ };
3442
+ }
3443
+
3444
+ function shouldDismissKeyboardForPoint({ point, viewport, keyboardVisible }) {
3445
+ if (!keyboardVisible) {
3446
+ return { dismiss: false, reason: 'keyboard_not_visible' };
3447
+ }
3448
+ if (!point || !viewport) {
3449
+ return { dismiss: false, reason: 'missing_geometry' };
3450
+ }
3451
+ const top = Number(viewport.top || 0);
3452
+ const bottom = Number(viewport.bottom);
3453
+ if (!Number.isFinite(bottom) || bottom <= top) {
3454
+ return { dismiss: false, reason: 'invalid_viewport' };
3455
+ }
3456
+ const threshold = top + (bottom - top) * 0.58;
3457
+ if (Number(point.y) >= threshold) {
3458
+ return {
3459
+ dismiss: true,
3460
+ reason: 'target_may_be_obscured_by_keyboard',
3461
+ threshold,
3462
+ };
3463
+ }
3464
+ return {
3465
+ dismiss: false,
3466
+ reason: 'target_above_keyboard_risk_area',
3467
+ threshold,
3468
+ };
3469
+ }
3470
+
3471
+ async function inputText(ctx, text, options = {}) {
3472
+ const bridgePayload = inputTextBridgePayload(text, options);
3473
+ let bridgeAttempt = null;
3474
+ try {
3475
+ const bridgeResult = await bridgePost(ctx, '/v1/action/input-text', bridgePayload);
3476
+ if (bridgeResult?.ok) {
3477
+ const result = {
3478
+ ...bridgeResult,
3479
+ transport: 'bridge',
3480
+ source: bridgeResult.source || 'native-view',
3481
+ request: bridgePayload,
3482
+ };
3483
+ if (booleanOption(options.hideKeyboard)) {
3484
+ result.keyboard = await hideKeyboard(ctx, options);
3485
+ }
3486
+ return result;
3487
+ }
3488
+ bridgeAttempt = {
3489
+ ok: false,
3490
+ command: 'input-text',
3491
+ requestPath: '/v1/action/input-text',
3492
+ result: bridgeResult,
3493
+ error: bridgeResult?.error || 'bridge_input_failed',
3494
+ message: bridgeResult?.message || 'The app bridge did not accept native text input.',
3495
+ };
3496
+ } catch (error) {
3497
+ bridgeAttempt = buildBridgeFailureResult(ctx, 'input-text', '/v1/action/input-text', error);
3498
+ }
3499
+
3500
+ if (!isAdbInputTextSafe(text)) {
3501
+ return {
3502
+ ok: false,
3503
+ error: 'unicode_text_requires_bridge_input',
3504
+ message: 'This text contains non-ASCII characters. Android adb shell input text cannot reliably enter Unicode; use an app build with AI App Bridge Android runtime 0.1.9+ and retry input-text/input_text.',
3505
+ textLength: text.length,
3506
+ bridge: bridgeAttempt,
3507
+ suggestion: 'Update the target app bridge dependency to ai-app-bridge-android 0.1.9+ and pass --package-name so the CLI can discover the app bridge port.',
3508
+ };
3509
+ }
3510
+
3511
+ await adb(ctx, ['shell', 'input', 'text', text.replace(/ /g, '%s')]);
3512
+ const result = {
3513
+ ok: true,
3514
+ transport: 'adb',
3515
+ source: 'adb-input-text-fallback',
3516
+ text,
3517
+ bridge: bridgeAttempt,
3518
+ };
3519
+ if (booleanOption(options.hideKeyboard)) {
3520
+ result.keyboard = await hideKeyboard(ctx, options);
3521
+ }
3522
+ return result;
3523
+ }
3524
+
3525
+ function inputTextBridgePayload(text, options = {}) {
3526
+ const payload = { text };
3527
+ const x = Number(options.tapX);
3528
+ const y = Number(options.tapY);
3529
+ if (Number.isFinite(x) && Number.isFinite(y)) {
3530
+ payload.x = x;
3531
+ payload.y = y;
3532
+ }
3533
+ return payload;
3534
+ }
3535
+
3536
+ function isAdbInputTextSafe(text) {
3537
+ return /^[\x20-\x7e]*$/.test(String(text));
3538
+ }
3539
+
3540
+ async function swipe(ctx, startX, startY, endX, endY, durationMs) {
3541
+ await adb(ctx, ['shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(durationMs)]);
3542
+ return { ok: true, transport: 'adb', startX, startY, endX, endY, durationMs };
3543
+ }
3544
+
3545
+ async function keyevent(ctx, keyCode) {
3546
+ await adb(ctx, ['shell', 'input', 'keyevent', String(keyCode)]);
3547
+ return { ok: true, transport: 'adb', keyCode };
3548
+ }
3549
+
3550
+ async function logcat(ctx, options) {
3551
+ const follow = Boolean(options.follow || options.live);
3552
+ const args = ['logcat', '-v', String(options.logcatFormat || options.format || 'threadtime')];
3553
+ if (options.clear || options.clearFirst) {
3554
+ await adb(ctx, ['logcat', '-c']);
3555
+ }
3556
+ if (!follow) {
3557
+ args.push('-d');
3558
+ }
3559
+ const since = options.logcatSince || options.since;
3560
+ if (since) {
3561
+ args.push('-T', String(since));
3562
+ } else if (!follow) {
3563
+ args.push('-t', String(options.logcatLines || options.lines || 200));
3564
+ }
3565
+ if (options.logcatFilter) {
3566
+ args.push(...String(options.logcatFilter).split(',').map((value) => value.trim()).filter(Boolean));
3567
+ }
3568
+ const text = follow
3569
+ ? await adbFollow(ctx, args, Number(options.durationMs || Number(options.durationSec || 5) * 1000))
3570
+ : (await adb(ctx, args)).stdout;
3571
+ const pid = await resolveLogcatPid(ctx, options);
3572
+ return filterLogcat(text, { ...options, pid });
3573
+ }
3574
+
3575
+ async function permissionState(ctx, permission) {
3576
+ const result = await adb(ctx, ['shell', 'dumpsys', 'package', ctx.packageName]);
3577
+ const pattern = new RegExp(`${escapeRegExp(permission)}:\\s+granted=(true|false)(?:,\\s*flags=\\[([^\\]]*)\\])?`);
3578
+ const match = pattern.exec(result.stdout);
3579
+ if (!match) {
3580
+ return {
3581
+ ok: false,
3582
+ packageName: ctx.packageName,
3583
+ permission,
3584
+ error: 'permission_not_found_in_dumpsys',
3585
+ };
3586
+ }
3587
+ return {
3588
+ ok: true,
3589
+ packageName: ctx.packageName,
3590
+ permission,
3591
+ granted: match[1] === 'true',
3592
+ flags: splitCsv(match[2] || ''),
3593
+ };
3594
+ }
3595
+
3596
+ async function permissionGrant(ctx, permission) {
3597
+ try {
3598
+ await adb(ctx, ['shell', 'pm', 'grant', ctx.packageName, permission]);
3599
+ return {
3600
+ action: 'grant',
3601
+ ...(await permissionState(ctx, permission)),
3602
+ };
3603
+ } catch (error) {
3604
+ return {
3605
+ ok: false,
3606
+ action: 'grant',
3607
+ packageName: ctx.packageName,
3608
+ permission,
3609
+ error: error.message || String(error),
3610
+ state: await safePermissionState(ctx, permission),
3611
+ };
3612
+ }
3613
+ }
3614
+
3615
+ async function permissionRevoke(ctx, permission) {
3616
+ try {
3617
+ await adb(ctx, ['shell', 'pm', 'revoke', ctx.packageName, permission]);
3618
+ return {
3619
+ action: 'revoke',
3620
+ ...(await permissionState(ctx, permission)),
3621
+ };
3622
+ } catch (error) {
3623
+ return {
3624
+ ok: false,
3625
+ action: 'revoke',
3626
+ packageName: ctx.packageName,
3627
+ permission,
3628
+ error: error.message || String(error),
3629
+ state: await safePermissionState(ctx, permission),
3630
+ };
3631
+ }
3632
+ }
3633
+
3634
+ async function safePermissionState(ctx, permission) {
3635
+ try {
3636
+ return await permissionState(ctx, permission);
3637
+ } catch (error) {
3638
+ return { ok: false, error: error.message || String(error) };
3639
+ }
3640
+ }
3641
+
3642
+ async function appopsSet(ctx, op, mode) {
3643
+ await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
3644
+ return {
3645
+ ok: true,
3646
+ packageName: ctx.packageName,
3647
+ op,
3648
+ mode,
3649
+ };
3650
+ }
3651
+
3652
+ async function resolveLogcatPid(ctx, options) {
3653
+ if (options.pid && options.pid !== true && options.pid !== 'current') {
3654
+ return String(options.pid);
3655
+ }
3656
+ if (options.pid === 'current' || options.appPid || options.packagePid) {
3657
+ try {
3658
+ const result = await adb(ctx, ['shell', 'pidof', '-s', ctx.packageName]);
3659
+ return result.stdout.trim().split(/\s+/).filter(Boolean)[0] || '';
3660
+ } catch (_) {
3661
+ return '';
3662
+ }
3663
+ }
3664
+ return '';
3665
+ }
3666
+
3667
+ function filterLogcat(text, options) {
3668
+ const tags = splitCsv(options.tag || options.tags);
3669
+ const grep = options.grep ? String(options.grep) : '';
3670
+ const grepCaseSensitive = Boolean(options.grepCaseSensitive);
3671
+ const minLevel = priorityValue(options.level || options.minLevel || '');
3672
+ const pid = options.pid ? String(options.pid) : '';
3673
+ const lines = String(text || '').split(/\r?\n/);
3674
+ const filtered = [];
3675
+ let previousIncluded = false;
3676
+ for (const line of lines) {
3677
+ if (!line) continue;
3678
+ const parsed = parseLogcatLine(line);
3679
+ if (!parsed) {
3680
+ if (previousIncluded) filtered.push(line);
3681
+ continue;
3682
+ }
3683
+ let include = true;
3684
+ if (pid && parsed.pid !== pid) include = false;
3685
+ if (tags.length && !tags.includes(parsed.tag)) include = false;
3686
+ if (minLevel >= 0 && priorityValue(parsed.priority) < minLevel) include = false;
3687
+ if (grep) {
3688
+ include = include && (
3689
+ grepCaseSensitive
3690
+ ? line.includes(grep)
3691
+ : line.toLowerCase().includes(grep.toLowerCase())
3692
+ );
3693
+ }
3694
+ previousIncluded = include;
3695
+ if (include) filtered.push(line);
3696
+ }
3697
+ const limit = Number(options.limitLines || options.outputLines || 0);
3698
+ const result = limit > 0 && filtered.length > limit ? filtered.slice(-limit) : filtered;
3699
+ return result.join('\n');
3700
+ }
3701
+
3702
+ function parseLogcatLine(line) {
3703
+ const match = /^\d\d-\d\d\s+\d\d:\d\d:\d\d\.\d+\s+(\d+)\s+(\d+)\s+([VDIWEAF])\s+([^:]+):\s?(.*)$/.exec(line);
3704
+ if (!match) return null;
3705
+ return {
3706
+ pid: match[1],
3707
+ tid: match[2],
3708
+ priority: match[3],
3709
+ tag: match[4].trim(),
3710
+ message: match[5],
3711
+ };
3712
+ }
3713
+
3714
+ function priorityValue(value) {
3715
+ const normalized = String(value || '').trim().toUpperCase();
3716
+ return { V: 0, D: 1, I: 2, W: 3, E: 4, F: 5, A: 5 }[normalized] ?? -1;
3717
+ }
3718
+
3719
+ function splitCsv(value) {
3720
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
3721
+ }
3722
+
3723
+ function adbFollow(ctx, args, durationMs) {
3724
+ const allArgs = [];
3725
+ if (ctx.serial) allArgs.push('-s', ctx.serial);
3726
+ allArgs.push(...args);
3727
+ const boundedDurationMs = Math.max(500, Math.min(durationMs || 5000, 60000));
3728
+ return new Promise((resolve, reject) => {
3729
+ const child = spawn(ctx.adb, allArgs, { windowsHide: true });
3730
+ let stdout = '';
3731
+ let stderr = '';
3732
+ const timer = setTimeout(() => {
3733
+ child.kill();
3734
+ }, boundedDurationMs);
3735
+ child.stdout.on('data', (chunk) => {
3736
+ stdout += chunk.toString();
3737
+ });
3738
+ child.stderr.on('data', (chunk) => {
3739
+ stderr += chunk.toString();
3740
+ });
3741
+ child.on('error', (error) => {
3742
+ clearTimeout(timer);
3743
+ reject(error);
3744
+ });
3745
+ child.on('close', (code) => {
3746
+ clearTimeout(timer);
3747
+ if (code !== 0 && code !== null && stdout.length === 0) {
3748
+ reject(new Error(`adb logcat failed with exit code ${code}: ${stderr}`));
3749
+ return;
3750
+ }
3751
+ resolve(stdout);
3752
+ });
3753
+ });
3754
+ }
3755
+
3756
+ async function launchNativeTest(ctx) {
3757
+ const component = `${ctx.packageName}/${ctx.nativeActivity}`;
3758
+ await adb(ctx, ['shell', 'am', 'start', '-n', component]);
3759
+ return { ok: true, transport: 'adb', component };
3760
+ }
3761
+
3762
+ async function launchFlutter(ctx, initialRoute) {
3763
+ const component = `${ctx.packageName}/${ctx.flutterActivity}`;
3764
+ const args = ['shell', 'am', 'start', '-n', component];
3765
+ if (initialRoute) args.push('-e', 'ai_app_initial_route', initialRoute);
3766
+ await adb(ctx, args);
3767
+ return { ok: true, transport: 'adb', component, initialRoute };
3768
+ }
3769
+
3770
+ async function smoke(ctx, options) {
3771
+ const summary = {
3772
+ packageName: ctx.packageName,
3773
+ port: ctx.port,
3774
+ native: {},
3775
+ flutter: {},
3776
+ };
3777
+
3778
+ await adb(ctx, ['shell', 'am', 'force-stop', ctx.packageName]);
3779
+ await sleep(700);
3780
+ await launchNativeTest(ctx);
3781
+ await sleep(2000);
3782
+
3783
+ const status = await retry(() => bridgeGet(ctx, '/v1/status'), 10, 500);
3784
+ assert(status.ok, 'bridge status is ok');
3785
+ summary.native.statusOk = true;
3786
+
3787
+ const tree = await bridgeGet(ctx, '/v1/view/tree');
3788
+ assert(tree.ok, 'native tree is ok');
3789
+ assert(findNodeByText(tree.root, 'AiApp Native Bridge Test').node, 'native title is visible in SDK tree');
3790
+ assert(findNodeByText(tree.root, 'Native Increment').node, 'native increment button is visible in SDK tree');
3791
+ summary.native.sdkTreeNodeCount = tree.nodeCount;
3792
+
3793
+ const uiaXml = await uiaTree(ctx);
3794
+ assert(uiaXml.includes('AiApp Native Bridge Test'), 'uiautomator tree contains native title');
3795
+ summary.native.uiaTreeOk = true;
3796
+
3797
+ const h5Dom = await bridgeGet(ctx, '/v1/h5/dom');
3798
+ assert(h5Dom.ok, 'native WebView DOM endpoint is ok');
3799
+ assert(h5Dom.dom?.bodyText?.includes('H5 DOM snapshot body text'), 'native WebView DOM contains body text');
3800
+ assert((h5Dom.dom?.controlCount || 0) >= 2, 'native WebView DOM contains controls');
3801
+ summary.native.h5DomControlCount = h5Dom.dom.controlCount;
3802
+
3803
+ const h5ClickResult = await h5Click(ctx, { selector: '#native-h5-button' });
3804
+ assert(h5ClickResult.ok, 'native WebView h5-click endpoint is ok');
3805
+ assert(JSON.stringify(h5ClickResult.result).includes('Native H5 clicked'), 'native WebView h5-click changed DOM');
3806
+ const h5WaitResult = await h5Wait(ctx, { targetText: 'Native H5 clicked', timeoutSec: 5 });
3807
+ assert(h5WaitResult.ok, 'native WebView h5-wait observes clicked text');
3808
+ const h5InputResult = await h5Input(ctx, { selector: '#native-h5-input', value: 'ai_app h5 input' });
3809
+ assert(h5InputResult.ok, 'native WebView h5-input endpoint is ok');
3810
+ assert(h5InputResult.result?.value === 'ai_app h5 input', 'native WebView h5-input changed input value');
3811
+ await keyevent(ctx, 111);
3812
+ await sleep(300);
3813
+ const h5ScrollResult = await h5Scroll(ctx, { selector: '#native-h5-input' });
3814
+ assert(h5ScrollResult.ok, 'native WebView h5-scroll endpoint is ok');
3815
+ const h5DomAfterClick = await bridgeGet(ctx, '/v1/h5/dom');
3816
+ assert(JSON.stringify(h5DomAfterClick.dom).includes('Native H5 clicked'), 'native WebView DOM read sees click result');
3817
+ assert(JSON.stringify(h5DomAfterClick.dom).includes('ai_app h5 input'), 'native WebView DOM read sees input result');
3818
+ summary.native.h5ClickOk = true;
3819
+ summary.native.h5InputOk = true;
3820
+ summary.native.h5WaitOk = true;
3821
+ summary.native.h5ScrollOk = true;
3822
+
3823
+ const webviewProbeUrl = `http://127.0.0.1:${ctx.devicePort || ctx.port}/v1/status?from=webview-cdp-smoke`;
3824
+ const webviewCdp = await webviewCdpCapture(ctx, {
3825
+ durationMs: 3000,
3826
+ pageUrlFilter: 'native-webview',
3827
+ urlFilter: 'webview-cdp-smoke',
3828
+ includeResponseBody: true,
3829
+ script: `(() => { const url = ${JSON.stringify(webviewProbeUrl)}; console.log('ai-bridge-webview-cdp-console', url); fetch(url).then((response) => { console.log('ai-bridge-webview-cdp-response', response.status); return response.text(); }).catch((error) => console.log('ai-bridge-webview-cdp-error', error.name + ':' + error.message)); return url; })()`,
3830
+ });
3831
+ const webviewCdpText = JSON.stringify(webviewCdp);
3832
+ assert(webviewCdp.requests.some((item) => String(item.url || item.responseUrl || '').includes('webview-cdp-smoke')), 'WebView CDP captured H5 network request');
3833
+ assert(webviewCdpText.includes('ai-bridge-webview-cdp-console'), 'WebView CDP captured console output');
3834
+ summary.native.webviewCdpOk = true;
3835
+ summary.native.webviewCdpCapture = webviewCdp.counts;
3836
+
3837
+ const artifactPrefix = 'ai_app_bridge_smoke_screenshot';
3838
+ const screenshotPath = screenshotOutputPath(options, artifactPrefix);
3839
+ const screenshotResult = await screenshot(ctx, screenshotPath, { ...options, artifactPrefix });
3840
+ assert(screenshotResult.width > 0 && screenshotResult.height > 0, 'adb screenshot has size');
3841
+ summary.native.screenshot = {
3842
+ width: screenshotResult.width,
3843
+ height: screenshotResult.height,
3844
+ path: screenshotResult.path,
3845
+ artifact: screenshotResult.artifact,
3846
+ };
3847
+
3848
+ await tapText(ctx, 'Native Increment');
3849
+ await sleep(700);
3850
+ const treeAfterTap = await bridgeGet(ctx, '/v1/view/tree');
3851
+ assert(findNodeByText(treeAfterTap.root, 'Native counter: 1').node, 'tap changed native counter');
3852
+ summary.native.tapChangedCounter = true;
3853
+
3854
+ await tapText(ctx, 'native_input');
3855
+ await keyevent(ctx, 123);
3856
+ await inputText(ctx, 'ai_appsmoke');
3857
+ await keyevent(ctx, 111);
3858
+ await sleep(700);
3859
+ const treeAfterInput = await bridgeGet(ctx, '/v1/view/tree');
3860
+ assert(JSON.stringify(treeAfterInput.root).includes('ai_appsmoke'), 'adb input changed native text field');
3861
+ summary.native.inputTextOk = true;
3862
+
3863
+ const eventsBefore = await bridgeGet(ctx, withQuery('/v1/events', { limit: 1 }));
3864
+ const sinceEventId = lastItem(eventsBefore.items)?.id || 0;
3865
+
3866
+ await tapText(ctx, 'Record Log');
3867
+ await sleep(300);
3868
+ await tapText(ctx, 'Record Network');
3869
+ await sleep(300);
3870
+ await tapText(ctx, 'Record State');
3871
+ await sleep(300);
3872
+ await tapText(ctx, 'Record Event');
3873
+ await sleep(300);
3874
+
3875
+ const listTree = await bridgeGet(ctx, '/v1/view/tree');
3876
+ assert(JSON.stringify(listTree.root).includes('Native List Row 24'), 'native long list row is present in bridge tree');
3877
+ summary.native.longListTreeOk = true;
3878
+
3879
+ await tapText(ctx, 'Open Dialog');
3880
+ const dialogWait = await waitText(ctx, 'Native Dialog Title', 8);
3881
+ assert(dialogWait.ok, 'native dialog title appeared');
3882
+ await tapText(ctx, 'DIALOG CONFIRM');
3883
+ await sleep(500);
3884
+
3885
+ const logs = await bridgeGet(ctx, '/v1/logs');
3886
+ const network = await bridgeGet(ctx, '/v1/network');
3887
+ const state = await bridgeGet(ctx, '/v1/state');
3888
+ const events = await bridgeGet(ctx, '/v1/events');
3889
+ assert(JSON.stringify(logs.items).includes('NativeBridgeTest'), 'logs endpoint contains native test entries');
3890
+ assert(JSON.stringify(network.items).includes('https://debug.local/native-test'), 'network endpoint contains native test request');
3891
+ assert(JSON.stringify(state.values).includes('native_test.screen'), 'state endpoint contains native test state');
3892
+ assert(JSON.stringify(events.items).includes('dialog_confirmed'), 'events endpoint contains dialog confirmation');
3893
+ summary.native.dialogOk = true;
3894
+ summary.native.capture = { logs: logs.count, network: network.count, state: state.count, events: events.count };
3895
+
3896
+ const limitedLogs = await bridgeGet(ctx, withQuery('/v1/logs', { limit: 1 }));
3897
+ const eventsSince = await bridgeGet(ctx, withQuery('/v1/events', { sinceId: sinceEventId, limit: 20 }));
3898
+ assert(limitedLogs.count <= 1 && limitedLogs.limit === 1, 'logs endpoint honors limit query');
3899
+ assert(JSON.stringify(eventsSince.items).includes('dialog_confirmed'), 'events endpoint honors sinceId query');
3900
+ summary.native.captureQueryOk = true;
3901
+
3902
+ const microphonePermission = 'android.permission.RECORD_AUDIO';
3903
+ const microphoneBefore = await permissionState(ctx, microphonePermission);
3904
+ if (microphoneBefore.ok && !microphoneBefore.granted) {
3905
+ await tapText(ctx, 'Request Microphone Permission');
3906
+ const permissionResult = await permissionDialog(ctx, {
3907
+ attempts: 10,
3908
+ intervalMs: 500,
3909
+ resourceId: 'permission_allow_one_time_button,permission_allow_foreground_only_button,permission_allow_button',
3910
+ });
3911
+ assert(permissionResult.ok, 'permission dialog allow button was tapped');
3912
+ await sleep(1000);
3913
+ const grantedMicrophone = await permissionState(ctx, microphonePermission);
3914
+ assert(grantedMicrophone.ok && grantedMicrophone.granted, 'microphone permission is granted after dialog handling');
3915
+ summary.native.permissionDialogOk = true;
3916
+ summary.native.permissionState = {
3917
+ permission: microphonePermission,
3918
+ granted: grantedMicrophone.granted,
3919
+ matched: permissionResult.matched,
3920
+ };
3921
+ } else {
3922
+ summary.native.permissionDialogSkipped = microphoneBefore.ok ? 'already_granted' : microphoneBefore.error;
3923
+ }
3924
+
3925
+ let scrolledUiaXml = '';
3926
+ for (let attempt = 0; attempt < 4; attempt += 1) {
3927
+ scrolledUiaXml = await uiaTree(ctx);
3928
+ if (scrolledUiaXml.includes('Native List Row 24') || scrolledUiaXml.includes('Finish')) {
3929
+ break;
3930
+ }
3931
+ await swipe(ctx, 540, 2100, 540, 500, 700);
3932
+ await sleep(800);
3933
+ }
3934
+ if (!scrolledUiaXml.includes('Native List Row 24') && !scrolledUiaXml.includes('Finish')) {
3935
+ throw new Error('native list bottom is not visible after repeated swipes');
3936
+ }
3937
+ summary.native.scrollOk = scrolledUiaXml.includes('Native List Row 24') || scrolledUiaXml.includes('Finish');
3938
+
3939
+ let backLeftNative = false;
3940
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3941
+ await keyevent(ctx, 4);
3942
+ await sleep(700);
3943
+ try {
3944
+ const statusAfterBack = await bridgeGet(ctx, '/v1/status');
3945
+ if (!String(statusAfterBack.activity?.current || '').includes('DebugBridgeNativeTestActivity')) {
3946
+ backLeftNative = true;
3947
+ break;
3948
+ }
3949
+ } catch (_) {
3950
+ backLeftNative = true;
3951
+ break;
3952
+ }
3953
+ }
3954
+ assert(backLeftNative, 'back left native debug activity');
3955
+ summary.native.backOk = true;
3956
+
3957
+ if (!options.skipFlutterLaunch) {
3958
+ await launchFlutter(ctx, '');
3959
+ const flutterStatus = await waitFlutterLayout(ctx, 20);
3960
+ const layout = flutterStatus.flutter?.layout;
3961
+ assert(layout, 'flutter snapshot has layout');
3962
+ assert(layout.widgetDump?.ok === true, 'flutter widget dump is ok');
3963
+ assert(String(layout.widgetDump?.text || '').length > 0, 'flutter widget dump has text');
3964
+ summary.flutter.widgetDumpOk = true;
3965
+ summary.flutter.widgetDumpLength = layout.widgetDump.length;
3966
+
3967
+ await flutterAction(ctx, { action: 'openHarness' });
3968
+ await sleep(1000);
3969
+ const flutterLogsBefore = await bridgeGet(ctx, withQuery('/v1/logs', { limit: 1 }));
3970
+ const flutterNetworkBefore = await bridgeGet(ctx, withQuery('/v1/network', { limit: 1 }));
3971
+ const sinceFlutterLogId = lastItem(flutterLogsBefore.items)?.id || 0;
3972
+ const sinceFlutterNetworkId = lastItem(flutterNetworkBefore.items)?.id || 0;
3973
+
3974
+ await flutterAction(ctx, { action: 'tapText', text: 'Record Auto Log Fixture' });
3975
+ await sleep(800);
3976
+ const flutterAutoLogs = await bridgeGet(ctx, withQuery('/v1/logs', { sinceId: sinceFlutterLogId, limit: 20 }));
3977
+ assert(JSON.stringify(flutterAutoLogs.items).includes('ai_app auto debugPrint fixture'), 'flutter debugPrint auto log is captured');
3978
+ assert(JSON.stringify(flutterAutoLogs.items).includes('ai_app auto flutter error fixture'), 'flutter FlutterError auto log is captured');
3979
+ summary.flutter.autoLogCaptureOk = true;
3980
+
3981
+ await flutterAction(ctx, { action: 'tapText', text: 'Run Dart HttpClient Fixture' });
3982
+ await sleep(1200);
3983
+ const flutterAutoNetwork = await bridgeGet(ctx, withQuery('/v1/network', { sinceId: sinceFlutterNetworkId, limit: 20 }));
3984
+ const flutterAutoNetworkText = JSON.stringify(flutterAutoNetwork.items);
3985
+ assert(flutterAutoNetworkText.includes('flutter-httpclient-auto'), 'flutter HttpClient auto network source is captured');
3986
+ assert(flutterAutoNetworkText.includes('/v1/events'), 'flutter HttpClient auto network URL is captured');
3987
+ assert(flutterAutoNetworkText.includes('dart_httpclient_fixture'), 'flutter HttpClient auto request body is captured');
3988
+ summary.flutter.autoHttpClientCaptureOk = true;
3989
+
3990
+ summary.flutter.h5DomTested = false;
3991
+ summary.flutter.h5DomNote = 'Flutter WebView DOM requires a generic WebView adapter or controller registry, not app-specific route code.';
3992
+ }
3993
+
3994
+ summary.ok = true;
3995
+ return summary;
3996
+ }
3997
+
3998
+ async function waitFlutterLayout(ctx, attempts) {
3999
+ for (let index = 0; index < attempts; index += 1) {
4000
+ const status = await bridgeGet(ctx, '/v1/status');
4001
+ if (status.flutter?.layout?.widgetDump?.ok === true) {
4002
+ return status;
4003
+ }
4004
+ await sleep(700);
4005
+ }
4006
+ return bridgeGet(ctx, '/v1/status');
4007
+ }
4008
+
4009
+ async function retry(action, attempts, delayMs) {
4010
+ let lastError;
4011
+ for (let index = 0; index < attempts; index += 1) {
4012
+ try {
4013
+ return await action();
4014
+ } catch (error) {
4015
+ lastError = error;
4016
+ await sleep(delayMs);
4017
+ }
4018
+ }
4019
+ throw lastError;
4020
+ }
4021
+
4022
+ function assert(condition, message) {
4023
+ if (!condition) throw new Error(`ASSERT FAILED: ${message}`);
4024
+ }
4025
+
4026
+ function lastItem(items) {
4027
+ return Array.isArray(items) && items.length > 0 ? items[items.length - 1] : null;
4028
+ }
4029
+
4030
+ function sleep(ms) {
4031
+ return new Promise((resolve) => setTimeout(resolve, ms));
4032
+ }
4033
+
4034
+ function uiautomatorLockPath(ctx = {}) {
4035
+ const key = sanitizeLockKey([
4036
+ ctx.serial || 'default',
4037
+ ctx.adb || defaults.adb,
4038
+ ].join('-'));
4039
+ return path.join(os.tmpdir(), `ai-app-bridge-uiautomator-${key}.lock`);
4040
+ }
4041
+
4042
+ function sanitizeLockKey(value) {
4043
+ return String(value || 'default').replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 120) || 'default';
4044
+ }
4045
+
4046
+ async function withFileLock(lockPath, action, options = {}) {
4047
+ const timeoutMs = Number(options.timeoutMs || 30000);
4048
+ const staleMs = Number(options.staleMs || 120000);
4049
+ const pollMs = Number(options.pollMs || 100);
4050
+ const startMs = Date.now();
4051
+ let handle = null;
4052
+
4053
+ while (handle === null) {
4054
+ try {
4055
+ handle = fs.openSync(lockPath, 'wx');
4056
+ try {
4057
+ fs.writeFileSync(handle, JSON.stringify({ pid: process.pid, createdAtMs: Date.now() }));
4058
+ } catch (writeError) {
4059
+ try {
4060
+ fs.closeSync(handle);
4061
+ } catch (_) {
4062
+ // Ignore close failure while unwinding the lock creation failure.
4063
+ }
4064
+ try {
4065
+ fs.unlinkSync(lockPath);
4066
+ } catch (_) {
4067
+ // Ignore cleanup failure while preserving the original write error.
4068
+ }
4069
+ handle = null;
4070
+ throw writeError;
4071
+ }
4072
+ } catch (error) {
4073
+ if (error && error.code === 'EEXIST') {
4074
+ removeStaleLock(lockPath, staleMs);
4075
+ if (Date.now() - startMs >= timeoutMs) {
4076
+ const timeout = new Error(`timed out waiting for lock: ${lockPath}`);
4077
+ timeout.code = 'LOCK_TIMEOUT';
4078
+ throw timeout;
4079
+ }
4080
+ await sleep(pollMs);
4081
+ continue;
4082
+ }
4083
+ throw error;
4084
+ }
4085
+ }
4086
+
4087
+ try {
4088
+ return await action();
4089
+ } finally {
4090
+ try {
4091
+ fs.closeSync(handle);
4092
+ } catch (_) {
4093
+ // Ignore close failures; unlink below is the operation that releases the lock for peers.
4094
+ }
4095
+ try {
4096
+ fs.unlinkSync(lockPath);
4097
+ } catch (_) {
4098
+ // A stale-lock cleanup racing with process shutdown should not mask the original result.
4099
+ }
4100
+ }
4101
+ }
4102
+
4103
+ function removeStaleLock(lockPath, staleMs) {
4104
+ try {
4105
+ const stat = fs.statSync(lockPath);
4106
+ if (Date.now() - stat.mtimeMs > staleMs) {
4107
+ fs.unlinkSync(lockPath);
4108
+ }
4109
+ } catch (_) {
4110
+ // Another process may have released the lock between the exists check and stat/unlink.
4111
+ }
4112
+ }
4113
+
4114
+ function requiredString(value, name) {
4115
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} is required`);
4116
+ return value;
4117
+ }
4118
+
4119
+ function requiredNumber(value, name) {
4120
+ const number = Number(value);
4121
+ if (!Number.isFinite(number)) throw new Error(`${name} is required`);
4122
+ return number;
4123
+ }
4124
+
4125
+ module.exports = {
4126
+ buildBridgeFailureResult,
4127
+ defaultInstallerButtonTexts,
4128
+ artifactTimestamp,
4129
+ compactBridgeTree,
4130
+ compactStatus,
4131
+ compactUiaTree,
4132
+ defaultArtifactDirectory,
4133
+ defaultArtifactPath,
4134
+ findFlutterNode,
4135
+ findTappableNodeByText,
4136
+ firstErrorLine,
4137
+ flutterNodePoint,
4138
+ flutterPhysicalViewport,
4139
+ helpText,
4140
+ installerButtonTextsForSurface,
4141
+ isAdbInputTextSafe,
4142
+ isLikelyInstallerSurface,
4143
+ nodeTapState,
4144
+ normalizeBridgeError,
4145
+ parseWebViewDevToolsSockets,
4146
+ parseKeyboardState,
4147
+ parseUiaBounds,
4148
+ parseUiaViewport,
4149
+ parseComponentFromWindowLine,
4150
+ parseForegroundWindow,
4151
+ chooseWebViewDevToolsSocket,
4152
+ chooseWebViewPage,
4153
+ shapeNetworkCapture,
4154
+ compactNetworkRecord,
4155
+ pruneGeneratedArtifacts,
4156
+ shouldSkipInstallerTapForInstalledPackage,
4157
+ shouldDismissKeyboardForPoint,
4158
+ screenshotOutputPath,
4159
+ statusSearchText,
4160
+ uiautomatorLockPath,
4161
+ waitTextConditionsMet,
4162
+ withFileLock,
4163
+ };
4164
+