dsh-device-deck 0.0.0-stage → 0.3.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/CHANGELOG.md +101 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/LICENSE-scrcpy +202 -0
- package/NOTICE +9 -0
- package/README.md +184 -2
- package/README.zh-CN.md +153 -0
- package/bin/scrcpy-server +0 -0
- package/bin/scrcpy-server.version +1 -0
- package/client.js +2647 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +9 -0
- package/index.js +2181 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
package/index.js
ADDED
|
@@ -0,0 +1,2181 @@
|
|
|
1
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createServer as createNetServer } from 'node:net';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { Readable, Transform } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import fsp from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { Adb, AdbServerClient } from '@yume-chan/adb';
|
|
13
|
+
import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp';
|
|
14
|
+
import { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } from '@yume-chan/adb-scrcpy';
|
|
15
|
+
import { AndroidScreenPowerMode } from '@yume-chan/scrcpy';
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
21
|
+
const DATA_DIR = path.join(DSH_HOME, 'device-deck');
|
|
22
|
+
const SHOT_DIR = path.join(DATA_DIR, 'screenshots');
|
|
23
|
+
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
|
24
|
+
const STATE_FILE = path.join(DATA_DIR, 'state.json');
|
|
25
|
+
const SERVER_BIN = path.join(HERE, 'bin', 'scrcpy-server');
|
|
26
|
+
const REMOTE_SERVER = '/data/local/tmp/dsh-device-deck-scrcpy.jar';
|
|
27
|
+
const REMOTE_UPLOAD_DIR = '/sdcard/Download';
|
|
28
|
+
|
|
29
|
+
const MAX_SHOTS = 30;
|
|
30
|
+
const IDLE_TEARDOWN_MS = 30_000;
|
|
31
|
+
const MAX_SUBSCRIBER_BACKLOG = 1024 * 1024;
|
|
32
|
+
const MAX_GOP_BYTES = 8 * 1024 * 1024;
|
|
33
|
+
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const T_META = 1;
|
|
36
|
+
const T_CONFIG = 2;
|
|
37
|
+
const T_DATA = 3;
|
|
38
|
+
const T_ERROR = 4;
|
|
39
|
+
const T_END = 5;
|
|
40
|
+
|
|
41
|
+
const SERIAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
42
|
+
|
|
43
|
+
const state = {
|
|
44
|
+
selected: null,
|
|
45
|
+
lastSerial: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let adbPathCache;
|
|
49
|
+
|
|
50
|
+
function ensureDirs() {
|
|
51
|
+
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
|
52
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
53
|
+
loadState();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadState() {
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (typeof parsed?.selected === 'string') state.selected = parsed.selected;
|
|
61
|
+
if (typeof parsed?.lastSerial === 'string') state.lastSerial = parsed.lastSerial;
|
|
62
|
+
} catch {
|
|
63
|
+
/* first run */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveState() {
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
70
|
+
const tmp = `${STATE_FILE}.tmp`;
|
|
71
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
72
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
73
|
+
} catch {
|
|
74
|
+
/* state is best effort */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveAdb() {
|
|
79
|
+
if (adbPathCache !== undefined) return adbPathCache;
|
|
80
|
+
const candidates = [];
|
|
81
|
+
if (process.env.DSH_DEVICE_MIRROR_ADB) candidates.push(process.env.DSH_DEVICE_MIRROR_ADB);
|
|
82
|
+
candidates.push(
|
|
83
|
+
'/opt/homebrew/bin/adb',
|
|
84
|
+
'/usr/local/bin/adb',
|
|
85
|
+
'/usr/bin/adb',
|
|
86
|
+
path.join(os.homedir(), 'Library/Android/sdk/platform-tools/adb'),
|
|
87
|
+
path.join(os.homedir(), 'Android/Sdk/platform-tools/adb'),
|
|
88
|
+
path.join(os.homedir(), '.local/bin/adb'),
|
|
89
|
+
);
|
|
90
|
+
if (process.platform === 'win32') {
|
|
91
|
+
const local = process.env.LOCALAPPDATA || '';
|
|
92
|
+
candidates.push(
|
|
93
|
+
path.join(local, 'Android/Sdk/platform-tools/adb.exe'),
|
|
94
|
+
path.join(local, 'Android/Sdk/platform-tools/adb'),
|
|
95
|
+
'adb.exe',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const candidate of candidates) {
|
|
99
|
+
if (!candidate.includes(path.sep)) continue;
|
|
100
|
+
try {
|
|
101
|
+
if (fs.existsSync(candidate)) {
|
|
102
|
+
adbPathCache = candidate;
|
|
103
|
+
return candidate;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
adbPathCache = process.platform === 'win32' ? 'adb.exe' : 'adb';
|
|
110
|
+
return adbPathCache;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runAdb(args, options = {}) {
|
|
114
|
+
return execFileAsync(resolveAdb(), args, {
|
|
115
|
+
timeout: options.timeout ?? 8000,
|
|
116
|
+
encoding: options.encoding ?? 'utf8',
|
|
117
|
+
maxBuffer: options.maxBuffer ?? 8 * 1024 * 1024,
|
|
118
|
+
windowsHide: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function adbErrorHint(error) {
|
|
123
|
+
const text = String(error?.stderr || error?.message || '');
|
|
124
|
+
if (/not found|ENOENT|spawn/i.test(text)) {
|
|
125
|
+
return '未找到 adb。请安装 Android platform-tools,或设置环境变量 DSH_DEVICE_MIRROR_ADB。';
|
|
126
|
+
}
|
|
127
|
+
if (/no devices|device not found/i.test(text)) return '设备已断开。';
|
|
128
|
+
if (/unauthorized/i.test(text)) return '设备未授权。请在手机上确认 USB 调试授权。';
|
|
129
|
+
return text.split('\n').map((line) => line.trim()).find(Boolean) || 'ADB 调用失败';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function adbStatus() {
|
|
133
|
+
try {
|
|
134
|
+
const { stdout } = await runAdb(['version'], { timeout: 4000 });
|
|
135
|
+
const first = String(stdout).split('\n').map((line) => line.trim()).find(Boolean) || '';
|
|
136
|
+
return { available: true, path: resolveAdb(), version: first };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { available: false, path: resolveAdb(), error: adbErrorHint(error) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function listDevices() {
|
|
143
|
+
const { stdout } = await runAdb(['devices', '-l'], { timeout: 6000 });
|
|
144
|
+
const devices = [];
|
|
145
|
+
for (const line of String(stdout).split('\n')) {
|
|
146
|
+
const text = line.trim();
|
|
147
|
+
if (!text || text.startsWith('List of devices') || text.startsWith('*')) continue;
|
|
148
|
+
const parts = text.split(/\s+/);
|
|
149
|
+
const serial = parts.shift();
|
|
150
|
+
const deviceState = parts.shift();
|
|
151
|
+
if (!serial || !deviceState) continue;
|
|
152
|
+
const props = {};
|
|
153
|
+
for (const token of parts) {
|
|
154
|
+
const index = token.indexOf(':');
|
|
155
|
+
if (index > 0) props[token.slice(0, index)] = token.slice(index + 1);
|
|
156
|
+
}
|
|
157
|
+
devices.push({
|
|
158
|
+
serial,
|
|
159
|
+
state: deviceState,
|
|
160
|
+
model: props.model || '',
|
|
161
|
+
product: props.product || '',
|
|
162
|
+
device: props.device || '',
|
|
163
|
+
transportId: props.transport_id || '',
|
|
164
|
+
isEmulator:
|
|
165
|
+
serial.startsWith('emulator-') ||
|
|
166
|
+
props.product === 'sdk' ||
|
|
167
|
+
String(props.device || '').startsWith('emu'),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return devices;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function resolveSerial(requested) {
|
|
174
|
+
if (requested !== undefined && requested !== null && String(requested).trim() !== '') {
|
|
175
|
+
const text = String(requested).trim();
|
|
176
|
+
if (!SERIAL_RE.test(text)) throw new Error('设备串号格式不正确');
|
|
177
|
+
}
|
|
178
|
+
const devices = await listDevices();
|
|
179
|
+
const online = devices.filter((device) => device.state === 'device');
|
|
180
|
+
if (requested) {
|
|
181
|
+
const found = online.find((device) => device.serial === requested);
|
|
182
|
+
if (!found) throw new Error(`设备不在线:${requested}`);
|
|
183
|
+
return requested;
|
|
184
|
+
}
|
|
185
|
+
if (state.lastSerial) {
|
|
186
|
+
const preferred = online.find((device) => device.serial === state.lastSerial);
|
|
187
|
+
if (preferred) return preferred.serial;
|
|
188
|
+
}
|
|
189
|
+
if (!online.length) throw new Error('没有在线的 Android 设备。请连接 USB 并开启调试,或启动模拟器。');
|
|
190
|
+
return online[0].serial;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
function delay(ms) {
|
|
195
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveEmulatorBinary() {
|
|
199
|
+
const candidates = [];
|
|
200
|
+
for (const key of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) {
|
|
201
|
+
const root = process.env[key];
|
|
202
|
+
if (root) candidates.push(path.join(root, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator'));
|
|
203
|
+
}
|
|
204
|
+
candidates.push(
|
|
205
|
+
path.join(os.homedir(), 'Library/Android/sdk/emulator/emulator'),
|
|
206
|
+
path.join(os.homedir(), 'Android/Sdk/emulator/emulator'),
|
|
207
|
+
'/usr/local/share/android-sdk/emulator/emulator',
|
|
208
|
+
'/opt/homebrew/share/android-sdk/emulator/emulator',
|
|
209
|
+
);
|
|
210
|
+
if (process.platform === 'win32') {
|
|
211
|
+
const local = process.env.LOCALAPPDATA || '';
|
|
212
|
+
candidates.push(path.join(local, 'Android/Sdk/emulator/emulator.exe'));
|
|
213
|
+
}
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
try {
|
|
216
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
217
|
+
} catch {
|
|
218
|
+
/* ignore */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function listAvds() {
|
|
225
|
+
const binary = resolveEmulatorBinary();
|
|
226
|
+
if (!binary) {
|
|
227
|
+
return { available: false, avds: [], message: '未找到 Android emulator。请安装 Android Studio 或 SDK 工具。' };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const { stdout } = await execFileAsync(binary, ['-list-avds'], { timeout: 10_000, maxBuffer: 2 * 1024 * 1024 });
|
|
231
|
+
const avds = String(stdout)
|
|
232
|
+
.split('\n')
|
|
233
|
+
.map((line) => line.trim())
|
|
234
|
+
.filter((line) => line.length > 0 && !line.startsWith('INFO'));
|
|
235
|
+
return { available: true, avds };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return { available: false, avds: [], message: error.message };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function launchAvd(name) {
|
|
242
|
+
const clean = String(name || '').trim();
|
|
243
|
+
if (!clean || /[\r\n]/.test(clean)) throw new Error('模拟器名称不正确');
|
|
244
|
+
const binary = resolveEmulatorBinary();
|
|
245
|
+
if (!binary) throw new Error('未找到 Android emulator。请安装 Android Studio 或 SDK 工具。');
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
const child = spawn(binary, ['-avd', clean, '-netdelay', 'none', '-netspeed', 'full', '-no-window'], {
|
|
249
|
+
detached: true,
|
|
250
|
+
stdio: 'ignore',
|
|
251
|
+
});
|
|
252
|
+
child.once('error', (error) => {
|
|
253
|
+
if (settled) return;
|
|
254
|
+
settled = true;
|
|
255
|
+
reject(error);
|
|
256
|
+
});
|
|
257
|
+
child.once('spawn', () => {
|
|
258
|
+
if (settled) return;
|
|
259
|
+
settled = true;
|
|
260
|
+
child.unref();
|
|
261
|
+
resolve({ success: true, message: `正在启动 ${clean},约需 20–60 秒` });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseElapsedSeconds(etime) {
|
|
267
|
+
const [daysPart, clockPart] = String(etime).includes('-') ? String(etime).split('-') : ['0', String(etime)];
|
|
268
|
+
const days = Number(daysPart) || 0;
|
|
269
|
+
const segments = clockPart.split(':').map((segment) => Number(segment) || 0);
|
|
270
|
+
while (segments.length < 3) segments.unshift(0);
|
|
271
|
+
const [hours, minutes, seconds] = segments.slice(-3);
|
|
272
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function runningEmulatorProcesses() {
|
|
276
|
+
if (process.platform === 'win32') return [];
|
|
277
|
+
try {
|
|
278
|
+
const { stdout } = await execFileAsync('ps', ['-ax', '-o', 'pid=,etime=,command='], {
|
|
279
|
+
timeout: 6000,
|
|
280
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
281
|
+
});
|
|
282
|
+
const processes = [];
|
|
283
|
+
for (const rawLine of String(stdout).split('\n')) {
|
|
284
|
+
const line = rawLine.trim();
|
|
285
|
+
if (!line.includes('-avd ')) continue;
|
|
286
|
+
if (!line.includes('qemu-system') && !line.includes('/emulator/')) continue;
|
|
287
|
+
const match = line.match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
|
288
|
+
if (!match) continue;
|
|
289
|
+
const avd = match[3].match(/-avd\s+(\S+)/)?.[1];
|
|
290
|
+
if (!avd) continue;
|
|
291
|
+
processes.push({
|
|
292
|
+
pid: Number(match[1]),
|
|
293
|
+
uptimeSeconds: parseElapsedSeconds(match[2]),
|
|
294
|
+
avd,
|
|
295
|
+
serial: '',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return processes;
|
|
299
|
+
} catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const avdNameCache = new Map();
|
|
305
|
+
|
|
306
|
+
async function avdNameForSerial(serial) {
|
|
307
|
+
const cached = avdNameCache.get(serial);
|
|
308
|
+
if (cached && Date.now() - cached.at < 60_000) return cached.name;
|
|
309
|
+
try {
|
|
310
|
+
const { stdout } = await runAdb(['-s', serial, 'emu', 'avd', 'name'], { timeout: 2500 });
|
|
311
|
+
const name = String(stdout).split('\n').map((line) => line.trim()).find(Boolean) || '';
|
|
312
|
+
if (name && name !== 'OK' && !/^KO$/i.test(name)) {
|
|
313
|
+
avdNameCache.set(serial, { name, at: Date.now() });
|
|
314
|
+
return name;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
/* still booting */
|
|
318
|
+
}
|
|
319
|
+
return '';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function stopEmulator({ serial, pid }) {
|
|
323
|
+
if (serial) {
|
|
324
|
+
try {
|
|
325
|
+
await runAdb(['-s', serial, 'emu', 'kill'], { timeout: 8000 });
|
|
326
|
+
return { success: true, message: `已请求关闭 ${serial}` };
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (!pid) throw new Error(adbErrorHint(error));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error('缺少模拟器进程号');
|
|
332
|
+
try {
|
|
333
|
+
process.kill(pid, 'SIGTERM');
|
|
334
|
+
await delay(1200);
|
|
335
|
+
try {
|
|
336
|
+
process.kill(pid, 0);
|
|
337
|
+
process.kill(pid, 'SIGKILL');
|
|
338
|
+
} catch {
|
|
339
|
+
/* already exited */
|
|
340
|
+
}
|
|
341
|
+
return { success: true, message: '模拟器已停止' };
|
|
342
|
+
} catch (error) {
|
|
343
|
+
throw new Error(error.message || '停止模拟器失败');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function connectWifiDevice(address) {
|
|
348
|
+
const clean = String(address || '').trim();
|
|
349
|
+
if (!clean || !/^[A-Za-z0-9._:-]+$/.test(clean)) throw new Error('请输入正确的设备地址,例如 192.168.1.102:5555');
|
|
350
|
+
const finalAddress = clean.includes(':') ? clean : `${clean}:5555`;
|
|
351
|
+
const { stdout, stderr } = await runAdb(['connect', finalAddress], { timeout: 12_000 });
|
|
352
|
+
const output = String(stdout || stderr || '').trim();
|
|
353
|
+
const success = /connected to/i.test(output) && !/unable|failed|cannot/i.test(output);
|
|
354
|
+
return { success, message: output || '连接超时或被拒绝' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isMacOS() {
|
|
358
|
+
return process.platform === 'darwin';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function listIosSimulators() {
|
|
362
|
+
if (!isMacOS()) {
|
|
363
|
+
return { available: false, message: 'iOS 模拟器仅支持 macOS。', running: [], pending: [] };
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const { stdout } = await execFileAsync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], {
|
|
367
|
+
timeout: 20_000,
|
|
368
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
369
|
+
});
|
|
370
|
+
const data = JSON.parse(String(stdout));
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
const all = [];
|
|
373
|
+
for (const [runtime, simulators] of Object.entries(data.devices || {})) {
|
|
374
|
+
const version = String(runtime).match(/iOS-([\d-]+)$/)?.[1]?.replace(/-/g, '.') || '';
|
|
375
|
+
for (const simulator of simulators || []) {
|
|
376
|
+
if (simulator.isAvailable === false || seen.has(simulator.udid)) continue;
|
|
377
|
+
seen.add(simulator.udid);
|
|
378
|
+
all.push({
|
|
379
|
+
udid: simulator.udid,
|
|
380
|
+
name: simulator.name,
|
|
381
|
+
state: simulator.state,
|
|
382
|
+
version,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const versionScore = (version) => {
|
|
387
|
+
const parts = String(version || '0').split('.').map((part) => Number(part) || 0);
|
|
388
|
+
return (parts[0] || 0) * 10_000 + (parts[1] || 0) * 100 + (parts[2] || 0);
|
|
389
|
+
};
|
|
390
|
+
const byName = new Map();
|
|
391
|
+
for (const simulator of all) {
|
|
392
|
+
const previous = byName.get(simulator.name);
|
|
393
|
+
if (!previous) {
|
|
394
|
+
byName.set(simulator.name, simulator);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const score = (item) => (item.state === 'Booted' ? 1 : 0) * 1_000_000 + versionScore(item.version);
|
|
398
|
+
if (score(simulator) > score(previous)) byName.set(simulator.name, simulator);
|
|
399
|
+
}
|
|
400
|
+
const unique = [...byName.values()];
|
|
401
|
+
const rank = (name) => {
|
|
402
|
+
if (String(name).startsWith('iPhone')) return 0;
|
|
403
|
+
if (String(name).startsWith('iPad')) return 1;
|
|
404
|
+
if (String(name).startsWith('Apple TV')) return 2;
|
|
405
|
+
if (String(name).startsWith('Apple Watch')) return 3;
|
|
406
|
+
return 4;
|
|
407
|
+
};
|
|
408
|
+
const running = unique.filter((item) => item.state === 'Booted');
|
|
409
|
+
const pending = unique
|
|
410
|
+
.filter((item) => item.state !== 'Booted')
|
|
411
|
+
.sort((a, b) => rank(a.name) - rank(b.name) || String(a.name).localeCompare(String(b.name)));
|
|
412
|
+
return { available: true, running, pending };
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return { available: false, message: error.message, running: [], pending: [] };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const IOS_DEVICES_TTL_MS = 4000;
|
|
419
|
+
let iosDevicesCache = { at: 0, value: null };
|
|
420
|
+
|
|
421
|
+
async function listIosDevices() {
|
|
422
|
+
if (!isMacOS()) {
|
|
423
|
+
return { available: false, message: 'iOS 真机仅支持 macOS。', items: [] };
|
|
424
|
+
}
|
|
425
|
+
if (iosDevicesCache.value && Date.now() - iosDevicesCache.at < IOS_DEVICES_TTL_MS) {
|
|
426
|
+
return iosDevicesCache.value;
|
|
427
|
+
}
|
|
428
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'device-deck-ios-'));
|
|
429
|
+
const file = path.join(dir, 'devices.json');
|
|
430
|
+
try {
|
|
431
|
+
await execFileAsync('xcrun', ['devicectl', '--timeout', '5', 'list', 'devices', '--json-output', file], {
|
|
432
|
+
timeout: 25_000,
|
|
433
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
434
|
+
});
|
|
435
|
+
const data = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
436
|
+
const items = [];
|
|
437
|
+
for (const device of data?.result?.devices || []) {
|
|
438
|
+
const hardware = device.properties?.hardware || device.hardwareProperties || {};
|
|
439
|
+
const connection = device.properties?.connection || device.connectionProperties || {};
|
|
440
|
+
const software = device.properties?.software || {};
|
|
441
|
+
const deviceState = device.properties?.state || device.deviceProperties || {};
|
|
442
|
+
if (hardware.reality !== 'physical') continue;
|
|
443
|
+
if (String(hardware.platform) !== 'iOS') continue;
|
|
444
|
+
const udid = String(hardware.udid || '');
|
|
445
|
+
if (!udid) continue;
|
|
446
|
+
items.push({
|
|
447
|
+
udid,
|
|
448
|
+
name: String(deviceState.name || hardware.marketingName || udid),
|
|
449
|
+
model: String(hardware.marketingName || ''),
|
|
450
|
+
productType: String(hardware.productType || ''),
|
|
451
|
+
osVersion: String(software.osVersionNumber?.stringValue || deviceState.osVersionNumber || ''),
|
|
452
|
+
transport: connection.transportType === 'wired' ? 'USB' : '网络',
|
|
453
|
+
pairing: String(connection.pairingState || ''),
|
|
454
|
+
booted: deviceState.bootState === 'booted',
|
|
455
|
+
developerMode: Boolean(deviceState.developerModeStatus?.enabled),
|
|
456
|
+
mirroring: iosMirrorStatus(udid),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
items.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
460
|
+
const value = { available: true, items };
|
|
461
|
+
iosDevicesCache = { at: Date.now(), value };
|
|
462
|
+
return value;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const message = String(error?.stderr || error?.message || '读取 iOS 真机失败').trim();
|
|
465
|
+
return { available: false, message, items: [] };
|
|
466
|
+
} finally {
|
|
467
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function xcodeDeveloperDir() {
|
|
472
|
+
try {
|
|
473
|
+
return execFileSync('xcode-select', ['-p'], { encoding: 'utf8', timeout: 4000 }).trim();
|
|
474
|
+
} catch {
|
|
475
|
+
return '';
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function simulatorAppCandidates() {
|
|
480
|
+
const candidates = [];
|
|
481
|
+
const override = String(process.env.DSH_DEVICE_DECK_SIMULATOR_APP || '').trim();
|
|
482
|
+
if (override) candidates.push({ path: override, label: 'Simulator' });
|
|
483
|
+
const developer = xcodeDeveloperDir();
|
|
484
|
+
if (developer) {
|
|
485
|
+
candidates.push({ path: path.join(developer, 'Applications', 'Simulator.app'), label: 'Simulator' });
|
|
486
|
+
candidates.push({ path: path.join(developer, '..', 'Applications', 'DeviceHub.app'), label: 'Device Hub' });
|
|
487
|
+
}
|
|
488
|
+
candidates.push({ path: '/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app', label: 'Simulator' });
|
|
489
|
+
return candidates;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function openSimulatorApp() {
|
|
493
|
+
if (!isMacOS()) throw new Error('iOS 模拟器仅支持 macOS。');
|
|
494
|
+
const target = simulatorAppCandidates().find((item) => fs.existsSync(item.path));
|
|
495
|
+
if (!target) {
|
|
496
|
+
throw new Error('当前 Xcode 未提供 Simulator 应用。请从 Xcode 打开模拟器,或设置 DSH_DEVICE_DECK_SIMULATOR_APP 指向模拟器应用。');
|
|
497
|
+
}
|
|
498
|
+
await execFileAsync('open', ['-a', target.path], { timeout: 10_000 });
|
|
499
|
+
return target.label;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function bootIosSimulator(udid) {
|
|
503
|
+
if (!isMacOS()) throw new Error('iOS 模拟器仅支持 macOS。');
|
|
504
|
+
const clean = String(udid || '').trim();
|
|
505
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(clean)) throw new Error('模拟器 UDID 不正确');
|
|
506
|
+
try {
|
|
507
|
+
await execFileAsync('xcrun', ['simctl', 'boot', clean], { timeout: 30_000 });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
const message = String(error.stderr || error.message || '');
|
|
510
|
+
if (!/Booted|current state/i.test(message)) throw new Error(message || '启动 iOS 模拟器失败');
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
await openSimulatorApp();
|
|
514
|
+
} catch {
|
|
515
|
+
/* the device can still boot headless */
|
|
516
|
+
}
|
|
517
|
+
return { success: true, message: '已启动 iOS 模拟器' };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function shutdownIosSimulator(udid) {
|
|
521
|
+
if (!isMacOS()) throw new Error('iOS 模拟器仅支持 macOS。');
|
|
522
|
+
const clean = String(udid || '').trim();
|
|
523
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(clean)) throw new Error('模拟器 UDID 不正确');
|
|
524
|
+
try {
|
|
525
|
+
await execFileAsync('xcrun', ['simctl', 'shutdown', clean], { timeout: 20_000 });
|
|
526
|
+
} catch (error) {
|
|
527
|
+
const message = String(error.stderr || error.message || '');
|
|
528
|
+
if (!/current state|Shutdown/i.test(message)) throw new Error(message || '关闭 iOS 模拟器失败');
|
|
529
|
+
}
|
|
530
|
+
return { success: true, message: '已关闭 iOS 模拟器' };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function openIosSimulator() {
|
|
534
|
+
const label = await openSimulatorApp();
|
|
535
|
+
return { success: true, message: `已打开 ${label}` };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const IOS_WDA_DIR = path.join(DATA_DIR, 'wda');
|
|
539
|
+
const IOS_WDA_DEVICE_PORT = 8100;
|
|
540
|
+
const IOS_WDA_MJPEG_PORT = 9100;
|
|
541
|
+
const IOS_WDA_BOOT_TIMEOUT_MS = 180_000;
|
|
542
|
+
const IOS_WDA_BUILD_TIMEOUT_MS = 900_000;
|
|
543
|
+
const IOS_WDA_IDLE_MS = 600_000;
|
|
544
|
+
|
|
545
|
+
const iosMirrors = new Map();
|
|
546
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
547
|
+
|
|
548
|
+
function resolveWdaProject() {
|
|
549
|
+
const override = String(process.env.DSH_DEVICE_DECK_WDA_PROJECT || '').trim();
|
|
550
|
+
if (override) return override;
|
|
551
|
+
try {
|
|
552
|
+
return path.join(path.dirname(requireFromHere.resolve('appium-webdriveragent/package.json')), 'WebDriverAgent.xcodeproj');
|
|
553
|
+
} catch {
|
|
554
|
+
return '';
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function resolveWdaBundleId() {
|
|
559
|
+
const override = String(process.env.DSH_DEVICE_DECK_WDA_BUNDLE_ID || '').trim();
|
|
560
|
+
return override || 'com.dsh.devicedeck.wda';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function resolveIosTeam() {
|
|
564
|
+
const override = String(process.env.DSH_DEVICE_DECK_IOS_TEAM || '').trim();
|
|
565
|
+
if (override) return override;
|
|
566
|
+
if (!isMacOS()) return '';
|
|
567
|
+
try {
|
|
568
|
+
const plist = path.join(os.tmpdir(), `device-deck-xcode-${process.pid}.plist`);
|
|
569
|
+
execFileSync('defaults', ['export', 'com.apple.dt.Xcode', plist], { timeout: 5000 });
|
|
570
|
+
const plistJson = execFileSync('plutil', ['-extract', 'IDEProvisioningTeams', 'json', '-o', '-', plist], {
|
|
571
|
+
timeout: 5000,
|
|
572
|
+
encoding: 'utf8',
|
|
573
|
+
});
|
|
574
|
+
fs.rmSync(plist, { force: true });
|
|
575
|
+
const teams = JSON.parse(plistJson);
|
|
576
|
+
const flat = Object.values(teams || {}).flat().filter((team) => team && team.teamID);
|
|
577
|
+
const personal = flat.find((team) => team.isFreeProvisioningTeam);
|
|
578
|
+
return personal?.teamID || flat[0]?.teamID || '';
|
|
579
|
+
} catch (error) {
|
|
580
|
+
console.warn('[device-deck] 无法读取 Xcode 签名团队', error?.message || error);
|
|
581
|
+
return '';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function resolveIproxy() {
|
|
586
|
+
const override = String(process.env.DSH_DEVICE_DECK_IPROXY || '').trim();
|
|
587
|
+
if (override) return override;
|
|
588
|
+
for (const candidate of ['/opt/homebrew/bin/iproxy', '/usr/local/bin/iproxy', '/usr/bin/iproxy']) {
|
|
589
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
590
|
+
}
|
|
591
|
+
return 'iproxy';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function reservePort() {
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
const server = createNetServer();
|
|
597
|
+
server.once('error', reject);
|
|
598
|
+
server.listen(0, '127.0.0.1', () => {
|
|
599
|
+
const address = server.address();
|
|
600
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
601
|
+
server.close(() => (port ? resolve(port) : reject(new Error('无法分配本地端口'))));
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function waitFor(check, timeout, message, fatal) {
|
|
607
|
+
const deadline = Date.now() + timeout;
|
|
608
|
+
while (Date.now() < deadline) {
|
|
609
|
+
try {
|
|
610
|
+
if (await check()) return true;
|
|
611
|
+
} catch {
|
|
612
|
+
/* keep polling until the deadline */
|
|
613
|
+
}
|
|
614
|
+
const fatalError = fatal?.();
|
|
615
|
+
if (fatalError) throw fatalError;
|
|
616
|
+
await delay(700);
|
|
617
|
+
}
|
|
618
|
+
throw new Error(message);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function runCommand(command, args, options = {}) {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
624
|
+
const lines = [];
|
|
625
|
+
const collect = (chunk) => {
|
|
626
|
+
for (const raw of String(chunk).split('\n')) {
|
|
627
|
+
const line = raw.trim();
|
|
628
|
+
if (!line) continue;
|
|
629
|
+
lines.push(line);
|
|
630
|
+
if (lines.length > 240) lines.shift();
|
|
631
|
+
options.onLine?.(line);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
child.stdout.on('data', collect);
|
|
635
|
+
child.stderr.on('data', collect);
|
|
636
|
+
const timer = setTimeout(() => {
|
|
637
|
+
child.kill('SIGTERM');
|
|
638
|
+
reject(new Error(`${command} 超时。${lines.slice(-3).join(' ')}`));
|
|
639
|
+
}, options.timeout ?? 60_000);
|
|
640
|
+
child.on('error', (error) => {
|
|
641
|
+
clearTimeout(timer);
|
|
642
|
+
reject(error);
|
|
643
|
+
});
|
|
644
|
+
child.on('close', (code) => {
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
if (code === 0) {
|
|
647
|
+
resolve(lines);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const locked = lines.some((line) => /device is locked|Unlock .* to Continue/i.test(line));
|
|
651
|
+
if (locked) {
|
|
652
|
+
reject(new Error('iPhone 已锁屏。请解锁设备并保持亮屏,然后重试。'));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const errorLine = [...lines].reverse().find((line) => /error:/i.test(line));
|
|
656
|
+
reject(new Error(`${command} 退出码 ${code}。${errorLine || lines.slice(-4).join(' ')}`));
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function findXctestrun(derived) {
|
|
662
|
+
const products = path.join(derived, 'Build', 'Products');
|
|
663
|
+
try {
|
|
664
|
+
const names = await fsp.readdir(products);
|
|
665
|
+
const file = names.find((name) => name.endsWith('.xctestrun'));
|
|
666
|
+
return file ? path.join(products, file) : '';
|
|
667
|
+
} catch {
|
|
668
|
+
return '';
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
class IosMirrorSession {
|
|
673
|
+
constructor(udid, kind = 'device') {
|
|
674
|
+
this.udid = udid;
|
|
675
|
+
this.kind = kind === 'simulator' ? 'simulator' : 'device';
|
|
676
|
+
this.status = 'idle';
|
|
677
|
+
this.message = '';
|
|
678
|
+
this.process = null;
|
|
679
|
+
this.forward = null;
|
|
680
|
+
this.mjpegForward = null;
|
|
681
|
+
this.port = 0;
|
|
682
|
+
this.mjpegPort = 0;
|
|
683
|
+
this.sessionId = '';
|
|
684
|
+
this.screen = { width: 390, height: 844 };
|
|
685
|
+
this.xctestrun = '';
|
|
686
|
+
this.output = [];
|
|
687
|
+
this.starting = null;
|
|
688
|
+
this.idleTimer = null;
|
|
689
|
+
this.streams = 0;
|
|
690
|
+
this.markerPath = '';
|
|
691
|
+
this.resigned = false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
pushLog(line) {
|
|
695
|
+
this.output.push(line);
|
|
696
|
+
if (this.output.length > 240) this.output.shift();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
touch() {
|
|
700
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
701
|
+
this.idleTimer = setTimeout(() => {
|
|
702
|
+
if (this.streams > 0) {
|
|
703
|
+
this.touch();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (this.status === 'ready') void this.stop();
|
|
707
|
+
}, IOS_WDA_IDLE_MS);
|
|
708
|
+
this.idleTimer.unref?.();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
ensure() {
|
|
712
|
+
if (this.status === 'ready') {
|
|
713
|
+
this.touch();
|
|
714
|
+
return Promise.resolve(this);
|
|
715
|
+
}
|
|
716
|
+
if (this.starting) return this.starting;
|
|
717
|
+
this.starting = this.start()
|
|
718
|
+
.catch((error) => {
|
|
719
|
+
this.status = 'error';
|
|
720
|
+
this.message = String(error?.message || error);
|
|
721
|
+
throw error;
|
|
722
|
+
})
|
|
723
|
+
.finally(() => {
|
|
724
|
+
this.starting = null;
|
|
725
|
+
});
|
|
726
|
+
return this.starting;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async start() {
|
|
730
|
+
this.status = 'starting';
|
|
731
|
+
this.message = '正在准备 WebDriverAgent…';
|
|
732
|
+
const project = resolveWdaProject();
|
|
733
|
+
if (!project) throw new Error('未找到 WebDriverAgent 工程。请安装 appium-webdriveragent,或设置 DSH_DEVICE_DECK_WDA_PROJECT。');
|
|
734
|
+
if (!fs.existsSync(project)) throw new Error(`WebDriverAgent 工程不存在:${project}`);
|
|
735
|
+
await fsp.mkdir(IOS_WDA_DIR, { recursive: true });
|
|
736
|
+
try {
|
|
737
|
+
await this.boot(project);
|
|
738
|
+
this.status = 'ready';
|
|
739
|
+
this.message = '';
|
|
740
|
+
this.touch();
|
|
741
|
+
return this;
|
|
742
|
+
} catch (error) {
|
|
743
|
+
await this.teardown();
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async boot(project) {
|
|
749
|
+
this.xctestrun = await this.prepareBuild(project);
|
|
750
|
+
const attempt = async () => {
|
|
751
|
+
this.message = '正在启动 WebDriverAgent…';
|
|
752
|
+
await this.launch();
|
|
753
|
+
this.message = '正在建立会话…';
|
|
754
|
+
await this.openSession();
|
|
755
|
+
};
|
|
756
|
+
try {
|
|
757
|
+
await attempt();
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (this.resigned || !this.profileExpired()) throw error;
|
|
760
|
+
this.resigned = true;
|
|
761
|
+
this.message = '签名已过期,正在重新构建 WebDriverAgent…';
|
|
762
|
+
await this.teardown();
|
|
763
|
+
this.output = [];
|
|
764
|
+
await fsp.rm(this.markerPath, { force: true }).catch(() => undefined);
|
|
765
|
+
this.xctestrun = await this.prepareBuild(project);
|
|
766
|
+
await attempt();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
profileExpired() {
|
|
771
|
+
return this.output.some((line) => /has expired|profile.*expired|expired.*profile/i.test(line));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async prepareBuild(project) {
|
|
775
|
+
const simulator = this.kind === 'simulator';
|
|
776
|
+
const bundleId = resolveWdaBundleId();
|
|
777
|
+
let team = '';
|
|
778
|
+
if (!simulator) {
|
|
779
|
+
team = resolveIosTeam();
|
|
780
|
+
if (!team) throw new Error('未找到 iOS 签名团队。请在 Xcode 登录 Apple ID,或设置 DSH_DEVICE_DECK_IOS_TEAM。');
|
|
781
|
+
}
|
|
782
|
+
const derived = path.join(IOS_WDA_DIR, simulator ? 'DerivedData-sim' : 'DerivedData');
|
|
783
|
+
const marker = path.join(IOS_WDA_DIR, simulator ? `build-sim-${bundleId}.json` : `build-${team}-${bundleId}.json`);
|
|
784
|
+
this.markerPath = marker;
|
|
785
|
+
const existing = await findXctestrun(derived);
|
|
786
|
+
if (existing && fs.existsSync(marker)) return existing;
|
|
787
|
+
const args = [
|
|
788
|
+
'build-for-testing',
|
|
789
|
+
'-project', project,
|
|
790
|
+
'-scheme', 'WebDriverAgentRunner',
|
|
791
|
+
'-destination', `id=${this.udid}`,
|
|
792
|
+
'-derivedDataPath', derived,
|
|
793
|
+
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
|
|
794
|
+
];
|
|
795
|
+
if (!simulator) {
|
|
796
|
+
args.push('-allowProvisioningUpdates', `DEVELOPMENT_TEAM=${team}`, 'CODE_SIGN_STYLE=Automatic');
|
|
797
|
+
}
|
|
798
|
+
this.message = '正在构建 WebDriverAgent(首次约 2–5 分钟)…';
|
|
799
|
+
await runCommand('xcodebuild', args, {
|
|
800
|
+
cwd: path.dirname(project),
|
|
801
|
+
timeout: IOS_WDA_BUILD_TIMEOUT_MS,
|
|
802
|
+
onLine: (line) => this.pushLog(line),
|
|
803
|
+
});
|
|
804
|
+
const built = await findXctestrun(derived);
|
|
805
|
+
if (!built) throw new Error('WebDriverAgent 构建结束,但未找到 xctestrun 产物。');
|
|
806
|
+
await fsp.writeFile(marker, JSON.stringify({ at: Date.now(), team, project, bundleId, kind: this.kind }));
|
|
807
|
+
return built;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async launch() {
|
|
811
|
+
const simulator = this.kind === 'simulator';
|
|
812
|
+
this.port = await reservePort();
|
|
813
|
+
this.mjpegPort = await reservePort();
|
|
814
|
+
if (simulator) {
|
|
815
|
+
this.forward = null;
|
|
816
|
+
this.mjpegForward = null;
|
|
817
|
+
} else {
|
|
818
|
+
const iproxy = resolveIproxy();
|
|
819
|
+
if (iproxy.includes(path.sep) && !fs.existsSync(iproxy)) {
|
|
820
|
+
throw new Error(`未找到 iproxy:${iproxy}。请安装 libimobiledevice。`);
|
|
821
|
+
}
|
|
822
|
+
this.forward = spawn(iproxy, [String(this.port), String(IOS_WDA_DEVICE_PORT), '-u', this.udid], {
|
|
823
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
824
|
+
});
|
|
825
|
+
this.mjpegForward = spawn(iproxy, [String(this.mjpegPort), String(IOS_WDA_MJPEG_PORT), '-u', this.udid], {
|
|
826
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
827
|
+
});
|
|
828
|
+
this.forward.on('error', () => undefined);
|
|
829
|
+
this.mjpegForward.on('error', () => undefined);
|
|
830
|
+
}
|
|
831
|
+
const env = {
|
|
832
|
+
...process.env,
|
|
833
|
+
TEST_RUNNER_USE_PORT: simulator ? String(this.port) : String(IOS_WDA_DEVICE_PORT),
|
|
834
|
+
TEST_RUNNER_MJPEG_SERVER_PORT: simulator ? String(this.mjpegPort) : String(IOS_WDA_MJPEG_PORT),
|
|
835
|
+
};
|
|
836
|
+
this.process = spawn('xcodebuild', ['test-without-building', '-xctestrun', this.xctestrun, '-destination', `id=${this.udid}`], {
|
|
837
|
+
env,
|
|
838
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
839
|
+
});
|
|
840
|
+
const collect = (chunk) => {
|
|
841
|
+
for (const line of String(chunk).split('\n')) {
|
|
842
|
+
const text = line.trim();
|
|
843
|
+
if (text) this.pushLog(text);
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
this.process.stdout.on('data', collect);
|
|
847
|
+
this.process.stderr.on('data', collect);
|
|
848
|
+
this.process.on('exit', () => {
|
|
849
|
+
if (this.status === 'ready') {
|
|
850
|
+
this.status = 'error';
|
|
851
|
+
this.message = 'WebDriverAgent 已退出。';
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
const lockedHint = () => (this.output.some((line) => /device is locked|Unlock .* to Continue/i.test(line))
|
|
855
|
+
? new Error('iPhone 已锁屏。请解锁设备并保持亮屏,然后重试。')
|
|
856
|
+
: null);
|
|
857
|
+
try {
|
|
858
|
+
await waitFor(async () => {
|
|
859
|
+
const data = await this.request('GET', '/status', null, 4000);
|
|
860
|
+
return Boolean(data?.value?.ready);
|
|
861
|
+
}, IOS_WDA_BOOT_TIMEOUT_MS, `WebDriverAgent 启动超时。${this.output.slice(-3).join(' ')}`, lockedHint);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (/not been explicitly trusted/i.test(this.output.join('\n'))) {
|
|
864
|
+
throw new Error('开发者证书未受信任。请到「设置 → 通用 → VPN 与设备管理」信任该证书。');
|
|
865
|
+
}
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async openSession() {
|
|
871
|
+
const payload = {
|
|
872
|
+
capabilities: {
|
|
873
|
+
alwaysMatch: {
|
|
874
|
+
platformName: 'ios',
|
|
875
|
+
'appium:automationName': 'XCUITest',
|
|
876
|
+
'appium:udid': this.udid,
|
|
877
|
+
'appium:noReset': true,
|
|
878
|
+
'appium:newCommandTimeout': 900,
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
const data = await this.request('POST', '/session', payload, 120_000);
|
|
883
|
+
this.sessionId = String(data?.value?.sessionId || data?.sessionId || '');
|
|
884
|
+
if (!this.sessionId) throw new Error('WebDriverAgent 会话创建失败。');
|
|
885
|
+
try {
|
|
886
|
+
await this.request('POST', `/session/${this.sessionId}/appium/settings`, {
|
|
887
|
+
settings: { mjpegServerFramerate: 24, mjpegServerScreenshotQuality: 60 },
|
|
888
|
+
}, 15_000);
|
|
889
|
+
} catch {
|
|
890
|
+
/* the stream still works with default settings */
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const data = await this.request('GET', `/session/${this.sessionId}/wda/screen`, null, 10_000);
|
|
894
|
+
const width = Number(data?.value?.screenSize?.width);
|
|
895
|
+
const height = Number(data?.value?.screenSize?.height);
|
|
896
|
+
if (width > 0 && height > 0) this.screen = { width, height };
|
|
897
|
+
} catch {
|
|
898
|
+
/* fall back to the default point size */
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async request(method, route, body, timeout = 20_000) {
|
|
903
|
+
const response = await fetch(`http://127.0.0.1:${this.port}${route}`, {
|
|
904
|
+
method,
|
|
905
|
+
headers: body === null ? undefined : { 'Content-Type': 'application/json' },
|
|
906
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
907
|
+
signal: AbortSignal.timeout(timeout),
|
|
908
|
+
cache: 'no-store',
|
|
909
|
+
});
|
|
910
|
+
const text = await response.text();
|
|
911
|
+
let data = null;
|
|
912
|
+
try {
|
|
913
|
+
data = text ? JSON.parse(text) : null;
|
|
914
|
+
} catch {
|
|
915
|
+
data = null;
|
|
916
|
+
}
|
|
917
|
+
if (!response.ok) throw new Error(data?.value?.message || `WebDriverAgent 返回 HTTP ${response.status}`);
|
|
918
|
+
return data;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
ratioPoint(ratioX, ratioY) {
|
|
922
|
+
const clamp = (value) => Math.max(0, Math.min(1, Number(value) || 0));
|
|
923
|
+
return {
|
|
924
|
+
x: Math.round(clamp(ratioX) * this.screen.width),
|
|
925
|
+
y: Math.round(clamp(ratioY) * this.screen.height),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async perform(action) {
|
|
930
|
+
if (this.status !== 'ready') await this.ensure();
|
|
931
|
+
this.touch();
|
|
932
|
+
const type = String(action?.type || '');
|
|
933
|
+
const base = `/session/${this.sessionId}`;
|
|
934
|
+
if (type === 'tap') {
|
|
935
|
+
const point = this.ratioPoint(action.ratioX, action.ratioY);
|
|
936
|
+
await this.request('POST', `${base}/wda/tap`, point, 20_000);
|
|
937
|
+
return { success: true };
|
|
938
|
+
}
|
|
939
|
+
if (type === 'swipe') {
|
|
940
|
+
const from = this.ratioPoint(action.ratioX, action.ratioY);
|
|
941
|
+
const to = this.ratioPoint(action.ratioEndX, action.ratioEndY);
|
|
942
|
+
await this.request('POST', `${base}/wda/dragfromtoforduration`, {
|
|
943
|
+
fromX: from.x,
|
|
944
|
+
fromY: from.y,
|
|
945
|
+
toX: to.x,
|
|
946
|
+
toY: to.y,
|
|
947
|
+
duration: Math.max(0.1, Math.min(2, Number(action.duration || 300) / 1000)),
|
|
948
|
+
}, 30_000);
|
|
949
|
+
return { success: true };
|
|
950
|
+
}
|
|
951
|
+
if (type === 'text') {
|
|
952
|
+
const value = String(action.text || '');
|
|
953
|
+
if (!value) return { success: true };
|
|
954
|
+
await this.request('POST', `${base}/wda/keys`, { value: Array.from(value) }, 30_000);
|
|
955
|
+
return { success: true };
|
|
956
|
+
}
|
|
957
|
+
if (type === 'home') {
|
|
958
|
+
await this.request('POST', `${base}/wda/pressButton`, { name: 'home' }, 15_000);
|
|
959
|
+
return { success: true };
|
|
960
|
+
}
|
|
961
|
+
if (type === 'screenshot') {
|
|
962
|
+
return { success: true, shot: await this.capture() };
|
|
963
|
+
}
|
|
964
|
+
throw new Error(`iOS 不支持的动作:${type || 'unknown'}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async capture() {
|
|
968
|
+
const data = await this.request('GET', `/session/${this.sessionId}/screenshot`, null, 20_000);
|
|
969
|
+
const base64 = data?.value;
|
|
970
|
+
if (typeof base64 !== 'string' || base64.length === 0) throw new Error('iOS 截图失败:未返回图像');
|
|
971
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
972
|
+
const filePath = path.join(SHOT_DIR, `device-${safeSerial(this.udid)}-${Date.now()}.png`);
|
|
973
|
+
await fsp.writeFile(filePath, buffer);
|
|
974
|
+
state.selected = filePath;
|
|
975
|
+
saveState();
|
|
976
|
+
return readShotMeta(filePath);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async teardown() {
|
|
980
|
+
if (this.idleTimer) {
|
|
981
|
+
clearTimeout(this.idleTimer);
|
|
982
|
+
this.idleTimer = null;
|
|
983
|
+
}
|
|
984
|
+
for (const child of [this.process, this.forward, this.mjpegForward]) {
|
|
985
|
+
if (child && child.exitCode === null) {
|
|
986
|
+
try {
|
|
987
|
+
child.kill('SIGTERM');
|
|
988
|
+
} catch {
|
|
989
|
+
/* already gone */
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
this.process = null;
|
|
994
|
+
this.forward = null;
|
|
995
|
+
this.mjpegForward = null;
|
|
996
|
+
this.sessionId = '';
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async stop() {
|
|
1000
|
+
await this.teardown();
|
|
1001
|
+
this.status = 'idle';
|
|
1002
|
+
this.message = '';
|
|
1003
|
+
iosMirrors.delete(this.udid);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function getIosMirror(udid, kind = 'device') {
|
|
1008
|
+
let mirror = iosMirrors.get(udid);
|
|
1009
|
+
if (!mirror) {
|
|
1010
|
+
mirror = new IosMirrorSession(udid, kind);
|
|
1011
|
+
iosMirrors.set(udid, mirror);
|
|
1012
|
+
}
|
|
1013
|
+
return mirror;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function iosMirrorStatus(udid) {
|
|
1017
|
+
const mirror = iosMirrors.get(udid);
|
|
1018
|
+
if (!mirror) return { status: 'idle', message: '' };
|
|
1019
|
+
return { status: mirror.status, message: mirror.message };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function iosUdid(value) {
|
|
1023
|
+
const clean = String(value || '').trim();
|
|
1024
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(clean)) throw new Error('iOS 设备 UDID 不正确');
|
|
1025
|
+
return clean;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function handleIosMirrorStart(req, res) {
|
|
1029
|
+
if (req.method !== 'POST') {
|
|
1030
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const body = await readJson(req);
|
|
1034
|
+
const mirror = getIosMirror(iosUdid(body.udid), body.kind);
|
|
1035
|
+
await mirror.ensure();
|
|
1036
|
+
sendJson(res, 200, { success: true, status: mirror.status, kind: mirror.kind, screen: mirror.screen });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function handleIosMirrorStop(req, res) {
|
|
1040
|
+
if (req.method !== 'POST') {
|
|
1041
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const body = await readJson(req);
|
|
1045
|
+
const udid = iosUdid(body.udid);
|
|
1046
|
+
const mirror = iosMirrors.get(udid);
|
|
1047
|
+
if (mirror) await mirror.stop();
|
|
1048
|
+
sendJson(res, 200, { success: true });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function handleIosMirrorStatus(req, res) {
|
|
1052
|
+
const query = routeQuery(req);
|
|
1053
|
+
const udid = iosUdid(query.get('udid'));
|
|
1054
|
+
const mirror = iosMirrors.get(udid);
|
|
1055
|
+
sendJson(res, 200, {
|
|
1056
|
+
success: true,
|
|
1057
|
+
status: mirror?.status || 'idle',
|
|
1058
|
+
message: mirror?.message || '',
|
|
1059
|
+
screen: mirror?.screen || null,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function handleIosMirrorStream(req, res) {
|
|
1064
|
+
const query = routeQuery(req);
|
|
1065
|
+
const mirror = getIosMirror(iosUdid(query.get('udid')), String(query.get('kind') || 'device'));
|
|
1066
|
+
await mirror.ensure();
|
|
1067
|
+
const response = await fetch(`http://127.0.0.1:${mirror.mjpegPort}/`, { cache: 'no-store' });
|
|
1068
|
+
if (!response.ok || !response.body) throw new Error('读取 iOS 画面失败');
|
|
1069
|
+
res.writeHead(200, {
|
|
1070
|
+
'Content-Type': response.headers.get('content-type') || 'multipart/x-mixed-replace; boundary=--BoundaryString',
|
|
1071
|
+
'Cache-Control': 'no-store, no-transform',
|
|
1072
|
+
Connection: 'keep-alive',
|
|
1073
|
+
'X-Accel-Buffering': 'no',
|
|
1074
|
+
});
|
|
1075
|
+
res.flushHeaders?.();
|
|
1076
|
+
mirror.streams += 1;
|
|
1077
|
+
const source = Readable.fromWeb(response.body);
|
|
1078
|
+
res.on('close', () => {
|
|
1079
|
+
source.destroy();
|
|
1080
|
+
mirror.streams = Math.max(0, mirror.streams - 1);
|
|
1081
|
+
mirror.touch();
|
|
1082
|
+
});
|
|
1083
|
+
source.on('error', () => {
|
|
1084
|
+
if (!res.writableEnded) res.end();
|
|
1085
|
+
});
|
|
1086
|
+
source.pipe(res);
|
|
1087
|
+
mirror.touch();
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function handleIosAction(req, res) {
|
|
1091
|
+
if (req.method !== 'POST') {
|
|
1092
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const body = await readJson(req);
|
|
1096
|
+
const mirror = getIosMirror(iosUdid(body.udid), body.kind);
|
|
1097
|
+
const result = await mirror.perform(body.action || {});
|
|
1098
|
+
sendJson(res, 200, { ...result, udid: mirror.udid });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function handleIosScreenshot(req, res) {
|
|
1102
|
+
if (req.method !== 'POST') {
|
|
1103
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const body = await readJson(req);
|
|
1107
|
+
const mirror = getIosMirror(iosUdid(body.udid), body.kind);
|
|
1108
|
+
if (mirror.status !== 'ready') await mirror.ensure();
|
|
1109
|
+
const shot = await mirror.capture();
|
|
1110
|
+
await pruneShots(mirror.udid);
|
|
1111
|
+
sendJson(res, 200, { success: true, udid: mirror.udid, shot });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function handleInventory(req, res) {
|
|
1115
|
+
const [adbResult, devicesResult, avdsResult, runningResult, iosResult, iosDevicesResult] = await Promise.allSettled([
|
|
1116
|
+
adbStatus(),
|
|
1117
|
+
listDevices(),
|
|
1118
|
+
listAvds(),
|
|
1119
|
+
runningEmulatorProcesses(),
|
|
1120
|
+
listIosSimulators(),
|
|
1121
|
+
listIosDevices(),
|
|
1122
|
+
]);
|
|
1123
|
+
const adb = adbResult.status === 'fulfilled' ? adbResult.value : { available: false, error: adbErrorHint(adbResult.reason) };
|
|
1124
|
+
const devices = devicesResult.status === 'fulfilled' ? devicesResult.value : [];
|
|
1125
|
+
const avds = avdsResult.status === 'fulfilled' ? avdsResult.value : { available: false, avds: [], message: adbErrorHint(avdsResult.reason) };
|
|
1126
|
+
const processes = runningResult.status === 'fulfilled' ? runningResult.value : [];
|
|
1127
|
+
const ios = iosResult.status === 'fulfilled' ? iosResult.value : { available: false, message: '读取 iOS 模拟器失败', running: [], pending: [] };
|
|
1128
|
+
ios.devices = iosDevicesResult.status === 'fulfilled'
|
|
1129
|
+
? iosDevicesResult.value
|
|
1130
|
+
: { available: false, message: '读取 iOS 真机失败', items: [] };
|
|
1131
|
+
|
|
1132
|
+
const online = devices.filter((device) => device.state === 'device');
|
|
1133
|
+
const emulators = online.filter((device) => device.isEmulator);
|
|
1134
|
+
const realDevices = online.filter((device) => !device.isEmulator);
|
|
1135
|
+
const avdPairs = await Promise.all(emulators.map(async (device) => ({ serial: device.serial, avd: await avdNameForSerial(device.serial) })));
|
|
1136
|
+
const avdBySerial = new Map(avdPairs.map((pair) => [pair.serial, pair.avd]));
|
|
1137
|
+
|
|
1138
|
+
const running = [];
|
|
1139
|
+
for (const device of emulators) {
|
|
1140
|
+
const avd = avdBySerial.get(device.serial) || '';
|
|
1141
|
+
const process = processes.find((item) => item.serial === device.serial) || (avd ? processes.find((item) => item.avd === avd) : undefined);
|
|
1142
|
+
running.push({
|
|
1143
|
+
platform: 'android',
|
|
1144
|
+
name: avd || device.model || device.serial,
|
|
1145
|
+
serial: device.serial,
|
|
1146
|
+
avd,
|
|
1147
|
+
pid: process?.pid ?? null,
|
|
1148
|
+
state: 'booted',
|
|
1149
|
+
kind: '模拟器',
|
|
1150
|
+
uptimeSeconds: process?.uptimeSeconds ?? null,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
for (const process of processes) {
|
|
1154
|
+
if (process.serial && running.some((item) => item.serial === process.serial)) continue;
|
|
1155
|
+
if (running.some((item) => item.avd === process.avd)) continue;
|
|
1156
|
+
running.push({
|
|
1157
|
+
platform: 'android',
|
|
1158
|
+
name: process.avd,
|
|
1159
|
+
serial: '',
|
|
1160
|
+
avd: process.avd,
|
|
1161
|
+
pid: process.pid,
|
|
1162
|
+
state: 'starting',
|
|
1163
|
+
kind: '模拟器',
|
|
1164
|
+
uptimeSeconds: process.uptimeSeconds,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
for (const device of realDevices) {
|
|
1168
|
+
running.push({
|
|
1169
|
+
platform: 'android',
|
|
1170
|
+
name: device.model || device.serial,
|
|
1171
|
+
serial: device.serial,
|
|
1172
|
+
avd: '',
|
|
1173
|
+
pid: null,
|
|
1174
|
+
state: 'booted',
|
|
1175
|
+
kind: '真机',
|
|
1176
|
+
uptimeSeconds: null,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
running.sort((a, b) => {
|
|
1180
|
+
if (a.state !== b.state) return a.state === 'booted' ? -1 : 1;
|
|
1181
|
+
const rank = (item) => (item.kind === '模拟器' ? 0 : 1);
|
|
1182
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
1183
|
+
return String(a.name).localeCompare(String(b.name));
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
const runningAvds = new Set(running.map((item) => item.avd).filter(Boolean));
|
|
1187
|
+
const pending = (avds.avds || []).filter((name) => !runningAvds.has(name));
|
|
1188
|
+
|
|
1189
|
+
sendJson(res, 200, {
|
|
1190
|
+
adb,
|
|
1191
|
+
android: {
|
|
1192
|
+
running,
|
|
1193
|
+
avds: {
|
|
1194
|
+
available: Boolean(avds.available),
|
|
1195
|
+
items: avds.avds || [],
|
|
1196
|
+
pending,
|
|
1197
|
+
message: avds.message,
|
|
1198
|
+
},
|
|
1199
|
+
},
|
|
1200
|
+
ios,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function handleEmulatorStart(req, res) {
|
|
1205
|
+
if (req.method !== 'POST') {
|
|
1206
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const body = await readJson(req);
|
|
1210
|
+
const result = await launchAvd(body.name);
|
|
1211
|
+
sendJson(res, 200, result);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async function handleEmulatorStop(req, res) {
|
|
1215
|
+
if (req.method !== 'POST') {
|
|
1216
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const body = await readJson(req);
|
|
1220
|
+
const result = await stopEmulator({ serial: body.serial, pid: body.pid });
|
|
1221
|
+
sendJson(res, 200, result);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function handleWifiConnect(req, res) {
|
|
1225
|
+
if (req.method !== 'POST') {
|
|
1226
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const body = await readJson(req);
|
|
1230
|
+
const result = await connectWifiDevice(body.address);
|
|
1231
|
+
sendJson(res, 200, result);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function handleIosBoot(req, res) {
|
|
1235
|
+
if (req.method !== 'POST') {
|
|
1236
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const body = await readJson(req);
|
|
1240
|
+
const result = await bootIosSimulator(body.udid);
|
|
1241
|
+
sendJson(res, 200, result);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function handleIosShutdown(req, res) {
|
|
1245
|
+
if (req.method !== 'POST') {
|
|
1246
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const body = await readJson(req);
|
|
1250
|
+
const result = await shutdownIosSimulator(body.udid);
|
|
1251
|
+
sendJson(res, 200, result);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function handleIosOpen(req, res) {
|
|
1255
|
+
if (req.method !== 'POST') {
|
|
1256
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
await readJson(req);
|
|
1260
|
+
const result = await openIosSimulator();
|
|
1261
|
+
sendJson(res, 200, result);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const sizeCache = new Map();
|
|
1265
|
+
|
|
1266
|
+
async function displaySize(serial) {
|
|
1267
|
+
if (sizeCache.has(serial)) return sizeCache.get(serial);
|
|
1268
|
+
const { stdout } = await runAdb(['-s', serial, 'shell', 'wm', 'size'], { timeout: 4000 });
|
|
1269
|
+
const text = String(stdout);
|
|
1270
|
+
const override = text.match(/Override size:\s*(\d+)x(\d+)/i);
|
|
1271
|
+
const physical = text.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
1272
|
+
const match = override || physical;
|
|
1273
|
+
if (!match) throw new Error('无法读取设备分辨率');
|
|
1274
|
+
const size = { width: Number(match[1]), height: Number(match[2]) };
|
|
1275
|
+
sizeCache.set(serial, size);
|
|
1276
|
+
return size;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function toAdbInputText(text) {
|
|
1280
|
+
return String(text).replace(/([\\"'$`&|;<>()[\]{}*?!#~])|\s/g, (match, special) =>
|
|
1281
|
+
special ? `\\${special}` : '%s',
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function quoteForDeviceShell(value) {
|
|
1286
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const serialChains = new Map();
|
|
1290
|
+
|
|
1291
|
+
function serializeAction(serial, task) {
|
|
1292
|
+
const previous = serialChains.get(serial) || Promise.resolve();
|
|
1293
|
+
const next = previous.then(task, task);
|
|
1294
|
+
serialChains.set(serial, next.catch(() => undefined));
|
|
1295
|
+
return next;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function executeAction(serial, action) {
|
|
1299
|
+
const type = String(action?.type || '');
|
|
1300
|
+
return serializeAction(serial, async () => {
|
|
1301
|
+
switch (type) {
|
|
1302
|
+
case 'tap': {
|
|
1303
|
+
const size = await orientedSize(serial, action);
|
|
1304
|
+
const x = Math.round(clamp01(action.ratioX) * size.width);
|
|
1305
|
+
const y = Math.round(clamp01(action.ratioY) * size.height);
|
|
1306
|
+
await runAdb(['-s', serial, 'shell', 'input', 'tap', String(x), String(y)], { timeout: 5000 });
|
|
1307
|
+
return { success: true };
|
|
1308
|
+
}
|
|
1309
|
+
case 'swipe': {
|
|
1310
|
+
const size = await orientedSize(serial, action);
|
|
1311
|
+
const x1 = Math.round(clamp01(action.ratioX) * size.width);
|
|
1312
|
+
const y1 = Math.round(clamp01(action.ratioY) * size.height);
|
|
1313
|
+
const x2 = Math.round(clamp01(action.ratioEndX) * size.width);
|
|
1314
|
+
const y2 = Math.round(clamp01(action.ratioEndY) * size.height);
|
|
1315
|
+
const duration = Math.max(100, Math.min(1200, Math.round(Number(action.duration) || 260)));
|
|
1316
|
+
await runAdb(
|
|
1317
|
+
['-s', serial, 'shell', 'input', 'swipe', String(x1), String(y1), String(x2), String(y2), String(duration)],
|
|
1318
|
+
{ timeout: 6000 },
|
|
1319
|
+
);
|
|
1320
|
+
return { success: true };
|
|
1321
|
+
}
|
|
1322
|
+
case 'key': {
|
|
1323
|
+
const keyMap = { back: 4, home: 3, recents: 187, power: 26, enter: 66, menu: 82, volumeup: 24, volumedown: 25 };
|
|
1324
|
+
const code = keyMap[String(action.key || '').toLowerCase()] || 4;
|
|
1325
|
+
await runAdb(['-s', serial, 'shell', 'input', 'keyevent', String(code)], { timeout: 5000 });
|
|
1326
|
+
return { success: true };
|
|
1327
|
+
}
|
|
1328
|
+
case 'text': {
|
|
1329
|
+
const text = String(action.text || '');
|
|
1330
|
+
if (!text) throw new Error('输入文本为空');
|
|
1331
|
+
if (!/^[\x20-\x7E]*$/.test(text)) {
|
|
1332
|
+
throw new Error('ADB 输入仅支持英文、数字与常见符号。中文请用系统输入法。');
|
|
1333
|
+
}
|
|
1334
|
+
await runAdb(['-s', serial, 'shell', 'input', 'text', toAdbInputText(text)], { timeout: 8000 });
|
|
1335
|
+
return { success: true };
|
|
1336
|
+
}
|
|
1337
|
+
case 'openUrl': {
|
|
1338
|
+
const url = String(action.url || '').trim();
|
|
1339
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) throw new Error('链接需要带协议,例如 https:// 或 app://');
|
|
1340
|
+
await runAdb(
|
|
1341
|
+
['-s', serial, 'shell', `am start -a android.intent.action.VIEW -d ${quoteForDeviceShell(url)}`],
|
|
1342
|
+
{ timeout: 8000 },
|
|
1343
|
+
);
|
|
1344
|
+
return { success: true };
|
|
1345
|
+
}
|
|
1346
|
+
default:
|
|
1347
|
+
throw new Error(`不支持的动作:${type}`);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function clamp01(value) {
|
|
1353
|
+
const number = Number(value);
|
|
1354
|
+
if (!Number.isFinite(number)) return 0;
|
|
1355
|
+
return Math.max(0, Math.min(1, number));
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async function orientedSize(serial, action) {
|
|
1359
|
+
const size = await displaySize(serial);
|
|
1360
|
+
const frameWidth = Number(action?.frameWidth);
|
|
1361
|
+
const frameHeight = Number(action?.frameHeight);
|
|
1362
|
+
if (frameWidth > 0 && frameHeight > 0 && (frameWidth > frameHeight) !== (size.width > size.height)) {
|
|
1363
|
+
return { width: size.height, height: size.width };
|
|
1364
|
+
}
|
|
1365
|
+
return size;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function safeSerial(serial) {
|
|
1369
|
+
return String(serial).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function pngSize(buffer) {
|
|
1373
|
+
if (buffer.length < 24 || buffer[0] !== 0x89 || buffer[1] !== 0x50) return null;
|
|
1374
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function readShotMeta(filePath) {
|
|
1378
|
+
const stat = await fsp.stat(filePath);
|
|
1379
|
+
const handle = await fsp.open(filePath, 'r');
|
|
1380
|
+
try {
|
|
1381
|
+
const head = Buffer.alloc(24);
|
|
1382
|
+
await handle.read(head, 0, 24, 0);
|
|
1383
|
+
const size = pngSize(head) || { width: 0, height: 0 };
|
|
1384
|
+
return {
|
|
1385
|
+
path: filePath,
|
|
1386
|
+
name: path.basename(filePath),
|
|
1387
|
+
at: stat.mtimeMs,
|
|
1388
|
+
bytes: stat.size,
|
|
1389
|
+
width: size.width,
|
|
1390
|
+
height: size.height,
|
|
1391
|
+
selected: state.selected === filePath,
|
|
1392
|
+
};
|
|
1393
|
+
} finally {
|
|
1394
|
+
await handle.close();
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async function captureShot(serial) {
|
|
1399
|
+
const { stdout } = await runAdb(['-s', serial, 'exec-out', 'screencap', '-p'], {
|
|
1400
|
+
encoding: 'buffer',
|
|
1401
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1402
|
+
timeout: 20_000,
|
|
1403
|
+
});
|
|
1404
|
+
const buffer = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
|
|
1405
|
+
if (buffer.length < 24 || buffer[0] !== 0x89) throw new Error('设备截图失败:返回内容不是 PNG');
|
|
1406
|
+
const filePath = path.join(SHOT_DIR, `device-${safeSerial(serial)}-${Date.now()}.png`);
|
|
1407
|
+
await fsp.writeFile(filePath, buffer);
|
|
1408
|
+
state.selected = filePath;
|
|
1409
|
+
state.lastSerial = serial;
|
|
1410
|
+
saveState();
|
|
1411
|
+
return readShotMeta(filePath);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function listShots(serial, limit = MAX_SHOTS) {
|
|
1415
|
+
let names = [];
|
|
1416
|
+
try {
|
|
1417
|
+
names = await fsp.readdir(SHOT_DIR);
|
|
1418
|
+
} catch {
|
|
1419
|
+
return [];
|
|
1420
|
+
}
|
|
1421
|
+
const prefix = serial ? `device-${safeSerial(serial)}-` : 'device-';
|
|
1422
|
+
const files = names.filter((name) => name.startsWith(prefix) && name.endsWith('.png'));
|
|
1423
|
+
const items = [];
|
|
1424
|
+
for (const name of files) {
|
|
1425
|
+
try {
|
|
1426
|
+
items.push(await readShotMeta(path.join(SHOT_DIR, name)));
|
|
1427
|
+
} catch {
|
|
1428
|
+
/* skip unreadable */
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
items.sort((a, b) => b.at - a.at);
|
|
1432
|
+
return limit === Infinity ? items : items.slice(0, limit);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
async function pruneShots(serial) {
|
|
1436
|
+
const items = await listShots(serial, Infinity);
|
|
1437
|
+
if (items.length <= MAX_SHOTS) return;
|
|
1438
|
+
for (const item of items.slice(MAX_SHOTS)) {
|
|
1439
|
+
if (item.path === state.selected) continue;
|
|
1440
|
+
try {
|
|
1441
|
+
await fsp.unlink(item.path);
|
|
1442
|
+
} catch {
|
|
1443
|
+
/* ignore */
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
async function selectShot(filePath) {
|
|
1449
|
+
const resolved = path.resolve(String(filePath || ''));
|
|
1450
|
+
if (!resolved.startsWith(SHOT_DIR + path.sep)) throw new Error('截图路径不在设备台目录内');
|
|
1451
|
+
const item = await readShotMeta(resolved);
|
|
1452
|
+
state.selected = resolved;
|
|
1453
|
+
saveState();
|
|
1454
|
+
return { ...item, selected: true };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function pickShot(serial) {
|
|
1458
|
+
if (state.selected) {
|
|
1459
|
+
try {
|
|
1460
|
+
const selected = await readShotMeta(state.selected);
|
|
1461
|
+
if (!serial || selected.name.startsWith(`device-${safeSerial(serial)}-`)) return selected;
|
|
1462
|
+
} catch {
|
|
1463
|
+
/* selected was deleted */
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const items = await listShots(serial);
|
|
1467
|
+
return items[0] || null;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function encodeFrame(type, flags, payload) {
|
|
1471
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
1472
|
+
const frame = Buffer.allocUnsafe(6 + body.length);
|
|
1473
|
+
frame[0] = type;
|
|
1474
|
+
frame[1] = flags;
|
|
1475
|
+
frame.writeUInt32BE(body.length, 2);
|
|
1476
|
+
body.copy(frame, 6);
|
|
1477
|
+
return frame;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function jsonFrame(type, value) {
|
|
1481
|
+
return encodeFrame(type, 0, Buffer.from(JSON.stringify(value)));
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
class StreamSession {
|
|
1485
|
+
constructor(serial, onDestroy) {
|
|
1486
|
+
this.serial = serial;
|
|
1487
|
+
this.onDestroy = onDestroy;
|
|
1488
|
+
this.clients = new Set();
|
|
1489
|
+
this.scrcpyClient = null;
|
|
1490
|
+
this.starting = null;
|
|
1491
|
+
this.destroyed = false;
|
|
1492
|
+
this.metadata = null;
|
|
1493
|
+
this.configPacket = null;
|
|
1494
|
+
this.screenOff = false;
|
|
1495
|
+
this.gop = [];
|
|
1496
|
+
this.gopBytes = 0;
|
|
1497
|
+
this.gopComplete = false;
|
|
1498
|
+
this.gopWaiters = new Set();
|
|
1499
|
+
this.idleTimer = null;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
async subscribe(client) {
|
|
1503
|
+
if (this.destroyed) throw new Error('流会话已结束');
|
|
1504
|
+
this.clients.add(client);
|
|
1505
|
+
if (this.idleTimer) {
|
|
1506
|
+
clearTimeout(this.idleTimer);
|
|
1507
|
+
this.idleTimer = null;
|
|
1508
|
+
}
|
|
1509
|
+
if (this.metadata) this.sendMetaTo(client);
|
|
1510
|
+
await this.ensureStarted();
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
unsubscribe(client) {
|
|
1514
|
+
this.clients.delete(client);
|
|
1515
|
+
if (this.clients.size === 0 && !this.destroyed) {
|
|
1516
|
+
void this.restoreScreen();
|
|
1517
|
+
this.scheduleIdle();
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
scheduleIdle() {
|
|
1522
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
1523
|
+
this.idleTimer = setTimeout(() => {
|
|
1524
|
+
this.destroy().catch(() => undefined);
|
|
1525
|
+
}, IDLE_TEARDOWN_MS);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
ensureStarted() {
|
|
1529
|
+
if (this.scrcpyClient) return Promise.resolve();
|
|
1530
|
+
if (this.starting) return this.starting;
|
|
1531
|
+
this.starting = this.start().finally(() => {
|
|
1532
|
+
this.starting = null;
|
|
1533
|
+
});
|
|
1534
|
+
return this.starting;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
async start() {
|
|
1538
|
+
if (!fs.existsSync(SERVER_BIN)) throw new Error('缺少 scrcpy-server 二进制文件');
|
|
1539
|
+
await runAdb(['-s', this.serial, 'push', SERVER_BIN, REMOTE_SERVER], { timeout: 20_000 });
|
|
1540
|
+
|
|
1541
|
+
const connector = new AdbServerNodeTcpConnector({ host: '127.0.0.1', port: 5037 });
|
|
1542
|
+
const server = new AdbServerClient(connector);
|
|
1543
|
+
const devices = await server.getDevices();
|
|
1544
|
+
const target = devices.find((device) => device.serial === this.serial);
|
|
1545
|
+
if (!target) throw new Error(`ADB 服务中未检测到设备:${this.serial}`);
|
|
1546
|
+
const adb = new Adb(await server.createTransport(target));
|
|
1547
|
+
|
|
1548
|
+
const options = new AdbScrcpyOptions3_3_3({
|
|
1549
|
+
audio: false,
|
|
1550
|
+
control: true,
|
|
1551
|
+
maxSize: 1080,
|
|
1552
|
+
sendFrameMeta: true,
|
|
1553
|
+
videoBitRate: 3_000_000,
|
|
1554
|
+
stayAwake: true,
|
|
1555
|
+
});
|
|
1556
|
+
const client = await AdbScrcpyClient.start(adb, REMOTE_SERVER, options);
|
|
1557
|
+
if (this.destroyed) {
|
|
1558
|
+
try {
|
|
1559
|
+
await client.close();
|
|
1560
|
+
} catch {
|
|
1561
|
+
/* ignore */
|
|
1562
|
+
}
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
this.scrcpyClient = client;
|
|
1566
|
+
|
|
1567
|
+
const video = await client.videoStream;
|
|
1568
|
+
const meta = video.metadata || {};
|
|
1569
|
+
const rawCodec = meta.codec;
|
|
1570
|
+
const isH265 = rawCodec === 1748121141 || String(rawCodec).toLowerCase().includes('h265');
|
|
1571
|
+
this.metadata = {
|
|
1572
|
+
serial: this.serial,
|
|
1573
|
+
deviceName: meta.deviceName || this.serial,
|
|
1574
|
+
codec: isH265 ? 'h265' : 'h264',
|
|
1575
|
+
width: meta.width || 1080,
|
|
1576
|
+
height: meta.height || 1920,
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
const controller = client.controller;
|
|
1580
|
+
if (controller) {
|
|
1581
|
+
try {
|
|
1582
|
+
await controller.setScreenPowerMode(AndroidScreenPowerMode.Off);
|
|
1583
|
+
this.screenOff = true;
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
console.warn('[device-deck] unable to turn the physical screen off', error);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
for (const subscriber of this.clients) this.sendMetaTo(subscriber);
|
|
1590
|
+
void this.consume(video.stream).catch((error) => {
|
|
1591
|
+
this.broadcastError(error?.message || '视频流异常');
|
|
1592
|
+
void this.destroy();
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async consume(stream) {
|
|
1597
|
+
const reader = stream.getReader();
|
|
1598
|
+
try {
|
|
1599
|
+
while (!this.destroyed) {
|
|
1600
|
+
const { done, value } = await reader.read();
|
|
1601
|
+
if (done) {
|
|
1602
|
+
this.broadcast(jsonFrame(T_END, { message: '设备画面已中断' }));
|
|
1603
|
+
break;
|
|
1604
|
+
}
|
|
1605
|
+
if (!value) continue;
|
|
1606
|
+
const isConfig = value.type === 'configuration';
|
|
1607
|
+
const isKey = Boolean(value.keyframe);
|
|
1608
|
+
const payload = Buffer.from(value.data.buffer, value.data.byteOffset, value.data.byteLength);
|
|
1609
|
+
if (isConfig) {
|
|
1610
|
+
this.configPacket = payload;
|
|
1611
|
+
this.gop = [];
|
|
1612
|
+
this.gopBytes = 0;
|
|
1613
|
+
this.gopComplete = false;
|
|
1614
|
+
} else if (isKey) {
|
|
1615
|
+
this.gop = [{ flags: 1, payload }];
|
|
1616
|
+
this.gopBytes = payload.length;
|
|
1617
|
+
this.gopComplete = true;
|
|
1618
|
+
for (const done of [...this.gopWaiters]) done(true);
|
|
1619
|
+
this.gopWaiters.clear();
|
|
1620
|
+
} else if (this.gopComplete) {
|
|
1621
|
+
this.gop.push({ flags: 0, payload });
|
|
1622
|
+
this.gopBytes += payload.length;
|
|
1623
|
+
if (this.gopBytes > MAX_GOP_BYTES) {
|
|
1624
|
+
this.gop = [];
|
|
1625
|
+
this.gopBytes = 0;
|
|
1626
|
+
this.gopComplete = false;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const flags = isKey ? 1 : 0;
|
|
1630
|
+
for (const subscriber of this.clients) {
|
|
1631
|
+
if (subscriber.closed) continue;
|
|
1632
|
+
subscriber.write(isConfig ? T_CONFIG : T_DATA, flags, payload);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
} finally {
|
|
1636
|
+
try {
|
|
1637
|
+
reader.releaseLock();
|
|
1638
|
+
} catch {
|
|
1639
|
+
/* ignore */
|
|
1640
|
+
}
|
|
1641
|
+
await this.destroy();
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
sendMetaTo(client) {
|
|
1646
|
+
if (!this.metadata || client.closed) return;
|
|
1647
|
+
client.write(T_META, 0, Buffer.from(JSON.stringify(this.metadata)));
|
|
1648
|
+
if (this.configPacket) client.write(T_CONFIG, 0, this.configPacket);
|
|
1649
|
+
if (!this.gopComplete || this.gop.length === 0) return;
|
|
1650
|
+
client.replaying = true;
|
|
1651
|
+
try {
|
|
1652
|
+
for (const frame of this.gop) {
|
|
1653
|
+
if (client.closed) break;
|
|
1654
|
+
client.write(T_DATA, frame.flags, frame.payload);
|
|
1655
|
+
}
|
|
1656
|
+
} finally {
|
|
1657
|
+
client.replaying = false;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
waitForGop(timeout = 5000) {
|
|
1662
|
+
if (this.gopComplete) return Promise.resolve(true);
|
|
1663
|
+
return new Promise((resolve) => {
|
|
1664
|
+
const done = (ready) => {
|
|
1665
|
+
clearTimeout(timer);
|
|
1666
|
+
this.gopWaiters.delete(done);
|
|
1667
|
+
resolve(ready);
|
|
1668
|
+
};
|
|
1669
|
+
const timer = setTimeout(() => done(false), timeout);
|
|
1670
|
+
this.gopWaiters.add(done);
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
broadcast(frame) {
|
|
1675
|
+
for (const subscriber of this.clients) {
|
|
1676
|
+
if (!subscriber.closed) subscriber.writeRaw(frame);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
broadcastError(message) {
|
|
1681
|
+
this.broadcast(jsonFrame(T_ERROR, { message }));
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async restoreScreen() {
|
|
1685
|
+
if (!this.screenOff || !this.scrcpyClient) return;
|
|
1686
|
+
const controller = this.scrcpyClient.controller;
|
|
1687
|
+
if (!controller) return;
|
|
1688
|
+
try {
|
|
1689
|
+
await controller.setScreenPowerMode(AndroidScreenPowerMode.Normal);
|
|
1690
|
+
this.screenOff = false;
|
|
1691
|
+
} catch {
|
|
1692
|
+
/* keep the flag so destroy can retry */
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async destroy() {
|
|
1697
|
+
if (this.destroyed) return;
|
|
1698
|
+
this.destroyed = true;
|
|
1699
|
+
if (this.idleTimer) {
|
|
1700
|
+
clearTimeout(this.idleTimer);
|
|
1701
|
+
this.idleTimer = null;
|
|
1702
|
+
}
|
|
1703
|
+
if (this.starting) {
|
|
1704
|
+
try {
|
|
1705
|
+
await this.starting;
|
|
1706
|
+
} catch {
|
|
1707
|
+
/* ignore */
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
for (const done of [...this.gopWaiters]) done(false);
|
|
1711
|
+
this.gopWaiters.clear();
|
|
1712
|
+
try {
|
|
1713
|
+
this.onDestroy?.();
|
|
1714
|
+
} catch {
|
|
1715
|
+
/* ignore */
|
|
1716
|
+
}
|
|
1717
|
+
await this.restoreScreen();
|
|
1718
|
+
if (this.scrcpyClient) {
|
|
1719
|
+
try {
|
|
1720
|
+
await this.scrcpyClient.close();
|
|
1721
|
+
} catch {
|
|
1722
|
+
/* ignore */
|
|
1723
|
+
}
|
|
1724
|
+
this.scrcpyClient = null;
|
|
1725
|
+
}
|
|
1726
|
+
for (const subscriber of this.clients) {
|
|
1727
|
+
try {
|
|
1728
|
+
subscriber.end();
|
|
1729
|
+
} catch {
|
|
1730
|
+
/* ignore */
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
this.clients.clear();
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const hub = {
|
|
1738
|
+
sessions: new Map(),
|
|
1739
|
+
async subscribe(serial, client) {
|
|
1740
|
+
let session = this.sessions.get(serial);
|
|
1741
|
+
if (session && !session.destroyed) {
|
|
1742
|
+
if (session.clients.size === 0) {
|
|
1743
|
+
await session.destroy();
|
|
1744
|
+
session = null;
|
|
1745
|
+
} else if (!session.gopComplete) {
|
|
1746
|
+
const ready = await session.waitForGop(5000);
|
|
1747
|
+
if (!ready) {
|
|
1748
|
+
client.writeError('画面正在重建,请稍后重试');
|
|
1749
|
+
client.end();
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (!session || session.destroyed) {
|
|
1755
|
+
session = new StreamSession(serial, () => this.sessions.delete(serial));
|
|
1756
|
+
this.sessions.set(serial, session);
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
await session.subscribe(client);
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
client.writeError(adbErrorHint(error));
|
|
1762
|
+
client.end();
|
|
1763
|
+
session.unsubscribe(client);
|
|
1764
|
+
await session.destroy();
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
},
|
|
1768
|
+
unsubscribe(serial, client) {
|
|
1769
|
+
this.sessions.get(serial)?.unsubscribe(client);
|
|
1770
|
+
},
|
|
1771
|
+
async destroyAll() {
|
|
1772
|
+
const list = [...this.sessions.values()];
|
|
1773
|
+
this.sessions.clear();
|
|
1774
|
+
await Promise.allSettled(list.map((session) => session.destroy()));
|
|
1775
|
+
},
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
function streamClient(res) {
|
|
1779
|
+
return {
|
|
1780
|
+
res,
|
|
1781
|
+
closed: false,
|
|
1782
|
+
replaying: false,
|
|
1783
|
+
write(type, flags, payload) {
|
|
1784
|
+
if (this.closed || res.writableEnded) return;
|
|
1785
|
+
if (!this.replaying && type === T_DATA && (flags & 1) === 0 && res.writableLength > MAX_SUBSCRIBER_BACKLOG) {
|
|
1786
|
+
this.closed = true;
|
|
1787
|
+
try {
|
|
1788
|
+
res.end();
|
|
1789
|
+
} catch {
|
|
1790
|
+
/* ignore */
|
|
1791
|
+
}
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
res.write(encodeFrame(type, flags, payload));
|
|
1796
|
+
} catch {
|
|
1797
|
+
this.closed = true;
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
writeRaw(frame) {
|
|
1801
|
+
if (this.closed || res.writableEnded) return;
|
|
1802
|
+
try {
|
|
1803
|
+
res.write(frame);
|
|
1804
|
+
} catch {
|
|
1805
|
+
this.closed = true;
|
|
1806
|
+
}
|
|
1807
|
+
},
|
|
1808
|
+
writeError(message) {
|
|
1809
|
+
this.write(T_ERROR, 0, Buffer.from(JSON.stringify({ message })));
|
|
1810
|
+
},
|
|
1811
|
+
end() {
|
|
1812
|
+
if (this.closed) return;
|
|
1813
|
+
this.closed = true;
|
|
1814
|
+
try {
|
|
1815
|
+
res.end();
|
|
1816
|
+
} catch {
|
|
1817
|
+
/* ignore */
|
|
1818
|
+
}
|
|
1819
|
+
},
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
async function readJson(req, limit = 1024 * 1024) {
|
|
1824
|
+
const chunks = [];
|
|
1825
|
+
let size = 0;
|
|
1826
|
+
for await (const chunk of req) {
|
|
1827
|
+
size += chunk.length;
|
|
1828
|
+
if (size > limit) throw new Error('请求体过大');
|
|
1829
|
+
chunks.push(chunk);
|
|
1830
|
+
}
|
|
1831
|
+
if (!chunks.length) return {};
|
|
1832
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function sendJson(res, status, value) {
|
|
1836
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
1837
|
+
res.writeHead(status, {
|
|
1838
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1839
|
+
'Content-Length': body.length,
|
|
1840
|
+
'Cache-Control': 'no-store',
|
|
1841
|
+
});
|
|
1842
|
+
res.end(body);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function routePath(req) {
|
|
1846
|
+
return new URL(req.url || '/', 'http://127.0.0.1').pathname;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function routeQuery(req) {
|
|
1850
|
+
return new URL(req.url || '/', 'http://127.0.0.1').searchParams;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function sanitizeUploadName(value) {
|
|
1854
|
+
const base = path.basename(String(value || '').replace(/\\/g, '/')).trim();
|
|
1855
|
+
const cleaned = base
|
|
1856
|
+
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '_')
|
|
1857
|
+
.replace(/^\.+/, '')
|
|
1858
|
+
.slice(0, 120);
|
|
1859
|
+
if (!cleaned || cleaned === '.' || cleaned === '..') return '';
|
|
1860
|
+
return cleaned;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async function handlePushFile(req, res) {
|
|
1864
|
+
if (req.method !== 'POST') {
|
|
1865
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
const query = routeQuery(req);
|
|
1869
|
+
const serial = await resolveSerial(query.get('serial') || undefined);
|
|
1870
|
+
const name = sanitizeUploadName(query.get('name') || '');
|
|
1871
|
+
if (!name) {
|
|
1872
|
+
sendJson(res, 400, { error: '文件名无效' });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const declared = Number(req.headers['content-length'] || 0);
|
|
1876
|
+
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
|
|
1877
|
+
sendJson(res, 413, { error: '文件超过 2 GB' });
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const temp = path.join(UPLOAD_DIR, `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
1882
|
+
let size = 0;
|
|
1883
|
+
try {
|
|
1884
|
+
await fsp.mkdir(UPLOAD_DIR, { recursive: true });
|
|
1885
|
+
const limiter = new Transform({
|
|
1886
|
+
transform(chunk, encoding, callback) {
|
|
1887
|
+
size += chunk.length;
|
|
1888
|
+
if (size > MAX_UPLOAD_BYTES) {
|
|
1889
|
+
callback(new Error('文件超过 2 GB'));
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
callback(null, chunk);
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1895
|
+
await pipeline(req, limiter, fs.createWriteStream(temp));
|
|
1896
|
+
|
|
1897
|
+
await runAdb(['-s', serial, 'shell', 'mkdir', '-p', REMOTE_UPLOAD_DIR], { timeout: 8000 });
|
|
1898
|
+
const remote = `${REMOTE_UPLOAD_DIR}/${name}`;
|
|
1899
|
+
await runAdb(['-s', serial, 'push', temp, remote], {
|
|
1900
|
+
timeout: 600000,
|
|
1901
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1902
|
+
});
|
|
1903
|
+
sendJson(res, 200, { success: true, serial, name, path: remote, bytes: size });
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
if (!res.headersSent) sendJson(res, 500, { error: adbErrorHint(error) });
|
|
1906
|
+
} finally {
|
|
1907
|
+
await fsp.rm(temp, { force: true }).catch(() => {});
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
async function handleDevices(req, res) {
|
|
1912
|
+
const adb = await adbStatus();
|
|
1913
|
+
if (!adb.available) {
|
|
1914
|
+
sendJson(res, 200, { adb, devices: [] });
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
try {
|
|
1918
|
+
const devices = await listDevices();
|
|
1919
|
+
sendJson(res, 200, { adb, devices });
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
sendJson(res, 200, { adb: { ...adb, available: false, error: adbErrorHint(error) }, devices: [] });
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async function handleAction(req, res) {
|
|
1926
|
+
if (req.method !== 'POST') {
|
|
1927
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const body = await readJson(req);
|
|
1931
|
+
const serial = await resolveSerial(body.serial);
|
|
1932
|
+
const result = await executeAction(serial, body.action || {});
|
|
1933
|
+
sendJson(res, 200, { success: true, ...result, serial });
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
async function handleScreenshot(req, res) {
|
|
1937
|
+
if (req.method !== 'POST') {
|
|
1938
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const body = await readJson(req);
|
|
1942
|
+
const serial = await resolveSerial(body.serial);
|
|
1943
|
+
const shot = await captureShot(serial);
|
|
1944
|
+
await pruneShots(serial);
|
|
1945
|
+
sendJson(res, 200, { success: true, serial, shot });
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
async function handleScreenshots(req, res) {
|
|
1949
|
+
const query = routeQuery(req);
|
|
1950
|
+
const serial = query.get('serial') || undefined;
|
|
1951
|
+
if (serial && !SERIAL_RE.test(serial)) {
|
|
1952
|
+
sendJson(res, 400, { error: '设备串号格式不正确' });
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const items = await listShots(serial);
|
|
1956
|
+
sendJson(res, 200, { success: true, items, selected: state.selected });
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function handleSelectScreenshot(req, res) {
|
|
1960
|
+
if (req.method !== 'POST') {
|
|
1961
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const body = await readJson(req);
|
|
1965
|
+
const shot = await selectShot(body.path);
|
|
1966
|
+
sendJson(res, 200, { success: true, shot });
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
async function handleFile(req, res) {
|
|
1970
|
+
const query = routeQuery(req);
|
|
1971
|
+
const requested = String(query.get('path') || '');
|
|
1972
|
+
const resolved = path.resolve(requested);
|
|
1973
|
+
if (!resolved.startsWith(SHOT_DIR + path.sep)) {
|
|
1974
|
+
sendJson(res, 403, { error: 'forbidden' });
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
let stat;
|
|
1978
|
+
try {
|
|
1979
|
+
stat = await fsp.stat(resolved);
|
|
1980
|
+
} catch {
|
|
1981
|
+
sendJson(res, 404, { error: 'not found' });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (!stat.isFile()) {
|
|
1985
|
+
sendJson(res, 404, { error: 'not found' });
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
res.writeHead(200, {
|
|
1989
|
+
'Content-Type': 'image/png',
|
|
1990
|
+
'Content-Length': stat.size,
|
|
1991
|
+
'Cache-Control': 'no-store',
|
|
1992
|
+
});
|
|
1993
|
+
const file = fs.createReadStream(resolved);
|
|
1994
|
+
res.on('close', () => file.destroy());
|
|
1995
|
+
file.pipe(res);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
async function handleStream(req, res) {
|
|
1999
|
+
const query = routeQuery(req);
|
|
2000
|
+
const serial = String(query.get('serial') || '');
|
|
2001
|
+
if (!SERIAL_RE.test(serial)) {
|
|
2002
|
+
sendJson(res, 400, { error: '设备串号格式不正确' });
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
res.writeHead(200, {
|
|
2006
|
+
'Content-Type': 'application/octet-stream',
|
|
2007
|
+
'Cache-Control': 'no-store, no-transform',
|
|
2008
|
+
Connection: 'keep-alive',
|
|
2009
|
+
'X-Accel-Buffering': 'no',
|
|
2010
|
+
});
|
|
2011
|
+
res.flushHeaders?.();
|
|
2012
|
+
const client = streamClient(res);
|
|
2013
|
+
res.on('close', () => {
|
|
2014
|
+
client.closed = true;
|
|
2015
|
+
hub.unsubscribe(serial, client);
|
|
2016
|
+
});
|
|
2017
|
+
try {
|
|
2018
|
+
await hub.subscribe(serial, client);
|
|
2019
|
+
} catch {
|
|
2020
|
+
client.end();
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function registerTool(ctx) {
|
|
2025
|
+
ctx.tools.register({
|
|
2026
|
+
name: 'device_screenshot',
|
|
2027
|
+
description:
|
|
2028
|
+
'Capture the connected Android device screen and return it as an image. Without fresh=true, returns the screenshot currently selected in the device mirror, or the newest saved one. Use this to inspect the live device UI.',
|
|
2029
|
+
parameters: {
|
|
2030
|
+
type: 'object',
|
|
2031
|
+
additionalProperties: false,
|
|
2032
|
+
properties: {
|
|
2033
|
+
serial: {
|
|
2034
|
+
type: 'string',
|
|
2035
|
+
description: 'Optional Android device serial. Defaults to the device selected in the mirror.',
|
|
2036
|
+
},
|
|
2037
|
+
fresh: {
|
|
2038
|
+
type: 'boolean',
|
|
2039
|
+
description: 'When true, capture the device screen now instead of using a saved screenshot.',
|
|
2040
|
+
},
|
|
2041
|
+
},
|
|
2042
|
+
},
|
|
2043
|
+
output: {
|
|
2044
|
+
schema: {
|
|
2045
|
+
type: 'object',
|
|
2046
|
+
additionalProperties: false,
|
|
2047
|
+
properties: {
|
|
2048
|
+
path: { type: 'string' },
|
|
2049
|
+
serial: { type: 'string' },
|
|
2050
|
+
width: { type: 'integer' },
|
|
2051
|
+
height: { type: 'integer' },
|
|
2052
|
+
image: {
|
|
2053
|
+
type: 'object',
|
|
2054
|
+
additionalProperties: false,
|
|
2055
|
+
properties: {
|
|
2056
|
+
attachmentId: { type: 'string' },
|
|
2057
|
+
mediaType: { type: 'string' },
|
|
2058
|
+
bytes: { type: 'integer' },
|
|
2059
|
+
width: { type: 'integer' },
|
|
2060
|
+
height: { type: 'integer' },
|
|
2061
|
+
name: { type: 'string' },
|
|
2062
|
+
},
|
|
2063
|
+
required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
|
|
2064
|
+
},
|
|
2065
|
+
},
|
|
2066
|
+
required: ['path', 'serial', 'width', 'height'],
|
|
2067
|
+
},
|
|
2068
|
+
render: (_args, value) => {
|
|
2069
|
+
const blocks = [
|
|
2070
|
+
{
|
|
2071
|
+
type: 'text',
|
|
2072
|
+
text: `<path>${value.path}</path>\n<device>${value.serial}</device>\n<type>image</type>\n<content>\n${value.image?.mediaType || 'image'} image, ${value.width}x${value.height} px\n</content>`,
|
|
2073
|
+
},
|
|
2074
|
+
];
|
|
2075
|
+
if (value.image) blocks.push({ type: 'image', attachment: value.image });
|
|
2076
|
+
return blocks;
|
|
2077
|
+
},
|
|
2078
|
+
},
|
|
2079
|
+
isConcurrencySafe: () => false,
|
|
2080
|
+
async execute(args) {
|
|
2081
|
+
const serial = await resolveSerial(args.serial);
|
|
2082
|
+
let shot = args.fresh ? null : await pickShot(serial);
|
|
2083
|
+
if (!shot) {
|
|
2084
|
+
shot = await captureShot(serial);
|
|
2085
|
+
await pruneShots(serial);
|
|
2086
|
+
}
|
|
2087
|
+
state.lastSerial = serial;
|
|
2088
|
+
saveState();
|
|
2089
|
+
const data = await fsp.readFile(shot.path);
|
|
2090
|
+
let image;
|
|
2091
|
+
try {
|
|
2092
|
+
const ref = await ctx.attachments.saveImage({ data, mediaType: 'image/png', name: shot.name });
|
|
2093
|
+
image = {
|
|
2094
|
+
attachmentId: ref.attachmentId,
|
|
2095
|
+
mediaType: ref.mediaType,
|
|
2096
|
+
bytes: ref.bytes,
|
|
2097
|
+
width: ref.width,
|
|
2098
|
+
height: ref.height,
|
|
2099
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
2100
|
+
};
|
|
2101
|
+
} catch {
|
|
2102
|
+
/* text-only fallback */
|
|
2103
|
+
}
|
|
2104
|
+
return {
|
|
2105
|
+
path: shot.path,
|
|
2106
|
+
serial,
|
|
2107
|
+
width: shot.width,
|
|
2108
|
+
height: shot.height,
|
|
2109
|
+
...(image === undefined ? {} : { image }),
|
|
2110
|
+
};
|
|
2111
|
+
},
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
export const name = 'device-deck';
|
|
2116
|
+
export const inject = ['webServer'];
|
|
2117
|
+
|
|
2118
|
+
export function apply(ctx) {
|
|
2119
|
+
ensureDirs();
|
|
2120
|
+
|
|
2121
|
+
const routes = [
|
|
2122
|
+
['/api/device-deck/devices', handleDevices],
|
|
2123
|
+
['/api/device-deck/inventory', handleInventory],
|
|
2124
|
+
['/api/device-deck/emulator/start', handleEmulatorStart],
|
|
2125
|
+
['/api/device-deck/emulator/stop', handleEmulatorStop],
|
|
2126
|
+
['/api/device-deck/wifi/connect', handleWifiConnect],
|
|
2127
|
+
['/api/device-deck/ios/boot', handleIosBoot],
|
|
2128
|
+
['/api/device-deck/ios/shutdown', handleIosShutdown],
|
|
2129
|
+
['/api/device-deck/ios/open', handleIosOpen],
|
|
2130
|
+
['/api/device-deck/ios/mirror/start', handleIosMirrorStart],
|
|
2131
|
+
['/api/device-deck/ios/mirror/stop', handleIosMirrorStop],
|
|
2132
|
+
['/api/device-deck/ios/mirror/status', handleIosMirrorStatus],
|
|
2133
|
+
['/api/device-deck/ios/stream', handleIosMirrorStream],
|
|
2134
|
+
['/api/device-deck/ios/action', handleIosAction],
|
|
2135
|
+
['/api/device-deck/ios/screenshot', handleIosScreenshot],
|
|
2136
|
+
['/api/device-deck/action', handleAction],
|
|
2137
|
+
['/api/device-deck/screenshot', handleScreenshot],
|
|
2138
|
+
['/api/device-deck/screenshots', handleScreenshots],
|
|
2139
|
+
['/api/device-deck/screenshots/select', handleSelectScreenshot],
|
|
2140
|
+
['/api/device-deck/file-push', handlePushFile],
|
|
2141
|
+
['/api/device-deck/file', handleFile],
|
|
2142
|
+
['/api/device-deck/stream', handleStream],
|
|
2143
|
+
];
|
|
2144
|
+
|
|
2145
|
+
for (const [route, handler] of routes) {
|
|
2146
|
+
ctx.effect(
|
|
2147
|
+
() =>
|
|
2148
|
+
ctx.webServer.register({
|
|
2149
|
+
kind: 'exact',
|
|
2150
|
+
path: route,
|
|
2151
|
+
handler: (req, res) => {
|
|
2152
|
+
Promise.resolve(handler(req, res)).catch((error) => {
|
|
2153
|
+
if (res.headersSent) {
|
|
2154
|
+
try {
|
|
2155
|
+
res.end();
|
|
2156
|
+
} catch {
|
|
2157
|
+
/* ignore */
|
|
2158
|
+
}
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
sendJson(res, 500, { error: adbErrorHint(error) });
|
|
2162
|
+
});
|
|
2163
|
+
},
|
|
2164
|
+
}),
|
|
2165
|
+
`device-deck route ${route}`,
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
ctx.effect(() => () => hub.destroyAll(), 'device-deck stream teardown');
|
|
2170
|
+
ctx.effect(() => () => {
|
|
2171
|
+
for (const mirror of iosMirrors.values()) void mirror.teardown();
|
|
2172
|
+
}, 'device-deck ios teardown');
|
|
2173
|
+
|
|
2174
|
+
ctx.inject(['tools', 'attachments'], (toolCtx) => {
|
|
2175
|
+
try {
|
|
2176
|
+
registerTool(toolCtx);
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
console.error('[device-deck] tool registration failed', error);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
}
|