@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.
- package/lib/package.json +1 -1
- package/lib/public/assets/{ApiKeyGate-vELAdQLu.js → ApiKeyGate-CYrHWuf9.js} +1 -1
- package/lib/public/assets/{BugReportButton-ySiZDywe.js → BugReportButton-CRRZnSs2.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Chtp0wAI.js → DeviceMosaicView-Dw7dK-Ri.js} +1 -1
- package/lib/public/assets/{EmptyState-CYQYScPo.js → EmptyState-BDSDbxbe.js} +1 -1
- package/lib/public/assets/{FieldGroup-Dea68RfI.js → FieldGroup-DTSy35Hp.js} +1 -1
- package/lib/public/assets/{Modal-BgzTzImP.js → Modal-BC9RPZeO.js} +1 -1
- package/lib/public/assets/{SegmentedControl-DyRq9blY.js → SegmentedControl-DUw7pxfw.js} +1 -1
- package/lib/public/assets/{SettingCard-CardwBu3.js → SettingCard-DwHb-9y7.js} +1 -1
- package/lib/public/assets/{Table-BczCxsDm.js → Table-C167c0iD.js} +1 -1
- package/lib/public/assets/{activity-CS-Op-T6.js → activity-C8ktgelv.js} +1 -1
- package/lib/public/assets/{ai-settings-CIvB7rNM.js → ai-settings-BtH8D-Vk.js} +3 -3
- package/lib/public/assets/{api-keys-peJzllzQ.js → api-keys-D0CPHe_-.js} +1 -1
- package/lib/public/assets/{apps-__WvcJaP.js → apps-f6Z9888a.js} +3 -3
- package/lib/public/assets/{arrow-left-Dx-cXFLT.js → arrow-left-CEnrTC1h.js} +1 -1
- package/lib/public/assets/{arrow-right-Ax_FfBMv.js → arrow-right-DJbja5Vo.js} +1 -1
- package/lib/public/assets/{arrow-up-right-B6uJmGeA.js → arrow-up-right-DvIZquNN.js} +1 -1
- package/lib/public/assets/{auth-shell-BQqcjjC9.js → auth-shell-5XCL86e9.js} +2 -2
- package/lib/public/assets/{builds-page-BA-_iEjx.js → builds-page-DQbsMgZp.js} +1 -1
- package/lib/public/assets/{button-DcbnByKB.js → button-CMj-iXRu.js} +1 -1
- package/lib/public/assets/{calendar-C3pYg1V7.js → calendar-BKb9JzMb.js} +1 -1
- package/lib/public/assets/{check-DKdLfzhJ.js → check-BioFa43z.js} +1 -1
- package/lib/public/assets/{chevron-left-Bb9PBLG5.js → chevron-left-BfiSwNHE.js} +1 -1
- package/lib/public/assets/{chevron-right-DJcpeY_o.js → chevron-right-BLqjv9Fz.js} +1 -1
- package/lib/public/assets/{circle-check-DY6ntqp3.js → circle-check-N7nCeFZH.js} +1 -1
- package/lib/public/assets/{circle-x-DgK9drRH.js → circle-x-DzRW2cW-.js} +1 -1
- package/lib/public/assets/{clock-I9zEVPan.js → clock-Q8bhs7zV.js} +1 -1
- package/lib/public/assets/{copy-Dy3oRuWT.js → copy-Dh1ppPe1.js} +1 -1
- package/lib/public/assets/device-explorer-Cbwojnbb.js +277 -0
- package/lib/public/assets/device-explorer-CcLOID3w.css +1 -0
- package/lib/public/assets/{download-BotTmDty.js → download-Dit5_Lob.js} +1 -1
- package/lib/public/assets/{forgot-password-BaWiD2U2.js → forgot-password-yhK1Eo6h.js} +1 -1
- package/lib/public/assets/{funnel-D2Qw4nTX.js → funnel-DQo9dvhT.js} +1 -1
- package/lib/public/assets/{index-CI9Nz3HY.js → index-kcl0icXA.js} +2 -2
- package/lib/public/assets/{input-cBBGau4X.js → input-DwXEwtQD.js} +1 -1
- package/lib/public/assets/{keycodes-CY4uwulo.js → keycodes-mlL7WkFd.js} +1 -1
- package/lib/public/assets/{layers-MHwJM2Ll.js → layers-DFEJsDA2.js} +1 -1
- package/lib/public/assets/{lock-Bpc8rJFu.js → lock-DVGX1wMR.js} +1 -1
- package/lib/public/assets/{login-BmcyszP5.js → login-BtczO6mT.js} +1 -1
- package/lib/public/assets/{maintenance-settings-DMFVuToe.js → maintenance-settings-BNkCNkdA.js} +2 -2
- package/lib/public/assets/{monitor-irjK6PRV.js → monitor-DXiFh_zm.js} +1 -1
- package/lib/public/assets/mouse-pointer-2-iiFLrBfo.js +6 -0
- package/lib/public/assets/{network-CVZ32MhH.js → network-D-64m0lI.js} +1 -1
- package/lib/public/assets/{overview-DRRSIpnD.js → overview-Bs8fij0f.js} +1 -1
- package/lib/public/assets/{page-header-Bl7uTiQF.js → page-header-2aP5HfMz.js} +1 -1
- package/lib/public/assets/{plus-C3lvaMcL.js → plus-l0nFcN3u.js} +1 -1
- package/lib/public/assets/{profile-page-B3wsnR1n.js → profile-page-DRQxEDPv.js} +1 -1
- package/lib/public/assets/{reset-password-BIGj_ULj.js → reset-password-TeZbTlY3.js} +1 -1
- package/lib/public/assets/{runbook-page-DEznNZhc.js → runbook-page-CfB3Sgqd.js} +1 -1
- package/lib/public/assets/{select-BpuZWZuE.js → select-CfLeV0bn.js} +1 -1
- package/lib/public/assets/{selector-detail-page-ZCvmtQ5Y.js → selector-detail-page-sjSLgf2y.js} +1 -1
- package/lib/public/assets/{selector-health-D-hvfY2E.js → selector-health-DobHOezq.js} +1 -1
- package/lib/public/assets/{selector-health-page-C0ZqZ_VT.js → selector-health-page-BeGTmjeJ.js} +1 -1
- package/lib/public/assets/{session-detail-page-Bv7IwFTQ.js → session-detail-page-B7Be2Uxw.js} +1 -1
- package/lib/public/assets/settings-DWlkn05y.js +6 -0
- package/lib/public/assets/{status-pill-outline-B5dWsaer.js → status-pill-outline-DqZaavRm.js} +1 -1
- package/lib/public/assets/{tablet-DxwkBlhd.js → tablet-D2ApmdVm.js} +1 -1
- package/lib/public/assets/{teams-D3s8GhkG.js → teams-DVm0mUGI.js} +1 -1
- package/lib/public/assets/{trash-2-Dyf4Jcyk.js → trash-2-BklI15pS.js} +1 -1
- package/lib/public/assets/{upload-B6ZJ1_f1.js → upload-BMYoLWMh.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-B-oOrt1d.js → use-password-reset-mode-Dx_zeU5K.js} +1 -1
- package/lib/public/assets/{users-QfiFclLn.js → users-CnVdUoqL.js} +1 -1
- package/lib/public/assets/{users-BfvPE2wX.js → users-DqVUPDed.js} +1 -1
- package/lib/public/assets/{video-off-C2quR4KF.js → video-off-DEmVhLd9.js} +1 -1
- package/lib/public/assets/{webhook-settings-BjbGi-Pi.js → webhook-settings-CHpCrpob.js} +1 -1
- package/lib/public/assets/{zap-C2lxP7TK.js → zap-ovWf2UbO.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +5 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +31 -95
- package/lib/src/device-managers/HealthMonitorService.js +10 -6
- package/lib/src/device-managers/android/AndroidH264StreamService.js +6 -1
- package/lib/src/device-managers/android/AndroidStreamService.js +9 -18
- package/lib/src/device-managers/android/clipboardBroadcast.js +24 -0
- package/lib/src/device-managers/android/previewHold.js +72 -0
- package/lib/src/device-managers/clipboardErrors.js +15 -0
- package/lib/test/unit/android-clipboard.spec.js +147 -0
- package/lib/test/unit/android-preview-hold.spec.js +179 -0
- package/lib/test/unit/clipboard-route.spec.js +110 -0
- package/lib/test/unit/health-monitor-stream-lookup.spec.js +107 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/lib/public/assets/device-explorer-BPyGYs0c.js +0 -246
- package/lib/public/assets/device-explorer-C9qg30Jn.css +0 -1
- package/lib/public/assets/mouse-pointer-2-CYkC-Dyq.js +0 -11
- 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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
});
|