mobile-debug-mcp 0.18.0 → 0.19.1

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.
@@ -29,6 +29,8 @@ interface UiElement {
29
29
  _interactable?: boolean
30
30
  }
31
31
 
32
+ const STABLE_IDLE_MS = 1000
33
+
32
34
  export class ToolsInteract {
33
35
 
34
36
  private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
@@ -260,4 +262,147 @@ export class ToolsInteract {
260
262
  return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
261
263
  }
262
264
 
265
+ static async observeUntilHandler({ type, query, timeoutMs = 5000, pollIntervalMs = 200, platform, deviceId }: { type: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
266
+ const start = Date.now()
267
+ const deadline = start + (timeoutMs || 0)
268
+ const q = (query === null || query === undefined) ? '' : String(query)
269
+
270
+ // Baseline state
271
+ let initialFingerprint: string | null = null
272
+ try {
273
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
274
+ initialFingerprint = fpRes?.fingerprint ?? null
275
+ } catch (err) { console.error('observeUntil: error getting initial fingerprint', err); initialFingerprint = null }
276
+
277
+ // For logs, capture a baseline snapshot (count or last line) to avoid matching historical lines
278
+ let baselineLastLine: string | null = null
279
+ try {
280
+ const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 })
281
+ const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
+ baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
283
+ } catch (err) {
284
+ // non-fatal but surface warning to aid debugging
285
+ try { console.warn('observeUntil: failed to get baseline logs (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
286
+ }
287
+
288
+
289
+ let lastChangeAt = Date.now()
290
+ let prevFingerprint = initialFingerprint
291
+
292
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
293
+
294
+ // Telemetry
295
+ let pollCount = 0
296
+ let timeToMatch: number | null = null
297
+ let matchSource: string | null = null
298
+
299
+ while (Date.now() <= deadline) {
300
+ pollCount++
301
+ try {
302
+ if (type === 'ui') {
303
+ // fast findElement with short timeout to avoid blocking
304
+ try {
305
+ const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
306
+ if (found && (found as any).found) {
307
+ timeToMatch = Date.now() - start
308
+ // determine matchSource heuristics
309
+ const el = (found as any).element || {}
310
+ if (el && el.resourceId && String(el.resourceId).toLowerCase().includes(q.toLowerCase())) matchSource = 'ui-resourceId'
311
+ else if (el && el.text && String(el.text).toLowerCase() === q.toLowerCase()) matchSource = 'ui-exact'
312
+ else matchSource = 'ui-partial'
313
+
314
+ return { success: true, type: 'ui', matched: true, details: `UI element matched '${q}'`, timestamp: Date.now(), element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
315
+ }
316
+ } catch (err) { console.error('observeUntil(ui) find error:', err) }
317
+ } else if (type === 'log') {
318
+ try {
319
+ // Try reading from active stream first
320
+ const stream = await ToolsObserve.readLogStreamHandler({ platform, sessionId: 'default', limit: 200 }) as any
321
+ const entries = (stream && Array.isArray(stream.entries)) ? stream.entries : []
322
+ for (const ent of entries) {
323
+ const msg = ent && (ent.message || ent.msg || ent) ? (ent.message || ent.msg || ent) : ''
324
+ if (q && String(msg).includes(q)) {
325
+ timeToMatch = Date.now() - start
326
+ matchSource = 'log-stream'
327
+ return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: msg, raw: ent }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
328
+ }
329
+ }
330
+
331
+ // Fallback to snapshot logs
332
+ const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
333
+ const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
334
+ // Only consider new lines after baselineLastLine when possible
335
+ let startIndex = 0
336
+ if (baselineLastLine) {
337
+ const idx = logsArr.lastIndexOf(baselineLastLine)
338
+ startIndex = idx >= 0 ? idx + 1 : 0
339
+ }
340
+ for (let i = startIndex; i < logsArr.length; i++) {
341
+ const line = logsArr[i]
342
+ if (q && String(line).includes(q)) {
343
+ timeToMatch = Date.now() - start
344
+ matchSource = 'log-snapshot'
345
+ return { success: true, type: 'log', matched: true, details: `Log matched '${q}'`, timestamp: Date.now(), log: { message: line }, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
346
+ }
347
+ }
348
+ } catch (err) { console.error('observeUntil(log) error:', err) }
349
+ } else if (type === 'screen') {
350
+ try {
351
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
352
+ const fp = fpRes?.fingerprint ?? null
353
+ if (fp !== null && fp !== undefined && fp !== initialFingerprint) {
354
+ if (q) {
355
+ // optionally validate query against new screen context
356
+ try {
357
+ const found = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, timeoutMs || 500), platform, deviceId })
358
+ if (found && (found as any).found) {
359
+ timeToMatch = Date.now() - start
360
+ matchSource = 'screen-validated-ui'
361
+ return { success: true, type: 'screen', matched: true, details: `Screen changed and query matched on new screen`, timestamp: Date.now(), newFingerprint: fp, element: (found as any).element, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
362
+ }
363
+ } catch (err) { console.error('observeUntil(screen) find error:', err) }
364
+ // If query provided but not matched yet, continue polling until timeout
365
+ } else {
366
+ timeToMatch = Date.now() - start
367
+ matchSource = 'screen-fingerprint'
368
+ return { success: true, type: 'screen', matched: true, details: 'Screen fingerprint changed', timestamp: Date.now(), newFingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
369
+ }
370
+ }
371
+ } catch (err) { console.error('observeUntil(screen) error:', err) }
372
+ } else if (type === 'idle') {
373
+ try {
374
+ const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
375
+ const fp = fpRes?.fingerprint ?? null
376
+ if (fp !== prevFingerprint) {
377
+ prevFingerprint = fp
378
+ lastChangeAt = Date.now()
379
+ } else {
380
+ if (Date.now() - lastChangeAt >= STABLE_IDLE_MS) {
381
+ timeToMatch = Date.now() - start
382
+ matchSource = 'idle-stable'
383
+ return { success: true, type: 'idle', matched: true, details: `UI stable for ${STABLE_IDLE_MS}ms`, timestamp: Date.now(), fingerprint: fp, telemetry: { pollCount, timeToMatch, elapsedMs: Date.now() - start, matchSource } }
384
+ }
385
+ }
386
+ } catch (err) { console.error('observeUntil(idle) error:', err) }
387
+ }
388
+ } catch (err) {
389
+ console.error('observeUntil: unexpected error', err)
390
+ }
391
+
392
+ // Respect poll interval and avoid tight loop
393
+ await sleep(pollIntervalMs || 200)
394
+ }
395
+
396
+ // On timeout, capture a failure snapshot to aid debugging (best-effort)
397
+ let snapshot: any = null
398
+ try {
399
+ snapshot = await ToolsObserve.captureDebugSnapshotHandler({ reason: `observe_until timeout for ${type}`, includeLogs: true, platform, deviceId })
400
+ } catch (err) {
401
+ snapshot = { error: err instanceof Error ? err.message : String(err) }
402
+ }
403
+
404
+ const elapsed = Date.now() - start
405
+ return { success: false, error: 'Timeout waiting for condition', type, timeoutMs, telemetry: { pollCount, elapsedMs: elapsed, matchSource: null }, snapshot }
406
+ }
407
+
263
408
  }
@@ -115,7 +115,7 @@ export class AndroidManage {
115
115
  try {
116
116
  await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
117
117
  return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
118
- } catch (e:any) {
118
+ } catch (e: unknown) {
119
119
  const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
120
120
  return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
121
121
  }
@@ -127,7 +127,7 @@ export class AndroidManage {
127
127
  try {
128
128
  await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
129
129
  return { device: deviceInfo, appTerminated: true }
130
- } catch (e:any) {
130
+ } catch (e: unknown) {
131
131
  const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
132
132
  return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
133
133
  }
@@ -149,7 +149,7 @@ export class AndroidManage {
149
149
  try {
150
150
  const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
151
151
  return { device: deviceInfo, dataCleared: output === 'Success' }
152
- } catch (e:any) {
152
+ } catch (e: unknown) {
153
153
  const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
154
154
  return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
155
155
  }
package/src/manage/ios.ts CHANGED
@@ -302,7 +302,7 @@ export class iOSManage {
302
302
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
303
303
  const device = await getIOSDeviceMetadata(deviceId)
304
304
  return { device, appStarted: !!result.output, launchTimeMs: 1000 }
305
- } catch (e:any) {
305
+ } catch (e: unknown) {
306
306
  const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
307
307
  const device = await getIOSDeviceMetadata(deviceId)
308
308
  return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
@@ -315,7 +315,7 @@ export class iOSManage {
315
315
  await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
316
316
  const device = await getIOSDeviceMetadata(deviceId)
317
317
  return { device, appTerminated: true }
318
- } catch (e:any) {
318
+ } catch (e: unknown) {
319
319
  const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
320
320
  const device = await getIOSDeviceMetadata(deviceId)
321
321
  return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
@@ -351,7 +351,7 @@ export class iOSManage {
351
351
  } catch (e) {
352
352
  throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
353
353
  }
354
- } catch (e:any) {
354
+ } catch (e: unknown) {
355
355
  const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
356
356
  return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
357
357
  }
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
+ import type { SchemaOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js"
4
5
  import {
5
6
  ListToolsRequestSchema,
6
7
  CallToolRequestSchema
@@ -19,6 +20,7 @@ import { ToolsInteract } from './interact/index.js'
19
20
  import { ToolsObserve } from './observe/index.js'
20
21
  import { AndroidManage } from './manage/index.js'
21
22
  import { iOSManage } from './manage/index.js'
23
+ import { ensureAdbAvailable } from './utils/android/utils.js'
22
24
 
23
25
 
24
26
  const server = new Server(
@@ -31,7 +33,22 @@ const server = new Server(
31
33
  tools: {}
32
34
  }
33
35
  }
34
- )
36
+ );
37
+
38
+ // Startup healthchecks (non-fatal) — verify adb availability and log chosen command
39
+ (async () => {
40
+ try {
41
+ const adbCheck = ensureAdbAvailable()
42
+ if (adbCheck.ok) console.debug('[startup] adb available:', adbCheck.adbCmd, adbCheck.version)
43
+ else console.warn('[startup] adb not available or failed to run:', adbCheck.adbCmd, adbCheck.error)
44
+ } catch (e: unknown) {
45
+ if (e instanceof Error) {
46
+ console.warn('[startup] error during adb healthcheck:', e.message)
47
+ } else {
48
+ console.warn('[startup] error during adb healthcheck:', String(e))
49
+ }
50
+ }
51
+ })()
35
52
 
36
53
  function wrapResponse<T>(data: T) {
37
54
  return {
@@ -487,9 +504,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
487
504
  }
488
505
  }
489
506
  ]
490
- }))
507
+ }));
491
508
 
492
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
509
+ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typeof CallToolRequestSchema>) => {
493
510
  const { name, arguments: args } = request.params
494
511
 
495
512
  try {
@@ -3,8 +3,51 @@ import { promises as fsPromises, existsSync } from 'fs'
3
3
  import path from 'path'
4
4
  import { detectJavaHome } from '../java.js'
5
5
  import { execCmd } from '../exec.js'
6
+ import { spawnSync } from 'child_process'
6
7
 
7
- export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
8
+ function findInPath(cmd: string): string | null {
9
+ try {
10
+ // prefer command -v for POSIX
11
+ const res = spawnSync('command', ['-v', cmd], { encoding: 'utf8' })
12
+ if (res.status === 0 && res.stdout) return res.stdout.trim()
13
+ } catch (e: unknown) { console.debug(`[findInPath] command -v ${cmd} failed: ${String(e)}`) }
14
+ try {
15
+ const res = spawnSync('which', [cmd], { encoding: 'utf8' })
16
+ if (res.status === 0 && res.stdout) return res.stdout.trim()
17
+ } catch (e: unknown) { console.debug(`[findInPath] which ${cmd} failed: ${String(e)}`) }
18
+ return null
19
+ }
20
+
21
+ export function resolveAdbCmd(): string {
22
+ // Priority: explicit env ADB_PATH -> ANDROID_SDK_ROOT/platform-tools/adb -> ANDROID_HOME/platform-tools/adb -> ~/Library/Android/sdk/platform-tools/adb -> PATH discovery -> 'adb'
23
+ if (process.env.ADB_PATH && process.env.ADB_PATH.trim()) return process.env.ADB_PATH
24
+ const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME
25
+ if (sdkRoot) {
26
+ const candidate = path.join(sdkRoot, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
27
+ if (existsSync(candidate)) return candidate
28
+ }
29
+ // common macOS user SDK path
30
+ const homeSdk = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb')
31
+ if (existsSync(homeSdk)) return homeSdk
32
+ const found = findInPath('adb')
33
+ if (found) return found
34
+ return 'adb'
35
+ }
36
+
37
+ export function getAdbCmd() { return resolveAdbCmd() }
38
+
39
+ export function ensureAdbAvailable() {
40
+ const adb = resolveAdbCmd()
41
+ try {
42
+ const res = spawnSync(adb, ['--version'], { encoding: 'utf8' })
43
+ if (res.status === 0) {
44
+ return { adbCmd: adb, ok: true, version: (res.stdout || res.stderr || '').trim() }
45
+ }
46
+ return { adbCmd: adb, ok: false, error: (res.stderr || res.stdout || '').trim() }
47
+ } catch (err: unknown) {
48
+ return { adbCmd: adb, ok: false, error: String(err) }
49
+ }
50
+ }
8
51
 
9
52
  /**
10
53
  * Prepare Gradle execution options for building an Android project.
@@ -31,22 +74,58 @@ export async function prepareGradle(projectPath: string): Promise<{ execCmd: str
31
74
 
32
75
  const detectedJavaHome = await detectJavaHome().catch(() => undefined)
33
76
  const env = Object.assign({}, process.env)
77
+
78
+ // Ensure child processes can find Android platform-tools (adb, etc.) by
79
+ // prepending the platform-tools directory to PATH for spawned processes.
80
+ const adbPath = resolveAdbCmd()
81
+ let platformToolsDir: string | undefined = undefined
82
+ try {
83
+ if (adbPath && adbPath !== 'adb' && existsSync(adbPath)) {
84
+ platformToolsDir = path.dirname(adbPath)
85
+ }
86
+ } catch (e: unknown) { console.debug(`[prepareGradle] error resolving adbPath: ${String(e)}`) }
87
+
88
+ const pathParts: string[] = []
34
89
  if (detectedJavaHome) {
35
90
  if (env.JAVA_HOME !== detectedJavaHome) {
36
91
  env.JAVA_HOME = detectedJavaHome
37
- env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`
38
92
  }
93
+ const javaBin = path.join(detectedJavaHome, 'bin')
94
+ pathParts.push(javaBin)
39
95
  gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`)
40
96
  gradleArgs.push('--no-daemon')
41
97
  env.GRADLE_JAVA_HOME = detectedJavaHome
42
98
  }
43
99
 
44
- try { delete env.SHELL } catch {}
100
+ if (platformToolsDir) {
101
+ // Prepend platform-tools so gradle and child tools find adb without modifying global env
102
+ if (!env.PATH || !env.PATH.includes(platformToolsDir)) {
103
+ pathParts.push(platformToolsDir)
104
+ }
105
+ } else if (process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME) {
106
+ const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || ''
107
+ const candidate = path.join(sdkRoot, 'platform-tools')
108
+ if (existsSync(candidate) && (!env.PATH || !env.PATH.includes(candidate))) {
109
+ pathParts.push(candidate)
110
+ }
111
+ } else {
112
+ // also try common user sdk location
113
+ const homeSdkTools = path.join(process.env.HOME || '', 'Library', 'Android', 'sdk', 'platform-tools')
114
+ if (existsSync(homeSdkTools) && (!env.PATH || !env.PATH.includes(homeSdkTools))) {
115
+ pathParts.push(homeSdkTools)
116
+ }
117
+ }
118
+
119
+ if (pathParts.length > 0) {
120
+ env.PATH = `${pathParts.join(path.delimiter)}${path.delimiter}${env.PATH || ''}`
121
+ }
122
+
123
+ try { delete env.SHELL } catch (e: unknown) { console.debug('[prepareGradle] failed to delete SHELL from env:', String(e)) }
45
124
 
46
125
  const useWrapper = existsSync(gradlewPath)
47
126
  const spawnOpts: any = { cwd: projectPath, env }
48
127
  if (useWrapper) {
49
- try { await fsPromises.chmod(gradlewPath, 0o755) } catch {}
128
+ try { await fsPromises.chmod(gradlewPath, 0o755) } catch (e: unknown) { console.debug('[prepareGradle] chmod failed for gradlew:', String(e)) }
50
129
  spawnOpts.shell = false
51
130
  } else {
52
131
  spawnOpts.shell = true
@@ -125,8 +204,7 @@ export async function getAndroidDeviceMetadata(appId: string, deviceId?: string)
125
204
  if (deviceLines.length === 1) {
126
205
  resolvedDeviceId = deviceLines[0];
127
206
  }
128
- } catch {
129
- // ignore and continue without resolvedDeviceId
207
+ } catch (e: unknown) { console.debug('[getAndroidDeviceMetadata] error detecting single device: ' + String(e))
130
208
  }
131
209
  }
132
210
 
@@ -139,7 +217,8 @@ export async function getAndroidDeviceMetadata(appId: string, deviceId?: string)
139
217
 
140
218
  const simulator = simOutput === '1'
141
219
  return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator }
142
- } catch {
220
+ } catch (e: unknown) {
221
+ console.debug('[getAndroidDeviceMetadata] failed to gather metadata: ' + String(e))
143
222
  return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false }
144
223
  }
145
224
  }
@@ -180,20 +259,14 @@ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]>
180
259
  try {
181
260
  const pm = await execAdb(['shell', 'pm', 'path', appId], serial)
182
261
  appInstalled = !!(pm && pm.includes('package:'))
183
- } catch {
184
- appInstalled = false
185
- }
262
+ } catch (e: unknown) { console.debug(`[listAndroidDevices] pm check failed for ${serial}: ${String(e)}`); appInstalled = false }
186
263
  }
187
264
  return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled } as DeviceInfo & { appInstalled?: boolean }
188
- } catch {
189
- return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean }
190
- }
265
+ } catch (e: unknown) { console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`); return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean } }
191
266
  }))
192
267
 
193
268
  return infos
194
- } catch {
195
- return []
196
- }
269
+ } catch (e: unknown) { console.debug('[listAndroidDevices] failed to list devices: ' + String(e)); return [] }
197
270
  }
198
271
 
199
272
  // UI helper utilities shared by observe/interact
@@ -219,9 +292,7 @@ export async function getScreenResolution(deviceId?: string): Promise<{ width: n
219
292
  if (match) {
220
293
  return { width: parseInt(match[1]), height: parseInt(match[2]) };
221
294
  }
222
- } catch {
223
- // ignore
224
- }
295
+ } catch (e: unknown) { console.debug('[getScreenResolution] failed to detect screen resolution: ' + String(e)) }
225
296
  return { width: 0, height: 0 };
226
297
  }
227
298
 
@@ -40,8 +40,8 @@ function startCompanionIfNeeded(companionPath: string | null, udid: string | nul
40
40
  const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' })
41
41
  child.unref()
42
42
  return { started: true }
43
- } catch (e:any) {
44
- return { started: false, error: e.message }
43
+ } catch (e: unknown) {
44
+ return { started: false, error: e instanceof Error ? e.message : String(e) }
45
45
  }
46
46
  }
47
47
 
package/src/utils/java.ts CHANGED
@@ -2,68 +2,114 @@ import { execSync } from 'child_process'
2
2
  import { existsSync } from 'fs'
3
3
  import path from 'path'
4
4
 
5
+ function isJavaVersionAcceptable(output?: string | null): boolean {
6
+ if (!output) return false
7
+ const s = String(output)
8
+ // Accept Java 17 or 21 (common supported LTS for Android builds)
9
+ if (/\b17\b/.test(s) || /17\./.test(s)) return true
10
+ if (/\b21\b/.test(s) || /21\./.test(s)) return true
11
+ return false
12
+ }
13
+
14
+ import { spawnSync } from 'child_process'
15
+ function javaVersionOf(javaBin: string): string | undefined {
16
+ try {
17
+ const res = spawnSync(javaBin, ['-version'], { encoding: 'utf8' })
18
+ // Java prints version to stderr traditionally
19
+ const out = (res.stdout || '') + (res.stderr || '')
20
+ return out || undefined
21
+ } catch (e: unknown) { console.debug('[javaVersionOf] java -version failed: ' + String(e)); return undefined }
22
+ }
23
+
5
24
  export async function detectJavaHome(): Promise<string | undefined> {
6
25
  try {
7
- // If JAVA_HOME is set, validate it's Java 17
8
- if (process.env.JAVA_HOME) {
9
- try {
10
- const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
11
- const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString()
12
- if (/\b17\b/.test(v) || /17\./.test(v)) return process.env.JAVA_HOME
13
- console.debug('[java.detect] Existing JAVA_HOME does not appear to be Java 17, will search for JDK17')
14
- } catch {
15
- console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK17')
26
+ // 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
27
+ const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR
28
+ if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
29
+ const v = javaVersionOf(path.join(envStudio, 'bin', 'java'))
30
+ if (isJavaVersionAcceptable(v)) {
31
+ console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
32
+ return envStudio
16
33
  }
34
+ console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
17
35
  }
18
36
 
19
- // macOS explicit path
20
- const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home'
21
- if (existsSync(explicit)) return explicit
22
-
23
- // Android Studio JBR candidates
37
+ // 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
24
38
  const jbrCandidates = [
25
39
  '/Applications/Android Studio.app/Contents/jbr',
40
+ '/Applications/Android Studio.app/Contents/jbr/Contents/Home',
26
41
  '/Applications/Android Studio Preview.app/Contents/jbr',
42
+ '/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home',
27
43
  '/Applications/Android Studio Preview 2022.3.app/Contents/jbr',
28
- '/Applications/Android Studio Preview 2023.1.app/Contents/jbr'
44
+ '/Applications/Android Studio Preview 2022.3.app/Contents/jbr/Contents/Home',
45
+ '/Applications/Android Studio Preview 2023.1.app/Contents/jbr',
46
+ '/Applications/Android Studio Preview 2023.1.app/Contents/jbr/Contents/Home'
29
47
  ]
30
48
  for (const p of jbrCandidates) {
31
49
  const javaBin = path.join(p, 'bin', 'java')
32
50
  if (existsSync(javaBin)) {
33
- try {
34
- const v = execSync(`"${javaBin}" -version`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString()
35
- if (/\b17\b/.test(v) || /17\./.test(v)) return p
36
- } catch {}
51
+ const v = javaVersionOf(javaBin)
52
+ if (isJavaVersionAcceptable(v)) {
53
+ console.debug('[java.detect] Found Android Studio JBR at:', p)
54
+ return p
55
+ }
37
56
  }
38
57
  }
39
58
 
40
- // macOS /usr/libexec/java_home
59
+ // 3) If JAVA_HOME set, validate it (accept 17 or 21)
60
+ if (process.env.JAVA_HOME) {
61
+ try {
62
+ const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
63
+ const v = javaVersionOf(javaBin)
64
+ if (isJavaVersionAcceptable(v)) {
65
+ console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
66
+ return process.env.JAVA_HOME
67
+ }
68
+ console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
69
+ } catch {
70
+ console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
71
+ }
72
+ }
73
+
74
+ // 4) macOS explicit path for JDK 17
75
+ const explicit = '/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home'
76
+ if (existsSync(explicit)) return explicit
77
+
78
+ // 5) macOS /usr/libexec/java_home try supported versions
79
+ try {
80
+ const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
81
+ if (out17) return out17
82
+ } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
41
83
  try {
42
- const out = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
43
- if (out) return out
44
- } catch {}
84
+ const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
85
+ if (out21) return out21
86
+ } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
45
87
 
46
- // macOS common JDK locations
88
+ // 6) macOS common JDK locations
47
89
  try {
48
90
  const homes = execSync('ls -1 /Library/Java/JavaVirtualMachines || true', { stdio: ['ignore', 'pipe', 'inherit'] }).toString().split(/\r?\n/).filter(Boolean)
49
91
  for (const h of homes) {
50
- if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17')) {
92
+ if (h.toLowerCase().includes('17') || h.toLowerCase().includes('jdk-17') || h.toLowerCase().includes('21') || h.toLowerCase().includes('jdk-21')) {
51
93
  const candidate = `/Library/Java/JavaVirtualMachines/${h}/Contents/Home`
52
94
  return candidate
53
95
  }
54
96
  }
55
- } catch {}
97
+ } catch (e: unknown) { console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
56
98
 
57
- // Linux locations
99
+ // 7) Linux locations
58
100
  const linuxCandidates = [
59
101
  '/usr/lib/jvm/java-17-openjdk-amd64',
60
102
  '/usr/lib/jvm/java-17-openjdk',
61
103
  '/usr/lib/jvm/zulu17',
62
- '/usr/lib/jvm/temurin-17-jdk'
104
+ '/usr/lib/jvm/temurin-17-jdk',
105
+ '/usr/lib/jvm/temurin-21-jdk',
106
+ '/usr/lib/jvm/java-21-openjdk-amd64'
63
107
  ]
64
108
  for (const p of linuxCandidates) {
65
- try { if (existsSync(p)) return p } catch {}
109
+ try { if (existsSync(p)) return p } catch (e: unknown) { console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
66
110
  }
67
- } catch {}
111
+ } catch (e: unknown) {
112
+ console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
113
+ }
68
114
  return undefined
69
115
  }
@@ -0,0 +1,24 @@
1
+ (async function main(){
2
+ try{
3
+ const inter = await import('../../src/interact/index.ts')
4
+ const manage = await import('../../src/manage/index.ts')
5
+ const ToolsInteract = (inter as any).ToolsInteract
6
+ const ToolsManage = (manage as any).ToolsManage
7
+
8
+ const ANDROID_ID = process.env.ANDROID_DEVICE || 'emulator-5554'
9
+ const IOS_UDID = process.env.IOS_DEVICE || '2EFFD8FD-5D09-47CC-95F8-28BBE30AF7ED'
10
+ console.log('Device test starting. Android:', ANDROID_ID, 'iOS:', IOS_UDID)
11
+
12
+ // Start modul8 on both platforms if present
13
+ try { await ToolsManage.startAppHandler({ platform: 'android', appId: 'com.ideamechanics.modul8', deviceId: ANDROID_ID }); console.log('Started android app (if installed)') } catch(e){ console.error('Android start skipped:', e.message || e) }
14
+ try { await ToolsManage.startAppHandler({ platform: 'ios', appId: 'com.ideamechanics.modul8.Modul8', deviceId: IOS_UDID }); console.log('Started ios app (if installed)') } catch(e){ console.error('iOS start skipped:', e.message || e) }
15
+
16
+ // Observe UI for Generate Session on both devices (will timeout if not present)
17
+ const aRes = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 20000, pollIntervalMs: 500, platform: 'android', deviceId: ANDROID_ID })
18
+ console.log('Android observe result:', JSON.stringify(aRes, null, 2))
19
+
20
+ const iRes = await ToolsInteract.observeUntilHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 20000, pollIntervalMs: 500, platform: 'ios', deviceId: IOS_UDID })
21
+ console.log('iOS observe result:', JSON.stringify(iRes, null, 2))
22
+
23
+ } catch (e) { console.error('ERR', e); process.exit(1) }
24
+ })()