mobile-debug-mcp 0.10.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.
- package/README.md +3 -1
- package/dist/android/interact.js +1 -145
- package/dist/android/manage.js +137 -0
- package/dist/android/observe.js +131 -86
- package/dist/android/run.js +187 -0
- package/dist/android/utils.js +134 -144
- package/dist/ios/interact.js +1 -168
- package/dist/ios/manage.js +145 -0
- package/dist/ios/observe.js +108 -1
- package/dist/ios/run.js +200 -0
- package/dist/ios/utils.js +17 -116
- package/dist/server.js +27 -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/docs/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/android/interact.ts +2 -155
- package/src/android/manage.ts +135 -0
- package/src/android/observe.ts +127 -95
- package/src/android/utils.ts +144 -146
- package/src/ios/interact.ts +2 -174
- package/src/ios/manage.ts +143 -0
- package/src/ios/observe.ts +109 -1
- package/src/ios/utils.ts +18 -120
- package/src/server.ts +28 -17
- package/src/tools/interact.ts +23 -62
- package/src/tools/manage.ts +171 -0
- package/src/tools/observe.ts +24 -74
- package/test/integration/logstream-real.ts +5 -4
- package/test/unit/build.test.ts +84 -0
- package/test/unit/build_and_install.test.ts +132 -0
- package/test/unit/install.test.ts +2 -2
- package/test/unit/logstream.test.ts +8 -9
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { resolveTargetDevice, listDevices } from '../resolve-device.js';
|
|
4
|
+
import { AndroidManage } from '../android/manage.js';
|
|
5
|
+
import { iOSManage } from '../ios/manage.js';
|
|
6
|
+
export class ToolsRun {
|
|
7
|
+
static async buildAppHandler({ platform, projectPath, variant }) {
|
|
8
|
+
// delegate to platform-specific build implementations
|
|
9
|
+
const chosen = platform || 'android';
|
|
10
|
+
if (chosen === 'android') {
|
|
11
|
+
const android = new AndroidManage();
|
|
12
|
+
const artifact = await android.build(projectPath, variant);
|
|
13
|
+
return artifact;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const ios = new iOSManage();
|
|
17
|
+
const artifact = await ios.build(projectPath, variant);
|
|
18
|
+
return artifact;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
static async installAppHandler({ platform, appPath, deviceId }) {
|
|
22
|
+
let chosenPlatform = platform;
|
|
23
|
+
try {
|
|
24
|
+
const stat = await fs.stat(appPath).catch(() => null);
|
|
25
|
+
if (stat && stat.isDirectory()) {
|
|
26
|
+
// If the directory itself looks like an .app bundle, treat as iOS
|
|
27
|
+
if (appPath.endsWith('.app')) {
|
|
28
|
+
chosenPlatform = 'ios';
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const files = (await fs.readdir(appPath).catch(() => []));
|
|
32
|
+
if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) {
|
|
33
|
+
chosenPlatform = 'ios';
|
|
34
|
+
}
|
|
35
|
+
else if (files.includes('gradlew') || files.includes('build.gradle') || files.includes('settings.gradle') || (files.includes('app') && (await fs.stat(path.join(appPath, 'app')).catch(() => null)))) {
|
|
36
|
+
chosenPlatform = 'android';
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
chosenPlatform = 'android';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (typeof appPath === 'string') {
|
|
44
|
+
const ext = path.extname(appPath).toLowerCase();
|
|
45
|
+
if (ext === '.apk')
|
|
46
|
+
chosenPlatform = 'android';
|
|
47
|
+
else if (ext === '.ipa' || ext === '.app')
|
|
48
|
+
chosenPlatform = 'ios';
|
|
49
|
+
else
|
|
50
|
+
chosenPlatform = 'android';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
chosenPlatform = 'android';
|
|
55
|
+
}
|
|
56
|
+
if (chosenPlatform === 'android') {
|
|
57
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
58
|
+
const androidRun = new AndroidManage();
|
|
59
|
+
const result = await androidRun.installApp(appPath, resolved.id);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
64
|
+
const iosRun = new iOSManage();
|
|
65
|
+
const result = await iosRun.installApp(appPath, resolved.id);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
static async startAppHandler({ platform, appId, deviceId }) {
|
|
70
|
+
if (platform === 'android') {
|
|
71
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
72
|
+
return await new AndroidManage().startApp(appId, resolved.id);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
76
|
+
return await new iOSManage().startApp(appId, resolved.id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
static async terminateAppHandler({ platform, appId, deviceId }) {
|
|
80
|
+
if (platform === 'android') {
|
|
81
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
82
|
+
return await new AndroidManage().terminateApp(appId, resolved.id);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
86
|
+
return await new iOSManage().terminateApp(appId, resolved.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
static async restartAppHandler({ platform, appId, deviceId }) {
|
|
90
|
+
if (platform === 'android') {
|
|
91
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
92
|
+
return await new AndroidManage().restartApp(appId, resolved.id);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
96
|
+
return await new iOSManage().restartApp(appId, resolved.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
static async resetAppDataHandler({ platform, appId, deviceId }) {
|
|
100
|
+
if (platform === 'android') {
|
|
101
|
+
const resolved = await resolveTargetDevice({ platform: 'android', appId, deviceId });
|
|
102
|
+
return await new AndroidManage().resetAppData(appId, resolved.id);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', appId, deviceId });
|
|
106
|
+
return await new iOSManage().resetAppData(appId, resolved.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
static async buildAndInstallHandler({ platform, projectPath, deviceId, timeout }) {
|
|
110
|
+
const events = [];
|
|
111
|
+
const pushEvent = (obj) => events.push(JSON.stringify(obj));
|
|
112
|
+
const effectiveTimeout = timeout ?? 180000; // reserved for future streaming/timeouts
|
|
113
|
+
void effectiveTimeout;
|
|
114
|
+
// determine platform if not provided by inspecting path
|
|
115
|
+
let chosenPlatform = platform;
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(projectPath).catch(() => null);
|
|
118
|
+
if (!chosenPlatform) {
|
|
119
|
+
if (stat && stat.isDirectory()) {
|
|
120
|
+
const files = (await fs.readdir(projectPath).catch(() => []));
|
|
121
|
+
if (files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')))
|
|
122
|
+
chosenPlatform = 'ios';
|
|
123
|
+
else
|
|
124
|
+
chosenPlatform = 'android';
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const ext = path.extname(projectPath).toLowerCase();
|
|
128
|
+
if (ext === '.apk')
|
|
129
|
+
chosenPlatform = 'android';
|
|
130
|
+
else if (ext === '.ipa' || ext === '.app')
|
|
131
|
+
chosenPlatform = 'ios';
|
|
132
|
+
else
|
|
133
|
+
chosenPlatform = 'android';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
chosenPlatform = chosenPlatform || 'android';
|
|
139
|
+
}
|
|
140
|
+
pushEvent({ type: 'build', status: 'started', platform: chosenPlatform });
|
|
141
|
+
let buildRes;
|
|
142
|
+
try {
|
|
143
|
+
buildRes = await ToolsRun.buildAppHandler({ platform: chosenPlatform, projectPath });
|
|
144
|
+
if (buildRes && buildRes.error) {
|
|
145
|
+
pushEvent({ type: 'build', status: 'failed', error: buildRes.error });
|
|
146
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: buildRes.error } };
|
|
147
|
+
}
|
|
148
|
+
pushEvent({ type: 'build', status: 'finished', artifactPath: buildRes.artifactPath });
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
152
|
+
pushEvent({ type: 'build', status: 'failed', error: msg });
|
|
153
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
|
|
154
|
+
}
|
|
155
|
+
// Install phase
|
|
156
|
+
const artifact = buildRes.artifactPath || projectPath;
|
|
157
|
+
pushEvent({ type: 'install', status: 'started', artifactPath: artifact, deviceId });
|
|
158
|
+
let installRes;
|
|
159
|
+
try {
|
|
160
|
+
installRes = await ToolsRun.installAppHandler({ platform: chosenPlatform, appPath: artifact, deviceId });
|
|
161
|
+
if (installRes && installRes.installed === true) {
|
|
162
|
+
pushEvent({ type: 'install', status: 'finished', artifactPath: artifact, device: installRes.device });
|
|
163
|
+
return { ndjson: events.join('\n') + '\n', result: { success: true, artifactPath: artifact, device: installRes.device, output: installRes.output } };
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
pushEvent({ type: 'install', status: 'failed', error: installRes.error || 'unknown' });
|
|
167
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: installRes.error || 'install failed' } };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
172
|
+
pushEvent({ type: 'install', status: 'failed', error: msg });
|
|
173
|
+
return { ndjson: events.join('\n') + '\n', result: { success: false, error: msg } };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
static async listDevicesHandler({ platform, appId }) {
|
|
177
|
+
const devices = await listDevices(platform, appId);
|
|
178
|
+
return { devices };
|
|
179
|
+
}
|
|
180
|
+
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.11.0]
|
|
6
|
+
- Tools refactor - broke functions into 3 distinct class types; interact (for UI manipulation), manage (for build, installing etc) and observe (observing the app whilst running)
|
|
7
|
+
- Add convenience method to build and install
|
|
8
|
+
|
|
5
9
|
## [0.10.0]
|
|
6
10
|
|
|
7
11
|
### Added / Changed
|
package/package.json
CHANGED
package/src/android/interact.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo
|
|
3
|
-
import { detectJavaHome } from "../utils/java.js"
|
|
1
|
+
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js"
|
|
4
3
|
import { AndroidObserve } from "./observe.js"
|
|
5
|
-
import { promises as fs } from "fs"
|
|
6
|
-
import { spawn } from "child_process"
|
|
7
|
-
import path from "path"
|
|
8
|
-
import { existsSync } from "fs"
|
|
9
4
|
|
|
10
5
|
|
|
11
6
|
export class AndroidInteract {
|
|
@@ -93,152 +88,4 @@ export class AndroidInteract {
|
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
async installApp(apkPath: string, deviceId?: string): Promise<import("../types.js").InstallAppResponse> {
|
|
97
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
98
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
99
|
-
|
|
100
|
-
// Helper to recursively find first APK under a directory
|
|
101
|
-
async function findApk(dir: string): Promise<string | undefined> {
|
|
102
|
-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
103
|
-
for (const e of entries) {
|
|
104
|
-
const full = path.join(dir, e.name)
|
|
105
|
-
if (e.isDirectory()) {
|
|
106
|
-
const found = await findApk(full)
|
|
107
|
-
if (found) return found
|
|
108
|
-
} else if (e.isFile() && full.endsWith('.apk')) {
|
|
109
|
-
return full
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return undefined
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
let apkToInstall = apkPath
|
|
117
|
-
|
|
118
|
-
// If a directory is provided, attempt to build via Gradle
|
|
119
|
-
const stat = await fs.stat(apkPath).catch(() => null)
|
|
120
|
-
if (stat && stat.isDirectory()) {
|
|
121
|
-
const gradlewPath = path.join(apkPath, 'gradlew')
|
|
122
|
-
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle'
|
|
123
|
-
|
|
124
|
-
await new Promise<void>(async (resolve, reject) => {
|
|
125
|
-
// Auto-detect and set JAVA_HOME (prefer JDK 17) so builds don't require manual environment setup
|
|
126
|
-
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
127
|
-
const env = Object.assign({}, process.env)
|
|
128
|
-
if (detectedJavaHome) {
|
|
129
|
-
// Override existing JAVA_HOME if detection found a preferably compatible JDK (e.g., JDK 17).
|
|
130
|
-
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
131
|
-
env.JAVA_HOME = detectedJavaHome
|
|
132
|
-
// Also ensure the JDK bin is on PATH so tools like jlink/javac are resolved from the detected JDK
|
|
133
|
-
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
|
|
134
|
-
console.debug('[android] Overriding JAVA_HOME with detected path:', detectedJavaHome)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Sanitize environment so user shell init scripts are less likely to override our JAVA_HOME.
|
|
139
|
-
try {
|
|
140
|
-
// Remove obvious shell profile hints; avoid touching SDKMAN symlinks or on-disk state.
|
|
141
|
-
delete env.SHELL
|
|
142
|
-
} catch {}
|
|
143
|
-
|
|
144
|
-
// If we detected a compatible JDK, instruct Gradle to use it and avoid daemon reuse
|
|
145
|
-
// Prepare gradle invocation
|
|
146
|
-
const gradleArgs = ['assembleDebug']
|
|
147
|
-
if (detectedJavaHome) {
|
|
148
|
-
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
149
|
-
gradleArgs.push('--no-daemon')
|
|
150
|
-
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Prefer invoking the wrapper directly without a shell to avoid user profile shims (sdkman) re-setting JAVA_HOME
|
|
154
|
-
const wrapperPath = path.join(apkPath, 'gradlew')
|
|
155
|
-
const useWrapper = existsSync(wrapperPath)
|
|
156
|
-
const execCmd = useWrapper ? wrapperPath : gradleCmd
|
|
157
|
-
const spawnOpts: any = { cwd: apkPath, env }
|
|
158
|
-
// When using wrapper, ensure it's executable and invoke directly (no shell)
|
|
159
|
-
if (useWrapper) {
|
|
160
|
-
// Ensure the wrapper is executable; swallow errors from chmod (best-effort).
|
|
161
|
-
await fs.chmod(wrapperPath, 0o755).catch(() => {})
|
|
162
|
-
spawnOpts.shell = false
|
|
163
|
-
} else {
|
|
164
|
-
// if using system 'gradle' allow shell to resolve platform PATH
|
|
165
|
-
spawnOpts.shell = true
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
169
|
-
let stderr = ''
|
|
170
|
-
proc.stderr?.on('data', d => stderr += d.toString())
|
|
171
|
-
proc.on('close', code => {
|
|
172
|
-
if (code === 0) resolve()
|
|
173
|
-
else reject(new Error(stderr || `Gradle build failed with code ${code}`))
|
|
174
|
-
})
|
|
175
|
-
proc.on('error', err => reject(err))
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
const built = await findApk(apkPath)
|
|
179
|
-
if (!built) throw new Error('Could not locate built APK after running Gradle')
|
|
180
|
-
apkToInstall = built
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Try normal adb install with streaming attempt
|
|
184
|
-
try {
|
|
185
|
-
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId)
|
|
186
|
-
if (res.code === 0) {
|
|
187
|
-
return { device: deviceInfo, installed: true, output: res.stdout }
|
|
188
|
-
}
|
|
189
|
-
// fallthrough to fallback
|
|
190
|
-
} catch (e) {
|
|
191
|
-
// Log and continue to fallback
|
|
192
|
-
console.debug('[android] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Fallback: push APK to device and use pm install -r
|
|
196
|
-
const basename = path.basename(apkToInstall)
|
|
197
|
-
const remotePath = `/data/local/tmp/${basename}`
|
|
198
|
-
await execAdb(['push', apkToInstall, remotePath], deviceId)
|
|
199
|
-
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
|
|
200
|
-
// cleanup remote file
|
|
201
|
-
try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
|
|
202
|
-
return { device: deviceInfo, installed: true, output: pmOut }
|
|
203
|
-
} catch (e) {
|
|
204
|
-
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
209
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
210
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
211
|
-
|
|
212
|
-
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
213
|
-
|
|
214
|
-
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
218
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
219
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
220
|
-
|
|
221
|
-
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
222
|
-
|
|
223
|
-
return { device: deviceInfo, appTerminated: true }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
227
|
-
await this.terminateApp(appId, deviceId)
|
|
228
|
-
const startResult = await this.startApp(appId, deviceId)
|
|
229
|
-
return {
|
|
230
|
-
device: startResult.device,
|
|
231
|
-
appRestarted: startResult.appStarted,
|
|
232
|
-
launchTimeMs: startResult.launchTimeMs
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
237
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
238
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
239
|
-
|
|
240
|
-
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
241
|
-
|
|
242
|
-
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
243
|
-
}
|
|
244
91
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
8
|
+
|
|
9
|
+
export class AndroidManage {
|
|
10
|
+
async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
|
|
11
|
+
void _variant
|
|
12
|
+
try {
|
|
13
|
+
// Always use the shared prepareGradle utility for consistent env/setup
|
|
14
|
+
const { execCmd, gradleArgs, spawnOpts } = await (await import('./utils.js')).prepareGradle(projectPath)
|
|
15
|
+
await new Promise<void>((resolve, reject) => {
|
|
16
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
17
|
+
let stderr = ''
|
|
18
|
+
proc.stderr?.on('data', d => stderr += d.toString())
|
|
19
|
+
proc.on('close', code => {
|
|
20
|
+
if (code === 0) resolve()
|
|
21
|
+
else reject(new Error(stderr || `Gradle failed with code ${code}`))
|
|
22
|
+
})
|
|
23
|
+
proc.on('error', err => reject(err))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const apk = await findApk(projectPath)
|
|
27
|
+
if (!apk) return { error: 'Could not find APK after build' }
|
|
28
|
+
return { artifactPath: apk }
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { error: e instanceof Error ? e.message : String(e) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async installApp(apkPath: string, deviceId?: string): Promise<InstallAppResponse> {
|
|
35
|
+
const metadata = await getAndroidDeviceMetadata('', deviceId)
|
|
36
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
let apkToInstall = apkPath
|
|
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 { delete env.SHELL } catch {}
|
|
52
|
+
|
|
53
|
+
const gradleArgs = ['assembleDebug']
|
|
54
|
+
if (detectedJavaHome) {
|
|
55
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
|
|
56
|
+
gradleArgs.push('--no-daemon')
|
|
57
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const wrapperPath = path.join(apkPath, 'gradlew')
|
|
61
|
+
const useWrapper = existsSync(wrapperPath)
|
|
62
|
+
const execCmd = useWrapper ? wrapperPath : 'gradle'
|
|
63
|
+
const spawnOpts: any = { cwd: apkPath, env }
|
|
64
|
+
if (useWrapper) {
|
|
65
|
+
await fs.chmod(wrapperPath, 0o755).catch(() => {})
|
|
66
|
+
spawnOpts.shell = false
|
|
67
|
+
} else spawnOpts.shell = true
|
|
68
|
+
|
|
69
|
+
const proc = spawn(execCmd, gradleArgs, spawnOpts)
|
|
70
|
+
let stderr = ''
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
proc.stderr?.on('data', d => stderr += d.toString())
|
|
73
|
+
proc.on('close', code => {
|
|
74
|
+
if (code === 0) resolve()
|
|
75
|
+
else reject(new Error(stderr || `Gradle build failed with code ${code}`))
|
|
76
|
+
})
|
|
77
|
+
proc.on('error', err => reject(err))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const built = await findApk(apkPath)
|
|
81
|
+
if (!built) throw new Error('Could not locate built APK after running Gradle')
|
|
82
|
+
apkToInstall = built
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const res = await spawnAdb(['install', '-r', apkToInstall], deviceId)
|
|
87
|
+
if (res.code === 0) {
|
|
88
|
+
return { device: deviceInfo, installed: true, output: res.stdout }
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const basename = path.basename(apkToInstall)
|
|
95
|
+
const remotePath = `/data/local/tmp/${basename}`
|
|
96
|
+
await execAdb(['push', apkToInstall, remotePath], deviceId)
|
|
97
|
+
const pmOut = await execAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
|
|
98
|
+
try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
|
|
99
|
+
return { device: deviceInfo, installed: true, output: pmOut }
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
106
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
107
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
108
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
109
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
113
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
114
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
115
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
116
|
+
return { device: deviceInfo, appTerminated: true }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
120
|
+
await this.terminateApp(appId, deviceId)
|
|
121
|
+
const startResult = await this.startApp(appId, deviceId)
|
|
122
|
+
return {
|
|
123
|
+
device: startResult.device,
|
|
124
|
+
appRestarted: startResult.appStarted,
|
|
125
|
+
launchTimeMs: startResult.launchTimeMs
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
130
|
+
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
131
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
132
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
133
|
+
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
134
|
+
}
|
|
135
|
+
}
|