mobile-debug-mcp 0.21.1 → 0.21.3

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.
@@ -219,6 +219,23 @@ export class ToolsInteract {
219
219
  // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
220
220
  return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
221
221
  }
222
+ // Helper: normalize various log objects into plain message strings for comparison
223
+ static _logsToMessages(logsArr) {
224
+ if (!Array.isArray(logsArr))
225
+ return [];
226
+ return logsArr.map((l) => {
227
+ if (typeof l === 'string')
228
+ return l;
229
+ if (l && (l.message || l.msg))
230
+ return l.message || l.msg;
231
+ try {
232
+ return JSON.stringify(l);
233
+ }
234
+ catch {
235
+ return String(l);
236
+ }
237
+ });
238
+ }
222
239
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
223
240
  const start = Date.now();
224
241
  let lastFingerprint = null;
@@ -275,7 +292,9 @@ export class ToolsInteract {
275
292
  initialFingerprint = fpRes.fingerprint ?? null;
276
293
  if (gl) {
277
294
  const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
278
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null;
295
+ // Normalize to last message string for baseline comparison
296
+ const msgs = ToolsInteract._logsToMessages(logsArr);
297
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null;
279
298
  }
280
299
  }
281
300
  catch (err) {
@@ -354,13 +373,15 @@ export class ToolsInteract {
354
373
  }
355
374
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
356
375
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
376
+ // Normalize to messages for comparison
377
+ const msgs = ToolsInteract._logsToMessages(logsArr);
357
378
  let startIndex = 0;
358
379
  if (baselineLastLine) {
359
- const idx = logsArr.lastIndexOf(baselineLastLine);
380
+ const idx = msgs.lastIndexOf(baselineLastLine);
360
381
  startIndex = idx >= 0 ? idx + 1 : 0;
361
382
  }
362
- for (let i = startIndex; i < logsArr.length; i++) {
363
- const line = logsArr[i];
383
+ for (let i = startIndex; i < msgs.length; i++) {
384
+ const line = msgs[i];
364
385
  if (q && String(line).includes(q)) {
365
386
  const now2 = Date.now();
366
387
  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 };
@@ -5,6 +5,7 @@ import { createWriteStream } from "fs";
5
5
  import { promises as fsPromises } from "fs";
6
6
  import path from "path";
7
7
  import { computeScreenFingerprint } from "../utils/ui/index.js";
8
+ import { parsePngSize } from "../utils/image.js";
8
9
  const activeLogStreams = new Map();
9
10
  export class AndroidObserve {
10
11
  async getDeviceMetadata(appId, deviceId) {
@@ -79,23 +80,98 @@ export class AndroidObserve {
79
80
  };
80
81
  }
81
82
  }
82
- async getLogs(appId, lines = 200, deviceId) {
83
+ async getLogs(filters = {}) {
84
+ const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
83
85
  const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
84
86
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
85
87
  try {
86
- const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
87
- const allLogs = stdout.split('\n');
88
- 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;
89
91
  if (appId) {
90
- const matchingLogs = allLogs.filter(line => line.includes(appId));
91
- if (matchingLogs.length > 0) {
92
- 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];
93
97
  }
94
- else {
95
- 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 { }
96
104
  }
97
105
  }
98
- 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
+ return { device: deviceInfo, logs: limited, logCount: limited.length };
99
175
  }
100
176
  catch (e) {
101
177
  console.error("Error fetching logs:", e);
@@ -120,7 +196,7 @@ export class AndroidObserve {
120
196
  child.kill();
121
197
  reject(new Error(`ADB screencap timed out after 10s`));
122
198
  }, 10000);
123
- child.on('close', (code) => {
199
+ child.on('close', async (code) => {
124
200
  clearTimeout(timeout);
125
201
  if (code !== 0) {
126
202
  reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
@@ -128,28 +204,91 @@ export class AndroidObserve {
128
204
  }
129
205
  const screenshotBuffer = Buffer.concat(chunks);
130
206
  const screenshotBase64 = screenshotBuffer.toString('base64');
131
- execAdb(['shell', 'wm', 'size'], deviceId)
132
- .then(sizeStdout => {
133
- let width = 0;
134
- let height = 0;
135
- const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
136
- if (match) {
137
- width = parseInt(match[1], 10);
138
- height = parseInt(match[2], 10);
207
+ const parsed = parsePngSize(screenshotBuffer);
208
+ if (parsed.width > 0 && parsed.height > 0) {
209
+ // Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
210
+ try {
211
+ const sharpModule = await import('sharp');
212
+ const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
213
+ const buf = screenshotBuffer;
214
+ const img = sharp(buf);
215
+ const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed (Android):', err); return {}; });
216
+ const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
217
+ let webpBuf = null;
218
+ let jpegBuf = null;
219
+ try {
220
+ webpBuf = await img.webp({ quality: 80 }).toBuffer();
221
+ }
222
+ catch (err) {
223
+ console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
224
+ webpBuf = null;
225
+ }
226
+ try {
227
+ jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
228
+ }
229
+ catch (err) {
230
+ console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
231
+ jpegBuf = null;
232
+ }
233
+ if (hasAlpha) {
234
+ if (webpBuf) {
235
+ const webpB64 = webpBuf.toString('base64');
236
+ const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
237
+ resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } });
238
+ return;
239
+ }
240
+ const pngB64 = buf.toString('base64');
241
+ resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
242
+ return;
243
+ }
244
+ if (webpBuf) {
245
+ const webpB64 = webpBuf.toString('base64');
246
+ const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
247
+ resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } });
248
+ return;
249
+ }
250
+ if (jpegBuf) {
251
+ resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } });
252
+ return;
253
+ }
254
+ // No conversions succeeded; return original PNG
255
+ resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
256
+ return;
139
257
  }
140
- resolve({
141
- device: deviceInfo,
142
- screenshot: screenshotBase64,
143
- resolution: { width, height }
144
- });
145
- })
146
- .catch(() => {
147
- resolve({
148
- device: deviceInfo,
149
- screenshot: screenshotBase64,
150
- resolution: { width: 0, height: 0 }
258
+ catch (err) {
259
+ console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
260
+ // Conversion failed - fall back to original PNG with parsed resolution
261
+ resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
262
+ return;
263
+ }
264
+ }
265
+ else {
266
+ // Fallback to querying wm size if parsing failed
267
+ execAdb(['shell', 'wm', 'size'], deviceId)
268
+ .then(sizeStdout => {
269
+ let width = 0;
270
+ let height = 0;
271
+ const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
272
+ if (match) {
273
+ width = parseInt(match[1], 10);
274
+ height = parseInt(match[2], 10);
275
+ }
276
+ resolve({
277
+ device: deviceInfo,
278
+ screenshot: screenshotBase64,
279
+ screenshot_mime: 'image/png',
280
+ resolution: { width, height }
281
+ });
282
+ })
283
+ .catch(() => {
284
+ resolve({
285
+ device: deviceInfo,
286
+ screenshot: screenshotBase64,
287
+ screenshot_mime: 'image/png',
288
+ resolution: { width: 0, height: 0 }
289
+ });
151
290
  });
152
- });
291
+ }
153
292
  });
154
293
  child.on('error', (err) => {
155
294
  clearTimeout(timeout);
@@ -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, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
50
+ return { device: response.device, logs, crashLines, logCount: response.logCount };
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, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
59
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount };
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 {
@@ -5,6 +5,7 @@ import { createWriteStream, promises as fsPromises } from 'fs';
5
5
  import path from 'path';
6
6
  import { parseLogLine } from '../utils/android/utils.js';
7
7
  import { computeScreenFingerprint } from '../utils/ui/index.js';
8
+ import { parsePngSize } from '../utils/image.js';
8
9
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
9
10
  function parseIDBFrame(frame) {
10
11
  if (!frame)
@@ -87,20 +88,128 @@ export class iOSObserve {
87
88
  async getDeviceMetadata(deviceId = "booted") {
88
89
  return getIOSDeviceMetadata(deviceId);
89
90
  }
90
- async getLogs(appId, deviceId = "booted") {
91
- 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
+ }
92
104
  if (appId) {
93
105
  validateBundleId(appId);
106
+ // constrain to subsystem or process matching appId
94
107
  args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
95
108
  }
96
- const result = await execCommand(args, deviceId);
97
- const device = await getIOSDeviceMetadata(deviceId);
98
- const logs = result.output ? result.output.split('\n') : [];
99
- return {
100
- device,
101
- logs,
102
- logCount: logs.length,
103
- };
109
+ else if (tag) {
110
+ // predicate by subsystem/category
111
+ args.push('--predicate', `subsystem contains "${tag}"`);
112
+ }
113
+ try {
114
+ const result = await execCommand(args, deviceId);
115
+ const device = await getIOSDeviceMetadata(deviceId);
116
+ const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
117
+ // Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
118
+ const parsed = rawLines.map(line => {
119
+ // Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
120
+ // Simpler approach: try to extract ISO timestamp at start
121
+ let ts = null;
122
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
123
+ if (tsMatch) {
124
+ const d = new Date(tsMatch[1]);
125
+ if (!isNaN(d.getTime()))
126
+ ts = d.toISOString();
127
+ }
128
+ // level mapping
129
+ let lvl = 'INFO';
130
+ const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i);
131
+ if (lvlMatch) {
132
+ const map = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' };
133
+ lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO';
134
+ }
135
+ // subsystem/category -> tag
136
+ let tagVal = '';
137
+ const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/);
138
+ if (tagMatch)
139
+ tagVal = tagMatch[1];
140
+ // pid extraction
141
+ let pidNum = null;
142
+ const pidMatch = line.match(/\[(\d+):\d+\]/);
143
+ if (pidMatch)
144
+ pidNum = Number(pidMatch[1]);
145
+ // message: extract robustly after the subsystem/category token when available
146
+ let message = line;
147
+ if (tagMatch) {
148
+ // tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
149
+ const marker = tagMatch[0];
150
+ const idx = line.indexOf(marker);
151
+ if (idx !== -1) {
152
+ message = line.slice(idx + marker.length).trim();
153
+ }
154
+ else {
155
+ // fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
156
+ const afterPidMatch = line.match(/\]\s+/);
157
+ if (afterPidMatch) {
158
+ const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length;
159
+ message = line.slice(afterPidIdx).trim();
160
+ }
161
+ else {
162
+ // remove leading level tokens like <Debug> and keep remainder
163
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
164
+ }
165
+ }
166
+ }
167
+ else {
168
+ // No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
169
+ message = line.replace(/^.*?<.*?>\s*/, '').trim();
170
+ }
171
+ return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message };
172
+ });
173
+ // Apply contains filter
174
+ let filtered = parsed;
175
+ if (contains)
176
+ filtered = filtered.filter(e => e.message && e.message.includes(contains));
177
+ // Apply since_seconds already applied by log show, but double-check timestamps
178
+ if (since_seconds) {
179
+ const sinceMs = Date.now() - (since_seconds * 1000);
180
+ filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs));
181
+ }
182
+ // level filter
183
+ if (level) {
184
+ const L = level.toUpperCase();
185
+ filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L);
186
+ }
187
+ // tag filter
188
+ if (tag)
189
+ filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
190
+ // pid filter
191
+ if (pid)
192
+ filtered = filtered.filter(e => e.pid === pid);
193
+ // If appId present but no predicate returned lines, try substring match
194
+ if (appId && filtered.length === 0) {
195
+ const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
196
+ if (matched.length > 0)
197
+ filtered = matched;
198
+ }
199
+ // Order oldest -> newest
200
+ filtered.sort((a, b) => {
201
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
202
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
203
+ return ta - tb;
204
+ });
205
+ const limited = filtered.slice(-Math.max(0, effectiveLimit));
206
+ return { device, logs: limited, logCount: limited.length };
207
+ }
208
+ catch (err) {
209
+ console.error('iOS getLogs failed:', err);
210
+ const device = await getIOSDeviceMetadata(deviceId);
211
+ return { device, logs: [], logCount: 0 };
212
+ }
104
213
  }
105
214
  async captureScreenshot(deviceId = "booted") {
106
215
  const device = await getIOSDeviceMetadata(deviceId);
@@ -109,11 +218,59 @@ export class iOSObserve {
109
218
  await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
110
219
  const buffer = await fs.readFile(tmpFile);
111
220
  const base64 = buffer.toString('base64');
221
+ const dims = parsePngSize(buffer);
222
+ // Try to generate WebP (preferred) and JPEG fallback using sharp (in-process, cross-platform)
223
+ try {
224
+ const sharpModule = await import('sharp');
225
+ const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
226
+ const img = sharp(buffer);
227
+ const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed:', err); return {}; });
228
+ // If image has alpha channel, prefer lossless PNG to preserve transparency
229
+ const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
230
+ // Generate WebP and JPEG buffers; log failures
231
+ let webpBuf = null;
232
+ let jpegBuf = null;
233
+ try {
234
+ webpBuf = await img.webp({ quality: 80 }).toBuffer();
235
+ }
236
+ catch (err) {
237
+ console.error('WebP conversion failed (iOS):', err instanceof Error ? err.message : String(err));
238
+ webpBuf = null;
239
+ }
240
+ try {
241
+ jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
242
+ }
243
+ catch (err) {
244
+ console.error('JPEG conversion failed (iOS):', err instanceof Error ? err.message : String(err));
245
+ jpegBuf = null;
246
+ }
247
+ await fs.rm(tmpFile).catch(() => { });
248
+ if (hasAlpha) {
249
+ // preserve alpha: return PNG if WebP not available
250
+ if (webpBuf) {
251
+ return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: base64, screenshot_fallback_mime: 'image/png', resolution: { width: dims.width, height: dims.height } };
252
+ }
253
+ // if webp unavailable, return original PNG
254
+ return { device, screenshot: base64, screenshot_mime: 'image/png', resolution: { width: dims.width, height: dims.height } };
255
+ }
256
+ // No alpha: prefer webp, fall back to jpeg
257
+ if (webpBuf) {
258
+ return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: jpegBuf ? jpegBuf.toString('base64') : undefined, screenshot_fallback_mime: jpegBuf ? 'image/jpeg' : undefined, resolution: { width: dims.width, height: dims.height } };
259
+ }
260
+ if (jpegBuf) {
261
+ return { device, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: dims.width, height: dims.height } };
262
+ }
263
+ }
264
+ catch (err) {
265
+ console.error('Screenshot conversion pipeline failed (iOS):', err instanceof Error ? err.message : String(err));
266
+ // fall through to png fallback
267
+ }
112
268
  await fs.rm(tmpFile).catch(() => { });
113
269
  return {
114
270
  device,
115
271
  screenshot: base64,
116
- resolution: { width: 0, height: 0 },
272
+ screenshot_mime: 'image/png',
273
+ resolution: { width: dims.width, height: dims.height },
117
274
  };
118
275
  }
119
276
  catch (e) {
package/dist/server.js CHANGED
@@ -148,7 +148,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
148
148
  },
149
149
  {
150
150
  name: "get_logs",
151
- description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
151
+ description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
152
152
  inputSchema: {
153
153
  type: "object",
154
154
  properties: {
@@ -164,9 +164,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
164
164
  type: "string",
165
165
  description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
166
166
  },
167
+ pid: { type: "number", description: "Filter by process id" },
168
+ tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
169
+ level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
170
+ contains: { type: "string", description: "Substring to match in log message" },
171
+ since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
172
+ limit: { type: "number", description: "Override default number of returned lines" },
167
173
  lines: {
168
174
  type: "number",
169
- description: "Number of log lines (android only)"
175
+ description: "Legacy - number of log lines (android only)"
170
176
  }
171
177
  },
172
178
  required: ["platform"]
@@ -563,12 +569,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
563
569
  };
564
570
  }
565
571
  if (name === "get_logs") {
566
- const { platform, appId, deviceId, lines } = args;
567
- const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
572
+ const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args;
573
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines });
574
+ const filtered = !!(pid || tag || level || contains || since_seconds || appId);
568
575
  return {
569
576
  content: [
570
- { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
571
- { type: 'text', text: (res.logs || []).join('\n') }
577
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
578
+ { type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
572
579
  ]
573
580
  };
574
581
  }
@@ -584,12 +591,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
584
591
  if (name === "capture_screenshot") {
585
592
  const { platform, deviceId } = args;
586
593
  const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId });
587
- return {
588
- content: [
589
- { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: res.resolution } }, null, 2) },
590
- { type: 'image', data: res.screenshot, mimeType: 'image/png' }
591
- ]
592
- };
594
+ const mime = res.screenshot_mime || 'image/png';
595
+ const content = [
596
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: res.resolution, mimeType: mime } }, null, 2) },
597
+ { type: 'image', data: res.screenshot, mimeType: mime }
598
+ ];
599
+ // If a jpeg fallback is available, include a small note and the fallback as an additional image block for compatibility
600
+ if (res.screenshot_fallback) {
601
+ content.push({ type: 'text', text: JSON.stringify({ note: 'JPEG fallback included for compatibility', mimeType: res.screenshot_fallback_mime || 'image/jpeg' }) });
602
+ content.push({ type: 'image', data: res.screenshot_fallback, mimeType: res.screenshot_fallback_mime || 'image/jpeg' });
603
+ }
604
+ return { content };
593
605
  }
594
606
  if (name === "capture_debug_snapshot") {
595
607
  const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args;
@@ -13,7 +13,7 @@ async function main() {
13
13
  const shot = await obs.captureScreenshot(deviceId);
14
14
  console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
15
15
  console.log('[3] getLogs');
16
- const logs = await obs.getLogs(appId, undefined);
16
+ const logs = await obs.getLogs({ appId, deviceId });
17
17
  console.log('logs count:', logs.logCount);
18
18
  console.log('[4] terminateApp');
19
19
  const term = await manage.terminateApp(appId, deviceId);