@xenon-device-management/xenon 1.26.2 → 1.28.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.
Files changed (85) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{ApiKeyGate-vELAdQLu.js → ApiKeyGate-CYrHWuf9.js} +1 -1
  3. package/lib/public/assets/{BugReportButton-ySiZDywe.js → BugReportButton-CRRZnSs2.js} +1 -1
  4. package/lib/public/assets/{DeviceMosaicView-Chtp0wAI.js → DeviceMosaicView-Dw7dK-Ri.js} +1 -1
  5. package/lib/public/assets/{EmptyState-CYQYScPo.js → EmptyState-BDSDbxbe.js} +1 -1
  6. package/lib/public/assets/{FieldGroup-Dea68RfI.js → FieldGroup-DTSy35Hp.js} +1 -1
  7. package/lib/public/assets/{Modal-BgzTzImP.js → Modal-BC9RPZeO.js} +1 -1
  8. package/lib/public/assets/{SegmentedControl-DyRq9blY.js → SegmentedControl-DUw7pxfw.js} +1 -1
  9. package/lib/public/assets/{SettingCard-CardwBu3.js → SettingCard-DwHb-9y7.js} +1 -1
  10. package/lib/public/assets/{Table-BczCxsDm.js → Table-C167c0iD.js} +1 -1
  11. package/lib/public/assets/{activity-CS-Op-T6.js → activity-C8ktgelv.js} +1 -1
  12. package/lib/public/assets/{ai-settings-CIvB7rNM.js → ai-settings-BtH8D-Vk.js} +3 -3
  13. package/lib/public/assets/{api-keys-peJzllzQ.js → api-keys-D0CPHe_-.js} +1 -1
  14. package/lib/public/assets/{apps-__WvcJaP.js → apps-f6Z9888a.js} +3 -3
  15. package/lib/public/assets/{arrow-left-Dx-cXFLT.js → arrow-left-CEnrTC1h.js} +1 -1
  16. package/lib/public/assets/{arrow-right-Ax_FfBMv.js → arrow-right-DJbja5Vo.js} +1 -1
  17. package/lib/public/assets/{arrow-up-right-B6uJmGeA.js → arrow-up-right-DvIZquNN.js} +1 -1
  18. package/lib/public/assets/{auth-shell-BQqcjjC9.js → auth-shell-5XCL86e9.js} +2 -2
  19. package/lib/public/assets/{builds-page-BA-_iEjx.js → builds-page-DQbsMgZp.js} +1 -1
  20. package/lib/public/assets/{button-DcbnByKB.js → button-CMj-iXRu.js} +1 -1
  21. package/lib/public/assets/{calendar-C3pYg1V7.js → calendar-BKb9JzMb.js} +1 -1
  22. package/lib/public/assets/{check-DKdLfzhJ.js → check-BioFa43z.js} +1 -1
  23. package/lib/public/assets/{chevron-left-Bb9PBLG5.js → chevron-left-BfiSwNHE.js} +1 -1
  24. package/lib/public/assets/{chevron-right-DJcpeY_o.js → chevron-right-BLqjv9Fz.js} +1 -1
  25. package/lib/public/assets/{circle-check-DY6ntqp3.js → circle-check-N7nCeFZH.js} +1 -1
  26. package/lib/public/assets/{circle-x-DgK9drRH.js → circle-x-DzRW2cW-.js} +1 -1
  27. package/lib/public/assets/{clock-I9zEVPan.js → clock-Q8bhs7zV.js} +1 -1
  28. package/lib/public/assets/{copy-Dy3oRuWT.js → copy-Dh1ppPe1.js} +1 -1
  29. package/lib/public/assets/device-explorer-Cbwojnbb.js +277 -0
  30. package/lib/public/assets/device-explorer-CcLOID3w.css +1 -0
  31. package/lib/public/assets/{download-BotTmDty.js → download-Dit5_Lob.js} +1 -1
  32. package/lib/public/assets/{forgot-password-BaWiD2U2.js → forgot-password-yhK1Eo6h.js} +1 -1
  33. package/lib/public/assets/{funnel-D2Qw4nTX.js → funnel-DQo9dvhT.js} +1 -1
  34. package/lib/public/assets/{index-CI9Nz3HY.js → index-kcl0icXA.js} +2 -2
  35. package/lib/public/assets/{input-cBBGau4X.js → input-DwXEwtQD.js} +1 -1
  36. package/lib/public/assets/{keycodes-CY4uwulo.js → keycodes-mlL7WkFd.js} +1 -1
  37. package/lib/public/assets/{layers-MHwJM2Ll.js → layers-DFEJsDA2.js} +1 -1
  38. package/lib/public/assets/{lock-Bpc8rJFu.js → lock-DVGX1wMR.js} +1 -1
  39. package/lib/public/assets/{login-BmcyszP5.js → login-BtczO6mT.js} +1 -1
  40. package/lib/public/assets/{maintenance-settings-DMFVuToe.js → maintenance-settings-BNkCNkdA.js} +2 -2
  41. package/lib/public/assets/{monitor-irjK6PRV.js → monitor-DXiFh_zm.js} +1 -1
  42. package/lib/public/assets/mouse-pointer-2-iiFLrBfo.js +6 -0
  43. package/lib/public/assets/{network-CVZ32MhH.js → network-D-64m0lI.js} +1 -1
  44. package/lib/public/assets/{overview-DRRSIpnD.js → overview-Bs8fij0f.js} +1 -1
  45. package/lib/public/assets/{page-header-Bl7uTiQF.js → page-header-2aP5HfMz.js} +1 -1
  46. package/lib/public/assets/{plus-C3lvaMcL.js → plus-l0nFcN3u.js} +1 -1
  47. package/lib/public/assets/{profile-page-B3wsnR1n.js → profile-page-DRQxEDPv.js} +1 -1
  48. package/lib/public/assets/{reset-password-BIGj_ULj.js → reset-password-TeZbTlY3.js} +1 -1
  49. package/lib/public/assets/{runbook-page-DEznNZhc.js → runbook-page-CfB3Sgqd.js} +1 -1
  50. package/lib/public/assets/{select-BpuZWZuE.js → select-CfLeV0bn.js} +1 -1
  51. package/lib/public/assets/{selector-detail-page-ZCvmtQ5Y.js → selector-detail-page-sjSLgf2y.js} +1 -1
  52. package/lib/public/assets/{selector-health-D-hvfY2E.js → selector-health-DobHOezq.js} +1 -1
  53. package/lib/public/assets/{selector-health-page-C0ZqZ_VT.js → selector-health-page-BeGTmjeJ.js} +1 -1
  54. package/lib/public/assets/{session-detail-page-Bv7IwFTQ.js → session-detail-page-B7Be2Uxw.js} +1 -1
  55. package/lib/public/assets/settings-DWlkn05y.js +6 -0
  56. package/lib/public/assets/{status-pill-outline-B5dWsaer.js → status-pill-outline-DqZaavRm.js} +1 -1
  57. package/lib/public/assets/{tablet-DxwkBlhd.js → tablet-D2ApmdVm.js} +1 -1
  58. package/lib/public/assets/{teams-D3s8GhkG.js → teams-DVm0mUGI.js} +1 -1
  59. package/lib/public/assets/{trash-2-Dyf4Jcyk.js → trash-2-BklI15pS.js} +1 -1
  60. package/lib/public/assets/{upload-B6ZJ1_f1.js → upload-BMYoLWMh.js} +1 -1
  61. package/lib/public/assets/{use-password-reset-mode-B-oOrt1d.js → use-password-reset-mode-Dx_zeU5K.js} +1 -1
  62. package/lib/public/assets/{users-QfiFclLn.js → users-CnVdUoqL.js} +1 -1
  63. package/lib/public/assets/{users-BfvPE2wX.js → users-DqVUPDed.js} +1 -1
  64. package/lib/public/assets/{video-off-C2quR4KF.js → video-off-DEmVhLd9.js} +1 -1
  65. package/lib/public/assets/{webhook-settings-BjbGi-Pi.js → webhook-settings-CHpCrpob.js} +1 -1
  66. package/lib/public/assets/{zap-C2lxP7TK.js → zap-ovWf2UbO.js} +1 -1
  67. package/lib/public/index.html +1 -1
  68. package/lib/src/app/routers/control.js +5 -0
  69. package/lib/src/device-managers/AndroidDeviceManager.js +31 -95
  70. package/lib/src/device-managers/HealthMonitorService.js +10 -6
  71. package/lib/src/device-managers/android/AndroidH264StreamService.js +6 -1
  72. package/lib/src/device-managers/android/AndroidStreamService.js +9 -18
  73. package/lib/src/device-managers/android/clipboardBroadcast.js +24 -0
  74. package/lib/src/device-managers/android/previewHold.js +72 -0
  75. package/lib/src/device-managers/clipboardErrors.js +15 -0
  76. package/lib/test/unit/android-clipboard.spec.js +147 -0
  77. package/lib/test/unit/android-preview-hold.spec.js +179 -0
  78. package/lib/test/unit/clipboard-route.spec.js +110 -0
  79. package/lib/test/unit/health-monitor-stream-lookup.spec.js +107 -0
  80. package/lib/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +1 -1
  82. package/lib/public/assets/device-explorer-BPyGYs0c.js +0 -246
  83. package/lib/public/assets/device-explorer-C9qg30Jn.css +0 -1
  84. package/lib/public/assets/mouse-pointer-2-CYkC-Dyq.js +0 -11
  85. package/lib/public/assets/settings-DCR9vnQ5.js +0 -1
@@ -70,6 +70,7 @@ const streamType_1 = require("./streamType");
70
70
  const iosStreamPort_1 = require("./iosStreamPort");
71
71
  const androidH264Config_1 = require("./androidH264Config");
72
72
  const recording_store_1 = require("../../services/recording/recording-store");
73
+ const clipboardErrors_1 = require("../../device-managers/clipboardErrors");
73
74
  const scopeGuard_1 = require("../../middleware/scopeGuard");
74
75
  const roleGuard_1 = require("../../middleware/roleGuard");
75
76
  const deviceAccessGuard_1 = require("../../middleware/deviceAccessGuard");
@@ -360,6 +361,10 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
360
361
  return res.status(200).send({ success: true });
361
362
  }
362
363
  catch (err) {
364
+ // The device can't do it at all (Android): 501, not a failure to retry.
365
+ if (err instanceof clipboardErrors_1.ClipboardUnsupportedError) {
366
+ return res.status(501).send({ error: err.message });
367
+ }
363
368
  logger_1.default.error(`Manual control setClipboard failed: ${err.message}`);
364
369
  return res.status(500).send({ error: err.message });
365
370
  }
@@ -58,6 +58,8 @@ const displayState_1 = require("./android/displayState");
58
58
  const networkAddresses_1 = require("../helpers/networkAddresses");
59
59
  const appium_adb_1 = require("appium-adb");
60
60
  const logger_1 = __importDefault(require("../logger"));
61
+ const clipboardBroadcast_1 = require("./android/clipboardBroadcast");
62
+ const clipboardErrors_1 = require("./clipboardErrors");
61
63
  const lodash_1 = __importDefault(require("lodash"));
62
64
  const support_1 = require("@appium/support");
63
65
  const ChromeDriverManager_1 = __importDefault(require("./ChromeDriverManager"));
@@ -837,108 +839,42 @@ let AndroidDeviceManager = class AndroidDeviceManager {
837
839
  }
838
840
  });
839
841
  }
842
+ /**
843
+ * Reads the clipboard through Appium Settings. Android 10+ lets only the
844
+ * default input method read it, so Appium Settings is the IME for the length
845
+ * of one broadcast (see withAppiumIME). Failures are thrown, not swallowed:
846
+ * an empty string used to stand for both "empty" and "couldn't read".
847
+ */
840
848
  getClipboard(udid) {
841
849
  return __awaiter(this, void 0, void 0, function* () {
842
850
  const { adbInstance } = yield this.getAdb();
843
851
  if (!adbInstance)
844
- return '';
845
- try {
846
- // Wrap the targeted broadcast in the Appium IME context
847
- return yield this.withAppiumIME(adbInstance, udid, () => __awaiter(this, void 0, void 0, function* () {
848
- // 1. Try Targeted Broadcast method (Reliable for modern Android)
849
- const result = yield adbInstance.adbExec([
850
- '-s',
851
- udid,
852
- 'shell',
853
- 'am',
854
- 'broadcast',
855
- '-a',
856
- 'com.appium.settings.clipboard.get',
857
- '-n',
858
- 'io.appium.settings/.receivers.ClipboardReceiver',
859
- ]);
860
- // Parse result like: Broadcast completed: result=-1, data="BASE64_DATA"
861
- const dataMatch = /data="([^"]*)"/.exec(result);
862
- if (dataMatch) {
863
- const rawData = dataMatch[1];
864
- if (!rawData)
865
- return '';
866
- // Appium Settings returns Base64 for robustness
867
- try {
868
- const decoded = Buffer.from(rawData, 'base64').toString('utf8');
869
- // If it looks like printable text after decoding, use it
870
- if (/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded))
871
- return decoded;
872
- return rawData;
873
- }
874
- catch (e) {
875
- return rawData;
876
- }
877
- }
878
- // 2. Fallback: Query the content provider (Legacy/Alternative)
879
- const queryResult = yield adbInstance.adbExec([
880
- '-s',
881
- udid,
882
- 'shell',
883
- 'content',
884
- 'query',
885
- '--uri',
886
- 'content://io.appium.settings.clipboard/clipboard',
887
- ]);
888
- // Extract value using a more flexible regex that handles different formats
889
- const valMatch = /value=([^\s,]*)/i.exec(queryResult);
890
- if (valMatch) {
891
- const val = valMatch[1];
892
- // Most content providers return base64 for safety
893
- try {
894
- const decoded = Buffer.from(val, 'base64').toString('utf8');
895
- // Basic sanity check: if it contains non-printable characters, it might not have been base64
896
- if (/^[\x20-\x7E\s]*$/.test(decoded))
897
- return decoded;
898
- return val;
899
- }
900
- catch (e) {
901
- return val;
902
- }
903
- }
904
- return '';
905
- }));
906
- }
907
- catch (err) {
908
- logger_1.default.warn(`Failed to fetch Android clipboard for ${udid}: ${err instanceof Error ? err.message : err}`);
909
- }
910
- return '';
852
+ throw new Error('ADB is not available');
853
+ const output = yield this.withAppiumIME(adbInstance, udid, () => adbInstance.adbExec([
854
+ '-s',
855
+ udid,
856
+ 'shell',
857
+ 'am',
858
+ 'broadcast',
859
+ '-n',
860
+ clipboardBroadcast_1.CLIPBOARD_RECEIVER,
861
+ '-a',
862
+ clipboardBroadcast_1.CLIPBOARD_GET_ACTION,
863
+ ]));
864
+ return (0, clipboardBroadcast_1.parseClipboardBroadcast)(output);
911
865
  });
912
866
  }
913
- setClipboard(udid, content) {
867
+ /**
868
+ * Not possible on Android outside a test session. Appium Settings, the only
869
+ * helper Xenon can use here, has no clipboard-set action (8.0.5 holds just
870
+ * io.appium.settings.clipboard.get), and Appium itself sets the clipboard
871
+ * through a running UiAutomator2 session. The old broadcast reached the
872
+ * *read* receiver, which answered OK, so writes reported success and changed
873
+ * nothing.
874
+ */
875
+ setClipboard() {
914
876
  return __awaiter(this, void 0, void 0, function* () {
915
- const { adbInstance } = yield this.getAdb();
916
- if (!adbInstance)
917
- return;
918
- try {
919
- yield this.withAppiumIME(adbInstance, udid, () => __awaiter(this, void 0, void 0, function* () {
920
- yield adbInstance.adbExec([
921
- '-s',
922
- udid,
923
- 'shell',
924
- 'am',
925
- 'broadcast',
926
- '-a',
927
- 'com.appium.settings.clipboard.set',
928
- '-n',
929
- 'io.appium.settings/.receivers.ClipboardReceiver',
930
- '--es',
931
- 'label',
932
- 'clipboard',
933
- '--es',
934
- 'content',
935
- Buffer.from(content).toString('base64'), // Send as Base64 for safety
936
- ]);
937
- }));
938
- }
939
- catch (err) {
940
- logger_1.default.warn(`Failed to set Android clipboard for ${udid}: ${err instanceof Error ? err.message : err}`);
941
- }
877
+ throw new clipboardErrors_1.ClipboardUnsupportedError('Android doesn’t allow setting the clipboard from here: Appium Settings can only read it.');
942
878
  });
943
879
  }
944
880
  touchAndHold(udid, x, y, duration) {
@@ -159,15 +159,17 @@ let HealthMonitorService = class HealthMonitorService {
159
159
  let hasActiveManualStream = false;
160
160
  try {
161
161
  if (['ios', 'tvos'].includes(device.platform)) {
162
- yield Promise.resolve().then(() => __importStar(require('./ios/IOSStreamService')));
163
- const iosStream = typedi_1.Container.get('IOSStreamService');
162
+ // By class: TypeDI 0.10 ignores @Service({ name }), so a lookup
163
+ // by name always threw here and this check never saw a stream.
164
+ const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('./ios/IOSStreamService')));
165
+ const iosStream = typedi_1.Container.get(IOSStreamService);
164
166
  const streamStatus = iosStream.getStreamStatus(device.udid);
165
167
  hasActiveManualStream = !!(streamStatus &&
166
168
  (streamStatus.status === 'running' || streamStatus.status === 'starting'));
167
169
  }
168
170
  else if (device.platform === 'android') {
169
- yield Promise.resolve().then(() => __importStar(require('./android/AndroidStreamService')));
170
- const androidStream = typedi_1.Container.get('AndroidStreamService');
171
+ const { default: AndroidStreamService } = yield Promise.resolve().then(() => __importStar(require('./android/AndroidStreamService')));
172
+ const androidStream = typedi_1.Container.get(AndroidStreamService);
171
173
  const streamStatus = androidStream.getStreamStatus(device.udid);
172
174
  hasActiveManualStream = !!(streamStatus &&
173
175
  (streamStatus.status === 'running' || streamStatus.status === 'starting'));
@@ -205,10 +207,12 @@ let HealthMonitorService = class HealthMonitorService {
205
207
  let hasStreamNow = false;
206
208
  try {
207
209
  if (['ios', 'tvos'].includes(device.platform)) {
208
- hasStreamNow = !!typedi_1.Container.get('IOSStreamService').getStreamStatus(device.udid);
210
+ const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('./ios/IOSStreamService')));
211
+ hasStreamNow = !!typedi_1.Container.get(IOSStreamService).getStreamStatus(device.udid);
209
212
  }
210
213
  else if (device.platform === 'android') {
211
- hasStreamNow = !!typedi_1.Container.get('AndroidStreamService').getStreamStatus(device.udid);
214
+ const { default: AndroidStreamService } = yield Promise.resolve().then(() => __importStar(require('./android/AndroidStreamService')));
215
+ hasStreamNow = !!typedi_1.Container.get(AndroidStreamService).getStreamStatus(device.udid);
212
216
  }
213
217
  }
214
218
  catch (e) {
@@ -62,6 +62,7 @@ const H264Multiplexer_1 = require("./H264Multiplexer");
62
62
  const h264NalParser_1 = require("./h264NalParser");
63
63
  const androidH264Config_1 = require("../../app/routers/androidH264Config");
64
64
  const PluginContext_1 = require("../../PluginContext");
65
+ const previewHold_1 = require("./previewHold");
65
66
  const IDLE_TIMEOUT_MS = 600000; // stop a stream after 10 min with zero viewers
66
67
  const RESTART_MIN_INTERVAL_MS = 300; // fast restart after a healthy stream hits the cap/rotation
67
68
  // After this many consecutive restarts that produced no frames (device gone,
@@ -103,7 +104,11 @@ let AndroidH264StreamService = class AndroidH264StreamService {
103
104
  }
104
105
  else if (now - s.emptyAt > IDLE_TIMEOUT_MS) {
105
106
  logger_1.default.info(`[${udid}] Stopping idle H.264 stream (no viewers for ${IDLE_TIMEOUT_MS}ms)`);
106
- this.stop(udid);
107
+ // The stream went, but the live-preview hold stayed: with H.264
108
+ // preview, a tab closed without releasing kept the device busy for
109
+ // good. stop() itself must not release (a recording starting calls
110
+ // it); an idle stop does, if nothing else uses the device.
111
+ void this.stop(udid).then(() => (0, previewHold_1.releaseIdlePreviewHold)(udid));
107
112
  }
108
113
  }
109
114
  }, 60000);
@@ -59,7 +59,6 @@ const http_1 = __importDefault(require("http"));
59
59
  const sharp_1 = __importDefault(require("sharp"));
60
60
  const logger_1 = __importDefault(require("../../logger"));
61
61
  const device_store_1 = require("../../data-service/device-store");
62
- const device_service_1 = require("../../data-service/device-service");
63
62
  const DeviceLockManager_1 = require("./DeviceLockManager");
64
63
  const typedi_1 = require("typedi");
65
64
  const ResourceIsolationService_1 = require("../../services/ResourceIsolationService");
@@ -67,6 +66,7 @@ const PortAllocator_1 = require("../../services/PortAllocator");
67
66
  const ffmpegPath_1 = require("../../helpers/ffmpegPath");
68
67
  const singleFlight_1 = require("../../helpers/singleFlight");
69
68
  const androidStreamReuse_1 = require("./androidStreamReuse");
69
+ const previewHold_1 = require("./previewHold");
70
70
  // JPEG quality for the in-process sharp encoder (0-100). ~78 approximates the
71
71
  // old ffmpeg `-q:v 8` (mjpeg quantizer scale) — the low-lag/quality knob.
72
72
  const JPEG_QUALITY = 78;
@@ -90,7 +90,9 @@ let AndroidStreamService = class AndroidStreamService {
90
90
  }
91
91
  }
92
92
  }
93
- }), 3600000);
93
+ // Every minute, not every hour: an hourly check left an abandoned
94
+ // preview hold in place for up to 70 minutes. The loop is in memory.
95
+ }), 60000);
94
96
  }
95
97
  /**
96
98
  * Resolve the actual adb binary path (and remote-host args) via the same
@@ -400,7 +402,6 @@ let AndroidStreamService = class AndroidStreamService {
400
402
  }
401
403
  stopStream(udid) {
402
404
  return __awaiter(this, void 0, void 0, function* () {
403
- var _a;
404
405
  const session = this.sessions.get(udid);
405
406
  if (session) {
406
407
  session.status = 'stopped';
@@ -408,21 +409,11 @@ let AndroidStreamService = class AndroidStreamService {
408
409
  session.server.close();
409
410
  session.server = null;
410
411
  }
411
- // Principal Fix: Only release the device lock if THIS STREAM SERVICE owns it.
412
- // If an Appium automation session owns the lock, don't touch it.
413
- try {
414
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
415
- if (device && ((_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_'))) {
416
- logger_1.default.info(`[${udid}] Stream Stop: Releasing manual control lock.`);
417
- yield (0, device_service_1.unblockDevice)(udid, device.host);
418
- }
419
- else if (device && device.busy) {
420
- logger_1.default.info(`[${udid}] Stream Stop: Device busy with session ${device.session_id}. NOT releasing lock.`);
421
- }
422
- }
423
- catch (e) {
424
- /* Ignore unblocking failure on stop */
425
- }
412
+ // Release the live-preview hold only if nothing else uses the device:
413
+ // one hold covers both transports, so an idle MJPEG stream must not drop
414
+ // it under someone watching over H.264, nor under a recording. An
415
+ // Appium session's lock is never a preview hold and is never touched.
416
+ yield (0, previewHold_1.releaseIdlePreviewHold)(udid);
426
417
  // Release the mjpeg port lease this stream acquired in startStream, so the
427
418
  // allocator can reuse the port immediately instead of waiting out the lease
428
419
  // TTL. Symmetric with IOSStreamService.stopStream. Only the stream-owned
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLIPBOARD_GET_ACTION = exports.CLIPBOARD_RECEIVER = void 0;
4
+ exports.parseClipboardBroadcast = parseClipboardBroadcast;
5
+ /** Appium Settings' clipboard receiver and its only clipboard action (read). */
6
+ exports.CLIPBOARD_RECEIVER = 'io.appium.settings/.receivers.ClipboardReceiver';
7
+ exports.CLIPBOARD_GET_ACTION = 'io.appium.settings.clipboard.get';
8
+ /**
9
+ * The clipboard text from `am broadcast … io.appium.settings.clipboard.get`.
10
+ *
11
+ * Appium Settings answers `result=-1, data="<base64>"`, with an empty data for
12
+ * an empty clipboard. Anything else means the broadcast reached no receiver,
13
+ * or the wrong one (a missing app answers `result=0`), so it is an error, not
14
+ * an empty clipboard.
15
+ */
16
+ function parseClipboardBroadcast(output) {
17
+ const answered = /\bresult=-1\b/.test(output);
18
+ const data = /data="([^"]*)"/.exec(output);
19
+ if (!answered || !data) {
20
+ const last = output.trim().split('\n').pop() || 'no output';
21
+ throw new Error(`Appium Settings didn’t return the clipboard. Check that it is installed and up to date (${last}).`);
22
+ }
23
+ return data[1] ? Buffer.from(data[1], 'base64').toString('utf8') : '';
24
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.mayReleasePreviewHold = mayReleasePreviewHold;
16
+ exports.releaseIdlePreviewHold = releaseIdlePreviewHold;
17
+ const typedi_1 = require("typedi");
18
+ const logger_1 = __importDefault(require("../../logger"));
19
+ const device_store_1 = require("../../data-service/device-store");
20
+ const device_service_1 = require("../../data-service/device-service");
21
+ const manualLock_1 = require("../../services/recording/manualLock");
22
+ const recording_store_1 = require("../../services/recording/recording-store");
23
+ /**
24
+ * Whether a live-preview hold can go: it is a preview hold (never an Appium
25
+ * session's lock), nobody watches over either transport, and no recording
26
+ * holds the device.
27
+ */
28
+ function mayReleasePreviewHold(use) {
29
+ return ((0, manualLock_1.isManualLock)(use.sessionId) && !use.recording && use.h264Clients === 0 && use.mjpegViewers === 0);
30
+ }
31
+ /**
32
+ * Release a device's live-preview hold when a stream stops, unless something
33
+ * still uses the device. One hold covers both Android transports, so neither
34
+ * stream service may drop it while the other has viewers, and a recording
35
+ * holds the device the same way.
36
+ *
37
+ * The services are required here, when called, rather than imported: both of
38
+ * them call this, and importing them at the top would make the imports
39
+ * circular. They are looked up by class: TypeDI 0.10 ignores
40
+ * `@Service({ name })`, so a lookup by that name throws in the server.
41
+ */
42
+ function releaseIdlePreviewHold(udid) {
43
+ return __awaiter(this, void 0, void 0, function* () {
44
+ var _a, _b, _c, _d, _e;
45
+ try {
46
+ const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
47
+ if (!device)
48
+ return false;
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ const H264Service = require('./AndroidH264StreamService').default;
51
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
52
+ const MjpegService = require('./AndroidStreamService').default;
53
+ const h264 = typedi_1.Container.get(H264Service);
54
+ const mjpeg = typedi_1.Container.get(MjpegService);
55
+ const use = {
56
+ sessionId: device.session_id,
57
+ h264Clients: (_b = (_a = h264.getMultiplexer(udid)) === null || _a === void 0 ? void 0 : _a.clientCount) !== null && _b !== void 0 ? _b : 0,
58
+ mjpegViewers: (_d = (_c = mjpeg.getStreamStatus(udid)) === null || _c === void 0 ? void 0 : _c.viewerCount) !== null && _d !== void 0 ? _d : 0,
59
+ recording: yield typedi_1.Container.get(recording_store_1.RecordingStore).isRecording(udid),
60
+ };
61
+ if (!mayReleasePreviewHold(use))
62
+ return false;
63
+ yield (0, device_service_1.unblockDevice)(udid, device.host);
64
+ logger_1.default.info(`[${udid}] Released the live-preview hold: no viewers and no recording.`);
65
+ return true;
66
+ }
67
+ catch (e) {
68
+ logger_1.default.warn(`[${udid}] Could not check or release the live-preview hold: ${(_e = e === null || e === void 0 ? void 0 : e.message) !== null && _e !== void 0 ? _e : e}`);
69
+ return false;
70
+ }
71
+ });
72
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClipboardUnsupportedError = void 0;
4
+ /**
5
+ * A clipboard operation this device can't do at all, as opposed to one that
6
+ * failed this time. The control route answers 501 for it: retrying won't help.
7
+ */
8
+ class ClipboardUnsupportedError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.code = 'clipboard_unsupported';
12
+ this.name = 'ClipboardUnsupportedError';
13
+ }
14
+ }
15
+ exports.ClipboardUnsupportedError = ClipboardUnsupportedError;
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const AndroidDeviceManager_1 = __importDefault(require("../../src/device-managers/AndroidDeviceManager"));
19
+ const clipboardBroadcast_1 = require("../../src/device-managers/android/clipboardBroadcast");
20
+ const clipboardErrors_1 = require("../../src/device-managers/clipboardErrors");
21
+ /**
22
+ * Device control's clipboard on Android, measured 2026-09-26 on a Galaxy S9+
23
+ * (Android 10, Appium Settings 8.0.5):
24
+ *
25
+ * - Appium Settings can only *read* the clipboard. Its code holds one
26
+ * clipboard action, io.appium.settings.clipboard.get. Our "set" broadcast
27
+ * reached that same receiver, which read the clipboard, answered result=-1,
28
+ * and the server reported success while nothing changed.
29
+ * - The read works, but only while Appium Settings is the default input
30
+ * method (Android 10+ restricts clipboard reads to the IME). After an
31
+ * on-device Copy of "xenonread" it returned data="eGVub25yZWFk".
32
+ * - Both methods swallowed every error, so the route always answered 200.
33
+ */
34
+ const UDID = '381103b720057ece';
35
+ const ORIGINAL_IME = 'com.samsung.android.honeyboard/.service.HoneyBoardService';
36
+ const APPIUM_IME = 'io.appium.settings/.AppiumIME';
37
+ function fakeAdb(broadcast) {
38
+ const calls = [];
39
+ const adbExec = sinon_1.default.stub().callsFake((args) => __awaiter(this, void 0, void 0, function* () {
40
+ calls.push(args);
41
+ const cmd = args.slice(2).join(' ');
42
+ if (cmd === 'shell settings get secure default_input_method')
43
+ return `${ORIGINAL_IME}\n`;
44
+ if (cmd.startsWith('shell am broadcast')) {
45
+ if (broadcast instanceof Error)
46
+ throw broadcast;
47
+ return broadcast;
48
+ }
49
+ return '';
50
+ }));
51
+ return { adbInstance: { adbExec }, calls };
52
+ }
53
+ function managerWith(adb) {
54
+ // The constructor starts device tracking; these tests need only the methods.
55
+ const manager = Object.create(AndroidDeviceManager_1.default.prototype);
56
+ manager.getAdb = () => __awaiter(this, void 0, void 0, function* () { return adb; });
57
+ return manager;
58
+ }
59
+ const imeSets = (calls) => calls.filter((c) => c.slice(2, 5).join(' ') === 'shell ime set').map((c) => c[5]);
60
+ describe('Android clipboard', () => {
61
+ afterEach(() => sinon_1.default.restore());
62
+ describe('parseClipboardBroadcast', () => {
63
+ it('decodes the base64 text Appium Settings returns', () => {
64
+ (0, chai_1.expect)((0, clipboardBroadcast_1.parseClipboardBroadcast)('Broadcasting: Intent { … }\nBroadcast completed: result=-1, data="eGVub25yZWFk"')).to.equal('xenonread');
65
+ });
66
+ it('keeps non-ASCII text', () => {
67
+ const data = Buffer.from('héllo · 世界').toString('base64');
68
+ (0, chai_1.expect)((0, clipboardBroadcast_1.parseClipboardBroadcast)(`Broadcast completed: result=-1, data="${data}"`)).to.equal('héllo · 世界');
69
+ });
70
+ it('treats an empty answer as an empty clipboard', () => {
71
+ (0, chai_1.expect)((0, clipboardBroadcast_1.parseClipboardBroadcast)('Broadcast completed: result=-1, data=""')).to.equal('');
72
+ });
73
+ // A missing receiver answers result=0 with no data; that used to read as
74
+ // an empty clipboard.
75
+ it('refuses an answer that is not a clipboard', () => {
76
+ (0, chai_1.expect)(() => (0, clipboardBroadcast_1.parseClipboardBroadcast)('Broadcast completed: result=0')).to.throw(/Appium Settings didn’t return the clipboard/);
77
+ (0, chai_1.expect)(() => (0, clipboardBroadcast_1.parseClipboardBroadcast)('Broadcast completed: result=0, data="eA=="')).to.throw(/Appium Settings/);
78
+ });
79
+ });
80
+ describe('getClipboard', () => {
81
+ it('reads it with the documented broadcast, as the Appium IME', () => __awaiter(void 0, void 0, void 0, function* () {
82
+ var _a;
83
+ const adb = fakeAdb('Broadcast completed: result=-1, data="eGVub25yZWFk"');
84
+ (0, chai_1.expect)(yield managerWith(adb).getClipboard(UDID)).to.equal('xenonread');
85
+ const broadcast = (_a = adb.calls.find((c) => c.includes('broadcast'))) !== null && _a !== void 0 ? _a : [];
86
+ (0, chai_1.expect)(broadcast).to.include.members([
87
+ '-n',
88
+ 'io.appium.settings/.receivers.ClipboardReceiver',
89
+ '-a',
90
+ 'io.appium.settings.clipboard.get',
91
+ ]);
92
+ (0, chai_1.expect)(imeSets(adb.calls)).to.deep.equal([APPIUM_IME, ORIGINAL_IME]);
93
+ }));
94
+ it('says why when Appium Settings does not answer, and restores the keyboard', () => __awaiter(void 0, void 0, void 0, function* () {
95
+ const adb = fakeAdb('Broadcast completed: result=0');
96
+ let error;
97
+ try {
98
+ yield managerWith(adb).getClipboard(UDID);
99
+ }
100
+ catch (e) {
101
+ error = e;
102
+ }
103
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/Appium Settings didn’t return the clipboard/);
104
+ (0, chai_1.expect)(imeSets(adb.calls)).to.deep.equal([APPIUM_IME, ORIGINAL_IME]);
105
+ }));
106
+ it('does not swallow an adb failure', () => __awaiter(void 0, void 0, void 0, function* () {
107
+ const adb = fakeAdb(new Error('device offline'));
108
+ let error;
109
+ try {
110
+ yield managerWith(adb).getClipboard(UDID);
111
+ }
112
+ catch (e) {
113
+ error = e;
114
+ }
115
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.equal('device offline');
116
+ (0, chai_1.expect)(imeSets(adb.calls)).to.deep.equal([APPIUM_IME, ORIGINAL_IME]);
117
+ }));
118
+ it('fails when adb is unavailable, instead of returning nothing', () => __awaiter(void 0, void 0, void 0, function* () {
119
+ let error;
120
+ try {
121
+ yield managerWith({ adbInstance: null }).getClipboard(UDID);
122
+ }
123
+ catch (e) {
124
+ error = e;
125
+ }
126
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.equal('ADB is not available');
127
+ }));
128
+ });
129
+ describe('setClipboard', () => {
130
+ it('refuses, since Appium Settings can only read the clipboard, and touches nothing', () => __awaiter(void 0, void 0, void 0, function* () {
131
+ var _a;
132
+ const adb = fakeAdb('Broadcast completed: result=-1, data=""');
133
+ let error;
134
+ try {
135
+ // Through the interface, as the control route calls it.
136
+ const manager = managerWith(adb);
137
+ yield ((_a = manager.setClipboard) === null || _a === void 0 ? void 0 : _a.call(manager, UDID, 'xenon-clip-test'));
138
+ }
139
+ catch (e) {
140
+ error = e;
141
+ }
142
+ (0, chai_1.expect)(error).to.be.instanceOf(clipboardErrors_1.ClipboardUnsupportedError);
143
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/can only read/);
144
+ (0, chai_1.expect)(adb.calls).to.deep.equal([]);
145
+ }));
146
+ });
147
+ });