@aiscene/aiserver 1.5.9 → 1.6.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/dist/config/cli.d.ts +5 -0
- package/dist/config/cli.d.ts.map +1 -1
- package/dist/config/cli.js +33 -0
- package/dist/config/cli.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +15 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +28 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/scrcpy/preview-status.d.ts +14 -0
- package/dist/scrcpy/preview-status.d.ts.map +1 -0
- package/dist/scrcpy/preview-status.js +25 -0
- package/dist/scrcpy/preview-status.js.map +1 -0
- package/dist/scrcpy/server.d.ts +53 -0
- package/dist/scrcpy/server.d.ts.map +1 -0
- package/dist/scrcpy/server.js +739 -0
- package/dist/scrcpy/server.js.map +1 -0
- package/dist/scrcpy/timeout.d.ts +17 -0
- package/dist/scrcpy/timeout.d.ts.map +1 -0
- package/dist/scrcpy/timeout.js +36 -0
- package/dist/scrcpy/timeout.js.map +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrcpy preview service for AIServer
|
|
3
|
+
*
|
|
4
|
+
* 仿照 midscene/packages/android-playground/src/scrcpy-server.ts 实现,
|
|
5
|
+
* 通过 socket.io 提供稳定的 Android 投屏服务。
|
|
6
|
+
*
|
|
7
|
+
* 前端约定(与 nethp-docs-static 对齐):
|
|
8
|
+
* - 连接后 emit 'connect-device' { deviceId?, maxSize? }
|
|
9
|
+
* - 服务端推送:
|
|
10
|
+
* 'devices-list' { devices, currentDeviceId }
|
|
11
|
+
* 'preview-status' { phase, message }
|
|
12
|
+
* 'video-metadata' { codec, width, height }
|
|
13
|
+
* 'video-data' { data: Uint8Array, type: 'configuration'|'data', keyFrame, timestamp }
|
|
14
|
+
* 'control-ready'
|
|
15
|
+
* 'error' { message }
|
|
16
|
+
* 'device-switched' { deviceId }
|
|
17
|
+
* 'global-device-switched' { deviceId, timestamp }
|
|
18
|
+
*/
|
|
19
|
+
import { exec } from 'node:child_process';
|
|
20
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
21
|
+
import { createServer } from 'node:http';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { promisify } from 'node:util';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import cors from 'cors';
|
|
26
|
+
import express from 'express';
|
|
27
|
+
import { Server as SocketIoServer } from 'socket.io';
|
|
28
|
+
import { createLogger } from '../core/logger.js';
|
|
29
|
+
import { buildScrcpyPreviewStatusEvent, } from './preview-status.js';
|
|
30
|
+
import { withTimeout } from './timeout.js';
|
|
31
|
+
const logger = createLogger('ScrcpyServer');
|
|
32
|
+
const promiseExec = promisify(exec);
|
|
33
|
+
const requireCjs = createRequire(import.meta.url);
|
|
34
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
35
|
+
function isPrivateIP(hostname) {
|
|
36
|
+
return (LOOPBACK_HOSTS.has(hostname) ||
|
|
37
|
+
/^10\./.test(hostname) ||
|
|
38
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
|
39
|
+
/^192\.168\./.test(hostname) ||
|
|
40
|
+
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./.test(hostname));
|
|
41
|
+
}
|
|
42
|
+
function isAllowedOrigin(origin) {
|
|
43
|
+
if (!origin)
|
|
44
|
+
return true;
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(origin);
|
|
47
|
+
return isPrivateIP(url.hostname);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function resolveRequestedDeviceId(options, currentDeviceId) {
|
|
54
|
+
const requestedDeviceId = typeof options?.deviceId === 'string' ? options.deviceId.trim() : '';
|
|
55
|
+
return requestedDeviceId || currentDeviceId || undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 自动解析 scrcpy-server 二进制文件路径,
|
|
59
|
+
* 优先使用 config.serverBinPath,回退到 @aiscene/android/bin/scrcpy-server。
|
|
60
|
+
*/
|
|
61
|
+
function resolveServerBinPath(configured) {
|
|
62
|
+
if (configured && existsSync(configured)) {
|
|
63
|
+
return configured;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
// require.resolve 找到 package.json,从而得到包根
|
|
67
|
+
const pkgPath = requireCjs.resolve('@aiscene/android/package.json');
|
|
68
|
+
const candidate = path.resolve(path.dirname(pkgPath), 'bin/scrcpy-server');
|
|
69
|
+
if (existsSync(candidate))
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
// 最后退路:相对 cwd 的默认 node_modules 路径
|
|
76
|
+
const fallback = path.resolve(process.cwd(), 'node_modules/@aiscene/android/bin/scrcpy-server');
|
|
77
|
+
return fallback;
|
|
78
|
+
}
|
|
79
|
+
export class ScrcpyServer {
|
|
80
|
+
app;
|
|
81
|
+
httpServer;
|
|
82
|
+
io;
|
|
83
|
+
config;
|
|
84
|
+
adbClient = null;
|
|
85
|
+
currentDeviceId = null;
|
|
86
|
+
devicePollInterval = null;
|
|
87
|
+
lastDeviceListJson = '';
|
|
88
|
+
serverBinPath;
|
|
89
|
+
started = false;
|
|
90
|
+
constructor(config) {
|
|
91
|
+
this.config = config;
|
|
92
|
+
this.serverBinPath = resolveServerBinPath(config.serverBinPath);
|
|
93
|
+
this.app = express();
|
|
94
|
+
this.httpServer = createServer(this.app);
|
|
95
|
+
this.io = new SocketIoServer(this.httpServer, {
|
|
96
|
+
cors: {
|
|
97
|
+
origin(origin, callback) {
|
|
98
|
+
callback(null, isAllowedOrigin(origin));
|
|
99
|
+
},
|
|
100
|
+
methods: ['GET', 'POST'],
|
|
101
|
+
credentials: true,
|
|
102
|
+
},
|
|
103
|
+
// 视频帧二进制大小较大,提升允许的载荷上限
|
|
104
|
+
maxHttpBufferSize: 1e8,
|
|
105
|
+
});
|
|
106
|
+
this.app.use(cors({
|
|
107
|
+
origin(origin, callback) {
|
|
108
|
+
callback(null, isAllowedOrigin(origin));
|
|
109
|
+
},
|
|
110
|
+
credentials: true,
|
|
111
|
+
}));
|
|
112
|
+
this.setupApiRoutes();
|
|
113
|
+
this.setupSocketHandlers();
|
|
114
|
+
}
|
|
115
|
+
// ============================================================
|
|
116
|
+
// REST API
|
|
117
|
+
// ============================================================
|
|
118
|
+
setupApiRoutes() {
|
|
119
|
+
this.app.get('/api/health', (_req, res) => {
|
|
120
|
+
res.json({ ok: true, currentDeviceId: this.currentDeviceId });
|
|
121
|
+
});
|
|
122
|
+
this.app.get('/api/devices', async (_req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const devices = await this.getDevicesList();
|
|
125
|
+
res.json({ devices, currentDeviceId: this.currentDeviceId });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
res.status(500).json({
|
|
129
|
+
error: error.message || 'Failed to get devices list',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// ============================================================
|
|
135
|
+
// ADB helpers
|
|
136
|
+
// ============================================================
|
|
137
|
+
async getAdbClient() {
|
|
138
|
+
if (this.adbClient)
|
|
139
|
+
return this.adbClient;
|
|
140
|
+
const { AdbServerClient } = await import('@yume-chan/adb');
|
|
141
|
+
const { AdbServerNodeTcpConnector } = await import('@yume-chan/adb-server-node-tcp');
|
|
142
|
+
try {
|
|
143
|
+
await withTimeout(promiseExec('adb start-server'), this.config.adbConnectTimeoutMs, `Timed out starting adb server after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`);
|
|
144
|
+
logger.debug('adb server started');
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
logger.warn(`adb start-server failed: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
this.adbClient = new AdbServerClient(new AdbServerNodeTcpConnector({ host: '127.0.0.1', port: 5037 }));
|
|
150
|
+
return this.adbClient;
|
|
151
|
+
}
|
|
152
|
+
async getDevicesList() {
|
|
153
|
+
try {
|
|
154
|
+
const client = await this.getAdbClient();
|
|
155
|
+
const devices = (await withTimeout(client.getDevices(), this.config.adbConnectTimeoutMs, `Timed out listing devices after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`));
|
|
156
|
+
if (!devices || devices.length === 0)
|
|
157
|
+
return [];
|
|
158
|
+
return devices.map((d) => ({
|
|
159
|
+
id: d.serial,
|
|
160
|
+
name: d.product || d.model || d.serial,
|
|
161
|
+
status: d.state || 'device',
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
logger.warn(`Failed to get devices: ${error.message}`);
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async getAdb(deviceId) {
|
|
170
|
+
const { Adb } = await import('@yume-chan/adb');
|
|
171
|
+
const client = await this.getAdbClient();
|
|
172
|
+
const targetDeviceId = deviceId || this.currentDeviceId;
|
|
173
|
+
if (targetDeviceId) {
|
|
174
|
+
this.currentDeviceId = targetDeviceId;
|
|
175
|
+
return new Adb(await withTimeout(client.createTransport({ serial: targetDeviceId }), this.config.adbConnectTimeoutMs, `Timed out connecting to Android device ${targetDeviceId} via ADB after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`));
|
|
176
|
+
}
|
|
177
|
+
const devices = (await withTimeout(client.getDevices(), this.config.adbConnectTimeoutMs, `Timed out listing Android devices via ADB after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`));
|
|
178
|
+
if (!devices || devices.length === 0)
|
|
179
|
+
return null;
|
|
180
|
+
this.currentDeviceId = devices[0].serial;
|
|
181
|
+
return new Adb(await withTimeout(client.createTransport(devices[0]), this.config.adbConnectTimeoutMs, `Timed out connecting to ${devices[0].serial} via ADB`));
|
|
182
|
+
}
|
|
183
|
+
// ============================================================
|
|
184
|
+
// Scrcpy lifecycle
|
|
185
|
+
// ============================================================
|
|
186
|
+
async startScrcpy(adb, options = {}, onProgress) {
|
|
187
|
+
const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import('@yume-chan/adb-scrcpy');
|
|
188
|
+
const { ReadableStream } = await import('@yume-chan/stream-extra');
|
|
189
|
+
const { DefaultServerPath } = await import('@yume-chan/scrcpy');
|
|
190
|
+
if (!existsSync(this.serverBinPath)) {
|
|
191
|
+
throw new Error(`scrcpy-server binary not found at: ${this.serverBinPath}. ` +
|
|
192
|
+
`Please install @aiscene/android or set scrcpy.serverBinPath.`);
|
|
193
|
+
}
|
|
194
|
+
onProgress?.('pushing-server');
|
|
195
|
+
await withTimeout(AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(this.serverBinPath))), this.config.pushTimeoutMs, `Timed out pushing scrcpy server to device after ${Math.round(this.config.pushTimeoutMs / 1000)}s`);
|
|
196
|
+
const scrcpyOptions = new AdbScrcpyOptions3_3_3({
|
|
197
|
+
audio: false,
|
|
198
|
+
control: true,
|
|
199
|
+
maxSize: options.maxSize ?? this.config.maxSize,
|
|
200
|
+
// 让 web decoder 能区分 configuration 帧
|
|
201
|
+
sendFrameMeta: true,
|
|
202
|
+
videoBitRate: options.videoBitRate ?? this.config.videoBitRate,
|
|
203
|
+
// 降低端到端延迟与提升首帧速度
|
|
204
|
+
maxFps: 60,
|
|
205
|
+
// 让设备更频繁地发关键帧,掉帧/花屏可更快恢复(单位:秒)
|
|
206
|
+
// 注意:scrcpy 3.x 接受字符串 "1" 也可以是 number;保险用字符串
|
|
207
|
+
// 由 scrcpy server 用 ffmpeg/ MediaCodec 解释
|
|
208
|
+
// 如果你的 scrcpy 版本不支持此选项,会被忽略,无副作用
|
|
209
|
+
});
|
|
210
|
+
onProgress?.('starting-service');
|
|
211
|
+
const startPromise = AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
|
|
212
|
+
return withTimeout(startPromise, this.config.startTimeoutMs, `Timed out starting scrcpy service after ${Math.round(this.config.startTimeoutMs / 1000)}s`, {
|
|
213
|
+
onSettledAfterTimeout: async (lateClient) => {
|
|
214
|
+
try {
|
|
215
|
+
await lateClient?.close?.();
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
logger.warn(`Failed to close late scrcpy client: ${e.message}`);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// ============================================================
|
|
224
|
+
// Socket handlers
|
|
225
|
+
// ============================================================
|
|
226
|
+
broadcastDevicesList(devices) {
|
|
227
|
+
const currentJson = JSON.stringify(devices);
|
|
228
|
+
if (this.lastDeviceListJson === currentJson)
|
|
229
|
+
return;
|
|
230
|
+
this.lastDeviceListJson = currentJson;
|
|
231
|
+
if (this.currentDeviceId &&
|
|
232
|
+
!devices.some((d) => d.id === this.currentDeviceId)) {
|
|
233
|
+
this.currentDeviceId = null;
|
|
234
|
+
}
|
|
235
|
+
if (!this.currentDeviceId && devices.length > 0) {
|
|
236
|
+
const online = devices.filter((d) => d.status.toLowerCase() === 'device');
|
|
237
|
+
if (online.length > 0) {
|
|
238
|
+
this.currentDeviceId = online[0].id;
|
|
239
|
+
logger.info(`Auto-selected online device: ${this.currentDeviceId}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.io.emit('devices-list', {
|
|
243
|
+
devices,
|
|
244
|
+
currentDeviceId: this.currentDeviceId,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
setupSocketHandlers() {
|
|
248
|
+
this.io.on('connection', (socket) => {
|
|
249
|
+
logger.info(`Client connected: id=${socket.id}, address=${socket.handshake.address}`);
|
|
250
|
+
const state = {
|
|
251
|
+
scrcpyClient: null,
|
|
252
|
+
adb: null,
|
|
253
|
+
videoWidth: 0,
|
|
254
|
+
videoHeight: 0,
|
|
255
|
+
};
|
|
256
|
+
const emitStatus = (phase) => {
|
|
257
|
+
socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
|
|
258
|
+
};
|
|
259
|
+
const sendDevicesList = async () => {
|
|
260
|
+
try {
|
|
261
|
+
const devices = await this.getDevicesList();
|
|
262
|
+
socket.emit('devices-list', {
|
|
263
|
+
devices,
|
|
264
|
+
currentDeviceId: this.currentDeviceId,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
logger.warn(`sendDevicesList failed: ${error.message}`);
|
|
269
|
+
socket.emit('error', { message: 'failed to get devices list' });
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
socket.on('get-devices', () => {
|
|
273
|
+
void sendDevicesList();
|
|
274
|
+
});
|
|
275
|
+
socket.on('switch-device', async (deviceId) => {
|
|
276
|
+
try {
|
|
277
|
+
if (state.scrcpyClient) {
|
|
278
|
+
await state.scrcpyClient.close().catch(() => { });
|
|
279
|
+
state.scrcpyClient = null;
|
|
280
|
+
}
|
|
281
|
+
this.currentDeviceId = deviceId;
|
|
282
|
+
socket.emit('device-switched', { deviceId });
|
|
283
|
+
this.io.emit('global-device-switched', {
|
|
284
|
+
deviceId,
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
socket.emit('error', {
|
|
290
|
+
message: `Failed to switch device: ${error?.message || 'Unknown error'}`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
socket.on('connect-device', async (options = {}) => {
|
|
295
|
+
const { ScrcpyVideoCodecId } = await import('@yume-chan/scrcpy');
|
|
296
|
+
try {
|
|
297
|
+
emitStatus('connecting-device');
|
|
298
|
+
const requestedDeviceId = resolveRequestedDeviceId(options, this.currentDeviceId);
|
|
299
|
+
if (requestedDeviceId) {
|
|
300
|
+
this.currentDeviceId = requestedDeviceId;
|
|
301
|
+
}
|
|
302
|
+
state.adb = await this.getAdb(requestedDeviceId);
|
|
303
|
+
if (!state.adb) {
|
|
304
|
+
socket.emit('error', { message: 'No device found' });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
state.scrcpyClient = await this.startScrcpy(state.adb, options, emitStatus);
|
|
308
|
+
logger.info(`Scrcpy started for device=${this.currentDeviceId}, client=${socket.id}`);
|
|
309
|
+
const videoStreamRaw = state.scrcpyClient?.videoStream;
|
|
310
|
+
if (!videoStreamRaw) {
|
|
311
|
+
socket.emit('error', {
|
|
312
|
+
message: 'Video stream not available in scrcpy client',
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
emitStatus('waiting-for-video');
|
|
317
|
+
const videoStream = typeof videoStreamRaw?.then === 'function'
|
|
318
|
+
? await withTimeout(videoStreamRaw, this.config.videoStreamTimeoutMs, `Timed out waiting for scrcpy video stream metadata after ${Math.round(this.config.videoStreamTimeoutMs / 1000)}s`)
|
|
319
|
+
: videoStreamRaw;
|
|
320
|
+
const metadata = videoStream.metadata || {};
|
|
321
|
+
if (!metadata.codec)
|
|
322
|
+
metadata.codec = ScrcpyVideoCodecId.H264;
|
|
323
|
+
if (!metadata.width)
|
|
324
|
+
metadata.width = 1080;
|
|
325
|
+
if (!metadata.height)
|
|
326
|
+
metadata.height = 1920;
|
|
327
|
+
socket.emit('video-metadata', metadata);
|
|
328
|
+
state.videoWidth = metadata.width;
|
|
329
|
+
state.videoHeight = metadata.height;
|
|
330
|
+
const { stream } = videoStream;
|
|
331
|
+
const reader = stream.getReader();
|
|
332
|
+
const processStream = async () => {
|
|
333
|
+
try {
|
|
334
|
+
while (true) {
|
|
335
|
+
const { done, value } = await reader.read();
|
|
336
|
+
if (done)
|
|
337
|
+
break;
|
|
338
|
+
const frameType = value.type || 'data';
|
|
339
|
+
// scrcpy 包属性是 `keyframe`(小写 f),兼容两种命名后强制布尔
|
|
340
|
+
const isKey = !!(value.keyframe ?? value.keyFrame);
|
|
341
|
+
// 二进制 Uint8Array 直发,避免 Array.from 内存爆炸
|
|
342
|
+
socket.emit('video-data', {
|
|
343
|
+
data: value.data,
|
|
344
|
+
type: frameType,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
keyFrame: isKey,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
logger.error(`Video stream error: ${error.message}`);
|
|
352
|
+
if (socket.connected) {
|
|
353
|
+
socket.emit('error', {
|
|
354
|
+
message: 'video stream processing error',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// 流自然结束 — 通知前端尽快重连
|
|
360
|
+
if (socket.connected) {
|
|
361
|
+
socket.emit('error', { message: 'video stream ended' });
|
|
362
|
+
}
|
|
363
|
+
if (state.scrcpyClient) {
|
|
364
|
+
await state.scrcpyClient.close().catch(() => { });
|
|
365
|
+
state.scrcpyClient = null;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
void processStream();
|
|
369
|
+
if (state.scrcpyClient?.controller) {
|
|
370
|
+
socket.emit('control-ready');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
const message = error?.message || 'Unknown error';
|
|
375
|
+
logger.error(`connect-device failed: ${message}`);
|
|
376
|
+
if (state.scrcpyClient) {
|
|
377
|
+
await state.scrcpyClient.close().catch(() => { });
|
|
378
|
+
state.scrcpyClient = null;
|
|
379
|
+
}
|
|
380
|
+
socket.emit('error', {
|
|
381
|
+
message: `Failed to connect device: ${message}`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
// ============================================================
|
|
386
|
+
// Control channel: 触摸 / 按键 / 文本 / 物理按键快捷
|
|
387
|
+
// 客户端约定:
|
|
388
|
+
// socket.emit('control-touch', {
|
|
389
|
+
// action: 'down'|'move'|'up'|'cancel',
|
|
390
|
+
// x, y, // 0..1 归一化 或 已映射到设备坐标
|
|
391
|
+
// normalized?: boolean, // true 表示 x,y 是 0..1,需要乘 videoWidth/Height
|
|
392
|
+
// pointerId?: number,
|
|
393
|
+
// pressure?: number,
|
|
394
|
+
// })
|
|
395
|
+
// socket.emit('control-key', { keycode: number, action?: 'down'|'up'|'press', repeat?, metaState? })
|
|
396
|
+
// socket.emit('control-text', { text: string })
|
|
397
|
+
// socket.emit('control-back') // 等价于 back/screen on
|
|
398
|
+
// socket.emit('control-home') // Home
|
|
399
|
+
// socket.emit('control-app-switch') // 最近任务
|
|
400
|
+
// socket.emit('control-set-clipboard', { text, paste?: boolean })
|
|
401
|
+
// ============================================================
|
|
402
|
+
const ensureController = () => {
|
|
403
|
+
const controller = state.scrcpyClient?.controller;
|
|
404
|
+
if (!controller) {
|
|
405
|
+
socket.emit('error', {
|
|
406
|
+
message: 'Scrcpy controller not ready, connect-device first',
|
|
407
|
+
});
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
return controller;
|
|
411
|
+
};
|
|
412
|
+
const ACTION_MAP = {
|
|
413
|
+
down: 0,
|
|
414
|
+
up: 1,
|
|
415
|
+
move: 2,
|
|
416
|
+
cancel: 3,
|
|
417
|
+
};
|
|
418
|
+
// scrcpy 协议常量(参考 https://tangoadb.dev/scrcpy/control/touch/)
|
|
419
|
+
// ScrcpyPointerId.Finger = -2n
|
|
420
|
+
const FINGER_POINTER_ID = BigInt(-2);
|
|
421
|
+
const BUTTON_PRIMARY = 1;
|
|
422
|
+
socket.on('control-touch', async (raw = {}) => {
|
|
423
|
+
try {
|
|
424
|
+
const controller = ensureController();
|
|
425
|
+
if (!controller) {
|
|
426
|
+
logger.warn('control-touch: controller not ready');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// ⚠️ scrcpy 协议字段是 videoWidth / videoHeight(旧 d.ts 误写为 screenWidth),
|
|
430
|
+
// 必须与最近一次 video-metadata 的 width/height 完全一致,否则 server 会忽略
|
|
431
|
+
const videoWidth = state.videoWidth || 1080;
|
|
432
|
+
const videoHeight = state.videoHeight || 1920;
|
|
433
|
+
const normalized = raw.normalized === true;
|
|
434
|
+
let x = Number(raw.x);
|
|
435
|
+
let y = Number(raw.y);
|
|
436
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
437
|
+
logger.warn(`control-touch: invalid coords ${JSON.stringify(raw)}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (normalized) {
|
|
441
|
+
x = Math.max(0, Math.min(1, x)) * videoWidth;
|
|
442
|
+
y = Math.max(0, Math.min(1, y)) * videoHeight;
|
|
443
|
+
}
|
|
444
|
+
x = Math.round(Math.max(0, Math.min(videoWidth - 1, x)));
|
|
445
|
+
y = Math.round(Math.max(0, Math.min(videoHeight - 1, y)));
|
|
446
|
+
const actionStr = String(raw.action || 'down').toLowerCase();
|
|
447
|
+
const action = ACTION_MAP[actionStr] ?? 0;
|
|
448
|
+
const isUp = actionStr === 'up' || actionStr === 'cancel';
|
|
449
|
+
// pressure: down/move=1.0, up=0
|
|
450
|
+
const pressure = isUp ? 0 : 1;
|
|
451
|
+
// 手指模式 (Finger=-2n);如果前端有 pointerId 就直接用 BigInt
|
|
452
|
+
const pointerId = raw.pointerId !== undefined && raw.pointerId !== null
|
|
453
|
+
? BigInt(raw.pointerId)
|
|
454
|
+
: FINGER_POINTER_ID;
|
|
455
|
+
// 触摸(手指)模式下,scrcpy server 在 server 端的解码不依赖 button 字段
|
|
456
|
+
// —— 上一次设置 actionButton=Primary 反而让某些 ROM 把它当成"按下了实体鼠标键再松开",
|
|
457
|
+
// 结果手机上能看到 inject 但 click 不识别。回退到 0/0 最稳。
|
|
458
|
+
const actionButton = 0;
|
|
459
|
+
const buttons = 0;
|
|
460
|
+
logger.info(`inject touch ${actionStr} (${x},${y}) p=${pressure} pid=${pointerId} videoSize=${videoWidth}x${videoHeight}`);
|
|
461
|
+
await controller.injectTouch({
|
|
462
|
+
action,
|
|
463
|
+
pointerId,
|
|
464
|
+
pointerX: x,
|
|
465
|
+
pointerY: y,
|
|
466
|
+
// 重要:字段名是 videoWidth/videoHeight
|
|
467
|
+
videoWidth,
|
|
468
|
+
videoHeight,
|
|
469
|
+
pressure,
|
|
470
|
+
actionButton,
|
|
471
|
+
buttons,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
logger.warn(`control-touch failed: ${error.message}`);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
socket.on('control-key', async (raw = {}) => {
|
|
479
|
+
try {
|
|
480
|
+
const controller = ensureController();
|
|
481
|
+
if (!controller)
|
|
482
|
+
return;
|
|
483
|
+
const keycode = Number(raw.keycode);
|
|
484
|
+
if (!Number.isFinite(keycode))
|
|
485
|
+
return;
|
|
486
|
+
const repeat = Number(raw.repeat ?? 0);
|
|
487
|
+
const metaState = Number(raw.metaState ?? 0);
|
|
488
|
+
const action = String(raw.action || 'press').toLowerCase();
|
|
489
|
+
logger.info(`inject key ${action} keycode=${keycode}`);
|
|
490
|
+
const inject = (act) => controller.injectKeyCode({
|
|
491
|
+
action: act,
|
|
492
|
+
keyCode: keycode,
|
|
493
|
+
repeat,
|
|
494
|
+
metaState,
|
|
495
|
+
});
|
|
496
|
+
if (action === 'down') {
|
|
497
|
+
await inject(0);
|
|
498
|
+
}
|
|
499
|
+
else if (action === 'up') {
|
|
500
|
+
await inject(1);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
await inject(0);
|
|
504
|
+
await inject(1);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
logger.warn(`control-key failed: ${error.message}`);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
socket.on('control-text', async (raw = {}) => {
|
|
512
|
+
try {
|
|
513
|
+
const controller = ensureController();
|
|
514
|
+
if (!controller)
|
|
515
|
+
return;
|
|
516
|
+
const text = typeof raw === 'string' ? raw : String(raw.text ?? '');
|
|
517
|
+
if (!text)
|
|
518
|
+
return;
|
|
519
|
+
logger.info(`inject text len=${text.length}`);
|
|
520
|
+
await controller.injectText(text);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
logger.warn(`control-text failed: ${error.message}`);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
const pressKey = async (keyCode) => {
|
|
527
|
+
const controller = ensureController();
|
|
528
|
+
if (!controller)
|
|
529
|
+
return;
|
|
530
|
+
await controller.injectKeyCode({
|
|
531
|
+
action: 0,
|
|
532
|
+
keyCode,
|
|
533
|
+
repeat: 0,
|
|
534
|
+
metaState: 0,
|
|
535
|
+
});
|
|
536
|
+
await controller.injectKeyCode({
|
|
537
|
+
action: 1,
|
|
538
|
+
keyCode,
|
|
539
|
+
repeat: 0,
|
|
540
|
+
metaState: 0,
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
socket.on('control-back', async () => {
|
|
544
|
+
try {
|
|
545
|
+
await pressKey(4); // AndroidKeyCode.AndroidBack
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
logger.warn(`control-back failed: ${error.message}`);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
socket.on('control-home', async () => {
|
|
552
|
+
try {
|
|
553
|
+
await pressKey(3); // AndroidKeyCode.AndroidHome
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
logger.warn(`control-home failed: ${error.message}`);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
socket.on('control-app-switch', async () => {
|
|
560
|
+
try {
|
|
561
|
+
await pressKey(187); // AndroidKeyCode.AndroidAppSwitch
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
logger.warn(`control-app-switch failed: ${error.message}`);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
socket.on('control-set-clipboard', async (raw = {}) => {
|
|
568
|
+
try {
|
|
569
|
+
const controller = ensureController();
|
|
570
|
+
if (!controller)
|
|
571
|
+
return;
|
|
572
|
+
const text = String(raw.text ?? '');
|
|
573
|
+
if (!text)
|
|
574
|
+
return;
|
|
575
|
+
await controller.setClipboard({
|
|
576
|
+
sequence: BigInt(Date.now()),
|
|
577
|
+
paste: Boolean(raw.paste),
|
|
578
|
+
content: text,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
logger.warn(`control-set-clipboard failed: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
// ---- scroll: 鼠标滚轮(参考 https://tangoadb.dev/scrcpy/control/scroll/) ----
|
|
586
|
+
// raw: { x, y, scrollX?, scrollY?, normalized?: bool }
|
|
587
|
+
// 默认 scrollY = -1(向下滚动一格),向上滚动用 +1
|
|
588
|
+
socket.on('control-scroll', async (raw = {}) => {
|
|
589
|
+
try {
|
|
590
|
+
const controller = ensureController();
|
|
591
|
+
if (!controller)
|
|
592
|
+
return;
|
|
593
|
+
const videoWidth = state.videoWidth || 1080;
|
|
594
|
+
const videoHeight = state.videoHeight || 1920;
|
|
595
|
+
let x = Number(raw.x ?? videoWidth / 2);
|
|
596
|
+
let y = Number(raw.y ?? videoHeight / 2);
|
|
597
|
+
if (raw.normalized === true) {
|
|
598
|
+
x = Math.max(0, Math.min(1, x)) * videoWidth;
|
|
599
|
+
y = Math.max(0, Math.min(1, y)) * videoHeight;
|
|
600
|
+
}
|
|
601
|
+
x = Math.round(Math.max(0, Math.min(videoWidth - 1, x)));
|
|
602
|
+
y = Math.round(Math.max(0, Math.min(videoHeight - 1, y)));
|
|
603
|
+
const scrollX = Math.trunc(Number(raw.scrollX ?? 0));
|
|
604
|
+
const scrollY = Math.trunc(Number(raw.scrollY ?? -1));
|
|
605
|
+
await controller.injectScroll({
|
|
606
|
+
pointerX: x,
|
|
607
|
+
pointerY: y,
|
|
608
|
+
videoWidth,
|
|
609
|
+
videoHeight,
|
|
610
|
+
scrollX,
|
|
611
|
+
scrollY,
|
|
612
|
+
buttons: 0,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
logger.warn(`control-scroll failed: ${error.message}`);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
// ---- back-or-screen-on:右键模拟,息屏时唤醒 ----
|
|
620
|
+
socket.on('control-back-or-screen-on', async () => {
|
|
621
|
+
try {
|
|
622
|
+
const controller = ensureController();
|
|
623
|
+
if (!controller)
|
|
624
|
+
return;
|
|
625
|
+
// action: Down(0)=按下;通常一次发 Down 就够了
|
|
626
|
+
await controller.backOrScreenOn(0);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
logger.warn(`control-back-or-screen-on failed: ${error.message}`);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
// ---- rotate-device:旋转屏幕 ----
|
|
633
|
+
socket.on('control-rotate', async () => {
|
|
634
|
+
try {
|
|
635
|
+
const controller = ensureController();
|
|
636
|
+
if (!controller)
|
|
637
|
+
return;
|
|
638
|
+
await controller.rotateDevice();
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
logger.warn(`control-rotate failed: ${error.message}`);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
// ---- reset-video:让 scrcpy 重新发送 SPS/PPS 与关键帧(解决花屏) ----
|
|
645
|
+
socket.on('control-reset-video', async () => {
|
|
646
|
+
try {
|
|
647
|
+
const controller = ensureController();
|
|
648
|
+
if (!controller)
|
|
649
|
+
return;
|
|
650
|
+
await controller.resetVideo();
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
logger.warn(`control-reset-video failed: ${error.message}`);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
// ---- screen-power:开/关屏幕背光(不锁屏) ----
|
|
657
|
+
// raw: { mode: 'normal' | 'off' } normal=2, off=0
|
|
658
|
+
socket.on('control-screen-power', async (raw = {}) => {
|
|
659
|
+
try {
|
|
660
|
+
const controller = ensureController();
|
|
661
|
+
if (!controller)
|
|
662
|
+
return;
|
|
663
|
+
const mode = String(raw.mode || 'normal').toLowerCase() === 'off' ? 0 : 2;
|
|
664
|
+
await controller.setScreenPowerMode(mode);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
logger.warn(`control-screen-power failed: ${error.message}`);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
socket.on('disconnect', async (reason) => {
|
|
671
|
+
logger.info(`Client disconnected: id=${socket.id}, reason=${reason}`);
|
|
672
|
+
if (state.scrcpyClient) {
|
|
673
|
+
try {
|
|
674
|
+
await state.scrcpyClient.close();
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
logger.warn(`Failed to close scrcpy client on disconnect: ${error.message}`);
|
|
678
|
+
}
|
|
679
|
+
state.scrcpyClient = null;
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
// 立即异步推送设备列表,避免冷启动卡住
|
|
683
|
+
void sendDevicesList();
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
// ============================================================
|
|
687
|
+
// Lifecycle
|
|
688
|
+
// ============================================================
|
|
689
|
+
startDeviceMonitoring() {
|
|
690
|
+
if (this.devicePollInterval)
|
|
691
|
+
return;
|
|
692
|
+
this.devicePollInterval = setInterval(async () => {
|
|
693
|
+
try {
|
|
694
|
+
const devices = await this.getDevicesList();
|
|
695
|
+
this.broadcastDevicesList(devices);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
logger.warn(`Device polling error: ${error.message}`);
|
|
699
|
+
}
|
|
700
|
+
}, this.config.devicePollInterval);
|
|
701
|
+
}
|
|
702
|
+
async start() {
|
|
703
|
+
if (this.started)
|
|
704
|
+
return;
|
|
705
|
+
if (!this.config.enabled) {
|
|
706
|
+
logger.info('Scrcpy server is disabled by configuration');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.started = true;
|
|
710
|
+
await new Promise((resolve) => {
|
|
711
|
+
this.httpServer.listen(this.config.port, this.config.host, () => {
|
|
712
|
+
logger.info(`Scrcpy server running at http://${this.config.host}:${this.config.port}`);
|
|
713
|
+
logger.info(`Using scrcpy-server bin: ${this.serverBinPath}`);
|
|
714
|
+
this.startDeviceMonitoring();
|
|
715
|
+
resolve();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async close() {
|
|
720
|
+
if (!this.started)
|
|
721
|
+
return;
|
|
722
|
+
if (this.devicePollInterval) {
|
|
723
|
+
clearInterval(this.devicePollInterval);
|
|
724
|
+
this.devicePollInterval = null;
|
|
725
|
+
}
|
|
726
|
+
await new Promise((resolve) => {
|
|
727
|
+
if (!this.httpServer.listening) {
|
|
728
|
+
resolve();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
this.io.close(() => {
|
|
732
|
+
this.httpServer.close(() => resolve());
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
this.started = false;
|
|
736
|
+
logger.info('Scrcpy server closed');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
//# sourceMappingURL=server.js.map
|