mobile-debug-mcp 0.9.0 → 0.11.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.
Files changed (61) hide show
  1. package/.eslintignore +5 -0
  2. package/.eslintrc.cjs +18 -0
  3. package/.github/workflows/.gitkeep +0 -0
  4. package/.github/workflows/ci.yml +63 -0
  5. package/README.md +5 -16
  6. package/dist/android/interact.js +1 -123
  7. package/dist/android/manage.js +137 -0
  8. package/dist/android/observe.js +133 -88
  9. package/dist/android/run.js +187 -0
  10. package/dist/android/utils.js +181 -236
  11. package/dist/ios/interact.js +1 -168
  12. package/dist/ios/manage.js +145 -0
  13. package/dist/ios/observe.js +112 -5
  14. package/dist/ios/run.js +200 -0
  15. package/dist/ios/utils.js +19 -118
  16. package/dist/server.js +44 -42
  17. package/dist/tools/install.js +1 -1
  18. package/dist/tools/interact.js +39 -0
  19. package/dist/tools/logs.js +2 -2
  20. package/dist/tools/manage.js +180 -0
  21. package/dist/tools/observe.js +80 -0
  22. package/dist/tools/run.js +180 -0
  23. package/dist/utils/index.js +1 -0
  24. package/dist/utils/java.js +76 -0
  25. package/docs/CHANGELOG.md +25 -6
  26. package/eslint.config.cjs +36 -0
  27. package/eslint.config.js +60 -0
  28. package/package.json +8 -2
  29. package/src/android/interact.ts +2 -136
  30. package/src/android/manage.ts +135 -0
  31. package/src/android/observe.ts +129 -97
  32. package/src/android/utils.ts +199 -229
  33. package/src/ios/interact.ts +2 -175
  34. package/src/ios/manage.ts +143 -0
  35. package/src/ios/observe.ts +113 -5
  36. package/src/ios/utils.ts +20 -122
  37. package/src/server.ts +48 -58
  38. package/src/tools/interact.ts +45 -0
  39. package/src/tools/manage.ts +171 -0
  40. package/src/tools/observe.ts +82 -0
  41. package/src/utils/index.ts +1 -0
  42. package/src/utils/java.ts +69 -0
  43. package/test/integration/install.integration.ts +3 -3
  44. package/test/integration/logstream-real.ts +5 -4
  45. package/test/integration/run-install-android.ts +1 -1
  46. package/test/integration/run-install-ios.ts +1 -1
  47. package/test/integration/smoke-test.ts +1 -1
  48. package/test/integration/test-dist.ts +1 -1
  49. package/test/integration/test-ui-tree.ts +1 -1
  50. package/test/integration/wait_for_element_real.ts +1 -1
  51. package/test/unit/build.test.ts +84 -0
  52. package/test/unit/build_and_install.test.ts +132 -0
  53. package/test/unit/detect-java.test.ts +22 -0
  54. package/test/unit/install.test.ts +2 -8
  55. package/test/unit/logstream.test.ts +8 -9
  56. package/src/tools/app.ts +0 -46
  57. package/src/tools/devices.ts +0 -6
  58. package/src/tools/install.ts +0 -43
  59. package/src/tools/logs.ts +0 -62
  60. package/src/tools/screenshot.ts +0 -18
  61. package/src/tools/ui.ts +0 -62
package/.eslintignore ADDED
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .vscode/
5
+ coverage/
package/.eslintrc.cjs ADDED
@@ -0,0 +1,18 @@
1
+ module.exports = {
2
+ root: true,
3
+ parser: '@typescript-eslint/parser',
4
+ parserOptions: {
5
+ ecmaVersion: 2020,
6
+ sourceType: 'module',
7
+ project: './tsconfig.json'
8
+ },
9
+ plugins: ['@typescript-eslint', 'unused-imports'],
10
+ rules: {
11
+ // Use plugin to error on unused imports and provide autofix where possible
12
+ 'unused-imports/no-unused-imports': 'error',
13
+ 'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
14
+ // Disable the default TS rule to avoid duplicate warnings
15
+ '@typescript-eslint/no-unused-vars': 'off'
16
+ },
17
+ ignorePatterns: ['dist/', 'node_modules/', '.git/']
18
+ }
File without changes
@@ -0,0 +1,63 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ unit:
10
+ name: Unit tests
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Use Node.js
15
+ uses: actions/setup-node@v4
16
+ with:
17
+ node-version: '18'
18
+ - name: Install dependencies
19
+ run: npm ci
20
+ - name: Lint
21
+ run: npm run lint
22
+ - name: Build
23
+ run: npm run build
24
+ - name: Run unit tests
25
+ run: npm test
26
+
27
+ android-integration:
28
+ name: Android emulator integration (manual)
29
+ runs-on: ubuntu-latest
30
+ needs: unit
31
+ # only run integration when manually triggered to avoid long runs on every PR
32
+ if: github.event_name == 'workflow_dispatch'
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up JDK 17
37
+ uses: actions/setup-java@v4
38
+ with:
39
+ distribution: 'temurin'
40
+ java-version: '17'
41
+
42
+ - name: Set up Node.js
43
+ uses: actions/setup-node@v4
44
+ with:
45
+ node-version: '18'
46
+
47
+ - name: Install dependencies
48
+ run: npm ci
49
+
50
+ - name: Start Android emulator
51
+ uses: reactivecircus/android-emulator-runner@v2
52
+ with:
53
+ api-level: 30
54
+ target: google_apis
55
+ arch: x86_64
56
+ force-avd-creation: true
57
+
58
+ - name: Build and run Android integration tests
59
+ env:
60
+ ADB_TIMEOUT: 120000
61
+ run: |
62
+ npm run build
63
+ node test/integration/run-install-android.js || true
package/README.md CHANGED
@@ -2,17 +2,7 @@
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
- This README was shortened to keep high-level info only. Detailed tool definitions moved to docs/TOOLS.md.
6
-
7
- ## Quick start
8
-
9
- ```bash
10
- git clone https://github.com/YOUR_USERNAME/mobile-debug-mcp.git
11
- cd mobile-debug-mcp
12
- npm install
13
- npm run build
14
- npm start
15
- ```
5
+ > **Note:** iOS support is currently an untested Work In Progress (WIP). Please use with caution and report any issues.
16
6
 
17
7
  ## Requirements
18
8
 
@@ -34,14 +24,13 @@ npm start
34
24
  }
35
25
  }
36
26
  ```
37
-
38
- > Note: Avoid using `jsonc` fences with inline comments in README code blocks to prevent syntax-highlighting issues on some renderers.
27
+ ## Usage
28
+ I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
39
29
 
40
30
  ## Docs
41
31
 
42
- - Tools: docs/TOOLS.md (full input/response examples)
43
- - Changelog: docs/CHANGELOG.md
44
- - Tests: test/
32
+ - Tools: [Tools](docs/TOOLS.md) full input/response examples
33
+ - Changelog: [Changelog](docs/CHANGELOG.md)
45
34
 
46
35
  ## License
47
36
 
@@ -1,9 +1,5 @@
1
- import { execAdb, getAndroidDeviceMetadata, getDeviceInfo, detectJavaHome } from "./utils.js";
1
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
2
2
  import { AndroidObserve } from "./observe.js";
3
- import { promises as fs } from "fs";
4
- import { spawn } from "child_process";
5
- import path from "path";
6
- import { existsSync } from "fs";
7
3
  export class AndroidInteract {
8
4
  observe = new AndroidObserve();
9
5
  async waitForElement(text, timeout, deviceId) {
@@ -80,122 +76,4 @@ export class AndroidInteract {
80
76
  return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
81
77
  }
82
78
  }
83
- async installApp(apkPath, deviceId) {
84
- const metadata = await getAndroidDeviceMetadata("", deviceId);
85
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
86
- // Helper to recursively find first APK under a directory
87
- async function findApk(dir) {
88
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
89
- for (const e of entries) {
90
- const full = path.join(dir, e.name);
91
- if (e.isDirectory()) {
92
- const found = await findApk(full);
93
- if (found)
94
- return found;
95
- }
96
- else if (e.isFile() && full.endsWith('.apk')) {
97
- return full;
98
- }
99
- }
100
- return undefined;
101
- }
102
- try {
103
- let apkToInstall = apkPath;
104
- // If a directory is provided, attempt to build via Gradle
105
- const stat = await fs.stat(apkPath).catch(() => null);
106
- if (stat && stat.isDirectory()) {
107
- const gradlewPath = path.join(apkPath, 'gradlew');
108
- const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
109
- await new Promise(async (resolve, reject) => {
110
- // Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
111
- const detectedJavaHome = await detectJavaHome().catch(() => undefined);
112
- const env = Object.assign({}, process.env);
113
- if (detectedJavaHome) {
114
- // Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
115
- if (env.JAVA_HOME !== detectedJavaHome) {
116
- env.JAVA_HOME = detectedJavaHome;
117
- // Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
118
- env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
119
- console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome);
120
- }
121
- }
122
- // Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
123
- try {
124
- // Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
125
- delete env.SHELL;
126
- }
127
- catch (e) { }
128
- // If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
129
- // Prepare gradle invocation
130
- const gradleArgs = ['assembleDebug'];
131
- if (detectedJavaHome) {
132
- gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
133
- gradleArgs.push('--no-daemon');
134
- env.GRADLE_JAVA_HOME = detectedJavaHome;
135
- }
136
- // Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
137
- const wrapperPath = path.join(apkPath, 'gradlew');
138
- const useWrapper = existsSync(wrapperPath);
139
- const execCmd = useWrapper ? wrapperPath : gradleCmd;
140
- const spawnOpts = { cwd: apkPath, env };
141
- // When using wrapper, ensure it's executable and invoke directly (no shell)
142
- if (useWrapper) {
143
- // Ensure the wrapper is executable; swallow errors from chmod (best-effort).
144
- await fs.chmod(wrapperPath, 0o755).catch(() => { });
145
- spawnOpts.shell = false;
146
- }
147
- else {
148
- // if using system 'gradle' allow shell to resolve platform PATH
149
- spawnOpts.shell = true;
150
- }
151
- const proc = spawn(execCmd, gradleArgs, spawnOpts);
152
- let stderr = '';
153
- proc.stderr?.on('data', d => stderr += d.toString());
154
- proc.on('close', code => {
155
- if (code === 0)
156
- resolve();
157
- else
158
- reject(new Error(stderr || `Gradle build failed with code ${code}`));
159
- });
160
- proc.on('error', err => reject(err));
161
- });
162
- const built = await findApk(apkPath);
163
- if (!built)
164
- throw new Error('Could not locate built APK after running Gradle');
165
- apkToInstall = built;
166
- }
167
- const output = await execAdb(['install', '-r', apkToInstall], deviceId);
168
- return { device: deviceInfo, installed: true, output };
169
- }
170
- catch (e) {
171
- return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
172
- }
173
- }
174
- async startApp(appId, deviceId) {
175
- const metadata = await getAndroidDeviceMetadata(appId, deviceId);
176
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
177
- await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
178
- return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
179
- }
180
- async terminateApp(appId, deviceId) {
181
- const metadata = await getAndroidDeviceMetadata(appId, deviceId);
182
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
183
- await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
184
- return { device: deviceInfo, appTerminated: true };
185
- }
186
- async restartApp(appId, deviceId) {
187
- await this.terminateApp(appId, deviceId);
188
- const startResult = await this.startApp(appId, deviceId);
189
- return {
190
- device: startResult.device,
191
- appRestarted: startResult.appStarted,
192
- launchTimeMs: startResult.launchTimeMs
193
- };
194
- }
195
- async resetAppData(appId, deviceId) {
196
- const metadata = await getAndroidDeviceMetadata(appId, deviceId);
197
- const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
198
- const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
199
- return { device: deviceInfo, dataCleared: output === 'Success' };
200
- }
201
79
  }
@@ -0,0 +1,137 @@
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 { detectJavaHome } from '../utils/java.js';
7
+ export class AndroidManage {
8
+ async build(projectPath, _variant) {
9
+ void _variant;
10
+ try {
11
+ // Always use the shared prepareGradle utility for consistent env/setup
12
+ const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath);
13
+ await new Promise((resolve, reject) => {
14
+ const proc = spawn(execCmd, gradleArgs, spawnOpts);
15
+ let stderr = '';
16
+ proc.stderr?.on('data', d => stderr += d.toString());
17
+ proc.on('close', code => {
18
+ if (code === 0)
19
+ resolve();
20
+ else
21
+ reject(new Error(stderr || `Gradle failed with code ${code}`));
22
+ });
23
+ proc.on('error', err => reject(err));
24
+ });
25
+ const apk = await findApk(projectPath);
26
+ if (!apk)
27
+ return { error: 'Could not find APK after build' };
28
+ return { artifactPath: apk };
29
+ }
30
+ catch (e) {
31
+ return { error: e instanceof Error ? e.message : String(e) };
32
+ }
33
+ }
34
+ async installApp(apkPath, deviceId) {
35
+ const metadata = await getAndroidDeviceMetadata('', deviceId);
36
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
37
+ try {
38
+ let apkToInstall = apkPath;
39
+ const stat = await fs.stat(apkPath).catch(() => null);
40
+ if (stat && stat.isDirectory()) {
41
+ const detectedJavaHome = await detectJavaHome().catch(() => undefined);
42
+ const env = Object.assign({}, process.env);
43
+ if (detectedJavaHome) {
44
+ if (env.JAVA_HOME !== detectedJavaHome) {
45
+ env.JAVA_HOME = detectedJavaHome;
46
+ env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
47
+ console.debug('[android-run] Overriding JAVA_HOME with detected path:', detectedJavaHome);
48
+ }
49
+ }
50
+ try {
51
+ delete env.SHELL;
52
+ }
53
+ catch { }
54
+ const gradleArgs = ['assembleDebug'];
55
+ if (detectedJavaHome) {
56
+ gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
57
+ gradleArgs.push('--no-daemon');
58
+ env.GRADLE_JAVA_HOME = detectedJavaHome;
59
+ }
60
+ const wrapperPath = path.join(apkPath, 'gradlew');
61
+ const useWrapper = existsSync(wrapperPath);
62
+ const execCmd = useWrapper ? wrapperPath : 'gradle';
63
+ const spawnOpts = { cwd: apkPath, env };
64
+ if (useWrapper) {
65
+ await fs.chmod(wrapperPath, 0o755).catch(() => { });
66
+ spawnOpts.shell = false;
67
+ }
68
+ else
69
+ spawnOpts.shell = true;
70
+ const proc = spawn(execCmd, gradleArgs, spawnOpts);
71
+ let stderr = '';
72
+ await new Promise((resolve, reject) => {
73
+ proc.stderr?.on('data', d => stderr += d.toString());
74
+ proc.on('close', code => {
75
+ if (code === 0)
76
+ resolve();
77
+ else
78
+ reject(new Error(stderr || `Gradle build failed with code ${code}`));
79
+ });
80
+ proc.on('error', err => reject(err));
81
+ });
82
+ const built = await findApk(apkPath);
83
+ if (!built)
84
+ throw new Error('Could not locate built APK after running Gradle');
85
+ apkToInstall = built;
86
+ }
87
+ try {
88
+ const res = await spawnAdb(['install', '-r', apkToInstall], deviceId);
89
+ if (res.code === 0) {
90
+ return { device: deviceInfo, installed: true, output: res.stdout };
91
+ }
92
+ }
93
+ catch (e) {
94
+ console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e));
95
+ }
96
+ const basename = path.basename(apkToInstall);
97
+ const remotePath = `/data/local/tmp/${basename}`;
98
+ await execAdb(['push', apkToInstall, remotePath], deviceId);
99
+ const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId);
100
+ try {
101
+ await execAdb(['shell', 'rm', remotePath], deviceId);
102
+ }
103
+ catch { }
104
+ return { device: deviceInfo, installed: true, output: pmOut };
105
+ }
106
+ catch (e) {
107
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
108
+ }
109
+ }
110
+ async startApp(appId, deviceId) {
111
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
112
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
113
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
114
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
115
+ }
116
+ async terminateApp(appId, deviceId) {
117
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
118
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
119
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
120
+ return { device: deviceInfo, appTerminated: true };
121
+ }
122
+ async restartApp(appId, deviceId) {
123
+ await this.terminateApp(appId, deviceId);
124
+ const startResult = await this.startApp(appId, deviceId);
125
+ return {
126
+ device: startResult.device,
127
+ appRestarted: startResult.appStarted,
128
+ launchTimeMs: startResult.launchTimeMs
129
+ };
130
+ }
131
+ async resetAppData(appId, deviceId) {
132
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
133
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
134
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
135
+ return { device: deviceInfo, dataCleared: output === 'Success' };
136
+ }
137
+ }
@@ -1,91 +1,10 @@
1
1
  import { spawn } from "child_process";
2
2
  import { XMLParser } from "fast-xml-parser";
3
- import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
4
- // --- Helper Functions Specific to Observe ---
5
- const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
- function parseBounds(bounds) {
7
- const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
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 (e) {
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 { ADB, 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);
@@ -117,8 +36,8 @@ export class AndroidObserve {
117
36
  break; // Success
118
37
  }
119
38
  }
120
- catch (err) {
121
- console.error(`Attempt ${attempts} failed: ${err}`);
39
+ catch (e) {
40
+ console.error(`Attempt ${attempts} failed: ${e}`);
122
41
  }
123
42
  if (attempts === maxAttempts) {
124
43
  throw new Error(`Failed to retrieve valid UI dump after ${maxAttempts} attempts.`);
@@ -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(ADB, 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
  }