mobile-debug-mcp 0.21.1 → 0.21.2
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/dist/observe/android.js +85 -21
- package/dist/observe/ios.js +50 -1
- package/dist/server.js +11 -6
- package/dist/utils/image.js +18 -0
- package/docs/CHANGELOG.md +3 -0
- package/package.json +3 -2
- package/src/observe/android.ts +84 -20
- package/src/observe/ios.ts +54 -3
- package/src/server.ts +10 -5
- package/src/types.ts +6 -0
- package/src/utils/image.ts +14 -0
package/dist/observe/android.js
CHANGED
|
@@ -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) {
|
|
@@ -120,7 +121,7 @@ export class AndroidObserve {
|
|
|
120
121
|
child.kill();
|
|
121
122
|
reject(new Error(`ADB screencap timed out after 10s`));
|
|
122
123
|
}, 10000);
|
|
123
|
-
child.on('close', (code) => {
|
|
124
|
+
child.on('close', async (code) => {
|
|
124
125
|
clearTimeout(timeout);
|
|
125
126
|
if (code !== 0) {
|
|
126
127
|
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
|
|
@@ -128,28 +129,91 @@ export class AndroidObserve {
|
|
|
128
129
|
}
|
|
129
130
|
const screenshotBuffer = Buffer.concat(chunks);
|
|
130
131
|
const screenshotBase64 = screenshotBuffer.toString('base64');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
const parsed = parsePngSize(screenshotBuffer);
|
|
133
|
+
if (parsed.width > 0 && parsed.height > 0) {
|
|
134
|
+
// Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
|
|
135
|
+
try {
|
|
136
|
+
const sharpModule = await import('sharp');
|
|
137
|
+
const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
|
|
138
|
+
const buf = screenshotBuffer;
|
|
139
|
+
const img = sharp(buf);
|
|
140
|
+
const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed (Android):', err); return {}; });
|
|
141
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
142
|
+
let webpBuf = null;
|
|
143
|
+
let jpegBuf = null;
|
|
144
|
+
try {
|
|
145
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
149
|
+
webpBuf = null;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
156
|
+
jpegBuf = null;
|
|
157
|
+
}
|
|
158
|
+
if (hasAlpha) {
|
|
159
|
+
if (webpBuf) {
|
|
160
|
+
const webpB64 = webpBuf.toString('base64');
|
|
161
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
|
|
162
|
+
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 } });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const pngB64 = buf.toString('base64');
|
|
166
|
+
resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (webpBuf) {
|
|
170
|
+
const webpB64 = webpBuf.toString('base64');
|
|
171
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
|
|
172
|
+
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 } });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (jpegBuf) {
|
|
176
|
+
resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// No conversions succeeded; return original PNG
|
|
180
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
181
|
+
return;
|
|
139
182
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
resolution: { width, height }
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
183
|
+
catch (err) {
|
|
184
|
+
console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
|
|
185
|
+
// Conversion failed - fall back to original PNG with parsed resolution
|
|
186
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Fallback to querying wm size if parsing failed
|
|
192
|
+
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
193
|
+
.then(sizeStdout => {
|
|
194
|
+
let width = 0;
|
|
195
|
+
let height = 0;
|
|
196
|
+
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
|
|
197
|
+
if (match) {
|
|
198
|
+
width = parseInt(match[1], 10);
|
|
199
|
+
height = parseInt(match[2], 10);
|
|
200
|
+
}
|
|
201
|
+
resolve({
|
|
202
|
+
device: deviceInfo,
|
|
203
|
+
screenshot: screenshotBase64,
|
|
204
|
+
screenshot_mime: 'image/png',
|
|
205
|
+
resolution: { width, height }
|
|
206
|
+
});
|
|
207
|
+
})
|
|
208
|
+
.catch(() => {
|
|
209
|
+
resolve({
|
|
210
|
+
device: deviceInfo,
|
|
211
|
+
screenshot: screenshotBase64,
|
|
212
|
+
screenshot_mime: 'image/png',
|
|
213
|
+
resolution: { width: 0, height: 0 }
|
|
214
|
+
});
|
|
151
215
|
});
|
|
152
|
-
}
|
|
216
|
+
}
|
|
153
217
|
});
|
|
154
218
|
child.on('error', (err) => {
|
|
155
219
|
clearTimeout(timeout);
|
package/dist/observe/ios.js
CHANGED
|
@@ -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)
|
|
@@ -109,11 +110,59 @@ export class iOSObserve {
|
|
|
109
110
|
await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
110
111
|
const buffer = await fs.readFile(tmpFile);
|
|
111
112
|
const base64 = buffer.toString('base64');
|
|
113
|
+
const dims = parsePngSize(buffer);
|
|
114
|
+
// Try to generate WebP (preferred) and JPEG fallback using sharp (in-process, cross-platform)
|
|
115
|
+
try {
|
|
116
|
+
const sharpModule = await import('sharp');
|
|
117
|
+
const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
|
|
118
|
+
const img = sharp(buffer);
|
|
119
|
+
const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed:', err); return {}; });
|
|
120
|
+
// If image has alpha channel, prefer lossless PNG to preserve transparency
|
|
121
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
122
|
+
// Generate WebP and JPEG buffers; log failures
|
|
123
|
+
let webpBuf = null;
|
|
124
|
+
let jpegBuf = null;
|
|
125
|
+
try {
|
|
126
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
console.error('WebP conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
130
|
+
webpBuf = null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error('JPEG conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
137
|
+
jpegBuf = null;
|
|
138
|
+
}
|
|
139
|
+
await fs.rm(tmpFile).catch(() => { });
|
|
140
|
+
if (hasAlpha) {
|
|
141
|
+
// preserve alpha: return PNG if WebP not available
|
|
142
|
+
if (webpBuf) {
|
|
143
|
+
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 } };
|
|
144
|
+
}
|
|
145
|
+
// if webp unavailable, return original PNG
|
|
146
|
+
return { device, screenshot: base64, screenshot_mime: 'image/png', resolution: { width: dims.width, height: dims.height } };
|
|
147
|
+
}
|
|
148
|
+
// No alpha: prefer webp, fall back to jpeg
|
|
149
|
+
if (webpBuf) {
|
|
150
|
+
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 } };
|
|
151
|
+
}
|
|
152
|
+
if (jpegBuf) {
|
|
153
|
+
return { device, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: dims.width, height: dims.height } };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error('Screenshot conversion pipeline failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
158
|
+
// fall through to png fallback
|
|
159
|
+
}
|
|
112
160
|
await fs.rm(tmpFile).catch(() => { });
|
|
113
161
|
return {
|
|
114
162
|
device,
|
|
115
163
|
screenshot: base64,
|
|
116
|
-
|
|
164
|
+
screenshot_mime: 'image/png',
|
|
165
|
+
resolution: { width: dims.width, height: dims.height },
|
|
117
166
|
};
|
|
118
167
|
}
|
|
119
168
|
catch (e) {
|
package/dist/server.js
CHANGED
|
@@ -584,12 +584,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
584
584
|
if (name === "capture_screenshot") {
|
|
585
585
|
const { platform, deviceId } = args;
|
|
586
586
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId });
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
587
|
+
const mime = res.screenshot_mime || 'image/png';
|
|
588
|
+
const content = [
|
|
589
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: res.resolution, mimeType: mime } }, null, 2) },
|
|
590
|
+
{ type: 'image', data: res.screenshot, mimeType: mime }
|
|
591
|
+
];
|
|
592
|
+
// If a jpeg fallback is available, include a small note and the fallback as an additional image block for compatibility
|
|
593
|
+
if (res.screenshot_fallback) {
|
|
594
|
+
content.push({ type: 'text', text: JSON.stringify({ note: 'JPEG fallback included for compatibility', mimeType: res.screenshot_fallback_mime || 'image/jpeg' }) });
|
|
595
|
+
content.push({ type: 'image', data: res.screenshot_fallback, mimeType: res.screenshot_fallback_mime || 'image/jpeg' });
|
|
596
|
+
}
|
|
597
|
+
return { content };
|
|
593
598
|
}
|
|
594
599
|
if (name === "capture_debug_snapshot") {
|
|
595
600
|
const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parsePngSize(buf) {
|
|
2
|
+
try {
|
|
3
|
+
if (!buf || buf.length < 24)
|
|
4
|
+
return { width: 0, height: 0 };
|
|
5
|
+
// PNG signature + IHDR checks
|
|
6
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a)
|
|
7
|
+
return { width: 0, height: 0 };
|
|
8
|
+
const ihdr = buf.toString('ascii', 12, 16);
|
|
9
|
+
if (ihdr !== 'IHDR')
|
|
10
|
+
return { width: 0, height: 0 };
|
|
11
|
+
const width = buf.readUInt32BE(16);
|
|
12
|
+
const height = buf.readUInt32BE(20);
|
|
13
|
+
return { width, height };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { width: 0, height: 0 };
|
|
17
|
+
}
|
|
18
|
+
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.21.2]
|
|
6
|
+
- Fixed screenshots not working, imnproved tool
|
|
7
|
+
|
|
5
8
|
## [0.21.1]
|
|
6
9
|
- Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
|
|
7
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-debug-mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
29
29
|
"fast-xml-parser": "^5.5.1",
|
|
30
|
-
"zod": "^3.22.4"
|
|
30
|
+
"zod": "^3.22.4",
|
|
31
|
+
"sharp": "^0.32.1"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/node": "^25.4.0",
|
package/src/observe/android.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createWriteStream } from "fs"
|
|
|
6
6
|
import { promises as fsPromises } from "fs"
|
|
7
7
|
import path from "path"
|
|
8
8
|
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
|
+
import { parsePngSize } from "../utils/image.js"
|
|
9
10
|
|
|
10
11
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
11
12
|
|
|
@@ -142,7 +143,7 @@ export class AndroidObserve {
|
|
|
142
143
|
reject(new Error(`ADB screencap timed out after 10s`))
|
|
143
144
|
}, 10000)
|
|
144
145
|
|
|
145
|
-
child.on('close', (code) => {
|
|
146
|
+
child.on('close', async (code) => {
|
|
146
147
|
clearTimeout(timeout)
|
|
147
148
|
if (code !== 0) {
|
|
148
149
|
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`))
|
|
@@ -152,28 +153,91 @@ export class AndroidObserve {
|
|
|
152
153
|
const screenshotBuffer = Buffer.concat(chunks)
|
|
153
154
|
const screenshotBase64 = screenshotBuffer.toString('base64')
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
156
|
+
const parsed = parsePngSize(screenshotBuffer)
|
|
157
|
+
if (parsed.width > 0 && parsed.height > 0) {
|
|
158
|
+
// Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
|
|
159
|
+
try {
|
|
160
|
+
const sharpModule = await import('sharp'); const sharp = sharpModule && (sharpModule as any).default ? (sharpModule as any).default : sharpModule;
|
|
161
|
+
const buf = screenshotBuffer;
|
|
162
|
+
const img = sharp(buf);
|
|
163
|
+
const meta = await img.metadata().catch((err: any) => { console.error('sharp.metadata failed (Android):', err); return {} as any });
|
|
164
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
165
|
+
|
|
166
|
+
let webpBuf: Buffer | null = null;
|
|
167
|
+
let jpegBuf: Buffer | null = null;
|
|
168
|
+
try {
|
|
169
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
172
|
+
webpBuf = null;
|
|
163
173
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
try {
|
|
175
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
178
|
+
jpegBuf = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (hasAlpha) {
|
|
182
|
+
if (webpBuf) {
|
|
183
|
+
const webpB64 = webpBuf.toString('base64')
|
|
184
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
|
|
185
|
+
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 } } as any)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const pngB64 = buf.toString('base64')
|
|
189
|
+
resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (webpBuf) {
|
|
194
|
+
const webpB64 = webpBuf.toString('base64')
|
|
195
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
|
|
196
|
+
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 } } as any)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (jpegBuf) {
|
|
201
|
+
resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// No conversions succeeded; return original PNG
|
|
206
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
207
|
+
return
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
|
|
210
|
+
// Conversion failed - fall back to original PNG with parsed resolution
|
|
211
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
// Fallback to querying wm size if parsing failed
|
|
216
|
+
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
217
|
+
.then(sizeStdout => {
|
|
218
|
+
let width = 0
|
|
219
|
+
let height = 0
|
|
220
|
+
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/)
|
|
221
|
+
if (match) {
|
|
222
|
+
width = parseInt(match[1], 10)
|
|
223
|
+
height = parseInt(match[2], 10)
|
|
224
|
+
}
|
|
225
|
+
resolve({
|
|
226
|
+
device: deviceInfo,
|
|
227
|
+
screenshot: screenshotBase64,
|
|
228
|
+
screenshot_mime: 'image/png',
|
|
229
|
+
resolution: { width, height }
|
|
230
|
+
})
|
|
168
231
|
})
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
232
|
+
.catch(() => {
|
|
233
|
+
resolve({
|
|
234
|
+
device: deviceInfo,
|
|
235
|
+
screenshot: screenshotBase64,
|
|
236
|
+
screenshot_mime: 'image/png',
|
|
237
|
+
resolution: { width: 0, height: 0 }
|
|
238
|
+
})
|
|
175
239
|
})
|
|
176
|
-
|
|
240
|
+
}
|
|
177
241
|
})
|
|
178
242
|
|
|
179
243
|
child.on('error', (err) => {
|
package/src/observe/ios.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createWriteStream, promises as fsPromises } from 'fs'
|
|
|
6
6
|
import path from 'path'
|
|
7
7
|
import { parseLogLine } from '../utils/android/utils.js'
|
|
8
8
|
import { computeScreenFingerprint } from '../utils/ui/index.js'
|
|
9
|
+
import { parsePngSize } from '../utils/image.js'
|
|
9
10
|
|
|
10
11
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
12
|
|
|
@@ -146,13 +147,63 @@ export class iOSObserve {
|
|
|
146
147
|
|
|
147
148
|
const buffer = await fs.readFile(tmpFile)
|
|
148
149
|
const base64 = buffer.toString('base64')
|
|
149
|
-
|
|
150
|
-
await fs.rm(tmpFile).catch(() => {})
|
|
151
150
|
|
|
151
|
+
const dims = parsePngSize(buffer)
|
|
152
|
+
|
|
153
|
+
// Try to generate WebP (preferred) and JPEG fallback using sharp (in-process, cross-platform)
|
|
154
|
+
try {
|
|
155
|
+
const sharpModule = await import('sharp'); const sharp = sharpModule && (sharpModule as any).default ? (sharpModule as any).default : sharpModule;
|
|
156
|
+
const img = sharp(buffer);
|
|
157
|
+
const meta = await img.metadata().catch((err: any) => { console.error('sharp.metadata failed:', err); return {} as any });
|
|
158
|
+
|
|
159
|
+
// If image has alpha channel, prefer lossless PNG to preserve transparency
|
|
160
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
161
|
+
|
|
162
|
+
// Generate WebP and JPEG buffers; log failures
|
|
163
|
+
let webpBuf: Buffer | null = null;
|
|
164
|
+
let jpegBuf: Buffer | null = null;
|
|
165
|
+
try {
|
|
166
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error('WebP conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
169
|
+
webpBuf = null;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error('JPEG conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
175
|
+
jpegBuf = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await fs.rm(tmpFile).catch(() => {});
|
|
179
|
+
|
|
180
|
+
if (hasAlpha) {
|
|
181
|
+
// preserve alpha: return PNG if WebP not available
|
|
182
|
+
if (webpBuf) {
|
|
183
|
+
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 } }
|
|
184
|
+
}
|
|
185
|
+
// if webp unavailable, return original PNG
|
|
186
|
+
return { device, screenshot: base64, screenshot_mime: 'image/png', resolution: { width: dims.width, height: dims.height } }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// No alpha: prefer webp, fall back to jpeg
|
|
190
|
+
if (webpBuf) {
|
|
191
|
+
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 } }
|
|
192
|
+
}
|
|
193
|
+
if (jpegBuf) {
|
|
194
|
+
return { device, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: dims.width, height: dims.height } }
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error('Screenshot conversion pipeline failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
198
|
+
// fall through to png fallback
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await fs.rm(tmpFile).catch(() => {})
|
|
152
202
|
return {
|
|
153
203
|
device,
|
|
154
204
|
screenshot: base64,
|
|
155
|
-
|
|
205
|
+
screenshot_mime: 'image/png',
|
|
206
|
+
resolution: { width: dims.width, height: dims.height },
|
|
156
207
|
}
|
|
157
208
|
} catch (e) {
|
|
158
209
|
await fs.rm(tmpFile).catch(() => {})
|
package/src/server.ts
CHANGED
|
@@ -628,12 +628,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
628
628
|
if (name === "capture_screenshot") {
|
|
629
629
|
const { platform, deviceId } = args as any
|
|
630
630
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
631
|
+
const mime = (res as any).screenshot_mime || 'image/png'
|
|
632
|
+
const content: any[] = [
|
|
633
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
|
|
634
|
+
{ type: 'image', data: (res as any).screenshot, mimeType: mime }
|
|
635
|
+
]
|
|
636
|
+
// If a jpeg fallback is available, include a small note and the fallback as an additional image block for compatibility
|
|
637
|
+
if ((res as any).screenshot_fallback) {
|
|
638
|
+
content.push({ type: 'text', text: JSON.stringify({ note: 'JPEG fallback included for compatibility', mimeType: (res as any).screenshot_fallback_mime || 'image/jpeg' }) })
|
|
639
|
+
content.push({ type: 'image', data: (res as any).screenshot_fallback, mimeType: (res as any).screenshot_fallback_mime || 'image/jpeg' })
|
|
636
640
|
}
|
|
641
|
+
return { content }
|
|
637
642
|
}
|
|
638
643
|
|
|
639
644
|
if (name === "capture_debug_snapshot") {
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,9 @@ export interface GetCrashResponse {
|
|
|
50
50
|
export interface CaptureAndroidScreenResponse {
|
|
51
51
|
device: DeviceInfo;
|
|
52
52
|
screenshot: string; // base64 encoded string
|
|
53
|
+
screenshot_mime?: string; // e.g. image/webp, image/jpeg, image/png
|
|
54
|
+
screenshot_fallback?: string; // optional fallback base64 (e.g., jpeg)
|
|
55
|
+
screenshot_fallback_mime?: string;
|
|
53
56
|
resolution: {
|
|
54
57
|
width: number;
|
|
55
58
|
height: number;
|
|
@@ -59,6 +62,9 @@ export interface CaptureAndroidScreenResponse {
|
|
|
59
62
|
export interface CaptureIOSScreenshotResponse {
|
|
60
63
|
device: DeviceInfo;
|
|
61
64
|
screenshot: string; // base64 encoded string
|
|
65
|
+
screenshot_mime?: string; // e.g. image/webp, image/jpeg, image/png
|
|
66
|
+
screenshot_fallback?: string; // optional fallback base64 (e.g., jpeg)
|
|
67
|
+
screenshot_fallback_mime?: string;
|
|
62
68
|
resolution: {
|
|
63
69
|
width: number;
|
|
64
70
|
height: number;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function parsePngSize(buf: Buffer): { width: number; height: number } {
|
|
2
|
+
try {
|
|
3
|
+
if (!buf || buf.length < 24) return { width: 0, height: 0 };
|
|
4
|
+
// PNG signature + IHDR checks
|
|
5
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return { width: 0, height: 0 };
|
|
6
|
+
const ihdr = buf.toString('ascii', 12, 16);
|
|
7
|
+
if (ihdr !== 'IHDR') return { width: 0, height: 0 };
|
|
8
|
+
const width = buf.readUInt32BE(16);
|
|
9
|
+
const height = buf.readUInt32BE(20);
|
|
10
|
+
return { width, height };
|
|
11
|
+
} catch {
|
|
12
|
+
return { width: 0, height: 0 };
|
|
13
|
+
}
|
|
14
|
+
}
|