@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.
Files changed (49) hide show
  1. package/dist/core/device-proxy.d.ts +17 -0
  2. package/dist/core/device-proxy.d.ts.map +1 -1
  3. package/dist/core/device-proxy.js +95 -5
  4. package/dist/core/device-proxy.js.map +1 -1
  5. package/dist/debug/types.d.ts +56 -4
  6. package/dist/debug/types.d.ts.map +1 -1
  7. package/dist/debug/websocket-server.d.ts +8 -0
  8. package/dist/debug/websocket-server.d.ts.map +1 -1
  9. package/dist/debug/websocket-server.js +180 -17
  10. package/dist/debug/websocket-server.js.map +1 -1
  11. package/dist/executor/action-executor.d.ts +5 -5
  12. package/dist/executor/action-executor.d.ts.map +1 -1
  13. package/dist/executor/action-executor.js +32 -25
  14. package/dist/executor/action-executor.js.map +1 -1
  15. package/dist/executor/android-executor.d.ts +3 -14
  16. package/dist/executor/android-executor.d.ts.map +1 -1
  17. package/dist/executor/android-executor.js +21 -96
  18. package/dist/executor/android-executor.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +22 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/scrcpy/server.d.ts +13 -0
  23. package/dist/scrcpy/server.d.ts.map +1 -1
  24. package/dist/scrcpy/server.js +333 -148
  25. package/dist/scrcpy/server.js.map +1 -1
  26. package/dist/task/guada-online-batch-poller.d.ts +21 -0
  27. package/dist/task/guada-online-batch-poller.d.ts.map +1 -0
  28. package/dist/task/guada-online-batch-poller.js +132 -0
  29. package/dist/task/guada-online-batch-poller.js.map +1 -0
  30. package/dist/task/online-loop-poller.d.ts +20 -0
  31. package/dist/task/online-loop-poller.d.ts.map +1 -0
  32. package/dist/task/online-loop-poller.js +152 -0
  33. package/dist/task/online-loop-poller.js.map +1 -0
  34. package/dist/task/scheduler.d.ts +1 -0
  35. package/dist/task/scheduler.d.ts.map +1 -1
  36. package/dist/task/scheduler.js +47 -0
  37. package/dist/task/scheduler.js.map +1 -1
  38. package/dist/task/test-case-reference-resolver.d.ts +16 -0
  39. package/dist/task/test-case-reference-resolver.d.ts.map +1 -0
  40. package/dist/task/test-case-reference-resolver.js +283 -0
  41. package/dist/task/test-case-reference-resolver.js.map +1 -0
  42. package/dist/web/debug-page.d.ts.map +1 -1
  43. package/dist/web/debug-page.js +49 -58
  44. package/dist/web/debug-page.js.map +1 -1
  45. package/dist/web/server.d.ts +5 -0
  46. package/dist/web/server.d.ts.map +1 -1
  47. package/dist/web/server.js +35 -0
  48. package/dist/web/server.js.map +1 -1
  49. package/package.json +1 -1
@@ -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 getDevicesList() {
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 = (await withTimeout(client.getDevices(), this.config.adbConnectTimeoutMs, `Timed out listing Android devices via ADB after ${Math.round(this.config.adbConnectTimeoutMs / 1000)}s`));
254
- if (!devices || devices.length === 0)
255
- return null;
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 = availableDevice.serial;
260
- return new Adb(await withTimeout(client.createTransport(availableDevice), this.config.adbConnectTimeoutMs, `Timed out connecting to ${availableDevice.serial} via ADB`));
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': async (options = {}) => {
551
- try {
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: `Failed to connect device: ${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.started = true;
1111
- await new Promise((resolve) => {
1112
- this.httpServer.listen(this.config.port, this.config.host, () => {
1113
- logger.info(`Scrcpy WebSocket server running at ws://${this.config.host}:${this.config.port}`);
1114
- logger.info(`Using scrcpy-server bin: ${this.serverBinPath}`);
1115
- this.startDeviceMonitoring();
1116
- resolve();
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
  });