@aiscene/aiserver 1.9.6 → 1.9.8
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/core/device-proxy.d.ts +17 -0
- package/dist/core/device-proxy.d.ts.map +1 -1
- package/dist/core/device-proxy.js +95 -5
- package/dist/core/device-proxy.js.map +1 -1
- package/dist/debug/types.d.ts +56 -4
- package/dist/debug/types.d.ts.map +1 -1
- package/dist/debug/websocket-server.d.ts +8 -0
- package/dist/debug/websocket-server.d.ts.map +1 -1
- package/dist/debug/websocket-server.js +180 -17
- package/dist/debug/websocket-server.js.map +1 -1
- package/dist/executor/action-executor.d.ts +5 -5
- package/dist/executor/action-executor.d.ts.map +1 -1
- package/dist/executor/action-executor.js +32 -25
- package/dist/executor/action-executor.js.map +1 -1
- package/dist/executor/android-executor.d.ts +3 -14
- package/dist/executor/android-executor.d.ts.map +1 -1
- package/dist/executor/android-executor.js +21 -96
- package/dist/executor/android-executor.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -10
- package/dist/index.js.map +1 -1
- package/dist/scrcpy/server.d.ts +13 -0
- package/dist/scrcpy/server.d.ts.map +1 -1
- package/dist/scrcpy/server.js +333 -148
- package/dist/scrcpy/server.js.map +1 -1
- package/dist/task/guada-online-batch-poller.d.ts +21 -0
- package/dist/task/guada-online-batch-poller.d.ts.map +1 -0
- package/dist/task/guada-online-batch-poller.js +132 -0
- package/dist/task/guada-online-batch-poller.js.map +1 -0
- package/dist/task/online-loop-poller.d.ts +20 -0
- package/dist/task/online-loop-poller.d.ts.map +1 -0
- package/dist/task/online-loop-poller.js +152 -0
- package/dist/task/online-loop-poller.js.map +1 -0
- package/dist/task/scheduler.d.ts +1 -0
- package/dist/task/scheduler.d.ts.map +1 -1
- package/dist/task/scheduler.js +47 -0
- package/dist/task/scheduler.js.map +1 -1
- package/dist/task/test-case-reference-resolver.d.ts +16 -0
- package/dist/task/test-case-reference-resolver.d.ts.map +1 -0
- package/dist/task/test-case-reference-resolver.js +283 -0
- package/dist/task/test-case-reference-resolver.js.map +1 -0
- package/dist/web/debug-page.d.ts.map +1 -1
- package/dist/web/debug-page.js +49 -58
- package/dist/web/debug-page.js.map +1 -1
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +35 -0
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
package/dist/scrcpy/server.js
CHANGED
|
@@ -139,8 +139,13 @@ export class ScrcpyServer {
|
|
|
139
139
|
currentDeviceId = null;
|
|
140
140
|
devicePollInterval = null;
|
|
141
141
|
lastDeviceListJson = '';
|
|
142
|
+
lastDeviceList = [];
|
|
143
|
+
lastDeviceListAt = 0;
|
|
144
|
+
deviceListInFlight = null;
|
|
145
|
+
deviceConnectLocks = new Map();
|
|
142
146
|
serverBinPath;
|
|
143
147
|
started = false;
|
|
148
|
+
startPromise = null;
|
|
144
149
|
projectionBlockedDevices = new Map();
|
|
145
150
|
/** 所有活动 session(按 ws 索引),用于广播 */
|
|
146
151
|
sessions = new Map();
|
|
@@ -210,7 +215,7 @@ export class ScrcpyServer {
|
|
|
210
215
|
this.adbClient = new AdbServerClient(new AdbServerNodeTcpConnector({ host: '127.0.0.1', port: 5037 }));
|
|
211
216
|
return this.adbClient;
|
|
212
217
|
}
|
|
213
|
-
async
|
|
218
|
+
async loadDevicesListFromAdb() {
|
|
214
219
|
try {
|
|
215
220
|
const client = await this.getAdbClient();
|
|
216
221
|
const devices = (await withTimeout(client.getDevices(), this.config.adbConnectTimeoutMs, `Timed out listing devices after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`));
|
|
@@ -227,6 +232,38 @@ export class ScrcpyServer {
|
|
|
227
232
|
return [];
|
|
228
233
|
}
|
|
229
234
|
}
|
|
235
|
+
async getDevicesList(forceRefresh = false) {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const cacheTtlMs = 2000;
|
|
238
|
+
if (!forceRefresh &&
|
|
239
|
+
this.lastDeviceListAt > 0 &&
|
|
240
|
+
now - this.lastDeviceListAt < cacheTtlMs) {
|
|
241
|
+
return this.lastDeviceList;
|
|
242
|
+
}
|
|
243
|
+
if (this.deviceListInFlight) {
|
|
244
|
+
return this.deviceListInFlight;
|
|
245
|
+
}
|
|
246
|
+
this.deviceListInFlight = this.loadDevicesListFromAdb()
|
|
247
|
+
.then((devices) => {
|
|
248
|
+
this.lastDeviceList = devices;
|
|
249
|
+
this.lastDeviceListAt = Date.now();
|
|
250
|
+
return devices;
|
|
251
|
+
})
|
|
252
|
+
.finally(() => {
|
|
253
|
+
this.deviceListInFlight = null;
|
|
254
|
+
});
|
|
255
|
+
return this.deviceListInFlight;
|
|
256
|
+
}
|
|
257
|
+
selectAvailableDeviceId(devices) {
|
|
258
|
+
const online = devices.find((device) => device.status.toLowerCase() === 'device' &&
|
|
259
|
+
!this.isProjectionBlocked(device.id));
|
|
260
|
+
return online?.id || null;
|
|
261
|
+
}
|
|
262
|
+
isSessionOpen(state) {
|
|
263
|
+
return (!state.closed &&
|
|
264
|
+
state.ws.readyState === WebSocket.OPEN &&
|
|
265
|
+
this.sessions.get(state.ws) === state);
|
|
266
|
+
}
|
|
230
267
|
isProjectionBlocked(deviceId) {
|
|
231
268
|
if (!deviceId)
|
|
232
269
|
return false;
|
|
@@ -250,14 +287,12 @@ export class ScrcpyServer {
|
|
|
250
287
|
this.currentDeviceId = targetDeviceId;
|
|
251
288
|
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`));
|
|
252
289
|
}
|
|
253
|
-
const devices =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const availableDevice = devices.find((device) => !this.isProjectionBlocked(device.serial));
|
|
257
|
-
if (!availableDevice)
|
|
290
|
+
const devices = await this.getDevicesList();
|
|
291
|
+
const availableDeviceId = this.selectAvailableDeviceId(devices);
|
|
292
|
+
if (!availableDeviceId)
|
|
258
293
|
return null;
|
|
259
|
-
this.currentDeviceId =
|
|
260
|
-
return new Adb(await withTimeout(client.createTransport(
|
|
294
|
+
this.currentDeviceId = availableDeviceId;
|
|
295
|
+
return new Adb(await withTimeout(client.createTransport({ serial: availableDeviceId }), this.config.adbConnectTimeoutMs, `Timed out connecting to ${availableDeviceId} via ADB`));
|
|
261
296
|
}
|
|
262
297
|
async wakeDeviceScreen(deviceId) {
|
|
263
298
|
const targetDeviceId = deviceId || this.currentDeviceId;
|
|
@@ -386,7 +421,7 @@ export class ScrcpyServer {
|
|
|
386
421
|
await Promise.allSettled(releases);
|
|
387
422
|
}
|
|
388
423
|
async refreshDevicesList() {
|
|
389
|
-
const devices = await this.getDevicesList();
|
|
424
|
+
const devices = await this.getDevicesList(true);
|
|
390
425
|
this.lastDeviceListJson = '';
|
|
391
426
|
this.broadcastDevicesList(devices);
|
|
392
427
|
}
|
|
@@ -403,6 +438,8 @@ export class ScrcpyServer {
|
|
|
403
438
|
scrcpyClient: null,
|
|
404
439
|
adb: null,
|
|
405
440
|
deviceId: null,
|
|
441
|
+
connectPromise: null,
|
|
442
|
+
closed: false,
|
|
406
443
|
videoWidth: 0,
|
|
407
444
|
videoHeight: 0,
|
|
408
445
|
streamReader: null,
|
|
@@ -415,9 +452,14 @@ export class ScrcpyServer {
|
|
|
415
452
|
async sendDevicesListTo(state) {
|
|
416
453
|
try {
|
|
417
454
|
const devices = await this.getDevicesList();
|
|
455
|
+
if (!this.isSessionOpen(state))
|
|
456
|
+
return;
|
|
418
457
|
if (this.currentDeviceId && this.isProjectionBlocked(this.currentDeviceId)) {
|
|
419
458
|
this.currentDeviceId = null;
|
|
420
459
|
}
|
|
460
|
+
if (!this.currentDeviceId) {
|
|
461
|
+
this.currentDeviceId = this.selectAvailableDeviceId(devices);
|
|
462
|
+
}
|
|
421
463
|
wsSend(state.ws, 'devices-list', {
|
|
422
464
|
devices,
|
|
423
465
|
currentDeviceId: this.currentDeviceId,
|
|
@@ -451,6 +493,185 @@ export class ScrcpyServer {
|
|
|
451
493
|
state.videoWidth = 0;
|
|
452
494
|
state.videoHeight = 0;
|
|
453
495
|
}
|
|
496
|
+
async connectDeviceForSession(state, options = {}, emitStatus) {
|
|
497
|
+
const { ws } = state;
|
|
498
|
+
const safeOptions = options || {};
|
|
499
|
+
let connectLockKey = null;
|
|
500
|
+
try {
|
|
501
|
+
emitStatus('connecting-device');
|
|
502
|
+
// 重复 connect-device 时,先释放旧资源避免 scrcpy 进程残留。
|
|
503
|
+
if (state.scrcpyClient) {
|
|
504
|
+
await this.releaseSession(state);
|
|
505
|
+
}
|
|
506
|
+
if (!this.isSessionOpen(state))
|
|
507
|
+
return;
|
|
508
|
+
let targetDeviceId = resolveRequestedDeviceId(safeOptions, this.currentDeviceId);
|
|
509
|
+
if (!targetDeviceId) {
|
|
510
|
+
const devices = await this.getDevicesList();
|
|
511
|
+
if (!this.isSessionOpen(state))
|
|
512
|
+
return;
|
|
513
|
+
targetDeviceId = this.selectAvailableDeviceId(devices) || undefined;
|
|
514
|
+
}
|
|
515
|
+
if (!targetDeviceId) {
|
|
516
|
+
wsSend(ws, 'error', { message: 'No device found' });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (this.isProjectionBlocked(targetDeviceId)) {
|
|
520
|
+
logger.info(`Skip scrcpy connect for scheduled task device: deviceId=${targetDeviceId}`);
|
|
521
|
+
wsSend(ws, 'error', { message: this.getProjectionBlockedMessage(targetDeviceId) });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const lockedBy = this.deviceConnectLocks.get(targetDeviceId);
|
|
525
|
+
if (lockedBy && lockedBy !== state.id) {
|
|
526
|
+
logger.info(`Skip duplicate scrcpy connect: deviceId=${targetDeviceId},owner=${lockedBy},client=${state.id}`);
|
|
527
|
+
wsSend(ws, 'error', {
|
|
528
|
+
message: `设备 ${targetDeviceId} 正在建立投屏连接,请稍后重试`,
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
connectLockKey = targetDeviceId;
|
|
533
|
+
this.deviceConnectLocks.set(connectLockKey, state.id);
|
|
534
|
+
this.currentDeviceId = targetDeviceId;
|
|
535
|
+
state.deviceId = targetDeviceId;
|
|
536
|
+
state.adb = await this.getAdb(targetDeviceId);
|
|
537
|
+
if (!this.isSessionOpen(state)) {
|
|
538
|
+
await this.releaseSession(state);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!state.adb) {
|
|
542
|
+
wsSend(ws, 'error', { message: 'No device found' });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
await this.wakeDeviceScreen(targetDeviceId);
|
|
546
|
+
if (!this.isSessionOpen(state)) {
|
|
547
|
+
await this.releaseSession(state);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
state.scrcpyClient = await this.startScrcpy(state.adb, safeOptions, emitStatus);
|
|
551
|
+
if (!this.isSessionOpen(state)) {
|
|
552
|
+
await this.releaseSession(state);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
logger.info(`Scrcpy started for device=${targetDeviceId},client=${state.id}`);
|
|
556
|
+
const videoStreamRaw = state.scrcpyClient?.videoStream;
|
|
557
|
+
if (!videoStreamRaw) {
|
|
558
|
+
wsSend(ws, 'error', {
|
|
559
|
+
message: 'Video stream not available in scrcpy client',
|
|
560
|
+
});
|
|
561
|
+
await this.releaseSession(state);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
emitStatus('waiting-for-video');
|
|
565
|
+
const videoStream = typeof videoStreamRaw?.then === 'function'
|
|
566
|
+
? await withTimeout(videoStreamRaw, this.config.videoStreamTimeoutMs, `Timed out waiting for scrcpy video stream metadata after ${Math.round(this.config.videoStreamTimeoutMs / 1000)}s`)
|
|
567
|
+
: videoStreamRaw;
|
|
568
|
+
if (!this.isSessionOpen(state)) {
|
|
569
|
+
await this.releaseSession(state);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const metadata = videoStream.metadata;
|
|
573
|
+
// 严格校验:缺失元数据直接报错,避免兜底 H264/1080x1920 与真实流不匹配。
|
|
574
|
+
if (!metadata ||
|
|
575
|
+
!metadata.codec ||
|
|
576
|
+
!metadata.width ||
|
|
577
|
+
!metadata.height) {
|
|
578
|
+
const detail = JSON.stringify(metadata || {});
|
|
579
|
+
logger.error(`incomplete video metadata: ${detail}`);
|
|
580
|
+
wsSend(ws, 'error', {
|
|
581
|
+
message: `Incomplete video metadata from scrcpy: ${detail}`,
|
|
582
|
+
});
|
|
583
|
+
await this.releaseSession(state);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// 唤醒屏幕,避免设备熄屏时 scrcpy 推黑画面。
|
|
587
|
+
const controller = state.scrcpyClient?.controller;
|
|
588
|
+
if (controller) {
|
|
589
|
+
try {
|
|
590
|
+
await controller.setScreenPowerMode?.(2);
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
logger.warn(`setScreenPowerMode failed: ${e.message}`);
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
await controller.backOrScreenOn?.(0);
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
logger.warn(`backOrScreenOn failed: ${e.message}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!this.isSessionOpen(state)) {
|
|
603
|
+
await this.releaseSession(state);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
wsSend(ws, 'video-metadata', {
|
|
607
|
+
codec: metadata.codec,
|
|
608
|
+
width: metadata.width,
|
|
609
|
+
height: metadata.height,
|
|
610
|
+
});
|
|
611
|
+
state.videoWidth = metadata.width;
|
|
612
|
+
state.videoHeight = metadata.height;
|
|
613
|
+
const { stream } = videoStream;
|
|
614
|
+
const reader = stream.getReader();
|
|
615
|
+
state.streamReader = reader;
|
|
616
|
+
const processStream = async () => {
|
|
617
|
+
try {
|
|
618
|
+
while (true) {
|
|
619
|
+
const { done, value } = await reader.read();
|
|
620
|
+
if (done)
|
|
621
|
+
break;
|
|
622
|
+
const data = value.data;
|
|
623
|
+
const frameType = value.type || 'data';
|
|
624
|
+
const isKey = !!(value.keyframe ?? value.keyFrame) || isH264IdrFrame(data);
|
|
625
|
+
const isConfig = frameType === 'configuration';
|
|
626
|
+
if (!this.isSessionOpen(state))
|
|
627
|
+
break;
|
|
628
|
+
ws.send(buildVideoFrameBuffer(data, isKey, isConfig, Date.now()), { binary: true });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const msg = error.message || '';
|
|
633
|
+
if (!/cancel|abort/i.test(msg)) {
|
|
634
|
+
logger.error(`Video stream error: ${msg}`);
|
|
635
|
+
if (this.isSessionOpen(state)) {
|
|
636
|
+
wsSend(ws, 'error', {
|
|
637
|
+
message: 'video stream processing error',
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
if (state.streamReader === reader) {
|
|
645
|
+
state.streamReader = null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (this.isSessionOpen(state)) {
|
|
649
|
+
wsSend(ws, 'error', { message: 'video stream ended' });
|
|
650
|
+
}
|
|
651
|
+
await this.releaseSession(state);
|
|
652
|
+
};
|
|
653
|
+
void processStream();
|
|
654
|
+
if (state.scrcpyClient?.controller && this.isSessionOpen(state)) {
|
|
655
|
+
wsSend(ws, 'control-ready');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
const message = error?.message || 'Unknown error';
|
|
660
|
+
logger.error(`connect-device failed: ${message}`);
|
|
661
|
+
await this.releaseSession(state);
|
|
662
|
+
if (this.isSessionOpen(state)) {
|
|
663
|
+
wsSend(ws, 'error', {
|
|
664
|
+
message: `Failed to connect device: ${message}`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
if (connectLockKey &&
|
|
670
|
+
this.deviceConnectLocks.get(connectLockKey) === state.id) {
|
|
671
|
+
this.deviceConnectLocks.delete(connectLockKey);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
454
675
|
attachSocketHandlers(state) {
|
|
455
676
|
const { ws } = state;
|
|
456
677
|
const emitStatus = (phase) => {
|
|
@@ -526,6 +747,12 @@ export class ScrcpyServer {
|
|
|
526
747
|
'get-devices': () => this.sendDevicesListTo(state),
|
|
527
748
|
'switch-device': async (deviceId) => {
|
|
528
749
|
try {
|
|
750
|
+
if (state.connectPromise) {
|
|
751
|
+
wsSend(ws, 'error', {
|
|
752
|
+
message: 'Device connection is already in progress',
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
529
756
|
const id = typeof deviceId === 'string' ? deviceId : String(deviceId ?? '');
|
|
530
757
|
if (this.isProjectionBlocked(id)) {
|
|
531
758
|
wsSend(ws, 'error', { message: this.getProjectionBlockedMessage(id) });
|
|
@@ -547,134 +774,25 @@ export class ScrcpyServer {
|
|
|
547
774
|
});
|
|
548
775
|
}
|
|
549
776
|
},
|
|
550
|
-
'connect-device':
|
|
551
|
-
|
|
777
|
+
'connect-device': (options = {}) => {
|
|
778
|
+
if (state.connectPromise) {
|
|
552
779
|
emitStatus('connecting-device');
|
|
553
|
-
// 重复 connect-device 时,先释放旧资源避免 scrcpy 残留
|
|
554
|
-
if (state.scrcpyClient) {
|
|
555
|
-
await this.releaseSession(state);
|
|
556
|
-
}
|
|
557
|
-
const requestedDeviceId = resolveRequestedDeviceId(options || {}, this.currentDeviceId);
|
|
558
|
-
if (this.isProjectionBlocked(requestedDeviceId)) {
|
|
559
|
-
const blockedDeviceId = requestedDeviceId || '';
|
|
560
|
-
logger.info(`Skip scrcpy connect for scheduled task device: deviceId=${blockedDeviceId}`);
|
|
561
|
-
wsSend(ws, 'error', { message: this.getProjectionBlockedMessage(blockedDeviceId) });
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
if (requestedDeviceId) {
|
|
565
|
-
this.currentDeviceId = requestedDeviceId;
|
|
566
|
-
state.deviceId = requestedDeviceId;
|
|
567
|
-
}
|
|
568
|
-
state.adb = await this.getAdb(requestedDeviceId);
|
|
569
|
-
if (!state.adb) {
|
|
570
|
-
wsSend(ws, 'error', { message: 'No device found' });
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
await this.wakeDeviceScreen(this.currentDeviceId || requestedDeviceId);
|
|
574
|
-
state.scrcpyClient = await this.startScrcpy(state.adb, options || {}, emitStatus);
|
|
575
|
-
logger.info(`Scrcpy started for device=${this.currentDeviceId},client=${state.id}`);
|
|
576
|
-
const videoStreamRaw = state.scrcpyClient?.videoStream;
|
|
577
|
-
if (!videoStreamRaw) {
|
|
578
|
-
wsSend(ws, 'error', {
|
|
579
|
-
message: 'Video stream not available in scrcpy client',
|
|
580
|
-
});
|
|
581
|
-
await this.releaseSession(state);
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
emitStatus('waiting-for-video');
|
|
585
|
-
const videoStream = typeof videoStreamRaw?.then === 'function'
|
|
586
|
-
? await withTimeout(videoStreamRaw, this.config.videoStreamTimeoutMs, `Timed out waiting for scrcpy video stream metadata after ${Math.round(this.config.videoStreamTimeoutMs / 1000)}s`)
|
|
587
|
-
: videoStreamRaw;
|
|
588
|
-
const metadata = videoStream.metadata;
|
|
589
|
-
// 严格校验:缺失元数据直接报错,避免兜底 H264/1080x1920 与真实流不匹配
|
|
590
|
-
if (!metadata ||
|
|
591
|
-
!metadata.codec ||
|
|
592
|
-
!metadata.width ||
|
|
593
|
-
!metadata.height) {
|
|
594
|
-
const detail = JSON.stringify(metadata || {});
|
|
595
|
-
logger.error(`incomplete video metadata: ${detail}`);
|
|
596
|
-
wsSend(ws, 'error', {
|
|
597
|
-
message: `Incomplete video metadata from scrcpy: ${detail}`,
|
|
598
|
-
});
|
|
599
|
-
await this.releaseSession(state);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
// 唤醒屏幕,避免设备熄屏时 scrcpy 推黑画面
|
|
603
|
-
const controller = state.scrcpyClient?.controller;
|
|
604
|
-
if (controller) {
|
|
605
|
-
try {
|
|
606
|
-
await controller.setScreenPowerMode?.(2);
|
|
607
|
-
}
|
|
608
|
-
catch (e) {
|
|
609
|
-
logger.warn(`setScreenPowerMode failed: ${e.message}`);
|
|
610
|
-
}
|
|
611
|
-
try {
|
|
612
|
-
await controller.backOrScreenOn?.(0);
|
|
613
|
-
}
|
|
614
|
-
catch (e) {
|
|
615
|
-
logger.warn(`backOrScreenOn failed: ${e.message}`);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
wsSend(ws, 'video-metadata', {
|
|
619
|
-
codec: metadata.codec,
|
|
620
|
-
width: metadata.width,
|
|
621
|
-
height: metadata.height,
|
|
622
|
-
});
|
|
623
|
-
state.videoWidth = metadata.width;
|
|
624
|
-
state.videoHeight = metadata.height;
|
|
625
|
-
const { stream } = videoStream;
|
|
626
|
-
const reader = stream.getReader();
|
|
627
|
-
state.streamReader = reader;
|
|
628
|
-
const processStream = async () => {
|
|
629
|
-
try {
|
|
630
|
-
while (true) {
|
|
631
|
-
const { done, value } = await reader.read();
|
|
632
|
-
if (done)
|
|
633
|
-
break;
|
|
634
|
-
const data = value.data;
|
|
635
|
-
const frameType = value.type || 'data';
|
|
636
|
-
const isKey = !!(value.keyframe ?? value.keyFrame) || isH264IdrFrame(data);
|
|
637
|
-
const isConfig = frameType === 'configuration';
|
|
638
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
639
|
-
break;
|
|
640
|
-
ws.send(buildVideoFrameBuffer(data, isKey, isConfig, Date.now()), { binary: true });
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
catch (error) {
|
|
644
|
-
const msg = error.message || '';
|
|
645
|
-
if (!/cancel|abort/i.test(msg)) {
|
|
646
|
-
logger.error(`Video stream error: ${msg}`);
|
|
647
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
648
|
-
wsSend(ws, 'error', {
|
|
649
|
-
message: 'video stream processing error',
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
finally {
|
|
656
|
-
if (state.streamReader === reader) {
|
|
657
|
-
state.streamReader = null;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
661
|
-
wsSend(ws, 'error', { message: 'video stream ended' });
|
|
662
|
-
}
|
|
663
|
-
await this.releaseSession(state);
|
|
664
|
-
};
|
|
665
|
-
void processStream();
|
|
666
|
-
if (state.scrcpyClient?.controller) {
|
|
667
|
-
wsSend(ws, 'control-ready');
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
catch (error) {
|
|
671
|
-
const message = error?.message || 'Unknown error';
|
|
672
|
-
logger.error(`connect-device failed: ${message}`);
|
|
673
|
-
await this.releaseSession(state);
|
|
674
780
|
wsSend(ws, 'error', {
|
|
675
|
-
message:
|
|
781
|
+
message: 'Device connection is already in progress',
|
|
676
782
|
});
|
|
783
|
+
return;
|
|
677
784
|
}
|
|
785
|
+
const promise = this.connectDeviceForSession(state, options || {}, emitStatus);
|
|
786
|
+
state.connectPromise = promise;
|
|
787
|
+
void promise
|
|
788
|
+
.catch((error) => {
|
|
789
|
+
logger.error(`connect-device promise failed: ${error.message}`);
|
|
790
|
+
})
|
|
791
|
+
.finally(() => {
|
|
792
|
+
if (state.connectPromise === promise) {
|
|
793
|
+
state.connectPromise = null;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
678
796
|
},
|
|
679
797
|
'control-touch': async (raw = {}) => {
|
|
680
798
|
try {
|
|
@@ -1076,6 +1194,7 @@ export class ScrcpyServer {
|
|
|
1076
1194
|
});
|
|
1077
1195
|
ws.on('close', async (code, reason) => {
|
|
1078
1196
|
logger.info(`Client disconnected: id=${state.id}, code=${code}, reason=${reason?.toString() || ''}`);
|
|
1197
|
+
state.closed = true;
|
|
1079
1198
|
this.sessions.delete(ws);
|
|
1080
1199
|
androidRecordingManager.cancel(state.id);
|
|
1081
1200
|
await this.releaseSession(state);
|
|
@@ -1092,7 +1211,7 @@ export class ScrcpyServer {
|
|
|
1092
1211
|
return;
|
|
1093
1212
|
this.devicePollInterval = setInterval(async () => {
|
|
1094
1213
|
try {
|
|
1095
|
-
const devices = await this.getDevicesList();
|
|
1214
|
+
const devices = await this.getDevicesList(true);
|
|
1096
1215
|
this.broadcastDevicesList(devices);
|
|
1097
1216
|
}
|
|
1098
1217
|
catch (error) {
|
|
@@ -1100,31 +1219,97 @@ export class ScrcpyServer {
|
|
|
1100
1219
|
}
|
|
1101
1220
|
}, this.config.devicePollInterval);
|
|
1102
1221
|
}
|
|
1222
|
+
listenOnPort(port) {
|
|
1223
|
+
return new Promise((resolve, reject) => {
|
|
1224
|
+
const cleanup = () => {
|
|
1225
|
+
this.httpServer.off('error', onError);
|
|
1226
|
+
this.httpServer.off('listening', onListening);
|
|
1227
|
+
this.wss.off('error', onError);
|
|
1228
|
+
};
|
|
1229
|
+
const onError = (error) => {
|
|
1230
|
+
cleanup();
|
|
1231
|
+
reject(error);
|
|
1232
|
+
};
|
|
1233
|
+
const onListening = () => {
|
|
1234
|
+
cleanup();
|
|
1235
|
+
resolve();
|
|
1236
|
+
};
|
|
1237
|
+
this.httpServer.once('error', onError);
|
|
1238
|
+
this.httpServer.once('listening', onListening);
|
|
1239
|
+
this.wss.once('error', onError);
|
|
1240
|
+
this.httpServer.listen(port, this.config.host);
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
async listenWithPortFallback() {
|
|
1244
|
+
const requestedPort = this.config.port;
|
|
1245
|
+
const maxAttempts = 20;
|
|
1246
|
+
for (let offset = 0; offset <= maxAttempts; offset++) {
|
|
1247
|
+
const port = requestedPort + offset;
|
|
1248
|
+
try {
|
|
1249
|
+
await this.listenOnPort(port);
|
|
1250
|
+
if (port !== requestedPort) {
|
|
1251
|
+
logger.warn(`Scrcpy port ${requestedPort} is unavailable; using ${port} instead`);
|
|
1252
|
+
this.config.port = port;
|
|
1253
|
+
}
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
const err = error;
|
|
1258
|
+
if (err.code === 'EADDRINUSE') {
|
|
1259
|
+
logger.warn(`Scrcpy port ${port} is already in use, trying ${port + 1}`);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
throw error;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
throw new Error(`Unable to start scrcpy server: ports ${requestedPort}-${requestedPort + maxAttempts} are all in use`);
|
|
1266
|
+
}
|
|
1103
1267
|
async start() {
|
|
1104
1268
|
if (this.started)
|
|
1105
1269
|
return;
|
|
1270
|
+
if (this.startPromise)
|
|
1271
|
+
return this.startPromise;
|
|
1106
1272
|
if (!this.config.enabled) {
|
|
1107
1273
|
logger.info('Scrcpy server is disabled by configuration');
|
|
1108
1274
|
return;
|
|
1109
1275
|
}
|
|
1110
|
-
this.
|
|
1111
|
-
|
|
1112
|
-
this.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1276
|
+
this.startPromise = (async () => {
|
|
1277
|
+
await this.listenWithPortFallback();
|
|
1278
|
+
this.started = true;
|
|
1279
|
+
logger.info(`Scrcpy WebSocket server running at ws://${this.config.host}:${this.config.port}`);
|
|
1280
|
+
logger.info(`Using scrcpy-server bin: ${this.serverBinPath}`);
|
|
1281
|
+
this.startDeviceMonitoring();
|
|
1282
|
+
})();
|
|
1283
|
+
try {
|
|
1284
|
+
await this.startPromise;
|
|
1285
|
+
}
|
|
1286
|
+
finally {
|
|
1287
|
+
this.startPromise = null;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
getPort() {
|
|
1291
|
+
return this.config.port;
|
|
1292
|
+
}
|
|
1293
|
+
getUrl() {
|
|
1294
|
+
return `http://${this.config.host}:${this.config.port}`;
|
|
1119
1295
|
}
|
|
1120
1296
|
async close() {
|
|
1121
|
-
if (!this.started)
|
|
1297
|
+
if (!this.started && !this.startPromise)
|
|
1122
1298
|
return;
|
|
1299
|
+
if (this.startPromise) {
|
|
1300
|
+
try {
|
|
1301
|
+
await this.startPromise;
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
// ignore start errors while closing
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1123
1307
|
if (this.devicePollInterval) {
|
|
1124
1308
|
clearInterval(this.devicePollInterval);
|
|
1125
1309
|
this.devicePollInterval = null;
|
|
1126
1310
|
}
|
|
1127
1311
|
for (const sess of this.sessions.values()) {
|
|
1312
|
+
sess.closed = true;
|
|
1128
1313
|
await this.releaseSession(sess);
|
|
1129
1314
|
try {
|
|
1130
1315
|
sess.ws.close(1001, 'server shutting down');
|
|
@@ -1135,11 +1320,11 @@ export class ScrcpyServer {
|
|
|
1135
1320
|
}
|
|
1136
1321
|
this.sessions.clear();
|
|
1137
1322
|
await new Promise((resolve) => {
|
|
1138
|
-
if (!this.httpServer.listening) {
|
|
1139
|
-
resolve();
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
1323
|
this.wss.close(() => {
|
|
1324
|
+
if (!this.httpServer.listening) {
|
|
1325
|
+
resolve();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1143
1328
|
this.httpServer.close(() => resolve());
|
|
1144
1329
|
});
|
|
1145
1330
|
});
|