@xenon-device-management/xenon 1.10.0 → 1.10.1

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/lib/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {
@@ -2,15 +2,28 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveAndroidH264 = resolveAndroidH264;
4
4
  /**
5
- * Normalize the three accepted flag shapes to one:
6
- * false | undefined → { enabled:false } (pure MJPEG — default)
7
- * true → { enabled:true, scrcpy } (new default source)
8
- * { source } → { enabled:true, source } (explicit; scrcpy default)
5
+ * Normalize the accepted flag shapes to one `{ enabled, source }`. Config values
6
+ * are untrusted and can arrive as a real boolean, a STRINGIFIED boolean
7
+ * (`"true"` / `"false"` — some Appium config sources and the Xenon Control form
8
+ * serialize booleans as strings), or a `{ source }` object:
9
+ *
10
+ * false | "false" | undefined | "" → disabled (pure MJPEG)
11
+ * true | "true" → enabled, scrcpy (default source)
12
+ * { source } → enabled, that source (scrcpy default)
13
+ * any other truthy scalar → enabled, scrcpy
14
+ *
15
+ * The string handling matters: without it a stringified `"false"` is truthy and
16
+ * would wrongly ENABLE the feature, so the flag could never be turned off from a
17
+ * source that stringifies booleans.
9
18
  */
10
19
  function resolveAndroidH264(cfg) {
11
- if (!cfg)
20
+ if (!cfg || cfg === 'false')
12
21
  return { enabled: false, source: 'scrcpy' };
13
- if (cfg === true)
22
+ if (cfg === true || cfg === 'true')
14
23
  return { enabled: true, source: 'scrcpy' };
15
- return { enabled: true, source: cfg.source === 'screenrecord' ? 'screenrecord' : 'scrcpy' };
24
+ if (typeof cfg === 'object') {
25
+ const source = cfg.source;
26
+ return { enabled: true, source: source === 'screenrecord' ? 'screenrecord' : 'scrcpy' };
27
+ }
28
+ return { enabled: true, source: 'scrcpy' };
16
29
  }
@@ -83,6 +83,10 @@ let AndroidH264StreamService = class AndroidH264StreamService {
83
83
  constructor() {
84
84
  this.sessions = new Map();
85
85
  this.startPromises = new Map();
86
+ // udids where scrcpy proved incompatible (e.g. it aborts on init — the Exynos
87
+ // "stack corruption" crash). Once seen, skip scrcpy for that device and use the
88
+ // screenrecord H.264 source directly instead of re-crashing scrcpy every start.
89
+ this.scrcpyIncompatible = new Set();
86
90
  this.startWatchdog();
87
91
  }
88
92
  startWatchdog() {
@@ -197,26 +201,84 @@ let AndroidH264StreamService = class AndroidH264StreamService {
197
201
  * a crash and stop the session rather than silently going dark.
198
202
  */
199
203
  openScrcpyCapture(udid, onPacket) {
204
+ return __awaiter(this, void 0, void 0, function* () {
205
+ var _a;
206
+ // A device where scrcpy already proved incompatible skips straight to the
207
+ // screenrecord H.264 source — no point re-crashing scrcpy every time.
208
+ if (this.scrcpyIncompatible.has(udid)) {
209
+ logger_1.default.info(`[${udid}] scrcpy known-incompatible here; using screenrecord H.264 source.`);
210
+ return this.openScreenrecordCapture(udid, onPacket);
211
+ }
212
+ try {
213
+ return yield this.startScrcpyOrThrow(udid, onPacket);
214
+ }
215
+ catch (e) {
216
+ // scrcpy failed to produce frames (aborted on init, closed early, or timed
217
+ // out). Fall back to the screenrecord H.264 source — still real H.264
218
+ // (~30fps), far better than dropping all the way to ~1fps MJPEG.
219
+ this.scrcpyIncompatible.add(udid);
220
+ logger_1.default.warn(`[${udid}] scrcpy H.264 unavailable (${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}); falling back to screenrecord H.264 source.`);
221
+ return this.openScreenrecordCapture(udid, onPacket);
222
+ }
223
+ });
224
+ }
225
+ /**
226
+ * Start scrcpy and resolve only once it produces its FIRST packet (proof it
227
+ * works). Reject if it closes/errors/times-out before that — the signal a
228
+ * device is scrcpy-incompatible (e.g. the Exynos `stack corruption` abort),
229
+ * which {@link openScrcpyCapture} turns into a screenrecord fallback.
230
+ */
231
+ startScrcpyOrThrow(udid, onPacket) {
200
232
  return __awaiter(this, void 0, void 0, function* () {
201
233
  const { ScrcpyServerSession, scrcpyMaxSizeFromDims } = yield Promise.resolve().then(() => __importStar(require('./ScrcpyServerSession')));
202
234
  const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
203
235
  const maxSize = scrcpyMaxSizeFromDims(Number(device === null || device === void 0 ? void 0 : device.screenWidth), Number(device === null || device === void 0 ? void 0 : device.screenHeight));
204
236
  const parser = new h264NalParser_1.H264NalParser();
205
237
  const session = new ScrcpyServerSession(udid);
206
- session.on('data', (b) => {
207
- for (const p of parser.push(b))
208
- onPacket(p);
209
- });
210
- session.on('close', () => {
211
- var _a;
212
- // Crash recovery: scrcpy has no time cap, so a close while running is unexpected.
213
- if (((_a = this.sessions.get(udid)) === null || _a === void 0 ? void 0 : _a.status) === 'running') {
214
- logger_1.default.warn(`[${udid}] scrcpy stream closed unexpectedly; stopping H.264 session.`);
215
- this.stop(udid);
216
- }
238
+ return new Promise((resolve, reject) => {
239
+ let settled = false;
240
+ const fail = (e) => {
241
+ if (settled)
242
+ return;
243
+ settled = true;
244
+ clearTimeout(timer);
245
+ try {
246
+ session.stop();
247
+ }
248
+ catch (_a) {
249
+ /* best-effort */
250
+ }
251
+ reject(e instanceof Error ? e : new Error(String(e)));
252
+ };
253
+ // scrcpy forces an initial keyframe, so a healthy device produces its first
254
+ // packet within ~1–2s; this bound only catches a hung (never-frames) scrcpy.
255
+ const timer = setTimeout(() => fail(new Error('scrcpy produced no frames within 8s')), 8000);
256
+ session.on('data', (b) => {
257
+ for (const p of parser.push(b)) {
258
+ onPacket(p);
259
+ if (!settled) {
260
+ settled = true;
261
+ clearTimeout(timer);
262
+ resolve({ kill: () => session.stop() });
263
+ }
264
+ }
265
+ });
266
+ session.on('close', () => {
267
+ var _a;
268
+ if (!settled) {
269
+ fail(new Error('scrcpy closed before producing frames'));
270
+ return;
271
+ }
272
+ // Crash after it was streaming: stop the session. On the client's
273
+ // reconnect this device is now marked incompatible, so it gets screenrecord.
274
+ if (((_a = this.sessions.get(udid)) === null || _a === void 0 ? void 0 : _a.status) === 'running') {
275
+ logger_1.default.warn(`[${udid}] scrcpy stream closed unexpectedly; stopping H.264 session.`);
276
+ this.scrcpyIncompatible.add(udid);
277
+ this.stop(udid);
278
+ }
279
+ });
280
+ session.start(maxSize).catch(fail);
217
281
  });
218
- yield session.start(maxSize);
219
- return { kill: () => session.stop() };
220
282
  });
221
283
  }
222
284
  /**
@@ -66,7 +66,7 @@ exports.SCRCPY_DEVICE_JAR_PATH = '/data/local/tmp/scrcpy-server-manual.jar';
66
66
  * so the socket carries plain Annex-B H.264 that the existing H264NalParser
67
67
  * consumes unchanged. `send_dummy_byte=true` (the tunnel_forward readiness byte)
68
68
  * is explicit because the socket reader skips exactly one leading byte.
69
- * Arg NAMES verified against the vendored scrcpy 3.3.4 jar's dex; the version
69
+ * Arg NAMES verified against the vendored scrcpy 2.7 jar's dex; the version
70
70
  * constant and this argv move together (see scrcpyVersion.ts / vendor/README.md).
71
71
  * No `scid` → the server listens on `localabstract:scrcpy` (per-device namespace).
72
72
  */
@@ -12,7 +12,7 @@ const path_1 = __importDefault(require("path"));
12
12
  * <version> argument both derive from this — they must always agree, so this is
13
13
  * the single source of truth. Bump procedure: see vendor/README.md.
14
14
  */
15
- exports.SCRCPY_SERVER_VERSION = '3.3.4';
15
+ exports.SCRCPY_SERVER_VERSION = '2.7';
16
16
  function scrcpyServerJarFilename() {
17
17
  return `scrcpy-server-${exports.SCRCPY_SERVER_VERSION}.jar`;
18
18
  }
@@ -17,4 +17,21 @@ describe('resolveAndroidH264', () => {
17
17
  source: 'screenrecord',
18
18
  });
19
19
  });
20
+ it('object with an unrecognized source → defaults to scrcpy', () => {
21
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)({ source: 'bogus' })).to.deep.equal({
22
+ enabled: true,
23
+ source: 'scrcpy',
24
+ });
25
+ });
26
+ it('stringified booleans are handled (form/config may serialize as strings)', () => {
27
+ // "true" enables; "false" MUST disable (without this, a truthy "false" would
28
+ // wrongly enable and the flag could never be turned off).
29
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)('true')).to.deep.equal({ enabled: true, source: 'scrcpy' });
30
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)('false')).to.deep.equal({ enabled: false, source: 'scrcpy' });
31
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)('')).to.deep.equal({ enabled: false, source: 'scrcpy' });
32
+ });
33
+ it('null and other truthy scalars are tolerated', () => {
34
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)(null)).to.deep.equal({ enabled: false, source: 'scrcpy' });
35
+ (0, chai_1.expect)((0, androidH264Config_1.resolveAndroidH264)('yes')).to.deep.equal({ enabled: true, source: 'scrcpy' });
36
+ });
20
37
  });
@@ -23,6 +23,7 @@ function make() {
23
23
  const svc = Object.create(AndroidH264StreamService_1.default.prototype);
24
24
  svc.sessions = new Map();
25
25
  svc.startPromises = new Map();
26
+ svc.scrcpyIncompatible = new Set();
26
27
  return svc;
27
28
  }
28
29
  describe('AndroidH264StreamService', () => {
@@ -150,4 +151,38 @@ describe('AndroidH264StreamService', () => {
150
151
  yield svc.start('udid-ws-scrcpy'); // no opts
151
152
  (0, chai_1.expect)(used).to.equal('scrcpy');
152
153
  }));
154
+ it('openScrcpyCapture uses scrcpy when it produces frames (startScrcpyOrThrow resolves)', () => __awaiter(void 0, void 0, void 0, function* () {
155
+ const svc = make();
156
+ let usedScreenrecord = false;
157
+ svc.startScrcpyOrThrow = () => __awaiter(void 0, void 0, void 0, function* () { return ({ kill: () => undefined }); });
158
+ svc.openScreenrecordCapture = () => __awaiter(void 0, void 0, void 0, function* () {
159
+ usedScreenrecord = true;
160
+ return { kill: () => undefined };
161
+ });
162
+ yield svc.openScrcpyCapture('udid-ok', () => undefined);
163
+ (0, chai_1.expect)(usedScreenrecord, 'must not fall back when scrcpy works').to.equal(false);
164
+ (0, chai_1.expect)(svc.scrcpyIncompatible.has('udid-ok')).to.equal(false);
165
+ }));
166
+ it('openScrcpyCapture falls back to screenrecord when scrcpy fails, and caches the incompatibility', () => __awaiter(void 0, void 0, void 0, function* () {
167
+ const svc = make();
168
+ let scrcpyAttempts = 0;
169
+ let screenrecordCalls = 0;
170
+ svc.startScrcpyOrThrow = () => __awaiter(void 0, void 0, void 0, function* () {
171
+ scrcpyAttempts++;
172
+ throw new Error('scrcpy closed before producing frames');
173
+ });
174
+ svc.openScreenrecordCapture = () => __awaiter(void 0, void 0, void 0, function* () {
175
+ screenrecordCalls++;
176
+ return { kill: () => undefined };
177
+ });
178
+ // First attempt: scrcpy tried, fails → screenrecord fallback + marked incompatible.
179
+ yield svc.openScrcpyCapture('udid-bad', () => undefined);
180
+ (0, chai_1.expect)(scrcpyAttempts).to.equal(1);
181
+ (0, chai_1.expect)(screenrecordCalls).to.equal(1);
182
+ (0, chai_1.expect)(svc.scrcpyIncompatible.has('udid-bad')).to.equal(true);
183
+ // Second attempt: skip scrcpy entirely, go straight to screenrecord.
184
+ yield svc.openScrcpyCapture('udid-bad', () => undefined);
185
+ (0, chai_1.expect)(scrcpyAttempts, 'scrcpy must not be retried once known-incompatible').to.equal(1);
186
+ (0, chai_1.expect)(screenrecordCalls).to.equal(2);
187
+ }));
153
188
  });
@@ -4,14 +4,14 @@ const chai_1 = require("chai");
4
4
  const ScrcpyServerSession_1 = require("../../src/device-managers/android/ScrcpyServerSession");
5
5
  describe('buildScrcpyServerArgs', () => {
6
6
  it('builds the exact video-only app_process argv', () => {
7
- const argv = (0, ScrcpyServerSession_1.buildScrcpyServerArgs)({ version: '3.3.4', jarDevicePath: ScrcpyServerSession_1.SCRCPY_DEVICE_JAR_PATH, maxSize: 1560 });
7
+ const argv = (0, ScrcpyServerSession_1.buildScrcpyServerArgs)({ version: '2.7', jarDevicePath: ScrcpyServerSession_1.SCRCPY_DEVICE_JAR_PATH, maxSize: 1560 });
8
8
  (0, chai_1.expect)(argv).to.deep.equal([
9
9
  'shell',
10
10
  `CLASSPATH=${ScrcpyServerSession_1.SCRCPY_DEVICE_JAR_PATH}`,
11
11
  'app_process',
12
12
  '/',
13
13
  'com.genymobile.scrcpy.Server',
14
- '3.3.4',
14
+ '2.7',
15
15
  'tunnel_forward=true',
16
16
  'audio=false',
17
17
  'control=false',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {