aiphone-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/bin/aiphone-mcp.js +2 -0
- package/package.json +33 -0
- package/src/adb.js +523 -0
- package/src/image.js +73 -0
- package/src/server.js +1163 -0
- package/src/uiparser.js +142 -0
package/src/adb.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
export function adbRun(adbPath, args) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const proc = spawn(adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
6
|
+
const chunks = [];
|
|
7
|
+
const errChunks = [];
|
|
8
|
+
proc.stdout.on('data', (d) => chunks.push(d));
|
|
9
|
+
proc.stderr.on('data', (d) => errChunks.push(d));
|
|
10
|
+
proc.on('close', (code) => {
|
|
11
|
+
if (code === 0) {
|
|
12
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
13
|
+
} else {
|
|
14
|
+
const stderr = Buffer.concat(errChunks).toString('utf8');
|
|
15
|
+
reject(new Error(`ADB command failed (exit ${code}): ${stderr.trim()}`));
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
proc.on('error', (err) => reject(new Error(`Failed to spawn adb: ${err.message}`)));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function adbRunBinary(adbPath, args) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const proc = spawn(adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
const chunks = [];
|
|
26
|
+
const errChunks = [];
|
|
27
|
+
proc.stdout.on('data', (d) => chunks.push(d));
|
|
28
|
+
proc.stderr.on('data', (d) => errChunks.push(d));
|
|
29
|
+
proc.on('close', (code) => {
|
|
30
|
+
if (code === 0) {
|
|
31
|
+
resolve(Buffer.concat(chunks));
|
|
32
|
+
} else {
|
|
33
|
+
const stderr = Buffer.concat(errChunks).toString('utf8');
|
|
34
|
+
reject(new Error(`ADB command failed (exit ${code}): ${stderr.trim()}`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
proc.on('error', (err) => reject(new Error(`Failed to spawn adb: ${err.message}`)));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function listDevices(adbPath) {
|
|
42
|
+
const output = await adbRun(adbPath, ['devices']);
|
|
43
|
+
const lines = output.split('\n');
|
|
44
|
+
const serials = [];
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed || trimmed.startsWith('List of devices')) continue;
|
|
48
|
+
const parts = trimmed.split(/\s+/);
|
|
49
|
+
if (parts.length >= 2 && parts[1] === 'device') {
|
|
50
|
+
serials.push(parts[0]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return serials;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function listInstalledPackages(adbPath, serial) {
|
|
57
|
+
validateSerial(serial);
|
|
58
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'pm', 'list', 'packages']);
|
|
59
|
+
const lines = output.split('\n');
|
|
60
|
+
const packages = [];
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.startsWith('package:')) {
|
|
64
|
+
packages.push(trimmed.slice('package:'.length));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return packages;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function screenshot(adbPath, serial) {
|
|
71
|
+
validateSerial(serial);
|
|
72
|
+
const bytes = await adbRunBinary(adbPath, [...serialArgs(serial), 'exec-out', 'screencap', '-p']);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function uiDump(adbPath, serial, devicePath = '/sdcard/window_dump.xml') {
|
|
77
|
+
validateSerial(serial);
|
|
78
|
+
try {
|
|
79
|
+
const xml = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'uiautomator', 'dump', '/dev/tty']);
|
|
80
|
+
if (xml.includes('<hierarchy')) return xml;
|
|
81
|
+
} catch (_) {
|
|
82
|
+
// fall through to file-based approach
|
|
83
|
+
}
|
|
84
|
+
// Fallback
|
|
85
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'uiautomator', 'dump', devicePath]);
|
|
86
|
+
const xml = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'cat', devicePath]);
|
|
87
|
+
return xml;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function tapBounds(adbPath, serial, bounds) {
|
|
91
|
+
validateSerial(serial);
|
|
92
|
+
const cx = Math.floor((bounds[0] + bounds[2]) / 2);
|
|
93
|
+
const cy = Math.floor((bounds[1] + bounds[3]) / 2);
|
|
94
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'tap', String(cx), String(cy)]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function tapPoint(adbPath, serial, x, y) {
|
|
98
|
+
validateSerial(serial);
|
|
99
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'tap', String(x), String(y)]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function doubleTapPoint(adbPath, serial, x, y) {
|
|
103
|
+
validateSerial(serial);
|
|
104
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'tap', String(x), String(y)]);
|
|
105
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
106
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'tap', String(x), String(y)]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function typeText(adbPath, serial, text) {
|
|
110
|
+
validateSerial(serial);
|
|
111
|
+
// adb shell input text <arg> splits on spaces at the device shell level,
|
|
112
|
+
// causing only the first word to be typed. Passing a single quoted shell
|
|
113
|
+
// command string avoids this. Single quotes inside text are escaped with
|
|
114
|
+
// the POSIX '\'' technique.
|
|
115
|
+
const escaped = text.replace(/'/g, `'\\''`);
|
|
116
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', `input text '${escaped}'`]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function swipe(adbPath, serial, x1, y1, x2, y2, durationMs = 300) {
|
|
120
|
+
validateSerial(serial);
|
|
121
|
+
await adbRun(adbPath, [
|
|
122
|
+
...serialArgs(serial), 'shell', 'input', 'swipe',
|
|
123
|
+
String(x1), String(y1), String(x2), String(y2), String(durationMs),
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function swipeDirection(adbPath, serial, direction, screenW = 1080, screenH = 1920, cx = null, cy = null) {
|
|
128
|
+
const top = Math.round(screenH * 0.15);
|
|
129
|
+
const bottom = Math.round(screenH * 0.85);
|
|
130
|
+
const left = Math.round(screenW * 0.15);
|
|
131
|
+
const right = Math.round(screenW * 0.85);
|
|
132
|
+
const midX = cx != null ? Math.round(cx) : Math.floor(screenW / 2);
|
|
133
|
+
const midY = cy != null ? Math.round(cy) : Math.floor(screenH / 2);
|
|
134
|
+
|
|
135
|
+
switch (direction.toLowerCase()) {
|
|
136
|
+
case 'down':
|
|
137
|
+
case 'scroll_down':
|
|
138
|
+
// finger: bottom → top
|
|
139
|
+
return swipe(adbPath, serial, midX, bottom, midX, top);
|
|
140
|
+
case 'up':
|
|
141
|
+
case 'scroll_up':
|
|
142
|
+
// finger: top → bottom
|
|
143
|
+
return swipe(adbPath, serial, midX, top, midX, bottom);
|
|
144
|
+
case 'left':
|
|
145
|
+
case 'scroll_left':
|
|
146
|
+
// finger: left → right
|
|
147
|
+
return swipe(adbPath, serial, left, midY, right, midY);
|
|
148
|
+
case 'right':
|
|
149
|
+
case 'scroll_right':
|
|
150
|
+
// finger: right → left
|
|
151
|
+
return swipe(adbPath, serial, right, midY, left, midY);
|
|
152
|
+
default:
|
|
153
|
+
throw new Error(`Unknown swipe direction: "${direction}". Use up|down|left|right.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function pressKey(adbPath, serial, key) {
|
|
158
|
+
validateSerial(serial);
|
|
159
|
+
const keyMap = {
|
|
160
|
+
back: 4,
|
|
161
|
+
home: 3,
|
|
162
|
+
recent: 187,
|
|
163
|
+
recents: 187,
|
|
164
|
+
app_switch: 187,
|
|
165
|
+
enter: 66,
|
|
166
|
+
search: 84,
|
|
167
|
+
menu: 82,
|
|
168
|
+
delete: 67,
|
|
169
|
+
backspace: 67,
|
|
170
|
+
escape: 111,
|
|
171
|
+
power: 26,
|
|
172
|
+
volume_up: 24,
|
|
173
|
+
volume_down: 25,
|
|
174
|
+
camera: 27,
|
|
175
|
+
zoom_in: 168,
|
|
176
|
+
zoom_out: 169,
|
|
177
|
+
};
|
|
178
|
+
const lower = key.trim().toLowerCase();
|
|
179
|
+
const code = keyMap[lower] ?? parseInt(lower, 10);
|
|
180
|
+
if (Number.isNaN(code)) {
|
|
181
|
+
throw new Error(`Unknown key "${key}". Use back|home|recent|enter|search|menu|delete|<numeric keycode>.`);
|
|
182
|
+
}
|
|
183
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'keyevent', String(code)]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function launchApp(adbPath, serial, packageName) {
|
|
187
|
+
validateSerial(serial);
|
|
188
|
+
if (!isValidPackageName(packageName)) {
|
|
189
|
+
throw new Error(`Invalid package name: "${packageName}"`);
|
|
190
|
+
}
|
|
191
|
+
await adbRun(adbPath, [
|
|
192
|
+
...serialArgs(serial), 'shell', 'monkey',
|
|
193
|
+
'-p', packageName,
|
|
194
|
+
'-c', 'android.intent.category.LAUNCHER', '1',
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function openUrl(adbPath, serial, url) {
|
|
199
|
+
validateSerial(serial);
|
|
200
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
201
|
+
throw new Error(`URL must start with http:// or https://`);
|
|
202
|
+
}
|
|
203
|
+
await adbRun(adbPath, [
|
|
204
|
+
...serialArgs(serial), 'shell', 'am', 'start',
|
|
205
|
+
'-a', 'android.intent.action.VIEW',
|
|
206
|
+
'-d', url,
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function rotate(adbPath, serial, rotation) {
|
|
211
|
+
validateSerial(serial);
|
|
212
|
+
const r = Math.max(0, Math.min(3, Math.floor(rotation)));
|
|
213
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']);
|
|
214
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'settings', 'put', 'system', 'user_rotation', String(r)]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function getForegroundApp(adbPath, serial) {
|
|
218
|
+
validateSerial(serial);
|
|
219
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'dumpsys', 'window']);
|
|
220
|
+
let currentFocus = null;
|
|
221
|
+
let focusedApp = null;
|
|
222
|
+
for (const line of output.split('\n')) {
|
|
223
|
+
const trimmed = line.trim();
|
|
224
|
+
if (trimmed.startsWith('mCurrentFocus')) currentFocus = trimmed;
|
|
225
|
+
else if (trimmed.startsWith('mFocusedApp')) focusedApp = trimmed;
|
|
226
|
+
}
|
|
227
|
+
return { currentFocus, focusedApp };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function adbTcpip(adbPath, serial, port = 5555) {
|
|
231
|
+
validateSerial(serial);
|
|
232
|
+
const p = Math.max(1, Math.min(65535, Math.floor(port)));
|
|
233
|
+
await adbRun(adbPath, [...serialArgs(serial), 'tcpip', String(p)]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function getDeviceIp(adbPath, serial) {
|
|
237
|
+
validateSerial(serial);
|
|
238
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'ip', 'route']);
|
|
239
|
+
const results = [];
|
|
240
|
+
for (const line of output.split('\n')) {
|
|
241
|
+
const trimmed = line.trim();
|
|
242
|
+
if (!trimmed) continue;
|
|
243
|
+
// Example: "192.168.101.0/24 dev wlan0 proto kernel scope link src 192.168.101.7"
|
|
244
|
+
const ifaceMatch = trimmed.match(/dev\s+(\S+)/);
|
|
245
|
+
const srcMatch = trimmed.match(/src\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
246
|
+
const netMatch = trimmed.match(/^(\S+)/);
|
|
247
|
+
if (ifaceMatch && srcMatch) {
|
|
248
|
+
results.push({
|
|
249
|
+
iface: ifaceMatch[1],
|
|
250
|
+
network: netMatch ? netMatch[1] : '',
|
|
251
|
+
ip: srcMatch[1],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function adbConnect(adbPath, ip, port = 5555) {
|
|
259
|
+
if (!isValidIp(ip)) throw new Error(`Invalid IP address: "${ip}"`);
|
|
260
|
+
const p = Math.max(1, Math.min(65535, Math.floor(port)));
|
|
261
|
+
const output = await adbRun(adbPath, ['connect', `${ip}:${p}`]);
|
|
262
|
+
return output.trim();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function adbDisconnect(adbPath, target) {
|
|
266
|
+
const args = (target && target.trim()) ? ['disconnect', target.trim()] : ['disconnect'];
|
|
267
|
+
const output = await adbRun(adbPath, args);
|
|
268
|
+
return output.trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function forceStopApp(adbPath, serial, packageName) {
|
|
272
|
+
validateSerial(serial);
|
|
273
|
+
if (!isValidPackageName(packageName)) throw new Error(`Invalid package name: "${packageName}"`);
|
|
274
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'am', 'force-stop', packageName]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function isAppInstalled(adbPath, serial, packageName) {
|
|
278
|
+
validateSerial(serial);
|
|
279
|
+
if (!isValidPackageName(packageName)) throw new Error(`Invalid package name: "${packageName}"`);
|
|
280
|
+
// Pass packageName as filter arg to pm list packages to reduce output
|
|
281
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'pm', 'list', 'packages', packageName]);
|
|
282
|
+
return output.split('\n').some((line) => line.trim() === `package:${packageName}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function getScreenSize(adbPath, serial) {
|
|
286
|
+
validateSerial(serial);
|
|
287
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'wm', 'size']);
|
|
288
|
+
const m = output.match(/(\d+)x(\d+)/);
|
|
289
|
+
if (!m) throw new Error(`Could not parse screen size from: ${output.trim()}`);
|
|
290
|
+
return { width: parseInt(m[1], 10), height: parseInt(m[2], 10) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function getRawUiXml(adbPath, serial, devicePath = '/sdcard/window_dump.xml') {
|
|
294
|
+
return uiDump(adbPath, serial, devicePath);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function clearInputAndType(adbPath, serial, text) {
|
|
298
|
+
validateSerial(serial);
|
|
299
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'keyevent', '277']); // KEYCODE_CTRL_A
|
|
300
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'input', 'keyevent', '67']); // KEYCODE_DEL
|
|
301
|
+
const escaped = text.replace(/'/g, `'\\''`);
|
|
302
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', `input text '${escaped}'`]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function getDeviceInfo(adbPath, serial) {
|
|
306
|
+
validateSerial(serial);
|
|
307
|
+
|
|
308
|
+
// Helper: run a single getprop and return trimmed value (never throws)
|
|
309
|
+
const prop = async (key) => {
|
|
310
|
+
try {
|
|
311
|
+
const v = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'getprop', key]);
|
|
312
|
+
return v.trim();
|
|
313
|
+
} catch { return null; }
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Helper: run a shell command and return trimmed output (never throws)
|
|
317
|
+
const shell = async (...cmd) => {
|
|
318
|
+
try {
|
|
319
|
+
const v = await adbRun(adbPath, [...serialArgs(serial), 'shell', ...cmd]);
|
|
320
|
+
return v.trim();
|
|
321
|
+
} catch { return null; }
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const [
|
|
325
|
+
model, brand, manufacturer, device, boardPlatform,
|
|
326
|
+
androidVersion, sdkVersion, buildId, buildType, buildFingerprint,
|
|
327
|
+
serialNo, abi,
|
|
328
|
+
] = await Promise.all([
|
|
329
|
+
prop('ro.product.model'),
|
|
330
|
+
prop('ro.product.brand'),
|
|
331
|
+
prop('ro.product.manufacturer'),
|
|
332
|
+
prop('ro.product.device'),
|
|
333
|
+
prop('ro.board.platform'),
|
|
334
|
+
prop('ro.build.version.release'),
|
|
335
|
+
prop('ro.build.version.sdk'),
|
|
336
|
+
prop('ro.build.display.id'),
|
|
337
|
+
prop('ro.build.type'),
|
|
338
|
+
prop('ro.build.fingerprint'),
|
|
339
|
+
prop('ro.serialno'),
|
|
340
|
+
prop('ro.product.cpu.abi'),
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
const [wmSize, wmDensity] = await Promise.all([
|
|
344
|
+
shell('wm', 'size'),
|
|
345
|
+
shell('wm', 'density'),
|
|
346
|
+
]);
|
|
347
|
+
const sizeMatch = wmSize ? wmSize.match(/(\d+)x(\d+)/) : null;
|
|
348
|
+
const densityMatch = wmDensity ? wmDensity.match(/(\d+)/) : null;
|
|
349
|
+
|
|
350
|
+
const batteryRaw = await shell('dumpsys', 'battery');
|
|
351
|
+
const battery = {};
|
|
352
|
+
if (batteryRaw) {
|
|
353
|
+
for (const line of batteryRaw.split('\n')) {
|
|
354
|
+
const m = line.match(/^\s*([^:]+):\s*(.+)$/);
|
|
355
|
+
if (m) battery[m[1].trim()] = m[2].trim();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const memRaw = await shell('cat', '/proc/meminfo');
|
|
360
|
+
const mem = {};
|
|
361
|
+
if (memRaw) {
|
|
362
|
+
for (const line of memRaw.split('\n')) {
|
|
363
|
+
const m = line.match(/^(\w+):\s+(\d+)\s*kB/);
|
|
364
|
+
if (m) mem[m[1]] = parseInt(m[2], 10) * 1024; // bytes
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const parseDF = (raw) => {
|
|
369
|
+
if (!raw) return null;
|
|
370
|
+
const lines = raw.split('\n').filter((l) => l.trim() && !l.startsWith('Filesystem'));
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const line of lines) {
|
|
373
|
+
const parts = line.trim().split(/\s+/);
|
|
374
|
+
if (parts.length >= 6) {
|
|
375
|
+
out.push({
|
|
376
|
+
filesystem: parts[0],
|
|
377
|
+
size_bytes: parseDfSize(parts[1]),
|
|
378
|
+
used_bytes: parseDfSize(parts[2]),
|
|
379
|
+
avail_bytes: parseDfSize(parts[3]),
|
|
380
|
+
mount: parts[5],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return out.length ? out : null;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const [dfData, dfSdcard] = await Promise.all([
|
|
388
|
+
shell('df', '/data'),
|
|
389
|
+
shell('df', '/sdcard'),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
const ipRoute = await shell('ip', 'route');
|
|
393
|
+
const ifaces = [];
|
|
394
|
+
if (ipRoute) {
|
|
395
|
+
for (const line of ipRoute.split('\n')) {
|
|
396
|
+
const t = line.trim();
|
|
397
|
+
const ifaceM = t.match(/dev\s+(\S+)/);
|
|
398
|
+
const srcM = t.match(/src\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
399
|
+
const netM = t.match(/^(\S+)/);
|
|
400
|
+
if (ifaceM && srcM) {
|
|
401
|
+
ifaces.push({ iface: ifaceM[1], network: netM ? netM[1] : '', ip: srcM[1] });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
device: {
|
|
408
|
+
model,
|
|
409
|
+
brand,
|
|
410
|
+
manufacturer,
|
|
411
|
+
device,
|
|
412
|
+
board_platform: boardPlatform,
|
|
413
|
+
serial: serialNo,
|
|
414
|
+
cpu_abi: abi,
|
|
415
|
+
},
|
|
416
|
+
software: {
|
|
417
|
+
android_version: androidVersion,
|
|
418
|
+
sdk_level: sdkVersion ? parseInt(sdkVersion, 10) : null,
|
|
419
|
+
build_id: buildId,
|
|
420
|
+
build_type: buildType,
|
|
421
|
+
build_fingerprint: buildFingerprint,
|
|
422
|
+
},
|
|
423
|
+
screen: {
|
|
424
|
+
width: sizeMatch ? parseInt(sizeMatch[1], 10) : null,
|
|
425
|
+
height: sizeMatch ? parseInt(sizeMatch[2], 10) : null,
|
|
426
|
+
density_dpi: densityMatch ? parseInt(densityMatch[1], 10) : null,
|
|
427
|
+
},
|
|
428
|
+
battery: {
|
|
429
|
+
level: battery['level'] ? parseInt(battery['level'], 10) : null,
|
|
430
|
+
status: battery['status'] ?? null,
|
|
431
|
+
health: battery['health'] ?? null,
|
|
432
|
+
plugged: battery['plugged'] ?? null,
|
|
433
|
+
voltage_mv: battery['voltage'] ? parseInt(battery['voltage'], 10) : null,
|
|
434
|
+
temperature_c: battery['temperature'] ? (parseInt(battery['temperature'], 10) / 10) : null,
|
|
435
|
+
technology: battery['technology'] ?? null,
|
|
436
|
+
},
|
|
437
|
+
memory: {
|
|
438
|
+
total_bytes: mem['MemTotal'] ?? null,
|
|
439
|
+
free_bytes: mem['MemFree'] ?? null,
|
|
440
|
+
available_bytes: mem['MemAvailable'] ?? null,
|
|
441
|
+
cached_bytes: mem['Cached'] ?? null,
|
|
442
|
+
},
|
|
443
|
+
storage: [
|
|
444
|
+
...(parseDF(dfData) ?? []),
|
|
445
|
+
...(parseDF(dfSdcard) ?? []),
|
|
446
|
+
],
|
|
447
|
+
network_interfaces: ifaces,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseDfSize(str) {
|
|
452
|
+
if (!str) return null;
|
|
453
|
+
const m = str.match(/^(\d+(?:\.\d+)?)([KMGTP]?)$/i);
|
|
454
|
+
if (!m) return null;
|
|
455
|
+
const n = parseFloat(m[1]);
|
|
456
|
+
const unit = m[2].toUpperCase();
|
|
457
|
+
const mult = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4, P: 1024 ** 5 };
|
|
458
|
+
return Math.round(n * (mult[unit] ?? 1));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function serialArgs(serial) {
|
|
462
|
+
if (!serial) return [];
|
|
463
|
+
if (!/^[A-Za-z0-9:.\-_]+$/.test(serial)) throw new Error(`Invalid device serial: "${serial}"`);
|
|
464
|
+
return ['-s', serial];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function validateSerial(serial) {
|
|
468
|
+
if (serial) serialArgs(serial);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isValidPackageName(pkg) {
|
|
472
|
+
return /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(pkg);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isValidIp(ip) {
|
|
476
|
+
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) &&
|
|
477
|
+
ip.split('.').every((o) => parseInt(o, 10) <= 255);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function postNotification(adbPath, serial, { title, text, tag = 'aiphone', style = 'bigtext' } = {}) {
|
|
481
|
+
validateSerial(serial);
|
|
482
|
+
if (!title || !text) throw new Error('title and text are required');
|
|
483
|
+
await adbRun(adbPath, [
|
|
484
|
+
...serialArgs(serial), 'shell', 'cmd', 'notification', 'post',
|
|
485
|
+
'-S', style,
|
|
486
|
+
'-t', title,
|
|
487
|
+
tag,
|
|
488
|
+
text,
|
|
489
|
+
]);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function dumpNotifications(adbPath, serial) {
|
|
493
|
+
validateSerial(serial);
|
|
494
|
+
const raw = await adbRun(adbPath, [...serialArgs(serial), 'shell', 'dumpsys', 'notification']);
|
|
495
|
+
return raw;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function setWifi(adbPath, serial, enable) {
|
|
499
|
+
validateSerial(serial);
|
|
500
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'svc', 'wifi', enable ? 'enable' : 'disable']);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function setMobileData(adbPath, serial, enable) {
|
|
504
|
+
validateSerial(serial);
|
|
505
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'svc', 'data', enable ? 'enable' : 'disable']);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function setAirplaneMode(adbPath, serial, enable) {
|
|
509
|
+
validateSerial(serial);
|
|
510
|
+
const value = enable ? '1' : '0';
|
|
511
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'settings', 'put', 'global', 'airplane_mode_on', value]);
|
|
512
|
+
await adbRun(adbPath, [...serialArgs(serial), 'shell', 'am', 'broadcast', '-a', 'android.intent.action.AIRPLANE_MODE']);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function adbShell(adbPath, serial, command) {
|
|
516
|
+
validateSerial(serial);
|
|
517
|
+
if (!command || !command.trim()) throw new Error('command is required');
|
|
518
|
+
// Split on whitespace but preserve quoted strings
|
|
519
|
+
const parts = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
520
|
+
if (!parts) throw new Error('could not parse command');
|
|
521
|
+
const output = await adbRun(adbPath, [...serialArgs(serial), 'shell', ...parts]);
|
|
522
|
+
return output;
|
|
523
|
+
}
|
package/src/image.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image processing utilities for screenshot optimization.
|
|
3
|
+
* Uses sharp for fast native resizing and format conversion.
|
|
4
|
+
*/
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resizes and compresses a raw image buffer.
|
|
9
|
+
*
|
|
10
|
+
* @param {Buffer} inputBuffer - Raw image bytes (PNG from screencap)
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {number} [opts.maxWidth=1080] - Maximum output width in pixels
|
|
13
|
+
* @param {number} [opts.maxHeight=1920] - Maximum output height in pixels
|
|
14
|
+
* @param {string} [opts.format='webp'] - Output format: 'webp' | 'jpeg' | 'png'
|
|
15
|
+
* @param {number} [opts.quality=75] - Compression quality 1–100 (ignored for png)
|
|
16
|
+
* @returns {Promise<{ buffer: Buffer, format: string, width: number, height: number, originalBytes: number }>}
|
|
17
|
+
*/
|
|
18
|
+
export async function processScreenshot(inputBuffer, opts = {}) {
|
|
19
|
+
const maxWidth = Math.max(1, Math.min(4096, Math.floor(opts.maxWidth ?? 1080)));
|
|
20
|
+
const maxHeight = Math.max(1, Math.min(4096, Math.floor(opts.maxHeight ?? 1920)));
|
|
21
|
+
const quality = Math.max(1, Math.min(100, Math.floor(opts.quality ?? 75)));
|
|
22
|
+
const format = validateFormat(opts.format ?? 'webp');
|
|
23
|
+
|
|
24
|
+
const originalBytes = inputBuffer.length;
|
|
25
|
+
|
|
26
|
+
let pipeline = sharp(inputBuffer, { failOn: 'none' }).rotate(); // auto-orient
|
|
27
|
+
|
|
28
|
+
// Only resize if the image exceeds the max dimensions (never upscale)
|
|
29
|
+
pipeline = pipeline.resize(maxWidth, maxHeight, {
|
|
30
|
+
fit: 'inside',
|
|
31
|
+
withoutEnlargement: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
switch (format) {
|
|
35
|
+
case 'webp':
|
|
36
|
+
pipeline = pipeline.webp({ quality, effort: 4 });
|
|
37
|
+
break;
|
|
38
|
+
case 'jpeg':
|
|
39
|
+
case 'jpg':
|
|
40
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
41
|
+
break;
|
|
42
|
+
case 'png':
|
|
43
|
+
// PNG compressionLevel 0–9; map quality inversely (higher quality = lower compression loss)
|
|
44
|
+
pipeline = pipeline.png({ compressionLevel: Math.round((100 - quality) / 11) });
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
buffer: data,
|
|
52
|
+
format: info.format,
|
|
53
|
+
width: info.width,
|
|
54
|
+
height: info.height,
|
|
55
|
+
originalBytes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mimeType(format) {
|
|
60
|
+
switch (format) {
|
|
61
|
+
case 'jpeg':
|
|
62
|
+
case 'jpg': return 'image/jpeg';
|
|
63
|
+
case 'png': return 'image/png';
|
|
64
|
+
case 'webp': return 'image/webp';
|
|
65
|
+
default: return 'image/webp';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateFormat(f) {
|
|
70
|
+
const lower = String(f).toLowerCase();
|
|
71
|
+
if (['webp', 'jpeg', 'jpg', 'png'].includes(lower)) return lower;
|
|
72
|
+
throw new Error(`Unsupported image format "${f}". Use webp | jpeg | png.`);
|
|
73
|
+
}
|