@midscene/android-playground 1.7.3 → 1.7.4

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
@@ -1,7 +1,7 @@
1
1
  import node_path from "node:path";
2
2
  import { createScrcpyPreviewDescriptor, definePlaygroundPlatform, launchPreparedPlaygroundPlatform } from "@midscene/playground";
3
3
  import { AndroidAgent, AndroidDevice, getConnectedDevicesWithDetails } from "@midscene/android";
4
- import { PLAYGROUND_SERVER_PORT, SCRCPY_SERVER_PORT } from "@midscene/shared/constants";
4
+ import { PLAYGROUND_SERVER_PORT, SCRCPY_PREVIEW_METADATA_TIMEOUT_MS, SCRCPY_PUSH_TIMEOUT_MS, SCRCPY_SERVER_PORT, SCRCPY_START_TIMEOUT_MS, SCRCPY_VIDEO_STREAM_TIMEOUT_MS } from "@midscene/shared/constants";
5
5
  import { findAvailablePort } from "@midscene/shared/node";
6
6
  import { exec } from "node:child_process";
7
7
  import { createReadStream } from "node:fs";
@@ -42,8 +42,8 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
42
42
  async getSetupSchema () {
43
43
  const targets = await getAdbTargets();
44
44
  return {
45
- title: 'Connect Android device',
46
- description: 'Select an available ADB device to create the current Android Agent.',
45
+ title: 'Welcome to\nMidscene.js Playground!',
46
+ description: 'Select an available ADB device to create the current Android Agent',
47
47
  primaryActionLabel: 'Create Agent',
48
48
  autoSubmitWhenReady: 1 === targets.length,
49
49
  fields: [
@@ -129,6 +129,24 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
129
129
  };
130
130
  }
131
131
  });
132
+ function getScrcpyPreviewStatusMessage(phase) {
133
+ switch(phase){
134
+ case 'connecting-device':
135
+ return 'Preparing Android device connection…';
136
+ case 'pushing-server':
137
+ return 'Uploading scrcpy runtime to device…';
138
+ case 'starting-service':
139
+ return 'Starting scrcpy service…';
140
+ case 'waiting-for-video':
141
+ return 'Waiting for first video frame…';
142
+ }
143
+ }
144
+ function buildScrcpyPreviewStatusEvent(phase) {
145
+ return {
146
+ phase,
147
+ message: getScrcpyPreviewStatusMessage(phase)
148
+ };
149
+ }
132
150
  function _define_property(obj, key, value) {
133
151
  if (key in obj) Object.defineProperty(obj, key, {
134
152
  value: value,
@@ -139,6 +157,48 @@ function _define_property(obj, key, value) {
139
157
  else obj[key] = value;
140
158
  return obj;
141
159
  }
160
+ class PromiseTimeoutError extends Error {
161
+ constructor(message, timeoutMs){
162
+ super(message), _define_property(this, "timeoutMs", void 0);
163
+ this.name = 'PromiseTimeoutError';
164
+ this.timeoutMs = timeoutMs;
165
+ }
166
+ }
167
+ function runTimeoutCallback(callback, context) {
168
+ if (!callback) return;
169
+ Promise.resolve().then(callback).catch((error)=>{
170
+ console.error(`Failed to run ${context}:`, error);
171
+ });
172
+ }
173
+ function withTimeout(promise, timeoutMs, message, options = {}) {
174
+ return new Promise((resolve, reject)=>{
175
+ let timedOut = false;
176
+ const timer = setTimeout(()=>{
177
+ timedOut = true;
178
+ runTimeoutCallback(options.onTimeout, 'timeout callback');
179
+ reject(new PromiseTimeoutError(message, timeoutMs));
180
+ }, timeoutMs);
181
+ Promise.resolve(promise).then((value)=>{
182
+ clearTimeout(timer);
183
+ if (timedOut) return void runTimeoutCallback(()=>options.onSettledAfterTimeout?.(value), 'post-timeout settle callback');
184
+ resolve(value);
185
+ }, (error)=>{
186
+ clearTimeout(timer);
187
+ if (timedOut) return void runTimeoutCallback(()=>options.onRejectedAfterTimeout?.(error), 'post-timeout rejection callback');
188
+ reject(error);
189
+ });
190
+ });
191
+ }
192
+ function scrcpy_server_define_property(obj, key, value) {
193
+ if (key in obj) Object.defineProperty(obj, key, {
194
+ value: value,
195
+ enumerable: true,
196
+ configurable: true,
197
+ writable: true
198
+ });
199
+ else obj[key] = value;
200
+ return obj;
201
+ }
142
202
  const debugPage = getDebug('android:playground');
143
203
  const promiseExec = promisify(exec);
144
204
  const LOOPBACK_HOSTS = new Set([
@@ -249,14 +309,15 @@ class ScrcpyServer {
249
309
  return null;
250
310
  }
251
311
  }
252
- async startScrcpy(adb, options = {}) {
312
+ async startScrcpy(adb, options = {}, onProgress) {
253
313
  const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
254
314
  const { ReadableStream } = await import("@yume-chan/stream-extra");
255
315
  const { DefaultServerPath } = await import("@yume-chan/scrcpy");
256
316
  const currentDir = 'undefined' != typeof __dirname ? __dirname : node_path.dirname(fileURLToPath(import.meta.url));
257
317
  const serverBinPath = node_path.resolve(currentDir, '../../bin/scrcpy-server');
258
318
  try {
259
- await AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(serverBinPath)));
319
+ onProgress?.('pushing-server');
320
+ await withTimeout(AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(serverBinPath))), SCRCPY_PUSH_TIMEOUT_MS, `Timed out pushing scrcpy server to device after ${Math.round(SCRCPY_PUSH_TIMEOUT_MS / 1000)}s`);
260
321
  const scrcpyOptions = new AdbScrcpyOptions3_3_3({
261
322
  audio: false,
262
323
  control: true,
@@ -265,7 +326,17 @@ class ScrcpyServer {
265
326
  videoBitRate: 2000000,
266
327
  ...options
267
328
  });
268
- return await AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
329
+ onProgress?.('starting-service');
330
+ const startPromise = AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
331
+ return await withTimeout(startPromise, SCRCPY_START_TIMEOUT_MS, `Timed out starting scrcpy service after ${Math.round(SCRCPY_START_TIMEOUT_MS / 1000)}s`, {
332
+ onSettledAfterTimeout: async (lateClient)=>{
333
+ try {
334
+ await lateClient.close();
335
+ } catch (closeError) {
336
+ console.error('failed to close late scrcpy client after timeout:', closeError);
337
+ }
338
+ }
339
+ });
269
340
  } catch (error) {
270
341
  console.error('failed to start scrcpy:', error);
271
342
  throw error;
@@ -276,6 +347,9 @@ class ScrcpyServer {
276
347
  debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
277
348
  let scrcpyClient = null;
278
349
  let adb = null;
350
+ const emitPreviewStatus = (phase)=>{
351
+ socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
352
+ };
279
353
  const sendDevicesList = async ()=>{
280
354
  try {
281
355
  debugPage('Socket request to get devices list');
@@ -324,6 +398,7 @@ class ScrcpyServer {
324
398
  const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
325
399
  try {
326
400
  debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
401
+ emitPreviewStatus('connecting-device');
327
402
  adb = await this.getAdb(this.currentDeviceId || void 0);
328
403
  if (!adb) {
329
404
  console.error('no available device found');
@@ -333,7 +408,7 @@ class ScrcpyServer {
333
408
  return;
334
409
  }
335
410
  debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
336
- scrcpyClient = await this.startScrcpy(adb, options);
411
+ scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
337
412
  debugPage('scrcpy service started successfully');
338
413
  debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
339
414
  const type = typeof scrcpyClient[name];
@@ -346,9 +421,11 @@ class ScrcpyServer {
346
421
  let videoStream;
347
422
  if ('object' == typeof scrcpyClient.videoStream && 'function' == typeof scrcpyClient.videoStream.then) {
348
423
  debugPage('videoStream is a Promise, waiting for resolution...');
349
- videoStream = await scrcpyClient.videoStream;
424
+ emitPreviewStatus('waiting-for-video');
425
+ videoStream = await withTimeout(scrcpyClient.videoStream, SCRCPY_VIDEO_STREAM_TIMEOUT_MS, `Timed out waiting for scrcpy video stream metadata after ${Math.round(SCRCPY_VIDEO_STREAM_TIMEOUT_MS / 1000)}s`);
350
426
  } else {
351
427
  debugPage('videoStream is not a Promise, directly use');
428
+ emitPreviewStatus('waiting-for-video');
352
429
  videoStream = scrcpyClient.videoStream;
353
430
  }
354
431
  debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
@@ -365,7 +442,7 @@ class ScrcpyServer {
365
442
  }
366
443
  debugPage('prepare to send video-metadata event to client, data: %s', JSON.stringify(metadata));
367
444
  socket.emit('video-metadata', metadata);
368
- debugPage('video-metadata event sent to client, id: %s', socket.id);
445
+ debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
369
446
  const { stream } = videoStream;
370
447
  const reader = stream.getReader();
371
448
  const processStream = async ()=>{
@@ -404,6 +481,14 @@ class ScrcpyServer {
404
481
  if (scrcpyClient?.controller) socket.emit('control-ready');
405
482
  } catch (error) {
406
483
  console.error('failed to connect device:', error);
484
+ if (scrcpyClient) {
485
+ try {
486
+ await scrcpyClient.close();
487
+ } catch (closeError) {
488
+ console.error('failed to close scrcpy client after error:', closeError);
489
+ }
490
+ scrcpyClient = null;
491
+ }
407
492
  socket.emit('error', {
408
493
  message: `Failed to connect device: ${error?.message || 'Unknown error'}`
409
494
  });
@@ -467,15 +552,15 @@ class ScrcpyServer {
467
552
  if (this.httpServer) return this.httpServer.close();
468
553
  }
469
554
  constructor(){
470
- _define_property(this, "app", void 0);
471
- _define_property(this, "httpServer", void 0);
472
- _define_property(this, "io", void 0);
473
- _define_property(this, "port", void 0);
474
- _define_property(this, "defaultPort", SCRCPY_SERVER_PORT);
475
- _define_property(this, "adbClient", null);
476
- _define_property(this, "currentDeviceId", null);
477
- _define_property(this, "devicePollInterval", null);
478
- _define_property(this, "lastDeviceList", '');
555
+ scrcpy_server_define_property(this, "app", void 0);
556
+ scrcpy_server_define_property(this, "httpServer", void 0);
557
+ scrcpy_server_define_property(this, "io", void 0);
558
+ scrcpy_server_define_property(this, "port", void 0);
559
+ scrcpy_server_define_property(this, "defaultPort", SCRCPY_SERVER_PORT);
560
+ scrcpy_server_define_property(this, "adbClient", null);
561
+ scrcpy_server_define_property(this, "currentDeviceId", null);
562
+ scrcpy_server_define_property(this, "devicePollInterval", null);
563
+ scrcpy_server_define_property(this, "lastDeviceList", '');
479
564
  this.app = express();
480
565
  this.httpServer = createServer(this.app);
481
566
  this.io = new Server(this.httpServer, {
package/dist/es/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import node_path from "node:path";
2
2
  import { AndroidAgent, AndroidDevice, getConnectedDevicesWithDetails } from "@midscene/android";
3
3
  import { createScrcpyPreviewDescriptor, definePlaygroundPlatform } from "@midscene/playground";
4
- import { PLAYGROUND_SERVER_PORT, SCRCPY_SERVER_PORT } from "@midscene/shared/constants";
4
+ import { PLAYGROUND_SERVER_PORT, SCRCPY_PREVIEW_METADATA_TIMEOUT_MS, SCRCPY_PUSH_TIMEOUT_MS, SCRCPY_SERVER_PORT, SCRCPY_START_TIMEOUT_MS, SCRCPY_VIDEO_STREAM_TIMEOUT_MS } from "@midscene/shared/constants";
5
5
  import { findAvailablePort } from "@midscene/shared/node";
6
6
  import { exec } from "node:child_process";
7
7
  import { createReadStream } from "node:fs";
@@ -42,8 +42,8 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
42
42
  async getSetupSchema () {
43
43
  const targets = await getAdbTargets();
44
44
  return {
45
- title: 'Connect Android device',
46
- description: 'Select an available ADB device to create the current Android Agent.',
45
+ title: 'Welcome to\nMidscene.js Playground!',
46
+ description: 'Select an available ADB device to create the current Android Agent',
47
47
  primaryActionLabel: 'Create Agent',
48
48
  autoSubmitWhenReady: 1 === targets.length,
49
49
  fields: [
@@ -129,6 +129,24 @@ const androidPlaygroundPlatform = definePlaygroundPlatform({
129
129
  };
130
130
  }
131
131
  });
132
+ function getScrcpyPreviewStatusMessage(phase) {
133
+ switch(phase){
134
+ case 'connecting-device':
135
+ return 'Preparing Android device connection…';
136
+ case 'pushing-server':
137
+ return 'Uploading scrcpy runtime to device…';
138
+ case 'starting-service':
139
+ return 'Starting scrcpy service…';
140
+ case 'waiting-for-video':
141
+ return 'Waiting for first video frame…';
142
+ }
143
+ }
144
+ function buildScrcpyPreviewStatusEvent(phase) {
145
+ return {
146
+ phase,
147
+ message: getScrcpyPreviewStatusMessage(phase)
148
+ };
149
+ }
132
150
  function _define_property(obj, key, value) {
133
151
  if (key in obj) Object.defineProperty(obj, key, {
134
152
  value: value,
@@ -139,6 +157,48 @@ function _define_property(obj, key, value) {
139
157
  else obj[key] = value;
140
158
  return obj;
141
159
  }
160
+ class PromiseTimeoutError extends Error {
161
+ constructor(message, timeoutMs){
162
+ super(message), _define_property(this, "timeoutMs", void 0);
163
+ this.name = 'PromiseTimeoutError';
164
+ this.timeoutMs = timeoutMs;
165
+ }
166
+ }
167
+ function runTimeoutCallback(callback, context) {
168
+ if (!callback) return;
169
+ Promise.resolve().then(callback).catch((error)=>{
170
+ console.error(`Failed to run ${context}:`, error);
171
+ });
172
+ }
173
+ function withTimeout(promise, timeoutMs, message, options = {}) {
174
+ return new Promise((resolve, reject)=>{
175
+ let timedOut = false;
176
+ const timer = setTimeout(()=>{
177
+ timedOut = true;
178
+ runTimeoutCallback(options.onTimeout, 'timeout callback');
179
+ reject(new PromiseTimeoutError(message, timeoutMs));
180
+ }, timeoutMs);
181
+ Promise.resolve(promise).then((value)=>{
182
+ clearTimeout(timer);
183
+ if (timedOut) return void runTimeoutCallback(()=>options.onSettledAfterTimeout?.(value), 'post-timeout settle callback');
184
+ resolve(value);
185
+ }, (error)=>{
186
+ clearTimeout(timer);
187
+ if (timedOut) return void runTimeoutCallback(()=>options.onRejectedAfterTimeout?.(error), 'post-timeout rejection callback');
188
+ reject(error);
189
+ });
190
+ });
191
+ }
192
+ function scrcpy_server_define_property(obj, key, value) {
193
+ if (key in obj) Object.defineProperty(obj, key, {
194
+ value: value,
195
+ enumerable: true,
196
+ configurable: true,
197
+ writable: true
198
+ });
199
+ else obj[key] = value;
200
+ return obj;
201
+ }
142
202
  const debugPage = getDebug('android:playground');
143
203
  const promiseExec = promisify(exec);
144
204
  const LOOPBACK_HOSTS = new Set([
@@ -249,14 +309,15 @@ class ScrcpyServer {
249
309
  return null;
250
310
  }
251
311
  }
252
- async startScrcpy(adb, options = {}) {
312
+ async startScrcpy(adb, options = {}, onProgress) {
253
313
  const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
254
314
  const { ReadableStream } = await import("@yume-chan/stream-extra");
255
315
  const { DefaultServerPath } = await import("@yume-chan/scrcpy");
256
316
  const currentDir = 'undefined' != typeof __dirname ? __dirname : node_path.dirname(fileURLToPath(import.meta.url));
257
317
  const serverBinPath = node_path.resolve(currentDir, '../../bin/scrcpy-server');
258
318
  try {
259
- await AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(serverBinPath)));
319
+ onProgress?.('pushing-server');
320
+ await withTimeout(AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(serverBinPath))), SCRCPY_PUSH_TIMEOUT_MS, `Timed out pushing scrcpy server to device after ${Math.round(SCRCPY_PUSH_TIMEOUT_MS / 1000)}s`);
260
321
  const scrcpyOptions = new AdbScrcpyOptions3_3_3({
261
322
  audio: false,
262
323
  control: true,
@@ -265,7 +326,17 @@ class ScrcpyServer {
265
326
  videoBitRate: 2000000,
266
327
  ...options
267
328
  });
268
- return await AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
329
+ onProgress?.('starting-service');
330
+ const startPromise = AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
331
+ return await withTimeout(startPromise, SCRCPY_START_TIMEOUT_MS, `Timed out starting scrcpy service after ${Math.round(SCRCPY_START_TIMEOUT_MS / 1000)}s`, {
332
+ onSettledAfterTimeout: async (lateClient)=>{
333
+ try {
334
+ await lateClient.close();
335
+ } catch (closeError) {
336
+ console.error('failed to close late scrcpy client after timeout:', closeError);
337
+ }
338
+ }
339
+ });
269
340
  } catch (error) {
270
341
  console.error('failed to start scrcpy:', error);
271
342
  throw error;
@@ -276,6 +347,9 @@ class ScrcpyServer {
276
347
  debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
277
348
  let scrcpyClient = null;
278
349
  let adb = null;
350
+ const emitPreviewStatus = (phase)=>{
351
+ socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
352
+ };
279
353
  const sendDevicesList = async ()=>{
280
354
  try {
281
355
  debugPage('Socket request to get devices list');
@@ -324,6 +398,7 @@ class ScrcpyServer {
324
398
  const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
325
399
  try {
326
400
  debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
401
+ emitPreviewStatus('connecting-device');
327
402
  adb = await this.getAdb(this.currentDeviceId || void 0);
328
403
  if (!adb) {
329
404
  console.error('no available device found');
@@ -333,7 +408,7 @@ class ScrcpyServer {
333
408
  return;
334
409
  }
335
410
  debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
336
- scrcpyClient = await this.startScrcpy(adb, options);
411
+ scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
337
412
  debugPage('scrcpy service started successfully');
338
413
  debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
339
414
  const type = typeof scrcpyClient[name];
@@ -346,9 +421,11 @@ class ScrcpyServer {
346
421
  let videoStream;
347
422
  if ('object' == typeof scrcpyClient.videoStream && 'function' == typeof scrcpyClient.videoStream.then) {
348
423
  debugPage('videoStream is a Promise, waiting for resolution...');
349
- videoStream = await scrcpyClient.videoStream;
424
+ emitPreviewStatus('waiting-for-video');
425
+ videoStream = await withTimeout(scrcpyClient.videoStream, SCRCPY_VIDEO_STREAM_TIMEOUT_MS, `Timed out waiting for scrcpy video stream metadata after ${Math.round(SCRCPY_VIDEO_STREAM_TIMEOUT_MS / 1000)}s`);
350
426
  } else {
351
427
  debugPage('videoStream is not a Promise, directly use');
428
+ emitPreviewStatus('waiting-for-video');
352
429
  videoStream = scrcpyClient.videoStream;
353
430
  }
354
431
  debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
@@ -365,7 +442,7 @@ class ScrcpyServer {
365
442
  }
366
443
  debugPage('prepare to send video-metadata event to client, data: %s', JSON.stringify(metadata));
367
444
  socket.emit('video-metadata', metadata);
368
- debugPage('video-metadata event sent to client, id: %s', socket.id);
445
+ debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
369
446
  const { stream } = videoStream;
370
447
  const reader = stream.getReader();
371
448
  const processStream = async ()=>{
@@ -404,6 +481,14 @@ class ScrcpyServer {
404
481
  if (scrcpyClient?.controller) socket.emit('control-ready');
405
482
  } catch (error) {
406
483
  console.error('failed to connect device:', error);
484
+ if (scrcpyClient) {
485
+ try {
486
+ await scrcpyClient.close();
487
+ } catch (closeError) {
488
+ console.error('failed to close scrcpy client after error:', closeError);
489
+ }
490
+ scrcpyClient = null;
491
+ }
407
492
  socket.emit('error', {
408
493
  message: `Failed to connect device: ${error?.message || 'Unknown error'}`
409
494
  });
@@ -467,15 +552,15 @@ class ScrcpyServer {
467
552
  if (this.httpServer) return this.httpServer.close();
468
553
  }
469
554
  constructor(){
470
- _define_property(this, "app", void 0);
471
- _define_property(this, "httpServer", void 0);
472
- _define_property(this, "io", void 0);
473
- _define_property(this, "port", void 0);
474
- _define_property(this, "defaultPort", SCRCPY_SERVER_PORT);
475
- _define_property(this, "adbClient", null);
476
- _define_property(this, "currentDeviceId", null);
477
- _define_property(this, "devicePollInterval", null);
478
- _define_property(this, "lastDeviceList", '');
555
+ scrcpy_server_define_property(this, "app", void 0);
556
+ scrcpy_server_define_property(this, "httpServer", void 0);
557
+ scrcpy_server_define_property(this, "io", void 0);
558
+ scrcpy_server_define_property(this, "port", void 0);
559
+ scrcpy_server_define_property(this, "defaultPort", SCRCPY_SERVER_PORT);
560
+ scrcpy_server_define_property(this, "adbClient", null);
561
+ scrcpy_server_define_property(this, "currentDeviceId", null);
562
+ scrcpy_server_define_property(this, "devicePollInterval", null);
563
+ scrcpy_server_define_property(this, "lastDeviceList", '');
479
564
  this.app = express();
480
565
  this.httpServer = createServer(this.app);
481
566
  this.io = new Server(this.httpServer, {
package/dist/lib/bin.js CHANGED
@@ -57,8 +57,8 @@ const androidPlaygroundPlatform = (0, playground_namespaceObject.definePlaygroun
57
57
  async getSetupSchema () {
58
58
  const targets = await getAdbTargets();
59
59
  return {
60
- title: 'Connect Android device',
61
- description: 'Select an available ADB device to create the current Android Agent.',
60
+ title: 'Welcome to\nMidscene.js Playground!',
61
+ description: 'Select an available ADB device to create the current Android Agent',
62
62
  primaryActionLabel: 'Create Agent',
63
63
  autoSubmitWhenReady: 1 === targets.length,
64
64
  fields: [
@@ -155,6 +155,24 @@ var external_cors_default = /*#__PURE__*/ __webpack_require__.n(external_cors_na
155
155
  const external_express_namespaceObject = require("express");
156
156
  var external_express_default = /*#__PURE__*/ __webpack_require__.n(external_express_namespaceObject);
157
157
  const external_socket_io_namespaceObject = require("socket.io");
158
+ function getScrcpyPreviewStatusMessage(phase) {
159
+ switch(phase){
160
+ case 'connecting-device':
161
+ return 'Preparing Android device connection…';
162
+ case 'pushing-server':
163
+ return 'Uploading scrcpy runtime to device…';
164
+ case 'starting-service':
165
+ return 'Starting scrcpy service…';
166
+ case 'waiting-for-video':
167
+ return 'Waiting for first video frame…';
168
+ }
169
+ }
170
+ function buildScrcpyPreviewStatusEvent(phase) {
171
+ return {
172
+ phase,
173
+ message: getScrcpyPreviewStatusMessage(phase)
174
+ };
175
+ }
158
176
  function _define_property(obj, key, value) {
159
177
  if (key in obj) Object.defineProperty(obj, key, {
160
178
  value: value,
@@ -165,6 +183,48 @@ function _define_property(obj, key, value) {
165
183
  else obj[key] = value;
166
184
  return obj;
167
185
  }
186
+ class PromiseTimeoutError extends Error {
187
+ constructor(message, timeoutMs){
188
+ super(message), _define_property(this, "timeoutMs", void 0);
189
+ this.name = 'PromiseTimeoutError';
190
+ this.timeoutMs = timeoutMs;
191
+ }
192
+ }
193
+ function runTimeoutCallback(callback, context) {
194
+ if (!callback) return;
195
+ Promise.resolve().then(callback).catch((error)=>{
196
+ console.error(`Failed to run ${context}:`, error);
197
+ });
198
+ }
199
+ function withTimeout(promise, timeoutMs, message, options = {}) {
200
+ return new Promise((resolve, reject)=>{
201
+ let timedOut = false;
202
+ const timer = setTimeout(()=>{
203
+ timedOut = true;
204
+ runTimeoutCallback(options.onTimeout, 'timeout callback');
205
+ reject(new PromiseTimeoutError(message, timeoutMs));
206
+ }, timeoutMs);
207
+ Promise.resolve(promise).then((value)=>{
208
+ clearTimeout(timer);
209
+ if (timedOut) return void runTimeoutCallback(()=>options.onSettledAfterTimeout?.(value), 'post-timeout settle callback');
210
+ resolve(value);
211
+ }, (error)=>{
212
+ clearTimeout(timer);
213
+ if (timedOut) return void runTimeoutCallback(()=>options.onRejectedAfterTimeout?.(error), 'post-timeout rejection callback');
214
+ reject(error);
215
+ });
216
+ });
217
+ }
218
+ function scrcpy_server_define_property(obj, key, value) {
219
+ if (key in obj) Object.defineProperty(obj, key, {
220
+ value: value,
221
+ enumerable: true,
222
+ configurable: true,
223
+ writable: true
224
+ });
225
+ else obj[key] = value;
226
+ return obj;
227
+ }
168
228
  const debugPage = (0, logger_namespaceObject.getDebug)('android:playground');
169
229
  const promiseExec = (0, external_node_util_namespaceObject.promisify)(external_node_child_process_namespaceObject.exec);
170
230
  const LOOPBACK_HOSTS = new Set([
@@ -275,14 +335,15 @@ class ScrcpyServer {
275
335
  return null;
276
336
  }
277
337
  }
278
- async startScrcpy(adb, options = {}) {
338
+ async startScrcpy(adb, options = {}, onProgress) {
279
339
  const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
280
340
  const { ReadableStream } = await import("@yume-chan/stream-extra");
281
341
  const { DefaultServerPath } = await import("@yume-chan/scrcpy");
282
342
  const currentDir = __dirname;
283
343
  const serverBinPath = external_node_path_default().resolve(currentDir, '../../bin/scrcpy-server');
284
344
  try {
285
- await AdbScrcpyClient.pushServer(adb, ReadableStream.from((0, external_node_fs_namespaceObject.createReadStream)(serverBinPath)));
345
+ onProgress?.('pushing-server');
346
+ await withTimeout(AdbScrcpyClient.pushServer(adb, ReadableStream.from((0, external_node_fs_namespaceObject.createReadStream)(serverBinPath))), constants_namespaceObject.SCRCPY_PUSH_TIMEOUT_MS, `Timed out pushing scrcpy server to device after ${Math.round(constants_namespaceObject.SCRCPY_PUSH_TIMEOUT_MS / 1000)}s`);
286
347
  const scrcpyOptions = new AdbScrcpyOptions3_3_3({
287
348
  audio: false,
288
349
  control: true,
@@ -291,7 +352,17 @@ class ScrcpyServer {
291
352
  videoBitRate: 2000000,
292
353
  ...options
293
354
  });
294
- return await AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
355
+ onProgress?.('starting-service');
356
+ const startPromise = AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
357
+ return await withTimeout(startPromise, constants_namespaceObject.SCRCPY_START_TIMEOUT_MS, `Timed out starting scrcpy service after ${Math.round(constants_namespaceObject.SCRCPY_START_TIMEOUT_MS / 1000)}s`, {
358
+ onSettledAfterTimeout: async (lateClient)=>{
359
+ try {
360
+ await lateClient.close();
361
+ } catch (closeError) {
362
+ console.error('failed to close late scrcpy client after timeout:', closeError);
363
+ }
364
+ }
365
+ });
295
366
  } catch (error) {
296
367
  console.error('failed to start scrcpy:', error);
297
368
  throw error;
@@ -302,6 +373,9 @@ class ScrcpyServer {
302
373
  debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
303
374
  let scrcpyClient = null;
304
375
  let adb = null;
376
+ const emitPreviewStatus = (phase)=>{
377
+ socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
378
+ };
305
379
  const sendDevicesList = async ()=>{
306
380
  try {
307
381
  debugPage('Socket request to get devices list');
@@ -350,6 +424,7 @@ class ScrcpyServer {
350
424
  const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
351
425
  try {
352
426
  debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
427
+ emitPreviewStatus('connecting-device');
353
428
  adb = await this.getAdb(this.currentDeviceId || void 0);
354
429
  if (!adb) {
355
430
  console.error('no available device found');
@@ -359,7 +434,7 @@ class ScrcpyServer {
359
434
  return;
360
435
  }
361
436
  debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
362
- scrcpyClient = await this.startScrcpy(adb, options);
437
+ scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
363
438
  debugPage('scrcpy service started successfully');
364
439
  debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
365
440
  const type = typeof scrcpyClient[name];
@@ -372,9 +447,11 @@ class ScrcpyServer {
372
447
  let videoStream;
373
448
  if ('object' == typeof scrcpyClient.videoStream && 'function' == typeof scrcpyClient.videoStream.then) {
374
449
  debugPage('videoStream is a Promise, waiting for resolution...');
375
- videoStream = await scrcpyClient.videoStream;
450
+ emitPreviewStatus('waiting-for-video');
451
+ videoStream = await withTimeout(scrcpyClient.videoStream, constants_namespaceObject.SCRCPY_VIDEO_STREAM_TIMEOUT_MS, `Timed out waiting for scrcpy video stream metadata after ${Math.round(constants_namespaceObject.SCRCPY_VIDEO_STREAM_TIMEOUT_MS / 1000)}s`);
376
452
  } else {
377
453
  debugPage('videoStream is not a Promise, directly use');
454
+ emitPreviewStatus('waiting-for-video');
378
455
  videoStream = scrcpyClient.videoStream;
379
456
  }
380
457
  debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
@@ -391,7 +468,7 @@ class ScrcpyServer {
391
468
  }
392
469
  debugPage('prepare to send video-metadata event to client, data: %s', JSON.stringify(metadata));
393
470
  socket.emit('video-metadata', metadata);
394
- debugPage('video-metadata event sent to client, id: %s', socket.id);
471
+ debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(constants_namespaceObject.SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
395
472
  const { stream } = videoStream;
396
473
  const reader = stream.getReader();
397
474
  const processStream = async ()=>{
@@ -430,6 +507,14 @@ class ScrcpyServer {
430
507
  if (scrcpyClient?.controller) socket.emit('control-ready');
431
508
  } catch (error) {
432
509
  console.error('failed to connect device:', error);
510
+ if (scrcpyClient) {
511
+ try {
512
+ await scrcpyClient.close();
513
+ } catch (closeError) {
514
+ console.error('failed to close scrcpy client after error:', closeError);
515
+ }
516
+ scrcpyClient = null;
517
+ }
433
518
  socket.emit('error', {
434
519
  message: `Failed to connect device: ${error?.message || 'Unknown error'}`
435
520
  });
@@ -493,15 +578,15 @@ class ScrcpyServer {
493
578
  if (this.httpServer) return this.httpServer.close();
494
579
  }
495
580
  constructor(){
496
- _define_property(this, "app", void 0);
497
- _define_property(this, "httpServer", void 0);
498
- _define_property(this, "io", void 0);
499
- _define_property(this, "port", void 0);
500
- _define_property(this, "defaultPort", constants_namespaceObject.SCRCPY_SERVER_PORT);
501
- _define_property(this, "adbClient", null);
502
- _define_property(this, "currentDeviceId", null);
503
- _define_property(this, "devicePollInterval", null);
504
- _define_property(this, "lastDeviceList", '');
581
+ scrcpy_server_define_property(this, "app", void 0);
582
+ scrcpy_server_define_property(this, "httpServer", void 0);
583
+ scrcpy_server_define_property(this, "io", void 0);
584
+ scrcpy_server_define_property(this, "port", void 0);
585
+ scrcpy_server_define_property(this, "defaultPort", constants_namespaceObject.SCRCPY_SERVER_PORT);
586
+ scrcpy_server_define_property(this, "adbClient", null);
587
+ scrcpy_server_define_property(this, "currentDeviceId", null);
588
+ scrcpy_server_define_property(this, "devicePollInterval", null);
589
+ scrcpy_server_define_property(this, "lastDeviceList", '');
505
590
  this.app = external_express_default()();
506
591
  this.httpServer = (0, external_node_http_namespaceObject.createServer)(this.app);
507
592
  this.io = new external_socket_io_namespaceObject.Server(this.httpServer, {