mobile-debug-mcp 0.21.2 → 0.21.4

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.
@@ -215,9 +215,200 @@ export class ToolsInteract {
215
215
  const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
216
216
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
217
217
  }
218
- static async waitForUIHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
219
- // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
220
- return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
218
+ static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
219
+ const overallStart = Date.now();
220
+ // Validate selector: require at least one of text, resource_id, or accessibility_id
221
+ if (!selector || (typeof selector === 'object' && Object.keys(selector).length === 0)) {
222
+ return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'At least one selector field must be provided (text, resource_id, or accessibility_id)' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
223
+ }
224
+ const hasText = selector && typeof selector.text === 'string' && selector.text.trim().length > 0;
225
+ const hasResId = selector && typeof selector.resource_id === 'string' && selector.resource_id.trim().length > 0;
226
+ const hasAccId = selector && typeof selector.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
227
+ if (!hasText && !hasResId && !hasAccId) {
228
+ return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'Selector must include at least one of: text, resource_id, accessibility_id' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
229
+ }
230
+ // Validate condition
231
+ if (!['exists', 'not_exists', 'visible', 'clickable'].includes(condition)) {
232
+ return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
233
+ }
234
+ // Platform check
235
+ if (platform && !['android', 'ios'].includes(platform)) {
236
+ return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } };
237
+ }
238
+ const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000));
239
+ const maxAttempts = (retry && retry.max_attempts) ? Math.max(1, retry.max_attempts) : 1;
240
+ const backoff = (retry && retry.backoff_ms) ? Math.max(0, retry.backoff_ms) : 0;
241
+ let attempts = 0;
242
+ let totalPollCount = 0;
243
+ // Precompute normalized selector values and helpers (constant across polls)
244
+ const normalize = (s) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim();
245
+ const containsFlag = !!selector.contains;
246
+ const selText = normalize(selector.text);
247
+ const selRid = normalize(selector.resource_id);
248
+ const selAid = normalize(selector.accessibility_id);
249
+ try {
250
+ while (attempts < maxAttempts) {
251
+ attempts++;
252
+ const attemptStart = Date.now();
253
+ const deadline = attemptStart + (timeout_ms || 0);
254
+ while (Date.now() <= deadline) {
255
+ totalPollCount++;
256
+ try {
257
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
258
+ const elements = (tree && Array.isArray(tree.elements)) ? tree.elements : [];
259
+ const matches = [];
260
+ for (let i = 0; i < elements.length; i++) {
261
+ const el = elements[i];
262
+ let ok = true;
263
+ // text
264
+ if (selector.text !== undefined && selector.text !== null) {
265
+ const val = normalize(el.text || el.label || el.value || '');
266
+ if (containsFlag) {
267
+ if (!val.includes(selText))
268
+ ok = false;
269
+ }
270
+ else {
271
+ if (val !== selText)
272
+ ok = false;
273
+ }
274
+ }
275
+ // resource_id
276
+ if (ok && selector.resource_id !== undefined && selector.resource_id !== null) {
277
+ const rid = normalize(el.resourceId || el.resourceID || el.id || '');
278
+ if (containsFlag) {
279
+ if (!rid.includes(selRid))
280
+ ok = false;
281
+ }
282
+ else {
283
+ if (rid !== selRid)
284
+ ok = false;
285
+ }
286
+ }
287
+ // accessibility_id
288
+ if (ok && selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
289
+ const aid = normalize(el.contentDescription || el.contentDesc || el.accessibilityLabel || el.label || '');
290
+ if (containsFlag) {
291
+ if (!aid.includes(selAid))
292
+ ok = false;
293
+ }
294
+ else {
295
+ if (aid !== selAid)
296
+ ok = false;
297
+ }
298
+ }
299
+ if (ok)
300
+ matches.push({ el, idx: i });
301
+ }
302
+ // Evaluate condition
303
+ const matchedCount = matches.length;
304
+ const pickIndexProvided = (match && typeof match.index === 'number');
305
+ const pickIndex = pickIndexProvided ? Number(match.index) : 0;
306
+ let chosen = null;
307
+ if (matches.length === 0) {
308
+ chosen = null;
309
+ }
310
+ else if (pickIndexProvided) {
311
+ // If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
312
+ if (pickIndex >= 0 && pickIndex < matches.length)
313
+ chosen = matches[pickIndex];
314
+ else
315
+ chosen = null;
316
+ }
317
+ else {
318
+ chosen = matches[0];
319
+ }
320
+ let conditionMet = false;
321
+ if (condition === 'exists') {
322
+ // when an index is specified, existence requires that specific index be present
323
+ conditionMet = pickIndexProvided ? (chosen !== null) : (matchedCount >= 1);
324
+ }
325
+ else if (condition === 'not_exists') {
326
+ // when an index is specified, not_exists is true if that index is absent
327
+ conditionMet = pickIndexProvided ? (chosen === null) : (matchedCount === 0);
328
+ }
329
+ else if (condition === 'visible') {
330
+ if (chosen) {
331
+ const b = chosen.el.bounds;
332
+ const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
333
+ conditionMet = visibleFlag;
334
+ }
335
+ else
336
+ conditionMet = false;
337
+ }
338
+ else if (condition === 'clickable') {
339
+ if (chosen) {
340
+ const b = chosen.el.bounds;
341
+ const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
342
+ const enabled = !!chosen.el.enabled;
343
+ const clickable = !!chosen.el.clickable || !!chosen.el._interactable;
344
+ conditionMet = visibleFlag && enabled && clickable;
345
+ }
346
+ else
347
+ conditionMet = false;
348
+ }
349
+ if (conditionMet) {
350
+ const now = Date.now();
351
+ const latency_ms = now - overallStart;
352
+ // Build element output per spec
353
+ const outEl = chosen ? {
354
+ text: chosen.el.text ?? null,
355
+ resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
356
+ accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
357
+ class: chosen.el.type ?? chosen.el.class ?? null,
358
+ bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
359
+ index: chosen.idx
360
+ } : null;
361
+ return {
362
+ status: 'success',
363
+ matched: matchedCount,
364
+ element: outEl,
365
+ metrics: { latency_ms, poll_count: totalPollCount, attempts }
366
+ };
367
+ }
368
+ }
369
+ catch (e) {
370
+ // Non-fatal per-poll error; record and continue
371
+ console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
372
+ }
373
+ // Sleep until next poll
374
+ await new Promise(r => setTimeout(r, effectivePoll || 50));
375
+ }
376
+ // Attempt timed out; if more attempts allowed, backoff then retry
377
+ if (attempts < maxAttempts) {
378
+ if (backoff > 0)
379
+ await new Promise(r => setTimeout(r, backoff));
380
+ continue;
381
+ }
382
+ // Final failure for this call
383
+ const elapsed = Date.now() - overallStart;
384
+ return {
385
+ status: 'timeout',
386
+ error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
387
+ metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
388
+ };
389
+ }
390
+ }
391
+ catch (err) {
392
+ const elapsed = Date.now() - overallStart;
393
+ return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } };
394
+ }
395
+ }
396
+ // Helper: normalize various log objects into plain message strings for comparison
397
+ static _logsToMessages(logsArr) {
398
+ if (!Array.isArray(logsArr))
399
+ return [];
400
+ return logsArr.map((l) => {
401
+ if (typeof l === 'string')
402
+ return l;
403
+ if (l && (l.message || l.msg))
404
+ return l.message || l.msg;
405
+ try {
406
+ return JSON.stringify(l);
407
+ }
408
+ catch {
409
+ return String(l);
410
+ }
411
+ });
221
412
  }
222
413
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
223
414
  const start = Date.now();
@@ -275,7 +466,9 @@ export class ToolsInteract {
275
466
  initialFingerprint = fpRes.fingerprint ?? null;
276
467
  if (gl) {
277
468
  const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
278
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null;
469
+ // Normalize to last message string for baseline comparison
470
+ const msgs = ToolsInteract._logsToMessages(logsArr);
471
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null;
279
472
  }
280
473
  }
281
474
  catch (err) {
@@ -354,13 +547,15 @@ export class ToolsInteract {
354
547
  }
355
548
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
356
549
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
550
+ // Normalize to messages for comparison
551
+ const msgs = ToolsInteract._logsToMessages(logsArr);
357
552
  let startIndex = 0;
358
553
  if (baselineLastLine) {
359
- const idx = logsArr.lastIndexOf(baselineLastLine);
554
+ const idx = msgs.lastIndexOf(baselineLastLine);
360
555
  startIndex = idx >= 0 ? idx + 1 : 0;
361
556
  }
362
- for (let i = startIndex; i < logsArr.length; i++) {
363
- const line = logsArr[i];
557
+ for (let i = startIndex; i < msgs.length; i++) {
558
+ const line = msgs[i];
364
559
  if (q && String(line).includes(q)) {
365
560
  const now2 = Date.now();
366
561
  return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true };
@@ -80,27 +80,104 @@ export class AndroidObserve {
80
80
  };
81
81
  }
82
82
  }
83
- async getLogs(appId, lines = 200, deviceId) {
83
+ async getLogs(filters = {}) {
84
+ const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
84
85
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
85
86
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
86
87
  try {
87
- const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
88
- const allLogs = stdout.split('\n');
89
- let filteredLogs = allLogs;
88
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
89
+ // If appId provided, try to get pid to filter at source
90
+ let pidArg = null;
90
91
  if (appId) {
91
- const matchingLogs = allLogs.filter(line => line.includes(appId));
92
- if (matchingLogs.length > 0) {
93
- filteredLogs = matchingLogs;
92
+ try {
93
+ const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '');
94
+ const pidTrim = (pidOut || '').trim();
95
+ if (pidTrim)
96
+ pidArg = pidTrim.split('\n')[0];
94
97
  }
95
- else {
96
- filteredLogs = allLogs;
98
+ catch (err) {
99
+ // Log a warning so failures to detect PID are visible during debugging
100
+ try {
101
+ console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err));
102
+ }
103
+ catch { }
97
104
  }
98
105
  }
99
- return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
106
+ const args = ['logcat', '-d', '-v', 'threadtime'];
107
+ // Apply pid filter via --pid if available
108
+ if (pidArg)
109
+ args.push(`--pid=${pidArg}`);
110
+ else if (pid)
111
+ args.push(`--pid=${pid}`);
112
+ // Apply tag/level filter if provided
113
+ if (tag && level) {
114
+ // Map verbose/debug/info/warn/error to single-letter levels
115
+ const levelMap = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' };
116
+ const L = (levelMap[(level || '').toUpperCase()] || 'V');
117
+ args.push(`${tag}:${L}`);
118
+ }
119
+ else {
120
+ // Default: show all levels
121
+ args.push('*:V');
122
+ }
123
+ // Use -t to limit lines (apply early)
124
+ args.push('-t', effectiveLimit.toString());
125
+ const stdout = await execAdb(args, deviceId);
126
+ const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : [];
127
+ // Parse lines into structured entries
128
+ const parsed = allLines.map(l => {
129
+ const entry = parseLogLine(l);
130
+ // normalize level
131
+ const levelChar = (entry.level || '').toUpperCase();
132
+ const levelMapChar = { 'V': 'VERBOSE', 'D': 'DEBUG', 'I': 'INFO', 'W': 'WARN', 'E': 'ERROR' };
133
+ const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO';
134
+ const iso = entry._iso || (() => {
135
+ const d = new Date(entry.timestamp || '');
136
+ if (!isNaN(d.getTime()))
137
+ return d.toISOString();
138
+ return null;
139
+ })();
140
+ let pidNum = null;
141
+ if (entry.pid)
142
+ pidNum = Number(entry.pid);
143
+ else if (pidArg)
144
+ pidNum = Number(pidArg);
145
+ const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null;
146
+ return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' };
147
+ });
148
+ // Apply client-side filters: contains, since_seconds
149
+ let filtered = parsed;
150
+ if (contains)
151
+ filtered = filtered.filter(e => e.message && e.message.includes(contains));
152
+ if (since_seconds) {
153
+ const sinceMs = Date.now() - (since_seconds * 1000);
154
+ filtered = filtered.filter(e => {
155
+ if (!e.timestamp)
156
+ return false;
157
+ const t = new Date(e.timestamp).getTime();
158
+ return t >= sinceMs;
159
+ });
160
+ }
161
+ // If appId provided and no pidArg, try to filter by appId substring in message/tag
162
+ if (appId && !pidArg) {
163
+ const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
164
+ if (matched.length > 0)
165
+ filtered = matched;
166
+ }
167
+ // Ensure ordering oldest -> newest (by timestamp when available)
168
+ filtered.sort((a, b) => {
169
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
170
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
171
+ return ta - tb;
172
+ });
173
+ const limited = filtered.slice(-Math.max(0, effectiveLimit));
174
+ const source = pidArg ? 'pid' : (appId ? 'package' : 'broad');
175
+ const meta = { pidArg, appIdProvided: !!appId, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid };
176
+ return { device: deviceInfo, logs: limited, logCount: limited.length, source, meta };
100
177
  }
101
178
  catch (e) {
102
179
  console.error("Error fetching logs:", e);
103
- return { device: deviceInfo, logs: [], logCount: 0 };
180
+ return { device: deviceInfo, logs: [], logCount: 0, source: 'broad', meta: { error: e instanceof Error ? e.message : String(e) } };
104
181
  }
105
182
  }
106
183
  async captureScreen(deviceId) {
@@ -33,19 +33,30 @@ export class ToolsObserve {
33
33
  // getCurrentScreen is Android-specific
34
34
  return await observe.getCurrentScreen(resolved.id);
35
35
  }
36
- static async getLogsHandler({ platform, appId, deviceId, lines }) {
36
+ static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }) {
37
37
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
38
+ const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines };
39
+ // Validate filters
40
+ if (level && !['VERBOSE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].includes(level.toString().toUpperCase())) {
41
+ return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } };
42
+ }
38
43
  if (observe instanceof AndroidObserve) {
39
- const response = await observe.getLogs(appId, lines ?? 200, resolved.id);
44
+ const response = await observe.getLogs(filters);
40
45
  const logs = Array.isArray(response.logs) ? response.logs : [];
41
- const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
42
- return { device: response.device, logs, crashLines };
46
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
47
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
48
+ if (anyFilterApplied && logs.length === 0)
49
+ return { device: response.device, logs: [], crashLines: [], logCount: 0, source: response.source, meta: response.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
50
+ return { device: response.device, logs, crashLines, logCount: response.logCount, source: response.source, meta: response.meta };
43
51
  }
44
52
  else {
45
- const resp = await observe.getLogs(appId, resolved.id);
53
+ const resp = await observe.getLogs(filters);
46
54
  const logs = Array.isArray(resp.logs) ? resp.logs : [];
47
- const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
48
- return { device: resp.device, logs, crashLines };
55
+ const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
56
+ const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
57
+ if (anyFilterApplied && logs.length === 0)
58
+ return { device: resp.device, logs: [], crashLines: [], logCount: 0, source: resp.source, meta: resp.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
59
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount, source: resp.source, meta: resp.meta };
49
60
  }
50
61
  }
51
62
  static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
@@ -154,9 +165,20 @@ export class ToolsObserve {
154
165
  if (!entries || entries.length === 0) {
155
166
  const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
156
167
  const raw = (gl && gl.logs) ? gl.logs : [];
157
- entries = raw.slice(-Math.max(0, logLines)).map(line => {
158
- const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO';
159
- return { timestamp: null, level, message: line };
168
+ // raw may be structured entries or strings
169
+ entries = raw.slice(-Math.max(0, logLines)).map(item => {
170
+ if (!item)
171
+ return { timestamp: null, level: 'INFO', message: '' };
172
+ if (typeof item === 'string') {
173
+ const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO';
174
+ return { timestamp: null, level, message: item };
175
+ }
176
+ const msg = item.message || item.msg || JSON.stringify(item);
177
+ const levelRaw = item.level || item.levelName || item._level || '';
178
+ const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
179
+ const ts = item.timestamp || item._iso || null;
180
+ const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null);
181
+ return { timestamp: tsNum, level, message: msg };
160
182
  });
161
183
  }
162
184
  else {
@@ -88,20 +88,156 @@ export class iOSObserve {
88
88
  async getDeviceMetadata(deviceId = "booted") {
89
89
  return getIOSDeviceMetadata(deviceId);
90
90
  }
91
- async getLogs(appId, deviceId = "booted") {
92
- const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m'];
91
+ async getLogs(filters = {}) {
92
+ const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
93
+ const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
94
+ // Default to last N seconds if no since_seconds provided; limit lines handled after parsing
95
+ const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
96
+ if (since_seconds) {
97
+ // log show accepts --last <time>
98
+ args.push('--last', `${since_seconds}s`);
99
+ }
100
+ else {
101
+ // default to last 60s to keep quick
102
+ args.push('--last', '60s');
103
+ }
104
+ let processNameUsed = undefined;
93
105
  if (appId) {
94
106
  validateBundleId(appId);
95
- args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
107
+ // prefer matching the simple process name (last segment of bundle id), but also match full bundle id in subsystem
108
+ const parts = appId.split('.');
109
+ const simpleName = parts[parts.length - 1];
110
+ processNameUsed = simpleName;
111
+ // predicate: match process by simple name or full bundle id, or subsystem contains bundle id
112
+ args.push('--predicate', `process == "${simpleName}" or process == "${appId}" or subsystem contains "${appId}"`);
113
+ }
114
+ else if (tag) {
115
+ // predicate by subsystem/category
116
+ args.push('--predicate', `subsystem contains "${tag}"`);
117
+ }
118
+ try {
119
+ // Attempt pid detection if appId provided and no explicit pid supplied — prefer process name derived from bundle id
120
+ let detectedPid = null;
121
+ if (appId && !pid) {
122
+ const parts = appId.split('.');
123
+ const simpleName = parts[parts.length - 1];
124
+ try {
125
+ const pgrepRes = await execCommand(['simctl', 'spawn', deviceId, 'pgrep', '-f', simpleName], deviceId);
126
+ const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : '';
127
+ const firstLine = out.split(/\r?\n/).find(Boolean);
128
+ if (firstLine) {
129
+ const n = Number(firstLine.trim());
130
+ if (!isNaN(n) && n > 0)
131
+ detectedPid = n;
132
+ }
133
+ }
134
+ catch {
135
+ // ignore pgrep failures — we'll fall back to process/bundle matching
136
+ }
137
+ }
138
+ const effectivePid = pid || detectedPid || null;
139
+ const result = await execCommand(args, deviceId);
140
+ const device = await getIOSDeviceMetadata(deviceId);
141
+ const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
142
+ // Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
143
+ const parsed = rawLines.map(line => {
144
+ // Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
145
+ // Simpler approach: try to extract ISO timestamp at start
146
+ let ts = null;
147
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
148
+ if (tsMatch) {
149
+ const d = new Date(tsMatch[1]);
150
+ if (!isNaN(d.getTime()))
151
+ ts = d.toISOString();
152
+ }
153
+ // level mapping
154
+ let lvl = 'INFO';
155
+ const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i);
156
+ if (lvlMatch) {
157
+ const map = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' };
158
+ lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO';
159
+ }
160
+ // subsystem/category -> tag
161
+ let tagVal = '';
162
+ const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/);
163
+ if (tagMatch)
164
+ tagVal = tagMatch[1];
165
+ // pid extraction
166
+ let pidNum = null;
167
+ const pidMatch = line.match(/\[(\d+):\d+\]/);
168
+ if (pidMatch)
169
+ pidNum = Number(pidMatch[1]);
170
+ // message: extract robustly after the subsystem/category token when available
171
+ let message = line;
172
+ if (tagMatch) {
173
+ // tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
174
+ const marker = tagMatch[0];
175
+ const idx = line.indexOf(marker);
176
+ if (idx !== -1) {
177
+ message = line.slice(idx + marker.length).trim();
178
+ }
179
+ else {
180
+ // fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
181
+ const afterPidMatch = line.match(/\]\s+/);
182
+ if (afterPidMatch) {
183
+ const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length;
184
+ message = line.slice(afterPidIdx).trim();
185
+ }
186
+ else {
187
+ // remove leading level tokens like <Debug> and keep remainder
188
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
189
+ }
190
+ }
191
+ }
192
+ else {
193
+ // No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
194
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
195
+ }
196
+ return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message };
197
+ });
198
+ // Apply contains filter
199
+ let filtered = parsed;
200
+ if (contains)
201
+ filtered = filtered.filter(e => e.message && e.message.includes(contains));
202
+ // Apply since_seconds already applied by log show, but double-check timestamps
203
+ if (since_seconds) {
204
+ const sinceMs = Date.now() - (since_seconds * 1000);
205
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs));
206
+ }
207
+ // level filter
208
+ if (level) {
209
+ const L = level.toUpperCase();
210
+ filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L);
211
+ }
212
+ // tag filter
213
+ if (tag)
214
+ filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
215
+ // pid filter (use detected/effective pid if available)
216
+ const pidToFilter = effectivePid;
217
+ if (pidToFilter)
218
+ filtered = filtered.filter(e => e.pid === pidToFilter);
219
+ // If appId present but no predicate returned lines, try substring match
220
+ if (appId && filtered.length === 0) {
221
+ const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)) || (e.message && processNameUsed && e.message.includes(processNameUsed)));
222
+ if (matched.length > 0)
223
+ filtered = matched;
224
+ }
225
+ // Order oldest -> newest
226
+ filtered.sort((a, b) => {
227
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
228
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
229
+ return ta - tb;
230
+ });
231
+ const source = pidToFilter ? 'pid' : (appId ? 'process' : 'broad');
232
+ const meta = { appIdProvided: !!appId, processNameUsed: processNameUsed || null, detectedPid: detectedPid || null, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid };
233
+ const limited = filtered.slice(-Math.max(0, effectiveLimit));
234
+ return { device, logs: limited, logCount: limited.length, source, meta };
235
+ }
236
+ catch (err) {
237
+ console.error('iOS getLogs failed:', err);
238
+ const device = await getIOSDeviceMetadata(deviceId);
239
+ return { device, logs: [], logCount: 0, source: 'broad', meta: { error: err instanceof Error ? err.message : String(err) } };
96
240
  }
97
- const result = await execCommand(args, deviceId);
98
- const device = await getIOSDeviceMetadata(deviceId);
99
- const logs = result.output ? result.output.split('\n') : [];
100
- return {
101
- device,
102
- logs,
103
- logCount: logs.length,
104
- };
105
241
  }
106
242
  async captureScreenshot(deviceId = "booted") {
107
243
  const device = await getIOSDeviceMetadata(deviceId);
@@ -275,7 +411,8 @@ export class iOSObserve {
275
411
  }
276
412
  async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
277
413
  try {
278
- const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
414
+ const simple = bundleId.split('.').pop() || bundleId;
415
+ const predicate = `process == "${simple}" or process == "${bundleId}" or subsystem contains "${bundleId}"`;
279
416
  if (iosActiveLogStreams.has(sessionId)) {
280
417
  try {
281
418
  iosActiveLogStreams.get(sessionId).proc.kill();