mobile-debug-mcp 0.32.4 → 0.34.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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -0
- package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolveAdbCmd } from '../utils/android/utils.js';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { DebuggerError, debuggerRemaining, debuggerSignal, nativeError } from './worker.js';
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
export class AndroidDebugNative {
|
|
7
|
+
async adb(device, args) {
|
|
8
|
+
try {
|
|
9
|
+
return (await exec(resolveAdbCmd(), ['-s', device, ...args], { timeout: debuggerRemaining(5000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 })).stdout.trim();
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
throw nativeError(e, 'DEVICE_UNAVAILABLE');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async identity(device, target) {
|
|
16
|
+
try {
|
|
17
|
+
const stat = await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/stat`]);
|
|
18
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/);
|
|
19
|
+
const status = await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/status`]);
|
|
20
|
+
const uid = Number(/^Uid:\s+(\d+)/m.exec(status)?.[1]);
|
|
21
|
+
const name = (await this.adb(device, ['shell', 'cat', `/proc/${target.pid}/cmdline`])).split('\0')[0];
|
|
22
|
+
if (!/^\d+$/.test(fields[19] ?? '') || uid !== target.uid || name !== target.process_name || (target.start_identity && fields[19] !== target.start_identity))
|
|
23
|
+
throw new Error('identity');
|
|
24
|
+
return { ...target, start_identity: fields[19] };
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEVICE_UNAVAILABLE'].includes(e.code))
|
|
28
|
+
throw e;
|
|
29
|
+
throw new DebuggerError('PROCESS_CHANGED');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async candidates(device, app) {
|
|
33
|
+
if (!/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(app))
|
|
34
|
+
throw new DebuggerError('INVALID_ARGUMENT');
|
|
35
|
+
const packages = await this.adb(device, ['shell', 'cmd', 'package', 'list', 'packages', '-U', app]);
|
|
36
|
+
const uid = Number(new RegExp(`^package:${app.replaceAll('.', '\\.')} uid:(\\d+)$`, 'm').exec(packages)?.[1]);
|
|
37
|
+
if (!Number.isSafeInteger(uid))
|
|
38
|
+
throw new DebuggerError('PROCESS_NOT_FOUND');
|
|
39
|
+
// `adb jdwp` is a subscription, not a one-shot command. Bound the sample.
|
|
40
|
+
let jdwpOutput;
|
|
41
|
+
try {
|
|
42
|
+
jdwpOutput = (await exec(resolveAdbCmd(), ['-s', device, 'jdwp'], { timeout: debuggerRemaining(500), signal: debuggerSignal(), maxBuffer: 1024 * 1024 })).stdout;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (debuggerSignal()?.aborted)
|
|
46
|
+
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
47
|
+
debuggerRemaining(1);
|
|
48
|
+
if (!e.killed || e.stderr?.trim())
|
|
49
|
+
throw nativeError(e, 'DEVICE_UNAVAILABLE');
|
|
50
|
+
jdwpOutput = e.stdout ?? '';
|
|
51
|
+
}
|
|
52
|
+
const jdwp = new Set(jdwpOutput.split(/\s+/).filter(id => /^\d+$/.test(id)));
|
|
53
|
+
const ps = await this.adb(device, ['shell', 'ps', '-A', '-o', 'PID,NAME']);
|
|
54
|
+
const result = [];
|
|
55
|
+
for (const line of ps.split('\n').slice(1)) {
|
|
56
|
+
const [pid, name] = line.trim().split(/\s+/);
|
|
57
|
+
if (jdwp.has(pid) && (name === app || name.startsWith(app + ':')))
|
|
58
|
+
result.push(await this.identity(device, { app_id: app, pid: Number(pid), process_name: name, start_identity: '', uid }));
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
async forward(device, pid) {
|
|
63
|
+
const port = Number(await this.adb(device, ['forward', '--no-rebind', 'tcp:0', `jdwp:${pid}`]));
|
|
64
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
65
|
+
throw new DebuggerError('DEBUGGER_ATTACH_FAILED');
|
|
66
|
+
return port;
|
|
67
|
+
}
|
|
68
|
+
async removeForward(device, port, pid) {
|
|
69
|
+
const list = await this.adb(device, ['forward', '--list']);
|
|
70
|
+
const entry = list.split('\n').find(line => line.trim().split(/\s+/)[1] === `tcp:${port}`);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return;
|
|
73
|
+
const [serial, local, remote] = entry.trim().split(/\s+/);
|
|
74
|
+
if (serial !== device || local !== `tcp:${port}` || remote !== `jdwp:${pid}`)
|
|
75
|
+
throw new DebuggerError('DEBUGGER_CLEANUP_FAILED');
|
|
76
|
+
await this.adb(device, ['forward', '--remove', `tcp:${port}`]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { DebuggerError } from './worker.js';
|
|
3
|
+
let pauseProvider = () => null;
|
|
4
|
+
export function registerDebuggerPauseProvider(provider) { pauseProvider = provider; }
|
|
5
|
+
const context = new AsyncLocalStorage();
|
|
6
|
+
export function withDebuggerGuard(platform, device, operation) {
|
|
7
|
+
return context.run({ platform, device, dispatched: false }, operation);
|
|
8
|
+
}
|
|
9
|
+
// Synchronous checks and native submission share a JS turn with pause updates.
|
|
10
|
+
export function checkDebuggerGuard(dispatch = false) {
|
|
11
|
+
const current = context.getStore();
|
|
12
|
+
if (!current || current.platform !== 'android')
|
|
13
|
+
return;
|
|
14
|
+
if (pauseProvider(current.device))
|
|
15
|
+
throw new DebuggerError('DEBUGGER_PAUSED');
|
|
16
|
+
if (dispatch)
|
|
17
|
+
current.dispatched = true;
|
|
18
|
+
}
|
|
19
|
+
export function guardDispatched() { return context.getStore()?.dispatched ?? false; }
|
|
20
|
+
export function pausedReadiness(device) {
|
|
21
|
+
const paused = pauseProvider(device);
|
|
22
|
+
if (!paused)
|
|
23
|
+
return null;
|
|
24
|
+
return { state: 'debugger_paused', scope: 'device_ui', basis: 'observed',
|
|
25
|
+
captured_at_ms: paused.captured_at_ms, sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
26
|
+
evidence: [{ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms, snapshot_revision: null }],
|
|
27
|
+
limitations: ['FOREGROUND_APP_UNVERIFIED', 'DEVICE_WIDE_UI_GUARD'],
|
|
28
|
+
capabilities: { loading: true, transitioning: true, settled: true, unresponsive: false, debugger_paused: true },
|
|
29
|
+
debug_session_id: paused.debug_session_id, target: paused.target };
|
|
30
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { registerDebuggerPauseProvider } from './guard.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { AndroidDebugNative } from './android.js';
|
|
4
|
+
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining } from './worker.js';
|
|
5
|
+
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
6
|
+
evaluation: false, mutation: false, coroutines: false, native: false };
|
|
7
|
+
const limitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
|
|
8
|
+
export function debuggerExpiry(created, activity, pausedAt, now) {
|
|
9
|
+
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
|
|
10
|
+
}
|
|
11
|
+
export class DebuggerSessions {
|
|
12
|
+
native;
|
|
13
|
+
workerFactory;
|
|
14
|
+
prerequisite;
|
|
15
|
+
sessions = new Map();
|
|
16
|
+
closing = false;
|
|
17
|
+
constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker) {
|
|
18
|
+
this.native = native;
|
|
19
|
+
this.workerFactory = workerFactory;
|
|
20
|
+
this.prerequisite = prerequisite;
|
|
21
|
+
}
|
|
22
|
+
prune() {
|
|
23
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
|
|
24
|
+
for (const s of terminal)
|
|
25
|
+
if (Date.now() - s.terminalAt >= 600000)
|
|
26
|
+
this.sessions.delete(s.id);
|
|
27
|
+
const remaining = terminal.filter(s => this.sessions.has(s.id));
|
|
28
|
+
for (const s of remaining.slice(0, Math.max(0, remaining.length - 100)))
|
|
29
|
+
this.sessions.delete(s.id);
|
|
30
|
+
}
|
|
31
|
+
get(id) { this.prune(); const s = this.sessions.get(id); if (!s)
|
|
32
|
+
throw new DebuggerError('DEBUGGER_NOT_FOUND'); s.activity = Date.now(); return s; }
|
|
33
|
+
serial(s, fn) { const out = s.queue.then(fn); s.queue = out.catch(() => { }); return out; }
|
|
34
|
+
result(s, payload = {}) {
|
|
35
|
+
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
|
|
36
|
+
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
|
|
37
|
+
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false } } : {}), debug_session_id: s.id,
|
|
38
|
+
device: { platform: 'android', id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
39
|
+
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
40
|
+
}
|
|
41
|
+
summary(id) { return this.result(this.get(id)); }
|
|
42
|
+
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
|
|
43
|
+
paused(device) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
|
|
44
|
+
assertRunning(device) { if (this.paused(device))
|
|
45
|
+
throw new DebuggerError('DEBUGGER_PAUSED'); }
|
|
46
|
+
event(s, event) {
|
|
47
|
+
if (s.cleanup.status === 'complete')
|
|
48
|
+
return;
|
|
49
|
+
if (event.event === 'stopped' && !s.cleaning) {
|
|
50
|
+
s.state = 'paused';
|
|
51
|
+
s.stop = event;
|
|
52
|
+
s.pausedAt ??= Date.now();
|
|
53
|
+
}
|
|
54
|
+
if (event.event === 'terminated' || event.event === 'worker_failed') {
|
|
55
|
+
if (!s.cleaning && s.state !== 'failed')
|
|
56
|
+
s.reason = event.reason ?? 'worker_failed';
|
|
57
|
+
if (!s.cleaning) {
|
|
58
|
+
s.state = event.event === 'worker_failed' ? 'failed' : event.reason === 'process_exit' || event.reason === 'transport_lost' ? 'disconnected' : 'detached';
|
|
59
|
+
s.stop = null;
|
|
60
|
+
// The worker has disposed or lost its transport. Cleanup remains pending until host resources are checked.
|
|
61
|
+
void this.serial(s, () => this.cleanup(s, false));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
s.events.push({ ...event, sequence: ++s.sequence, received_at_ms: Date.now() });
|
|
65
|
+
if (s.events.length > 256)
|
|
66
|
+
s.events.shift();
|
|
67
|
+
}
|
|
68
|
+
async capabilities(device, app, pid) {
|
|
69
|
+
const prerequisites = { jdk: false, device: false, process: false };
|
|
70
|
+
let candidates = [];
|
|
71
|
+
const reasons = [];
|
|
72
|
+
try {
|
|
73
|
+
await this.prerequisite();
|
|
74
|
+
prerequisites.jdk = true;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
78
|
+
throw e;
|
|
79
|
+
reasons.push('DEBUGGER_UNSUPPORTED');
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
candidates = await this.native.candidates(device, app);
|
|
83
|
+
prerequisites.device = true;
|
|
84
|
+
prerequisites.process = candidates.some(t => pid === undefined || pid === t.pid);
|
|
85
|
+
if (!prerequisites.process)
|
|
86
|
+
reasons.push('PROCESS_NOT_FOUND');
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
90
|
+
throw e;
|
|
91
|
+
reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
|
|
92
|
+
}
|
|
93
|
+
return { success: true, debug_session_id: null, device: { platform: 'android', id: device }, target: null, state: null,
|
|
94
|
+
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
95
|
+
capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
96
|
+
}
|
|
97
|
+
async attach(device, app, pid, timeout = 10000) {
|
|
98
|
+
this.prune();
|
|
99
|
+
const owned = [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete');
|
|
100
|
+
if (this.closing || owned.length >= 4 || owned.some(s => s.device === device))
|
|
101
|
+
throw new DebuggerError('DEBUGGER_BUSY');
|
|
102
|
+
const s = { id: randomUUID(), device, target: null, state: 'attaching', stop: null, reason: null,
|
|
103
|
+
queue: Promise.resolve(), events: [], sequence: 0, cleanup: { status: 'pending', remaining_resources: [] }, created: Date.now(), activity: Date.now() };
|
|
104
|
+
this.sessions.set(s.id, s);
|
|
105
|
+
return this.serial(s, async () => {
|
|
106
|
+
const deadline = Date.now() + timeout;
|
|
107
|
+
const remaining = () => { debuggerRemaining(timeout); const ms = deadline - Date.now(); if (ms <= 0)
|
|
108
|
+
throw new DebuggerError('DEBUGGER_TIMEOUT'); return ms; };
|
|
109
|
+
try {
|
|
110
|
+
await this.prerequisite();
|
|
111
|
+
remaining();
|
|
112
|
+
const candidates = (await this.native.candidates(device, app)).filter(t => pid === undefined || t.pid === pid);
|
|
113
|
+
remaining();
|
|
114
|
+
if (!candidates.length)
|
|
115
|
+
throw new DebuggerError('PROCESS_NOT_FOUND');
|
|
116
|
+
if (candidates.length > 1)
|
|
117
|
+
throw new DebuggerError('PROCESS_AMBIGUOUS');
|
|
118
|
+
s.target = candidates[0];
|
|
119
|
+
s.port = await this.native.forward(device, s.target.pid);
|
|
120
|
+
remaining();
|
|
121
|
+
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
|
|
122
|
+
s.worker = await this.workerFactory(event => this.event(s, event));
|
|
123
|
+
remaining();
|
|
124
|
+
s.cleanup.remaining_resources.push('jdi_worker');
|
|
125
|
+
await s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining());
|
|
126
|
+
await this.native.identity(device, s.target);
|
|
127
|
+
remaining();
|
|
128
|
+
if (s.state === 'attaching')
|
|
129
|
+
s.state = 'running';
|
|
130
|
+
if (['failed', 'disconnected', 'detached'].includes(s.state))
|
|
131
|
+
throw new DebuggerError('DEBUGGER_ATTACH_FAILED');
|
|
132
|
+
s.timer = setInterval(() => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const reason = debuggerExpiry(s.created, s.activity, s.pausedAt, now);
|
|
135
|
+
if (reason) {
|
|
136
|
+
s.reason = reason;
|
|
137
|
+
void this.serial(s, () => this.cleanup(s, true));
|
|
138
|
+
}
|
|
139
|
+
}, 1000);
|
|
140
|
+
s.timer.unref();
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
s.state = 'failed';
|
|
144
|
+
s.reason = e instanceof DebuggerError ? e.code : 'DEBUGGER_ATTACH_FAILED';
|
|
145
|
+
await this.cleanup(s, true);
|
|
146
|
+
return this.result(s, { success: false, error: { code: s.reason, message: s.reason, retryable: false } });
|
|
147
|
+
}
|
|
148
|
+
return this.result(s);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async cleanup(s, requestDetach) {
|
|
152
|
+
if (s.cleaning)
|
|
153
|
+
return s.cleaning;
|
|
154
|
+
s.cleaning = withDebuggerBudget(10000, undefined, async () => {
|
|
155
|
+
if (s.timer)
|
|
156
|
+
clearInterval(s.timer);
|
|
157
|
+
const terminal = ['failed', 'disconnected'].includes(s.state) ? s.state : 'detached';
|
|
158
|
+
s.state = 'detaching';
|
|
159
|
+
s.stop = null;
|
|
160
|
+
s.pausedAt = undefined;
|
|
161
|
+
const remaining = [];
|
|
162
|
+
if (s.worker) {
|
|
163
|
+
if (requestDetach) {
|
|
164
|
+
try {
|
|
165
|
+
await s.worker.call('detach', {}, 3000);
|
|
166
|
+
}
|
|
167
|
+
catch { /* EOF/transport closure is the fallback; execution remains unknown. */ }
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await s.worker.close();
|
|
171
|
+
s.worker = undefined;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
remaining.push('jdi_worker');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (s.port !== undefined && s.target) {
|
|
178
|
+
try {
|
|
179
|
+
await this.native.removeForward(s.device, s.port, s.target.pid);
|
|
180
|
+
s.port = undefined;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
remaining.push(`forward:tcp:${s.port}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
s.events = [];
|
|
187
|
+
s.cleanup = { status: remaining.length ? 'failed' : 'complete', remaining_resources: remaining };
|
|
188
|
+
s.state = terminal;
|
|
189
|
+
s.terminalAt = Date.now();
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
await s.cleaning;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
s.cleaning = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async command(id, command, args = {}, timeout = 10000) {
|
|
199
|
+
const s = this.get(id);
|
|
200
|
+
if (command === 'events') {
|
|
201
|
+
const deadline = Date.now() + timeout;
|
|
202
|
+
const after = args.afterSequence ?? 0;
|
|
203
|
+
if (after > s.sequence)
|
|
204
|
+
throw new DebuggerError('INVALID_ARGUMENT');
|
|
205
|
+
if (args.signal?.aborted)
|
|
206
|
+
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
207
|
+
if (s.worker && ['running', 'paused'].includes(s.state))
|
|
208
|
+
await s.worker.call('heartbeat', {}, Math.min(timeout, 1000));
|
|
209
|
+
while (Date.now() < deadline && !s.events.some(e => e.sequence > after) && s.cleanup.status === 'pending') {
|
|
210
|
+
if (args.signal?.aborted)
|
|
211
|
+
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())));
|
|
213
|
+
}
|
|
214
|
+
const events = s.events.filter(e => e.sequence > after).slice(0, 100);
|
|
215
|
+
return this.result(s, { events, next_cursor: events.at(-1)?.sequence ?? s.sequence, overflow: after < (s.events[0]?.sequence ?? s.sequence + 1) - 1, timed_out: events.length === 0 });
|
|
216
|
+
}
|
|
217
|
+
return this.serial(s, async () => {
|
|
218
|
+
if (command === 'detach') {
|
|
219
|
+
if (s.cleanup.status !== 'complete')
|
|
220
|
+
await this.cleanup(s, true);
|
|
221
|
+
return this.result(s, { success: s.cleanup.status === 'complete', execution_state: 'unknown' });
|
|
222
|
+
}
|
|
223
|
+
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
|
|
224
|
+
return this.result(s);
|
|
225
|
+
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
|
|
226
|
+
throw new DebuggerError('DEBUGGER_DISCONNECTED');
|
|
227
|
+
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
228
|
+
if (args.stopId !== s.stop?.stop_id)
|
|
229
|
+
throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
|
|
230
|
+
if (s.state !== 'paused')
|
|
231
|
+
throw new DebuggerError('DEBUGGER_NOT_PAUSED');
|
|
232
|
+
}
|
|
233
|
+
if (command === 'step' || command === 'resume') {
|
|
234
|
+
s.state = 'running';
|
|
235
|
+
s.stop = null;
|
|
236
|
+
s.pausedAt = undefined;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const data = await s.worker.call(command, args, timeout);
|
|
240
|
+
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
if (e instanceof DebuggerError && e.code === 'DEBUGGER_DISCONNECTED') {
|
|
244
|
+
s.state = 'disconnected';
|
|
245
|
+
s.reason = 'transport_lost';
|
|
246
|
+
await this.cleanup(s, false);
|
|
247
|
+
if (command === 'state')
|
|
248
|
+
return this.result(s);
|
|
249
|
+
}
|
|
250
|
+
if ((command === 'step' || command === 'resume' || command === 'set' || command === 'remove') && e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED', 'DEBUGGER_WORKER_FAILED'].includes(e.code)) {
|
|
251
|
+
s.state = 'failed';
|
|
252
|
+
s.reason = e.code;
|
|
253
|
+
await this.cleanup(s, true);
|
|
254
|
+
}
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
|
|
260
|
+
}
|
|
261
|
+
export const debuggerSessions = new DebuggerSessions();
|
|
262
|
+
registerDebuggerPauseProvider(device => debuggerSessions.paused(device));
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { readFile, mkdtemp, chmod, rm } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { detectJavaHome } from '../utils/java.js';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const operations = new AsyncLocalStorage();
|
|
12
|
+
export function withDebuggerBudget(timeout, signal, operation) {
|
|
13
|
+
return operations.run({ deadline: performance.now() + timeout, signal }, operation);
|
|
14
|
+
}
|
|
15
|
+
export function debuggerRemaining(fallback) {
|
|
16
|
+
const context = operations.getStore();
|
|
17
|
+
if (context?.signal?.aborted)
|
|
18
|
+
throw new DebuggerError('DEBUGGER_CANCELLED');
|
|
19
|
+
const remaining = Math.floor((context?.deadline ?? Infinity) - performance.now());
|
|
20
|
+
if (remaining <= 0)
|
|
21
|
+
throw new DebuggerError('DEBUGGER_TIMEOUT');
|
|
22
|
+
return Math.min(fallback, remaining);
|
|
23
|
+
}
|
|
24
|
+
export function debuggerSignal() { return operations.getStore()?.signal; }
|
|
25
|
+
export function nativeError(error, fallback) {
|
|
26
|
+
if (error instanceof DebuggerError)
|
|
27
|
+
return error;
|
|
28
|
+
if (debuggerSignal()?.aborted)
|
|
29
|
+
return new DebuggerError('DEBUGGER_CANCELLED');
|
|
30
|
+
if (error?.killed)
|
|
31
|
+
return new DebuggerError('DEBUGGER_TIMEOUT');
|
|
32
|
+
return new DebuggerError(fallback);
|
|
33
|
+
}
|
|
34
|
+
export class DebuggerError extends Error {
|
|
35
|
+
code;
|
|
36
|
+
constructor(code) {
|
|
37
|
+
super(code);
|
|
38
|
+
this.code = code;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const workerSource = fileURLToPath(new URL('../../worker/DebuggerWorker.java', import.meta.url));
|
|
42
|
+
let compiled;
|
|
43
|
+
export async function prepareWorker() {
|
|
44
|
+
compiled ??= (async () => {
|
|
45
|
+
const home = await detectJavaHome({ quiet: true });
|
|
46
|
+
const java = home ? path.join(home, 'bin', 'java') : 'java';
|
|
47
|
+
const javac = home ? path.join(home, 'bin', 'javac') : 'javac';
|
|
48
|
+
const version = await exec(java, ['-version'], { timeout: debuggerRemaining(5000), signal: debuggerSignal() });
|
|
49
|
+
const major = Number(/version "(\d+)/.exec(version.stderr)?.[1]);
|
|
50
|
+
if (!(major >= 17))
|
|
51
|
+
throw new DebuggerError('DEBUGGER_UNSUPPORTED');
|
|
52
|
+
const source = await readFile(workerSource);
|
|
53
|
+
const key = createHash('sha256').update(source).update(version.stderr).digest('hex').slice(0, 16);
|
|
54
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), `mobile-debug-jdi-${key}-`));
|
|
55
|
+
await chmod(directory, 0o700);
|
|
56
|
+
try {
|
|
57
|
+
await exec(javac, ['--release', '17', '--add-modules', 'jdk.jdi', '-d', directory, workerSource], { timeout: debuggerRemaining(15000), signal: debuggerSignal(), maxBuffer: 1024 * 1024 });
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
await rm(directory, { recursive: true, force: true });
|
|
61
|
+
throw nativeError(e, 'DEBUGGER_UNSUPPORTED');
|
|
62
|
+
}
|
|
63
|
+
return { java, directory };
|
|
64
|
+
})().catch(error => { compiled = undefined; throw error; });
|
|
65
|
+
return compiled;
|
|
66
|
+
}
|
|
67
|
+
export class JdiWorker {
|
|
68
|
+
child;
|
|
69
|
+
pending = new Map();
|
|
70
|
+
sequence = 0;
|
|
71
|
+
dead = false;
|
|
72
|
+
exited;
|
|
73
|
+
constructor(java, directory, onEvent) {
|
|
74
|
+
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
75
|
+
let buffer = '';
|
|
76
|
+
const fail = () => {
|
|
77
|
+
if (this.dead)
|
|
78
|
+
return;
|
|
79
|
+
this.dead = true;
|
|
80
|
+
for (const request of this.pending.values()) {
|
|
81
|
+
clearTimeout(request.timer);
|
|
82
|
+
request.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'));
|
|
83
|
+
}
|
|
84
|
+
this.pending.clear();
|
|
85
|
+
onEvent({ event: 'worker_failed' });
|
|
86
|
+
};
|
|
87
|
+
this.exited = new Promise(resolve => { this.child.once('exit', () => { fail(); resolve(); }); this.child.once('error', () => { fail(); resolve(); }); });
|
|
88
|
+
this.child.stdin.on('error', fail);
|
|
89
|
+
this.child.stderr.resume(); // Never copy worker data or VM diagnostics into MCP stdout/logs.
|
|
90
|
+
this.child.stdout.setEncoding('utf8');
|
|
91
|
+
this.child.stdout.on('data', (chunk) => {
|
|
92
|
+
buffer += chunk;
|
|
93
|
+
if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) {
|
|
94
|
+
fail();
|
|
95
|
+
this.child.kill('SIGKILL');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let end;
|
|
99
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
100
|
+
const line = buffer.slice(0, end);
|
|
101
|
+
buffer = buffer.slice(end + 1);
|
|
102
|
+
try {
|
|
103
|
+
if (Buffer.byteLength(line) > 1024 * 1024)
|
|
104
|
+
throw new Error('IPC_LIMIT');
|
|
105
|
+
const msg = JSON.parse(line);
|
|
106
|
+
if (msg.event)
|
|
107
|
+
onEvent(msg);
|
|
108
|
+
else {
|
|
109
|
+
const request = this.pending.get(msg.id);
|
|
110
|
+
if (!request)
|
|
111
|
+
continue;
|
|
112
|
+
clearTimeout(request.timer);
|
|
113
|
+
this.pending.delete(msg.id);
|
|
114
|
+
if (msg.error)
|
|
115
|
+
request.reject(new DebuggerError(msg.error.code ?? 'DEBUGGER_WORKER_FAILED'));
|
|
116
|
+
else
|
|
117
|
+
request.resolve(msg.result);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
fail();
|
|
122
|
+
this.child.kill('SIGKILL');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
call(command, args = {}, timeout = 10000) {
|
|
129
|
+
if (this.dead)
|
|
130
|
+
return Promise.reject(new DebuggerError('DEBUGGER_WORKER_FAILED'));
|
|
131
|
+
try {
|
|
132
|
+
timeout = Math.min(timeout, debuggerRemaining(timeout));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return Promise.reject(e);
|
|
136
|
+
}
|
|
137
|
+
const id = ++this.sequence;
|
|
138
|
+
const message = JSON.stringify({ ...args, command, id }) + '\n';
|
|
139
|
+
if (Buffer.byteLength(message) > 1024 * 1024)
|
|
140
|
+
return Promise.reject(new DebuggerError('INVALID_ARGUMENT'));
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const signal = debuggerSignal();
|
|
143
|
+
const abort = () => { this.pending.delete(id); clearTimeout(timer); reject(new DebuggerError('DEBUGGER_CANCELLED')); };
|
|
144
|
+
const done = () => signal?.removeEventListener('abort', abort);
|
|
145
|
+
const timer = setTimeout(() => { this.pending.delete(id); done(); reject(new DebuggerError('DEBUGGER_TIMEOUT')); }, timeout);
|
|
146
|
+
this.pending.set(id, { resolve: value => { done(); resolve(value); }, reject: error => { done(); reject(error); }, timer });
|
|
147
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
148
|
+
this.child.stdin.write(message);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async close() {
|
|
152
|
+
this.child.stdin.end();
|
|
153
|
+
const kill = setTimeout(() => this.child.kill('SIGKILL'), 2000);
|
|
154
|
+
try {
|
|
155
|
+
await this.exited;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
clearTimeout(kill);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function createWorker(onEvent) {
|
|
163
|
+
const { java, directory } = await prepareWorker();
|
|
164
|
+
return new JdiWorker(java, directory, onEvent);
|
|
165
|
+
}
|
package/dist/interact/android.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evidenceDispatch } from '../observe/evidence.js';
|
|
2
|
+
import { beforeInputDispatch } from './conditional-action.js';
|
|
1
3
|
import { markDispatchStarted } from '../utils/operation-budget.js';
|
|
2
4
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
|
|
3
5
|
import { AndroidObserve } from "../observe/index.js";
|
|
@@ -8,8 +10,9 @@ export class AndroidInteract {
|
|
|
8
10
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
9
11
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
10
12
|
try {
|
|
13
|
+
await beforeInputDispatch('android', deviceInfo.id);
|
|
11
14
|
markDispatchStarted();
|
|
12
|
-
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs });
|
|
15
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs }));
|
|
13
16
|
return { device: deviceInfo, success: true, x, y };
|
|
14
17
|
}
|
|
15
18
|
catch (e) {
|
|
@@ -20,8 +23,9 @@ export class AndroidInteract {
|
|
|
20
23
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
21
24
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
22
25
|
try {
|
|
26
|
+
await beforeInputDispatch('android', deviceInfo.id);
|
|
23
27
|
markDispatchStarted();
|
|
24
|
-
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs });
|
|
28
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs }));
|
|
25
29
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
|
|
26
30
|
}
|
|
27
31
|
catch (e) {
|
|
@@ -35,8 +39,9 @@ export class AndroidInteract {
|
|
|
35
39
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
36
40
|
const encodedText = text.replace(/\s/g, '%s');
|
|
37
41
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
42
|
+
await beforeInputDispatch('android', deviceInfo.id);
|
|
38
43
|
markDispatchStarted();
|
|
39
|
-
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs });
|
|
44
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs }));
|
|
40
45
|
return { device: deviceInfo, success: true, text };
|
|
41
46
|
}
|
|
42
47
|
catch (e) {
|
|
@@ -47,8 +52,9 @@ export class AndroidInteract {
|
|
|
47
52
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
48
53
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
49
54
|
try {
|
|
55
|
+
await beforeInputDispatch('android', deviceInfo.id);
|
|
50
56
|
markDispatchStarted();
|
|
51
|
-
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs });
|
|
57
|
+
await evidenceDispatch({ platform: 'android', id: deviceInfo.id }, () => execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs }));
|
|
52
58
|
return { device: deviceInfo, success: true };
|
|
53
59
|
}
|
|
54
60
|
catch (e) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { ToolsObserve } from '../observe/index.js';
|
|
3
|
+
import { getSnapshotSessionId, hasSnapshotBaseline } from '../observe/snapshot-metadata.js';
|
|
4
|
+
import { runWithSeparateBudget } from '../utils/operation-budget.js';
|
|
5
|
+
import { ToolsNetwork } from '../network/index.js';
|
|
6
|
+
const context = new AsyncLocalStorage();
|
|
7
|
+
export function createConditionalAction(platform, deviceId, expected) {
|
|
8
|
+
return { platform, deviceId, expected, result: {
|
|
9
|
+
status: 'not_checked',
|
|
10
|
+
expected: { snapshot_session_id: expected.snapshotSessionId, snapshot_revision: expected.snapshotRevision },
|
|
11
|
+
observed: null, elapsed_ms: 0, reason: 'ACTION_NOT_REACHED'
|
|
12
|
+
} };
|
|
13
|
+
}
|
|
14
|
+
export function withConditionalAction(condition, operation) {
|
|
15
|
+
return context.run(condition, operation);
|
|
16
|
+
}
|
|
17
|
+
export function isConditionBlocked() {
|
|
18
|
+
const status = context.getStore()?.result.status;
|
|
19
|
+
return status === 'mismatch' || status === 'unavailable';
|
|
20
|
+
}
|
|
21
|
+
// Guarded calls reset the observation window only when input is about to spawn.
|
|
22
|
+
export function notifyActionStart() {
|
|
23
|
+
if (!context.getStore())
|
|
24
|
+
ToolsNetwork.notifyActionStart();
|
|
25
|
+
}
|
|
26
|
+
export function conditionFailureCode(condition) {
|
|
27
|
+
return condition.result.status === 'mismatch' ? 'PRECONDITION_FAILED' : 'PRECONDITION_UNAVAILABLE';
|
|
28
|
+
}
|
|
29
|
+
export async function beforeInputDispatch(platform, deviceId) {
|
|
30
|
+
const condition = context.getStore();
|
|
31
|
+
if (!condition)
|
|
32
|
+
return;
|
|
33
|
+
const started = performance.now();
|
|
34
|
+
const result = condition.result;
|
|
35
|
+
result.status = 'unavailable';
|
|
36
|
+
delete result.reason;
|
|
37
|
+
try {
|
|
38
|
+
if (platform !== condition.platform || deviceId !== condition.deviceId)
|
|
39
|
+
result.reason = 'DEVICE_MISMATCH';
|
|
40
|
+
else if (condition.expected.snapshotSessionId !== getSnapshotSessionId())
|
|
41
|
+
result.reason = 'SESSION_MISMATCH';
|
|
42
|
+
else if (!hasSnapshotBaseline(`${platform}:${deviceId}`, condition.expected.snapshotRevision))
|
|
43
|
+
result.reason = 'BASELINE_UNAVAILABLE';
|
|
44
|
+
else {
|
|
45
|
+
const tree = await runWithSeparateBudget(condition.expected.timeoutMs ?? 15000, () => ToolsObserve.getUITreeHandler({ platform, deviceId }));
|
|
46
|
+
if (tree?.error)
|
|
47
|
+
result.reason = 'OBSERVATION_FAILED';
|
|
48
|
+
else if (!tree || !Array.isArray(tree.elements) || !Number.isSafeInteger(tree.snapshot_revision) || tree.snapshot_revision < 1 ||
|
|
49
|
+
tree.snapshot_session_id !== getSnapshotSessionId() || !Number.isFinite(tree.captured_at_ms) || tree.captured_at_ms <= 0 ||
|
|
50
|
+
!tree.resolution || !Number.isFinite(tree.resolution.width) || !Number.isFinite(tree.resolution.height) ||
|
|
51
|
+
!(tree.resolution.width > 0) || !(tree.resolution.height > 0))
|
|
52
|
+
result.reason = 'OBSERVATION_INVALID';
|
|
53
|
+
else if (tree.device?.platform !== platform || tree.device?.id !== deviceId)
|
|
54
|
+
result.reason = 'DEVICE_MISMATCH';
|
|
55
|
+
else {
|
|
56
|
+
result.observed = tree;
|
|
57
|
+
result.status = tree.snapshot_revision === condition.expected.snapshotRevision ? 'matched' : 'mismatch';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
result.reason = /TIMEOUT|timed out/i.test(String(error)) ? 'CONDITION_TIMEOUT' : 'OBSERVATION_FAILED';
|
|
63
|
+
}
|
|
64
|
+
result.elapsed_ms = performance.now() - started;
|
|
65
|
+
if (result.status !== 'matched')
|
|
66
|
+
throw new Error(conditionFailureCode(condition));
|
|
67
|
+
ToolsNetwork.notifyActionStart();
|
|
68
|
+
}
|