@midscene/android-playground 1.10.4 → 1.10.5-beta-20260714101501.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/es/bin.mjs CHANGED
@@ -146,6 +146,14 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
146
146
  };
147
147
  }
148
148
  });
149
+ function buildScrcpyPreviewErrorEvent(reason, sessionId, message) {
150
+ return {
151
+ reason,
152
+ sessionId,
153
+ message,
154
+ recoverable: 'process-exited' === reason || 'startup-timeout' === reason || 'stream-ended' === reason || 'stream-read-failed' === reason
155
+ };
156
+ }
149
157
  function getScrcpyPreviewStatusMessage(phase) {
150
158
  switch(phase){
151
159
  case 'connecting-device':
@@ -224,6 +232,11 @@ const LOOPBACK_HOSTS = new Set([
224
232
  '::1',
225
233
  '[::1]'
226
234
  ]);
235
+ const MAX_SCRCPY_OUTPUT_LINES = 100;
236
+ function appendBoundedScrcpyOutput(outputLines, line, maxLines = MAX_SCRCPY_OUTPUT_LINES) {
237
+ outputLines.push(line);
238
+ if (outputLines.length > maxLines) outputLines.splice(0, outputLines.length - maxLines);
239
+ }
227
240
  function isPrivateIP(hostname) {
228
241
  return LOOPBACK_HOSTS.has(hostname) || /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./.test(hostname);
229
242
  }
@@ -385,8 +398,48 @@ class ScrcpyServer {
385
398
  setupSocketHandlers() {
386
399
  this.io.on('connection', async (socket)=>{
387
400
  debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
388
- let scrcpyClient = null;
401
+ let activeSession = null;
402
+ let sessionGeneration = 0;
389
403
  let adb = null;
404
+ const closeScrcpySession = async (reason, sessionToClose = activeSession)=>{
405
+ if (!sessionToClose) return;
406
+ sessionToClose.closeReason ??= reason;
407
+ if (activeSession === sessionToClose) activeSession = null;
408
+ try {
409
+ debugPage('closing scrcpy session %s, reason: %s', sessionToClose.id, reason);
410
+ await sessionToClose.client.close();
411
+ } catch (error) {
412
+ console.error(`failed to close scrcpy client (${reason}):`, error);
413
+ }
414
+ };
415
+ const emitPreviewError = (session, reason, message)=>{
416
+ if (session.failureReported || session.closeReason) return;
417
+ session.failureReported = true;
418
+ debugPage('scrcpy session %s failed (%s), recent output:\n%s', session.id, reason, session.outputLines.join('\n'));
419
+ if (socket.connected) socket.emit('preview-error', buildScrcpyPreviewErrorEvent(reason, session.id, message));
420
+ };
421
+ const monitorScrcpySession = (session)=>{
422
+ (async ()=>{
423
+ try {
424
+ const reader = session.client.output.getReader();
425
+ while(true){
426
+ const { done, value } = await reader.read();
427
+ if (done) break;
428
+ for (const line of String(value).split(/\r?\n/))if (line) {
429
+ appendBoundedScrcpyOutput(session.outputLines, line);
430
+ debugPage('scrcpy[%s]: %s', session.id, line);
431
+ }
432
+ }
433
+ } catch (error) {
434
+ if (!session.closeReason) debugPage('failed reading scrcpy output for %s: %s', session.id, error);
435
+ }
436
+ })();
437
+ Promise.resolve(session.client.exited).then(()=>{
438
+ emitPreviewError(session, 'process-exited', 'Scrcpy service exited. Recovering preview.');
439
+ }).catch((error)=>{
440
+ emitPreviewError(session, 'process-exited', `Scrcpy service exited unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
441
+ });
442
+ };
390
443
  const emitPreviewStatus = (phase)=>{
391
444
  socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
392
445
  };
@@ -413,10 +466,8 @@ class ScrcpyServer {
413
466
  socket.on('switch-device', async (deviceId)=>{
414
467
  debugPage('received client request to switch device:', deviceId);
415
468
  try {
416
- if (scrcpyClient) {
417
- await scrcpyClient.close();
418
- scrcpyClient = null;
419
- }
469
+ sessionGeneration += 1;
470
+ await closeScrcpySession('switch device');
420
471
  this.currentDeviceId = deviceId;
421
472
  debugPage('device switched to:', deviceId);
422
473
  socket.emit('device-switched', {
@@ -435,21 +486,34 @@ class ScrcpyServer {
435
486
  });
436
487
  socket.on('connect-device', async (options = {})=>{
437
488
  const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
489
+ const generation = sessionGeneration + 1;
490
+ sessionGeneration = generation;
491
+ const sessionId = `${socket.id}:${generation}`;
492
+ const isCurrentGeneration = ()=>socket.connected && sessionGeneration === generation;
438
493
  try {
439
494
  debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
495
+ await closeScrcpySession('new connect-device request');
440
496
  emitPreviewStatus('connecting-device');
441
497
  const requestedDeviceId = resolveRequestedDeviceId(options, this.currentDeviceId);
442
498
  if (requestedDeviceId) this.currentDeviceId = requestedDeviceId;
443
499
  adb = await this.getAdb(requestedDeviceId);
444
500
  if (!adb) {
445
501
  console.error('no available device found');
446
- socket.emit('error', {
447
- message: 'No device found'
448
- });
502
+ socket.emit('preview-error', buildScrcpyPreviewErrorEvent('adb-unavailable', sessionId, 'No Android device is available.'));
449
503
  return;
450
504
  }
505
+ if (!isCurrentGeneration()) return;
451
506
  debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
452
- scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
507
+ const scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
508
+ if (!isCurrentGeneration()) return void await scrcpyClient.close();
509
+ const session = {
510
+ client: scrcpyClient,
511
+ failureReported: false,
512
+ id: sessionId,
513
+ outputLines: []
514
+ };
515
+ activeSession = session;
516
+ monitorScrcpySession(session);
453
517
  debugPage('scrcpy service started successfully');
454
518
  debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
455
519
  const type = typeof scrcpyClient[name];
@@ -469,6 +533,7 @@ class ScrcpyServer {
469
533
  emitPreviewStatus('waiting-for-video');
470
534
  videoStream = scrcpyClient.videoStream;
471
535
  }
536
+ if (!isCurrentGeneration() || activeSession !== session) return void await closeScrcpySession('stale video stream', session);
472
537
  debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
473
538
  const metadata = videoStream.metadata || {};
474
539
  debugPage('original metadata: %s', metadata);
@@ -485,14 +550,16 @@ class ScrcpyServer {
485
550
  socket.emit('video-metadata', metadata);
486
551
  debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
487
552
  const { stream } = videoStream;
553
+ const streamSession = session;
488
554
  const reader = stream.getReader();
489
555
  const processStream = async ()=>{
490
556
  try {
491
557
  while(true){
492
558
  const { done, value } = await reader.read();
493
559
  if (done) break;
560
+ if (!isCurrentGeneration() || activeSession !== streamSession) return;
494
561
  const frameType = value.type || 'data';
495
- socket.emit('video-data', {
562
+ socket.volatile.emit('video-data', {
496
563
  data: value.data,
497
564
  type: frameType,
498
565
  timestamp: Date.now(),
@@ -501,63 +568,39 @@ class ScrcpyServer {
501
568
  }
502
569
  } catch (error) {
503
570
  console.error('error processing video stream:', error);
504
- if (socket.connected) socket.emit('error', {
505
- message: 'video stream processing error'
506
- });
571
+ emitPreviewError(streamSession, 'stream-read-failed', `Scrcpy video stream failed: ${error instanceof Error ? error.message : String(error)}`);
572
+ closeScrcpySession('video stream error', streamSession);
507
573
  return;
508
574
  }
509
- if (socket.connected) socket.emit('error', {
510
- message: 'video stream ended'
511
- });
512
- if (scrcpyClient) {
513
- try {
514
- await scrcpyClient.close();
515
- } catch (closeError) {
516
- console.error('failed to close scrcpy client after stream ended:', closeError);
517
- }
518
- scrcpyClient = null;
519
- }
575
+ emitPreviewError(streamSession, 'stream-ended', 'Scrcpy video stream ended. Recovering preview.');
576
+ closeScrcpySession('video stream ended', streamSession);
520
577
  };
521
578
  processStream();
522
579
  } else {
523
580
  console.error('scrcpyClient object does not have videoStream property');
524
- socket.emit('error', {
525
- message: 'Video stream not available in scrcpy client'
526
- });
581
+ emitPreviewError(session, 'unknown', 'Video stream is not available in the scrcpy client.');
582
+ await closeScrcpySession('video stream unavailable', session);
583
+ return;
527
584
  }
528
585
  } catch (error) {
529
586
  console.error('error processing video stream:', error);
530
- socket.emit('error', {
531
- message: `Video stream processing error: ${error.message}`
532
- });
587
+ emitPreviewError(session, error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', `Video stream processing error: ${error.message}`);
588
+ await closeScrcpySession('video stream setup error', session);
533
589
  }
534
590
  if (scrcpyClient?.controller) socket.emit('control-ready');
535
591
  } catch (error) {
536
592
  console.error('failed to connect device:', error);
537
- if (scrcpyClient) {
538
- try {
539
- await scrcpyClient.close();
540
- } catch (closeError) {
541
- console.error('failed to close scrcpy client after error:', closeError);
542
- }
543
- scrcpyClient = null;
544
- }
545
- socket.emit('error', {
546
- message: `Failed to connect device: ${error?.message || 'Unknown error'}`
547
- });
593
+ const session = activeSession;
594
+ if (session?.id === sessionId) {
595
+ emitPreviewError(session, error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', `Failed to connect device: ${error?.message || 'Unknown error'}`);
596
+ await closeScrcpySession('connect-device error', session);
597
+ } else if (isCurrentGeneration()) socket.emit('preview-error', buildScrcpyPreviewErrorEvent(error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', sessionId, `Failed to connect device: ${error?.message || 'Unknown error'}`));
548
598
  }
549
599
  });
550
600
  socket.on('disconnect', async (reason)=>{
551
601
  debugPage('client disconnected, id: %s, reason: %s', socket.id, reason);
552
- if (scrcpyClient) {
553
- try {
554
- debugPage('closing scrcpy client');
555
- await scrcpyClient.close();
556
- } catch (error) {
557
- console.error('failed to close scrcpy client:', error);
558
- }
559
- scrcpyClient = null;
560
- }
602
+ sessionGeneration += 1;
603
+ await closeScrcpySession(`socket disconnect: ${reason}`);
561
604
  });
562
605
  sendDevicesList();
563
606
  });
package/dist/es/index.mjs CHANGED
@@ -146,6 +146,14 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
146
146
  };
147
147
  }
148
148
  });
149
+ function buildScrcpyPreviewErrorEvent(reason, sessionId, message) {
150
+ return {
151
+ reason,
152
+ sessionId,
153
+ message,
154
+ recoverable: 'process-exited' === reason || 'startup-timeout' === reason || 'stream-ended' === reason || 'stream-read-failed' === reason
155
+ };
156
+ }
149
157
  function getScrcpyPreviewStatusMessage(phase) {
150
158
  switch(phase){
151
159
  case 'connecting-device':
@@ -224,6 +232,11 @@ const LOOPBACK_HOSTS = new Set([
224
232
  '::1',
225
233
  '[::1]'
226
234
  ]);
235
+ const MAX_SCRCPY_OUTPUT_LINES = 100;
236
+ function appendBoundedScrcpyOutput(outputLines, line, maxLines = MAX_SCRCPY_OUTPUT_LINES) {
237
+ outputLines.push(line);
238
+ if (outputLines.length > maxLines) outputLines.splice(0, outputLines.length - maxLines);
239
+ }
227
240
  function isPrivateIP(hostname) {
228
241
  return LOOPBACK_HOSTS.has(hostname) || /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./.test(hostname);
229
242
  }
@@ -385,8 +398,48 @@ class ScrcpyServer {
385
398
  setupSocketHandlers() {
386
399
  this.io.on('connection', async (socket)=>{
387
400
  debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
388
- let scrcpyClient = null;
401
+ let activeSession = null;
402
+ let sessionGeneration = 0;
389
403
  let adb = null;
404
+ const closeScrcpySession = async (reason, sessionToClose = activeSession)=>{
405
+ if (!sessionToClose) return;
406
+ sessionToClose.closeReason ??= reason;
407
+ if (activeSession === sessionToClose) activeSession = null;
408
+ try {
409
+ debugPage('closing scrcpy session %s, reason: %s', sessionToClose.id, reason);
410
+ await sessionToClose.client.close();
411
+ } catch (error) {
412
+ console.error(`failed to close scrcpy client (${reason}):`, error);
413
+ }
414
+ };
415
+ const emitPreviewError = (session, reason, message)=>{
416
+ if (session.failureReported || session.closeReason) return;
417
+ session.failureReported = true;
418
+ debugPage('scrcpy session %s failed (%s), recent output:\n%s', session.id, reason, session.outputLines.join('\n'));
419
+ if (socket.connected) socket.emit('preview-error', buildScrcpyPreviewErrorEvent(reason, session.id, message));
420
+ };
421
+ const monitorScrcpySession = (session)=>{
422
+ (async ()=>{
423
+ try {
424
+ const reader = session.client.output.getReader();
425
+ while(true){
426
+ const { done, value } = await reader.read();
427
+ if (done) break;
428
+ for (const line of String(value).split(/\r?\n/))if (line) {
429
+ appendBoundedScrcpyOutput(session.outputLines, line);
430
+ debugPage('scrcpy[%s]: %s', session.id, line);
431
+ }
432
+ }
433
+ } catch (error) {
434
+ if (!session.closeReason) debugPage('failed reading scrcpy output for %s: %s', session.id, error);
435
+ }
436
+ })();
437
+ Promise.resolve(session.client.exited).then(()=>{
438
+ emitPreviewError(session, 'process-exited', 'Scrcpy service exited. Recovering preview.');
439
+ }).catch((error)=>{
440
+ emitPreviewError(session, 'process-exited', `Scrcpy service exited unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
441
+ });
442
+ };
390
443
  const emitPreviewStatus = (phase)=>{
391
444
  socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
392
445
  };
@@ -413,10 +466,8 @@ class ScrcpyServer {
413
466
  socket.on('switch-device', async (deviceId)=>{
414
467
  debugPage('received client request to switch device:', deviceId);
415
468
  try {
416
- if (scrcpyClient) {
417
- await scrcpyClient.close();
418
- scrcpyClient = null;
419
- }
469
+ sessionGeneration += 1;
470
+ await closeScrcpySession('switch device');
420
471
  this.currentDeviceId = deviceId;
421
472
  debugPage('device switched to:', deviceId);
422
473
  socket.emit('device-switched', {
@@ -435,21 +486,34 @@ class ScrcpyServer {
435
486
  });
436
487
  socket.on('connect-device', async (options = {})=>{
437
488
  const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
489
+ const generation = sessionGeneration + 1;
490
+ sessionGeneration = generation;
491
+ const sessionId = `${socket.id}:${generation}`;
492
+ const isCurrentGeneration = ()=>socket.connected && sessionGeneration === generation;
438
493
  try {
439
494
  debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
495
+ await closeScrcpySession('new connect-device request');
440
496
  emitPreviewStatus('connecting-device');
441
497
  const requestedDeviceId = resolveRequestedDeviceId(options, this.currentDeviceId);
442
498
  if (requestedDeviceId) this.currentDeviceId = requestedDeviceId;
443
499
  adb = await this.getAdb(requestedDeviceId);
444
500
  if (!adb) {
445
501
  console.error('no available device found');
446
- socket.emit('error', {
447
- message: 'No device found'
448
- });
502
+ socket.emit('preview-error', buildScrcpyPreviewErrorEvent('adb-unavailable', sessionId, 'No Android device is available.'));
449
503
  return;
450
504
  }
505
+ if (!isCurrentGeneration()) return;
451
506
  debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
452
- scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
507
+ const scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
508
+ if (!isCurrentGeneration()) return void await scrcpyClient.close();
509
+ const session = {
510
+ client: scrcpyClient,
511
+ failureReported: false,
512
+ id: sessionId,
513
+ outputLines: []
514
+ };
515
+ activeSession = session;
516
+ monitorScrcpySession(session);
453
517
  debugPage('scrcpy service started successfully');
454
518
  debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
455
519
  const type = typeof scrcpyClient[name];
@@ -469,6 +533,7 @@ class ScrcpyServer {
469
533
  emitPreviewStatus('waiting-for-video');
470
534
  videoStream = scrcpyClient.videoStream;
471
535
  }
536
+ if (!isCurrentGeneration() || activeSession !== session) return void await closeScrcpySession('stale video stream', session);
472
537
  debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
473
538
  const metadata = videoStream.metadata || {};
474
539
  debugPage('original metadata: %s', metadata);
@@ -485,14 +550,16 @@ class ScrcpyServer {
485
550
  socket.emit('video-metadata', metadata);
486
551
  debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
487
552
  const { stream } = videoStream;
553
+ const streamSession = session;
488
554
  const reader = stream.getReader();
489
555
  const processStream = async ()=>{
490
556
  try {
491
557
  while(true){
492
558
  const { done, value } = await reader.read();
493
559
  if (done) break;
560
+ if (!isCurrentGeneration() || activeSession !== streamSession) return;
494
561
  const frameType = value.type || 'data';
495
- socket.emit('video-data', {
562
+ socket.volatile.emit('video-data', {
496
563
  data: value.data,
497
564
  type: frameType,
498
565
  timestamp: Date.now(),
@@ -501,63 +568,39 @@ class ScrcpyServer {
501
568
  }
502
569
  } catch (error) {
503
570
  console.error('error processing video stream:', error);
504
- if (socket.connected) socket.emit('error', {
505
- message: 'video stream processing error'
506
- });
571
+ emitPreviewError(streamSession, 'stream-read-failed', `Scrcpy video stream failed: ${error instanceof Error ? error.message : String(error)}`);
572
+ closeScrcpySession('video stream error', streamSession);
507
573
  return;
508
574
  }
509
- if (socket.connected) socket.emit('error', {
510
- message: 'video stream ended'
511
- });
512
- if (scrcpyClient) {
513
- try {
514
- await scrcpyClient.close();
515
- } catch (closeError) {
516
- console.error('failed to close scrcpy client after stream ended:', closeError);
517
- }
518
- scrcpyClient = null;
519
- }
575
+ emitPreviewError(streamSession, 'stream-ended', 'Scrcpy video stream ended. Recovering preview.');
576
+ closeScrcpySession('video stream ended', streamSession);
520
577
  };
521
578
  processStream();
522
579
  } else {
523
580
  console.error('scrcpyClient object does not have videoStream property');
524
- socket.emit('error', {
525
- message: 'Video stream not available in scrcpy client'
526
- });
581
+ emitPreviewError(session, 'unknown', 'Video stream is not available in the scrcpy client.');
582
+ await closeScrcpySession('video stream unavailable', session);
583
+ return;
527
584
  }
528
585
  } catch (error) {
529
586
  console.error('error processing video stream:', error);
530
- socket.emit('error', {
531
- message: `Video stream processing error: ${error.message}`
532
- });
587
+ emitPreviewError(session, error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', `Video stream processing error: ${error.message}`);
588
+ await closeScrcpySession('video stream setup error', session);
533
589
  }
534
590
  if (scrcpyClient?.controller) socket.emit('control-ready');
535
591
  } catch (error) {
536
592
  console.error('failed to connect device:', error);
537
- if (scrcpyClient) {
538
- try {
539
- await scrcpyClient.close();
540
- } catch (closeError) {
541
- console.error('failed to close scrcpy client after error:', closeError);
542
- }
543
- scrcpyClient = null;
544
- }
545
- socket.emit('error', {
546
- message: `Failed to connect device: ${error?.message || 'Unknown error'}`
547
- });
593
+ const session = activeSession;
594
+ if (session?.id === sessionId) {
595
+ emitPreviewError(session, error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', `Failed to connect device: ${error?.message || 'Unknown error'}`);
596
+ await closeScrcpySession('connect-device error', session);
597
+ } else if (isCurrentGeneration()) socket.emit('preview-error', buildScrcpyPreviewErrorEvent(error?.message?.includes('Timed out') ? 'startup-timeout' : 'unknown', sessionId, `Failed to connect device: ${error?.message || 'Unknown error'}`));
548
598
  }
549
599
  });
550
600
  socket.on('disconnect', async (reason)=>{
551
601
  debugPage('client disconnected, id: %s, reason: %s', socket.id, reason);
552
- if (scrcpyClient) {
553
- try {
554
- debugPage('closing scrcpy client');
555
- await scrcpyClient.close();
556
- } catch (error) {
557
- console.error('failed to close scrcpy client:', error);
558
- }
559
- scrcpyClient = null;
560
- }
602
+ sessionGeneration += 1;
603
+ await closeScrcpySession(`socket disconnect: ${reason}`);
561
604
  });
562
605
  sendDevicesList();
563
606
  });