mobile-debug-mcp 0.10.0 → 0.12.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/README.md +20 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +162 -0
- package/dist/android/observe.js +133 -88
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +137 -147
- package/dist/ios/interact.js +4 -175
- package/dist/ios/manage.js +169 -0
- package/dist/ios/observe.js +129 -13
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +138 -124
- package/dist/server.js +45 -17
- package/dist/tools/interact.js +21 -71
- package/dist/tools/manage.js +180 -0
- package/dist/tools/observe.js +23 -69
- package/dist/tools/run.js +180 -0
- package/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +14 -0
- package/eslint.config.js +22 -1
- package/package.json +8 -5
- package/scripts/check-idb.js +83 -0
- package/scripts/check-idb.ts +73 -0
- package/scripts/idb-helper.ts +76 -0
- package/scripts/install-idb.js +88 -0
- package/scripts/install-idb.ts +90 -0
- package/scripts/run-ios-smoke.ts +34 -0
- package/scripts/run-ios-ui-tree-tap.ts +33 -0
- package/src/android/diagnostics.ts +23 -0
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +157 -0
- package/src/android/observe.ts +129 -97
- package/src/android/utils.ts +147 -149
- package/src/ios/interact.ts +5 -181
- package/src/ios/manage.ts +164 -0
- package/src/ios/observe.ts +130 -14
- package/src/ios/utils.ts +127 -128
- package/src/server.ts +47 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/src/types.ts +9 -0
- package/src/utils/diagnostics.ts +36 -0
- package/test/device/README.md +49 -0
- package/test/device/index.ts +27 -0
- package/test/device/manage/run-build-install-ios.ts +82 -0
- package/test/{integration → device/manage}/run-install-android.ts +4 -4
- package/test/{integration → device/manage}/run-install-ios.ts +4 -4
- package/test/{integration → device/observe}/logstream-real.ts +5 -4
- package/test/{integration → device/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/manage/build.test.ts +83 -0
- package/test/unit/manage/build_and_install.test.ts +134 -0
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
- package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
- package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
- package/tsconfig.json +2 -1
- package/test/integration/index.ts +0 -8
- package/test/integration/test-dist.mjs +0 -41
- /package/test/{integration → device/interact}/run-real-test.ts +0 -0
- /package/test/{integration → device/interact}/smoke-test.ts +0 -0
- /package/test/{integration → device/manage}/install.integration.ts +0 -0
- /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
- /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
# Mobile
|
|
1
|
+
# Mobile Dev Agent Tools
|
|
2
2
|
|
|
3
3
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
4
|
|
|
5
|
+
> **Note:** iOS support is currently only tested on simulator. Please use with caution and report any issues.
|
|
6
|
+
|
|
5
7
|
## Requirements
|
|
6
8
|
|
|
7
9
|
- Node.js >= 18
|
|
8
|
-
- Android SDK (adb) for Android support
|
|
10
|
+
- [Android SDK](https://developer.android.com/studio) (adb) for Android support
|
|
9
11
|
- Xcode command-line tools for iOS support
|
|
10
|
-
-
|
|
12
|
+
- [idb](https://github.com/facebook/idb) for iOS device support
|
|
11
13
|
|
|
12
14
|
## Configuration example
|
|
13
15
|
|
|
@@ -17,13 +19,17 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
|
|
|
17
19
|
"mobile-debug": {
|
|
18
20
|
"command": "npx",
|
|
19
21
|
"args": ["--yes","mobile-debug-mcp","server"],
|
|
20
|
-
"env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun" }
|
|
22
|
+
"env": { "ADB_PATH": "/path/to/adb", "XCRUN_PATH": "/usr/bin/xcrun", "IDB_PATH": "/path/to/idb" }
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
```
|
|
25
27
|
## Usage
|
|
26
|
-
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
After a crash tell the agent the following:
|
|
31
|
+
|
|
32
|
+
I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
|
|
27
33
|
|
|
28
34
|
## Docs
|
|
29
35
|
|
|
@@ -33,3 +39,12 @@ A minimal, secure MCP server for AI-assisted mobile development. Build, install,
|
|
|
33
39
|
## License
|
|
34
40
|
|
|
35
41
|
MIT
|
|
42
|
+
|
|
43
|
+
## IDB/ADB healthcheck and diagnostics
|
|
44
|
+
|
|
45
|
+
The agent provides healthcheck and optional auto-install scripts for iOS (idb) and Android (adb).
|
|
46
|
+
|
|
47
|
+
- Run `npm run healthcheck` to verify idb is available. Set `MCP_AUTO_INSTALL_IDB=true` to allow the installer to run in CI or non-interactive environments.
|
|
48
|
+
- Override detection with `IDB_PATH` or `ADB_PATH` environment variables.
|
|
49
|
+
- Tools now return structured diagnostics on failures: { exitCode, stdout, stderr, command, args, envSnapshot, suggestedFixes } which helps agents decide corrective actions.
|
|
50
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { getAdbCmd } from './utils.js';
|
|
3
|
+
import { makeEnvSnapshot } from '../utils/diagnostics.js';
|
|
4
|
+
export function execAdbWithDiagnostics(args, deviceId) {
|
|
5
|
+
const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
|
|
6
|
+
const timeout = 120000;
|
|
7
|
+
const res = spawnSync(getAdbCmd(), adbArgs, { encoding: 'utf8', timeout });
|
|
8
|
+
const runResult = {
|
|
9
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
10
|
+
stdout: res.stdout || '',
|
|
11
|
+
stderr: res.stderr || '',
|
|
12
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'ADB_PATH', 'HOME', 'JAVA_HOME']),
|
|
13
|
+
command: getAdbCmd(),
|
|
14
|
+
args: adbArgs,
|
|
15
|
+
suggestedFixes: []
|
|
16
|
+
};
|
|
17
|
+
if (res.status !== 0) {
|
|
18
|
+
if ((runResult.stderr || '').includes('device not found'))
|
|
19
|
+
runResult.suggestedFixes.push('Ensure device is connected and adb is authorized (adb devices)');
|
|
20
|
+
if ((runResult.stderr || '').includes('No such file or directory'))
|
|
21
|
+
runResult.suggestedFixes.push('Verify ADB_PATH or that adb is installed');
|
|
22
|
+
}
|
|
23
|
+
return { runResult };
|
|
24
|
+
}
|
package/dist/android/interact.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo
|
|
2
|
-
import { detectJavaHome } from "../utils/java.js";
|
|
1
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
|
|
3
2
|
import { AndroidObserve } from "./observe.js";
|
|
4
|
-
import { promises as fs } from "fs";
|
|
5
|
-
import { spawn } from "child_process";
|
|
6
|
-
import path from "path";
|
|
7
|
-
import { existsSync } from "fs";
|
|
8
3
|
export class AndroidInteract {
|
|
9
4
|
observe = new AndroidObserve();
|
|
10
5
|
async waitForElement(text, timeout, deviceId) {
|
|
@@ -81,143 +76,4 @@ export class AndroidInteract {
|
|
|
81
76
|
return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
|
|
82
77
|
}
|
|
83
78
|
}
|
|
84
|
-
async installApp(apkPath, deviceId) {
|
|
85
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
86
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
87
|
-
// Helper to recursively find first APK under a directory
|
|
88
|
-
async function findApk(dir) {
|
|
89
|
-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
90
|
-
for (const e of entries) {
|
|
91
|
-
const full = path.join(dir, e.name);
|
|
92
|
-
if (e.isDirectory()) {
|
|
93
|
-
const found = await findApk(full);
|
|
94
|
-
if (found)
|
|
95
|
-
return found;
|
|
96
|
-
}
|
|
97
|
-
else if (e.isFile() && full.endsWith('.apk')) {
|
|
98
|
-
return full;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
try {
|
|
104
|
-
let apkToInstall = apkPath;
|
|
105
|
-
// If a directory is provided, attempt to build via Gradle
|
|
106
|
-
const stat = await fs.stat(apkPath).catch(() => null);
|
|
107
|
-
if (stat && stat.isDirectory()) {
|
|
108
|
-
const gradlewPath = path.join(apkPath, 'gradlew');
|
|
109
|
-
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
|
|
110
|
-
await new Promise(async (resolve, reject) => {
|
|
111
|
-
// Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
|
|
112
|
-
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
113
|
-
const env = Object.assign({}, process.env);
|
|
114
|
-
if (detectedJavaHome) {
|
|
115
|
-
// Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
|
|
116
|
-
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
117
|
-
env.JAVA_HOME = detectedJavaHome;
|
|
118
|
-
// Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
|
|
119
|
-
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
|
|
120
|
-
console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
// Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
|
|
124
|
-
try {
|
|
125
|
-
// Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
|
|
126
|
-
delete env.SHELL;
|
|
127
|
-
}
|
|
128
|
-
catch { }
|
|
129
|
-
// If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
|
|
130
|
-
// Prepare gradle invocation
|
|
131
|
-
const gradleArgs = ['assembleDebug'];
|
|
132
|
-
if (detectedJavaHome) {
|
|
133
|
-
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
|
|
134
|
-
gradleArgs.push('--no-daemon');
|
|
135
|
-
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
136
|
-
}
|
|
137
|
-
// Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
|
|
138
|
-
const wrapperPath = path.join(apkPath, 'gradlew');
|
|
139
|
-
const useWrapper = existsSync(wrapperPath);
|
|
140
|
-
const execCmd = useWrapper ? wrapperPath : gradleCmd;
|
|
141
|
-
const spawnOpts = { cwd: apkPath, env };
|
|
142
|
-
// When using wrapper, ensure it's executable and invoke directly (no shell)
|
|
143
|
-
if (useWrapper) {
|
|
144
|
-
// Ensure the wrapper is executable; swallow errors from chmod (best-effort).
|
|
145
|
-
await fs.chmod(wrapperPath, 0o755).catch(() => { });
|
|
146
|
-
spawnOpts.shell = false;
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
// if using system 'gradle' allow shell to resolve platform PATH
|
|
150
|
-
spawnOpts.shell = true;
|
|
151
|
-
}
|
|
152
|
-
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
153
|
-
let stderr = '';
|
|
154
|
-
proc.stderr?.on('data', d => stderr += d.toString());
|
|
155
|
-
proc.on('close', code => {
|
|
156
|
-
if (code === 0)
|
|
157
|
-
resolve();
|
|
158
|
-
else
|
|
159
|
-
reject(new Error(stderr || `Gradle build failed with code ${code}`));
|
|
160
|
-
});
|
|
161
|
-
proc.on('error', err => reject(err));
|
|
162
|
-
});
|
|
163
|
-
const built = await findApk(apkPath);
|
|
164
|
-
if (!built)
|
|
165
|
-
throw new Error('Could not locate built APK after running Gradle');
|
|
166
|
-
apkToInstall = built;
|
|
167
|
-
}
|
|
168
|
-
// Try normal adb install with streaming attempt
|
|
169
|
-
try {
|
|
170
|
-
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId);
|
|
171
|
-
if (res.code === 0) {
|
|
172
|
-
return { device: deviceInfo, installed: true, output: res.stdout };
|
|
173
|
-
}
|
|
174
|
-
// fallthrough to fallback
|
|
175
|
-
}
|
|
176
|
-
catch (e) {
|
|
177
|
-
// Log and continue to fallback
|
|
178
|
-
console.debug('[android] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e));
|
|
179
|
-
}
|
|
180
|
-
// Fallback: push APK to device and use pm install -r
|
|
181
|
-
const basename = path.basename(apkToInstall);
|
|
182
|
-
const remotePath = `/data/local/tmp/${basename}`;
|
|
183
|
-
await execAdb(['push', apkToInstall, remotePath], deviceId);
|
|
184
|
-
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId);
|
|
185
|
-
// cleanup remote file
|
|
186
|
-
try {
|
|
187
|
-
await execAdb(['shell', 'rm', remotePath], deviceId);
|
|
188
|
-
}
|
|
189
|
-
catch { }
|
|
190
|
-
return { device: deviceInfo, installed: true, output: pmOut };
|
|
191
|
-
}
|
|
192
|
-
catch (e) {
|
|
193
|
-
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
async startApp(appId, deviceId) {
|
|
197
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
198
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
199
|
-
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
200
|
-
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
|
|
201
|
-
}
|
|
202
|
-
async terminateApp(appId, deviceId) {
|
|
203
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
204
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
205
|
-
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
206
|
-
return { device: deviceInfo, appTerminated: true };
|
|
207
|
-
}
|
|
208
|
-
async restartApp(appId, deviceId) {
|
|
209
|
-
await this.terminateApp(appId, deviceId);
|
|
210
|
-
const startResult = await this.startApp(appId, deviceId);
|
|
211
|
-
return {
|
|
212
|
-
device: startResult.device,
|
|
213
|
-
appRestarted: startResult.appStarted,
|
|
214
|
-
launchTimeMs: startResult.launchTimeMs
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
async resetAppData(appId, deviceId) {
|
|
218
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
219
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
220
|
-
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
221
|
-
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
222
|
-
}
|
|
223
79
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js';
|
|
6
|
+
import { execAdbWithDiagnostics } from './diagnostics.js';
|
|
7
|
+
import { detectJavaHome } from '../utils/java.js';
|
|
8
|
+
export class AndroidManage {
|
|
9
|
+
async build(projectPath, _variant) {
|
|
10
|
+
void _variant;
|
|
11
|
+
try {
|
|
12
|
+
// Always use the shared prepareGradle utility for consistent env/setup
|
|
13
|
+
const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath);
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
16
|
+
let stderr = '';
|
|
17
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
18
|
+
proc.on('close', code => {
|
|
19
|
+
if (code === 0)
|
|
20
|
+
resolve();
|
|
21
|
+
else
|
|
22
|
+
reject(new Error(stderr || `Gradle failed with code ${code}`));
|
|
23
|
+
});
|
|
24
|
+
proc.on('error', err => reject(err));
|
|
25
|
+
});
|
|
26
|
+
const apk = await findApk(projectPath);
|
|
27
|
+
if (!apk)
|
|
28
|
+
return { error: 'Could not find APK after build' };
|
|
29
|
+
return { artifactPath: apk };
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async installApp(apkPath, deviceId) {
|
|
36
|
+
const metadata = await getAndroidDeviceMetadata('', deviceId);
|
|
37
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
38
|
+
let apkToInstall = apkPath;
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fs.stat(apkPath).catch(() => null);
|
|
41
|
+
if (stat && stat.isDirectory()) {
|
|
42
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
43
|
+
const env = Object.assign({}, process.env);
|
|
44
|
+
if (detectedJavaHome) {
|
|
45
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
46
|
+
env.JAVA_HOME = detectedJavaHome;
|
|
47
|
+
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
|
|
48
|
+
console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
delete env.SHELL;
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
const gradleArgs = ['assembleDebug'];
|
|
56
|
+
if (detectedJavaHome) {
|
|
57
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
|
|
58
|
+
gradleArgs.push('--no-daemon');
|
|
59
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
60
|
+
}
|
|
61
|
+
const wrapperPath = path.join(apkPath, 'gradlew');
|
|
62
|
+
const useWrapper = existsSync(wrapperPath);
|
|
63
|
+
const execCmd = useWrapper ? wrapperPath : 'gradle';
|
|
64
|
+
const spawnOpts = { cwd: apkPath, env };
|
|
65
|
+
if (useWrapper) {
|
|
66
|
+
await fs.chmod(wrapperPath, 0o755).catch(() => { });
|
|
67
|
+
spawnOpts.shell = false;
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
spawnOpts.shell = true;
|
|
71
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts);
|
|
72
|
+
let stderr = '';
|
|
73
|
+
await new Promise((resolve, reject) => {
|
|
74
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
75
|
+
proc.on('close', code => {
|
|
76
|
+
if (code === 0)
|
|
77
|
+
resolve();
|
|
78
|
+
else
|
|
79
|
+
reject(new Error(stderr || `Gradle build failed with code ${code}`));
|
|
80
|
+
});
|
|
81
|
+
proc.on('error', err => reject(err));
|
|
82
|
+
});
|
|
83
|
+
const built = await findApk(apkPath);
|
|
84
|
+
if (!built)
|
|
85
|
+
throw new Error('Could not locate built APK after running Gradle');
|
|
86
|
+
apkToInstall = built;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId);
|
|
90
|
+
if (res.code === 0) {
|
|
91
|
+
return { device: deviceInfo, installed: true, output: res.stdout };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e));
|
|
96
|
+
}
|
|
97
|
+
const basename = path.basename(apkToInstall);
|
|
98
|
+
const remotePath = `/data/local/tmp/${basename}`;
|
|
99
|
+
await execAdb(['push', apkToInstall, remotePath], deviceId);
|
|
100
|
+
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId);
|
|
101
|
+
try {
|
|
102
|
+
await execAdb(['shell', 'rm', remotePath], deviceId);
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
return { device: deviceInfo, installed: true, output: pmOut };
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// gather diagnostics for attempted adb operations
|
|
109
|
+
const basename = path.basename(apkToInstall);
|
|
110
|
+
const remotePath = `/data/local/tmp/${basename}`;
|
|
111
|
+
const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId);
|
|
112
|
+
const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId);
|
|
113
|
+
const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId);
|
|
114
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async startApp(appId, deviceId) {
|
|
118
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
119
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
120
|
+
try {
|
|
121
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
122
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
|
|
126
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async terminateApp(appId, deviceId) {
|
|
130
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
131
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
132
|
+
try {
|
|
133
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
134
|
+
return { device: deviceInfo, appTerminated: true };
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
|
|
138
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async restartApp(appId, deviceId) {
|
|
142
|
+
await this.terminateApp(appId, deviceId);
|
|
143
|
+
const startResult = await this.startApp(appId, deviceId);
|
|
144
|
+
return {
|
|
145
|
+
device: startResult.device,
|
|
146
|
+
appRestarted: startResult.appStarted,
|
|
147
|
+
launchTimeMs: startResult.launchTimeMs
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async resetAppData(appId, deviceId) {
|
|
151
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
152
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
153
|
+
try {
|
|
154
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
155
|
+
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId);
|
|
159
|
+
return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/android/observe.js
CHANGED
|
@@ -1,91 +1,10 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { XMLParser } from "fast-xml-parser";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (match) {
|
|
9
|
-
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
|
|
10
|
-
}
|
|
11
|
-
return [0, 0, 0, 0];
|
|
12
|
-
}
|
|
13
|
-
function getCenter(bounds) {
|
|
14
|
-
const [x1, y1, x2, y2] = bounds;
|
|
15
|
-
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
16
|
-
}
|
|
17
|
-
async function getScreenResolution(deviceId) {
|
|
18
|
-
try {
|
|
19
|
-
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
20
|
-
const match = output.match(/Physical size: (\d+)x(\d+)/);
|
|
21
|
-
if (match) {
|
|
22
|
-
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// ignore
|
|
27
|
-
}
|
|
28
|
-
return { width: 0, height: 0 };
|
|
29
|
-
}
|
|
30
|
-
function traverseNode(node, elements, parentIndex = -1, depth = 0) {
|
|
31
|
-
if (!node)
|
|
32
|
-
return -1;
|
|
33
|
-
let currentIndex = -1;
|
|
34
|
-
// Check if it's a valid node with attributes we care about
|
|
35
|
-
if (node['@_class']) {
|
|
36
|
-
const text = node['@_text'] || null;
|
|
37
|
-
const contentDescription = node['@_content-desc'] || null;
|
|
38
|
-
const clickable = node['@_clickable'] === 'true';
|
|
39
|
-
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
40
|
-
// Filtering Logic:
|
|
41
|
-
// Keep if clickable OR has visible text OR has content description
|
|
42
|
-
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
43
|
-
if (isUseful) {
|
|
44
|
-
const element = {
|
|
45
|
-
text,
|
|
46
|
-
contentDescription,
|
|
47
|
-
type: node['@_class'] || 'unknown',
|
|
48
|
-
resourceId: node['@_resource-id'] || null,
|
|
49
|
-
clickable,
|
|
50
|
-
enabled: node['@_enabled'] === 'true',
|
|
51
|
-
visible: true,
|
|
52
|
-
bounds,
|
|
53
|
-
center: getCenter(bounds),
|
|
54
|
-
depth
|
|
55
|
-
};
|
|
56
|
-
if (parentIndex !== -1) {
|
|
57
|
-
element.parentId = parentIndex;
|
|
58
|
-
}
|
|
59
|
-
elements.push(element);
|
|
60
|
-
currentIndex = elements.length - 1;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// If current node was skipped (not useful or no class), children inherit parentIndex
|
|
64
|
-
// If current node was added, children use currentIndex
|
|
65
|
-
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
66
|
-
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
67
|
-
const childrenIndices = [];
|
|
68
|
-
// Traverse children
|
|
69
|
-
if (node.node) {
|
|
70
|
-
if (Array.isArray(node.node)) {
|
|
71
|
-
node.node.forEach((child) => {
|
|
72
|
-
const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
|
|
73
|
-
if (childIndex !== -1)
|
|
74
|
-
childrenIndices.push(childIndex);
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
|
|
79
|
-
if (childIndex !== -1)
|
|
80
|
-
childrenIndices.push(childIndex);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Update current element with children if it was added
|
|
84
|
-
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
85
|
-
elements[currentIndex].children = childrenIndices;
|
|
86
|
-
}
|
|
87
|
-
return currentIndex;
|
|
88
|
-
}
|
|
3
|
+
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js";
|
|
4
|
+
import { createWriteStream } from "fs";
|
|
5
|
+
import { promises as fsPromises } from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
const activeLogStreams = new Map();
|
|
89
8
|
export class AndroidObserve {
|
|
90
9
|
async getDeviceMetadata(appId, deviceId) {
|
|
91
10
|
return getAndroidDeviceMetadata(appId, deviceId);
|
|
@@ -148,7 +67,7 @@ export class AndroidObserve {
|
|
|
148
67
|
};
|
|
149
68
|
}
|
|
150
69
|
catch (e) {
|
|
151
|
-
const errorMessage = `Failed to get UI tree. ADB Path: '${
|
|
70
|
+
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
152
71
|
console.error(errorMessage);
|
|
153
72
|
return {
|
|
154
73
|
device: deviceInfo,
|
|
@@ -196,7 +115,7 @@ export class AndroidObserve {
|
|
|
196
115
|
// Need to construct ADB args manually since spawn handles it
|
|
197
116
|
const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
198
117
|
// Using spawn for screencap as well to ensure consistent process handling
|
|
199
|
-
const child = spawn(
|
|
118
|
+
const child = spawn(getAdbCmd(), args);
|
|
200
119
|
const chunks = [];
|
|
201
120
|
let stderr = '';
|
|
202
121
|
child.stdout.on('data', (chunk) => {
|
|
@@ -310,4 +229,130 @@ export class AndroidObserve {
|
|
|
310
229
|
};
|
|
311
230
|
}
|
|
312
231
|
}
|
|
232
|
+
async startLogStream(packageName, level = 'error', deviceId, sessionId = 'default') {
|
|
233
|
+
try {
|
|
234
|
+
const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '');
|
|
235
|
+
const pid = (pidOutput || '').trim();
|
|
236
|
+
if (!pid)
|
|
237
|
+
return { success: false, error: 'app_not_running' };
|
|
238
|
+
const levelMap = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' };
|
|
239
|
+
const filter = levelMap[level] || levelMap['error'];
|
|
240
|
+
if (activeLogStreams.has(sessionId)) {
|
|
241
|
+
try {
|
|
242
|
+
activeLogStreams.get(sessionId).proc.kill();
|
|
243
|
+
}
|
|
244
|
+
catch { }
|
|
245
|
+
activeLogStreams.delete(sessionId);
|
|
246
|
+
}
|
|
247
|
+
const args = ['logcat', `--pid=${pid}`, filter];
|
|
248
|
+
const proc = spawn(getAdbCmd(), args);
|
|
249
|
+
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
250
|
+
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
251
|
+
const stream = createWriteStream(file, { flags: 'a' });
|
|
252
|
+
proc.stdout.on('data', (chunk) => {
|
|
253
|
+
const text = chunk.toString();
|
|
254
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
255
|
+
for (const l of lines) {
|
|
256
|
+
const entry = parseLogLine(l);
|
|
257
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
proc.stderr.on('data', (chunk) => {
|
|
261
|
+
const text = chunk.toString();
|
|
262
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
263
|
+
for (const l of lines) {
|
|
264
|
+
const entry = { timestamp: '', level: 'E', tag: 'adb', message: l };
|
|
265
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
proc.on('close', () => {
|
|
269
|
+
stream.end();
|
|
270
|
+
activeLogStreams.delete(sessionId);
|
|
271
|
+
});
|
|
272
|
+
activeLogStreams.set(sessionId, { proc, file });
|
|
273
|
+
return { success: true, stream_started: true };
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async stopLogStream(sessionId = 'default') {
|
|
280
|
+
const entry = activeLogStreams.get(sessionId);
|
|
281
|
+
if (!entry)
|
|
282
|
+
return { success: true };
|
|
283
|
+
try {
|
|
284
|
+
entry.proc.kill();
|
|
285
|
+
}
|
|
286
|
+
catch { }
|
|
287
|
+
activeLogStreams.delete(sessionId);
|
|
288
|
+
return { success: true };
|
|
289
|
+
}
|
|
290
|
+
async readLogStream(sessionId = 'default', limit = 100, since) {
|
|
291
|
+
// Prefer active stream if present, otherwise fall back to a well-known NDJSON file for the session
|
|
292
|
+
const entry = activeLogStreams.get(sessionId);
|
|
293
|
+
let file;
|
|
294
|
+
if (entry && entry.file)
|
|
295
|
+
file = entry.file;
|
|
296
|
+
else {
|
|
297
|
+
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
298
|
+
const candidate = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
299
|
+
file = candidate;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const data = await fsPromises.readFile(file, 'utf8').catch(() => '');
|
|
303
|
+
if (!data)
|
|
304
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
305
|
+
const lines = data.split(/\r?\n/).filter(Boolean);
|
|
306
|
+
const parsed = lines.map(l => {
|
|
307
|
+
try {
|
|
308
|
+
const obj = JSON.parse(l);
|
|
309
|
+
if (typeof obj._iso === 'undefined') {
|
|
310
|
+
let iso = null;
|
|
311
|
+
if (obj.timestamp) {
|
|
312
|
+
const d = new Date(obj.timestamp);
|
|
313
|
+
if (!isNaN(d.getTime()))
|
|
314
|
+
iso = d.toISOString();
|
|
315
|
+
}
|
|
316
|
+
obj._iso = iso;
|
|
317
|
+
}
|
|
318
|
+
if (typeof obj.crash === 'undefined') {
|
|
319
|
+
const msg = (obj.message || '').toString();
|
|
320
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
321
|
+
if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
|
|
322
|
+
obj.crash = true;
|
|
323
|
+
if (exMatch)
|
|
324
|
+
obj.exception = exMatch[1];
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
obj.crash = false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return obj;
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return { message: l, _iso: null, crash: false };
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
let filtered = parsed;
|
|
337
|
+
if (since) {
|
|
338
|
+
let sinceMs = null;
|
|
339
|
+
if (/^\d+$/.test(since))
|
|
340
|
+
sinceMs = Number(since);
|
|
341
|
+
else {
|
|
342
|
+
const sDate = new Date(since);
|
|
343
|
+
if (!isNaN(sDate.getTime()))
|
|
344
|
+
sinceMs = sDate.getTime();
|
|
345
|
+
}
|
|
346
|
+
if (sinceMs !== null)
|
|
347
|
+
filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
|
|
348
|
+
}
|
|
349
|
+
const entries = filtered.slice(-Math.max(0, limit));
|
|
350
|
+
const crashEntry = entries.find(e => e.crash);
|
|
351
|
+
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
|
|
352
|
+
return { entries, crash_summary };
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
313
358
|
}
|