@houwert/conductor 0.29.3 → 0.31.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/README.md +1 -1
- package/dist/commands/assert-not-visible.js +4 -1
- package/dist/commands/assert-visible.js +5 -2
- package/dist/commands/back.js +2 -1
- package/dist/commands/capture-ui.js +7 -3
- package/dist/commands/clipboard.js +9 -0
- package/dist/commands/copy-text-from.js +2 -1
- package/dist/commands/crashes.js +5 -0
- package/dist/commands/delete-device.js +5 -0
- package/dist/commands/download-app.js +4 -0
- package/dist/commands/erase-text.js +4 -1
- package/dist/commands/focused.js +5 -2
- package/dist/commands/foreground-app.js +4 -1
- package/dist/commands/gestures.js +7 -0
- package/dist/commands/hide-keyboard.js +5 -0
- package/dist/commands/inspect.js +10 -3
- package/dist/commands/install-app.js +21 -4
- package/dist/commands/launch-app.js +3 -2
- package/dist/commands/list-apps.js +16 -0
- package/dist/commands/list-devices.js +37 -0
- package/dist/commands/memory.js +5 -0
- package/dist/commands/press-key.js +36 -3
- package/dist/commands/profile.js +4 -0
- package/dist/commands/screenshot.js +5 -2
- package/dist/commands/scroll-until-visible.js +5 -2
- package/dist/commands/scroll.js +4 -1
- package/dist/commands/start-device.js +27 -3
- package/dist/commands/stop-app.js +2 -1
- package/dist/commands/stop-device.js +25 -3
- package/dist/commands/swipe.js +8 -3
- package/dist/commands/tap.js +3 -2
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/daemon/input-backends.js +26 -1
- package/dist/daemon/log-collector.js +6 -0
- package/dist/daemon/server.js +54 -11
- package/dist/drivers/bootstrap.js +263 -4
- package/dist/drivers/devicectl.js +243 -0
- package/dist/drivers/flow-runner.js +26 -4
- package/dist/drivers/ios.js +96 -4
- package/dist/drivers/roku/app-ui-parser.js +122 -0
- package/dist/drivers/roku/discovery.js +136 -0
- package/dist/drivers/roku/ecp-client.js +396 -0
- package/dist/drivers/roku/key-mapping.js +67 -0
- package/dist/drivers/roku.js +237 -0
- package/dist/drivers/vega/page-source-parser.js +5 -118
- package/dist/drivers/xml.js +128 -0
- package/dist/enum-options.js +3 -1
- package/dist/index.js +1 -1
- package/dist/runner.js +48 -8
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +14 -3
- package/skills/conductor-device-setup/SKILL.md +62 -2
- package/skills/conductor-profiler/SKILL.md +1 -1
package/dist/drivers/ios.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -16,6 +49,7 @@ const promises_1 = __importDefault(require("fs/promises"));
|
|
|
16
49
|
const os_1 = __importDefault(require("os"));
|
|
17
50
|
const path_1 = __importDefault(require("path"));
|
|
18
51
|
const child_process_1 = require("child_process");
|
|
52
|
+
const devicectl = __importStar(require("./devicectl.js"));
|
|
19
53
|
/**
|
|
20
54
|
* How long a captured view hierarchy may be reused. Bounds the staleness of a
|
|
21
55
|
* cached snapshot when the screen changes without a driver-issued command
|
|
@@ -24,11 +58,18 @@ const child_process_1 = require("child_process");
|
|
|
24
58
|
*/
|
|
25
59
|
const HIERARCHY_CACHE_TTL_MS = 750;
|
|
26
60
|
class IOSDriver {
|
|
27
|
-
constructor(port = 1075, host = '127.0.0.1', deviceId, platform = 'ios'
|
|
61
|
+
constructor(port = 1075, host = '127.0.0.1', deviceId, platform = 'ios',
|
|
62
|
+
/**
|
|
63
|
+
* Physical devices route app lifecycle through devicectl instead of simctl,
|
|
64
|
+
* and can't offer the simulator-only conveniences (clipboard, location,
|
|
65
|
+
* TCC pre-approval, video capture).
|
|
66
|
+
*/
|
|
67
|
+
isPhysical = false) {
|
|
28
68
|
this.port = port;
|
|
29
69
|
this.host = host;
|
|
30
70
|
this.deviceId = deviceId;
|
|
31
71
|
this.platform = platform;
|
|
72
|
+
this.isPhysical = isPhysical;
|
|
32
73
|
this._recordingProcess = null;
|
|
33
74
|
/**
|
|
34
75
|
* Short-lived cache of the most recent view hierarchy, keyed by request
|
|
@@ -38,6 +79,12 @@ class IOSDriver {
|
|
|
38
79
|
*/
|
|
39
80
|
this.hierarchyCache = null;
|
|
40
81
|
}
|
|
82
|
+
/** Reject a simulator-only operation with a message that names the alternative. */
|
|
83
|
+
unsupportedOnDevice(operation, alternative) {
|
|
84
|
+
throw new Error(`${operation} is not supported on physical ${this.platform === 'tvos' ? 'tvOS' : 'iOS'} devices` +
|
|
85
|
+
(alternative ? ` — ${alternative}` : '') +
|
|
86
|
+
'.');
|
|
87
|
+
}
|
|
41
88
|
request(method, path, body) {
|
|
42
89
|
return new Promise((resolve, reject) => {
|
|
43
90
|
const bodyBuf = body !== undefined ? Buffer.from(JSON.stringify(body), 'utf-8') : undefined;
|
|
@@ -193,7 +240,18 @@ class IOSDriver {
|
|
|
193
240
|
for (const [key, value] of Object.entries(args ?? {})) {
|
|
194
241
|
argPairs.push(`-${key}`, value);
|
|
195
242
|
}
|
|
196
|
-
if (inject) {
|
|
243
|
+
if (this.isPhysical && (inject || argPairs.length > 0)) {
|
|
244
|
+
// devicectl is the device-side equivalent of `simctl launch`: it takes
|
|
245
|
+
// both launch arguments and an environment dictionary.
|
|
246
|
+
const deviceId = this.requireDeviceId();
|
|
247
|
+
await devicectl.launchApp(deviceId, bundleId, argPairs, inject
|
|
248
|
+
? {
|
|
249
|
+
DYLD_INSERT_LIBRARIES: inject.dylibPath,
|
|
250
|
+
CONDUCTOR_INPROC_PORT: String(inject.inprocPort),
|
|
251
|
+
}
|
|
252
|
+
: undefined);
|
|
253
|
+
}
|
|
254
|
+
else if (inject) {
|
|
197
255
|
// Injection requires simctl launch with SIMCTL_CHILD_ env — the XCTest
|
|
198
256
|
// /launchApp path only activates and can't set environment.
|
|
199
257
|
const deviceId = this.requireDeviceId();
|
|
@@ -220,6 +278,13 @@ class IOSDriver {
|
|
|
220
278
|
}
|
|
221
279
|
async clearAppState(bundleId) {
|
|
222
280
|
const deviceId = this.requireDeviceId();
|
|
281
|
+
if (this.isPhysical) {
|
|
282
|
+
// No get_app_container on device, so there's no bundle to reinstall from;
|
|
283
|
+
// the caller has to supply the .app again via install-app.
|
|
284
|
+
await devicectl.uninstallApp(deviceId, bundleId);
|
|
285
|
+
this.invalidateHierarchyCache();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
223
288
|
// Terminate first to prevent app from saving state after clear
|
|
224
289
|
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
225
290
|
// Capture the .app bundle path before uninstalling — uninstall deletes the UUID directory
|
|
@@ -240,26 +305,40 @@ class IOSDriver {
|
|
|
240
305
|
}
|
|
241
306
|
async uninstallApp(bundleId) {
|
|
242
307
|
const deviceId = this.requireDeviceId();
|
|
243
|
-
|
|
244
|
-
|
|
308
|
+
if (this.isPhysical) {
|
|
309
|
+
await devicectl.uninstallApp(deviceId, bundleId);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
313
|
+
await this.simctl(['uninstall', deviceId, bundleId]);
|
|
314
|
+
}
|
|
245
315
|
this.invalidateHierarchyCache();
|
|
246
316
|
}
|
|
247
317
|
async clearKeychain() {
|
|
318
|
+
if (this.isPhysical)
|
|
319
|
+
this.unsupportedOnDevice('clear-keychain');
|
|
248
320
|
const deviceId = this.requireDeviceId();
|
|
249
321
|
await this.simctl(['keychain', deviceId, 'reset']);
|
|
250
322
|
}
|
|
251
323
|
async openLink(url) {
|
|
324
|
+
if (this.isPhysical) {
|
|
325
|
+
this.unsupportedOnDevice('open-link', 'devicectl has no openurl equivalent');
|
|
326
|
+
}
|
|
252
327
|
const deviceId = this.requireDeviceId();
|
|
253
328
|
await this.simctl(['openurl', deviceId, url]);
|
|
254
329
|
this.invalidateHierarchyCache();
|
|
255
330
|
}
|
|
256
331
|
/** Read the simulator's clipboard. Uses `xcrun simctl pbpaste <udid>`. */
|
|
257
332
|
async clipboardRead() {
|
|
333
|
+
if (this.isPhysical)
|
|
334
|
+
this.unsupportedOnDevice('Reading the clipboard');
|
|
258
335
|
const deviceId = this.requireDeviceId();
|
|
259
336
|
return this.simctlCapture(['pbpaste', deviceId]);
|
|
260
337
|
}
|
|
261
338
|
/** Write to the simulator's clipboard. Uses `xcrun simctl pbcopy <udid>` over stdin. */
|
|
262
339
|
async clipboardWrite(text) {
|
|
340
|
+
if (this.isPhysical)
|
|
341
|
+
this.unsupportedOnDevice('Writing the clipboard');
|
|
263
342
|
const deviceId = this.requireDeviceId();
|
|
264
343
|
await new Promise((resolve, reject) => {
|
|
265
344
|
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', 'pbcopy', deviceId], {
|
|
@@ -275,6 +354,8 @@ class IOSDriver {
|
|
|
275
354
|
});
|
|
276
355
|
}
|
|
277
356
|
async setLocation(latitude, longitude) {
|
|
357
|
+
if (this.isPhysical)
|
|
358
|
+
this.unsupportedOnDevice('set-location');
|
|
278
359
|
const deviceId = this.requireDeviceId();
|
|
279
360
|
await this.simctl(['location', deviceId, 'set', `${latitude},${longitude}`]);
|
|
280
361
|
}
|
|
@@ -339,6 +420,12 @@ class IOSDriver {
|
|
|
339
420
|
mediaLibrary: 'media-library',
|
|
340
421
|
siri: 'siri',
|
|
341
422
|
};
|
|
423
|
+
// On device there's no TCC pre-approval path, so the runner's interruption
|
|
424
|
+
// monitor is the only thing that can answer permission dialogs.
|
|
425
|
+
if (this.isPhysical) {
|
|
426
|
+
await this.post('setPermissions', { permissions: expanded });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
342
429
|
const deviceId = this.requireDeviceId();
|
|
343
430
|
if (allValue !== undefined) {
|
|
344
431
|
// Best-effort bulk grant/revoke. 'all' covers TCC-managed permissions but
|
|
@@ -370,6 +457,8 @@ class IOSDriver {
|
|
|
370
457
|
await this.post('setPermissions', { permissions: expanded });
|
|
371
458
|
}
|
|
372
459
|
async addMedia(filePath) {
|
|
460
|
+
if (this.isPhysical)
|
|
461
|
+
this.unsupportedOnDevice('add-media');
|
|
373
462
|
const deviceId = this.requireDeviceId();
|
|
374
463
|
await this.simctl(['addmedia', deviceId, filePath]);
|
|
375
464
|
}
|
|
@@ -380,6 +469,9 @@ class IOSDriver {
|
|
|
380
469
|
throw new Error('getAirplaneMode is not supported on iOS simulators');
|
|
381
470
|
}
|
|
382
471
|
async startRecording(outputPath) {
|
|
472
|
+
if (this.isPhysical) {
|
|
473
|
+
this.unsupportedOnDevice('Screen recording', 'capture stills with `conductor screenshot`');
|
|
474
|
+
}
|
|
383
475
|
const deviceId = this.requireDeviceId();
|
|
384
476
|
if (this._recordingProcess)
|
|
385
477
|
await this.stopRecording();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseRokuAppUI = parseRokuAppUI;
|
|
4
|
+
/**
|
|
5
|
+
* Converts Roku's ECP `/query/app-ui` SceneGraph XML into the uiautomator-style
|
|
6
|
+
* `<node>` XML that conductor's Android element resolver (`parseAndroidHierarchy`,
|
|
7
|
+
* `findAndroidElement`, `inspectAndroidToText`) already understands — the same
|
|
8
|
+
* trick Vega uses, so Roku reuses the entire inspection and element-resolution path.
|
|
9
|
+
*
|
|
10
|
+
* app-ui shape:
|
|
11
|
+
* ```
|
|
12
|
+
* <app-ui>
|
|
13
|
+
* <status>OK</status>
|
|
14
|
+
* <topscreen>
|
|
15
|
+
* <plugin id="dev" name="MyApp"/>
|
|
16
|
+
* <screen focused="true" type="screen">
|
|
17
|
+
* <RenderableNode name="myId" subtype="Group" bounds="{0, 0, 1920, 1080}" …>
|
|
18
|
+
* ```
|
|
19
|
+
* Node `name` attributes (SceneGraph node ids) become `resource-id`, `subtype`
|
|
20
|
+
* becomes `class`, and `bounds` are accumulated with parent `translation` offsets
|
|
21
|
+
* into scene-absolute `[x1,y1][x2,y2]` rects — the Android adapter's format.
|
|
22
|
+
*/
|
|
23
|
+
const xml_js_1 = require("../xml.js");
|
|
24
|
+
const EMPTY = '<?xml version="1.0" encoding="UTF-8"?>\n<hierarchy />';
|
|
25
|
+
/** Parse Roku app-ui XML and re-emit it as uiautomator `<node>` XML. */
|
|
26
|
+
function parseRokuAppUI(xml, screenWidth = 1920, screenHeight = 1080) {
|
|
27
|
+
const root = (0, xml_js_1.parseXml)(xml);
|
|
28
|
+
if (!root)
|
|
29
|
+
return EMPTY;
|
|
30
|
+
const topscreen = (0, xml_js_1.childElement)(root, 'topscreen');
|
|
31
|
+
const screen = topscreen && (0, xml_js_1.childElement)(topscreen, 'screen');
|
|
32
|
+
if (!screen)
|
|
33
|
+
return EMPTY;
|
|
34
|
+
const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<hierarchy>'];
|
|
35
|
+
// A root node spanning the screen: it gives the screenshot cropper its reference
|
|
36
|
+
// bounds, matching the window node Android and Vega both emit.
|
|
37
|
+
// Never focused: `<screen focused>` means the screen holds device focus, not that
|
|
38
|
+
// it is the focused element, and claiming it here would shadow the real one.
|
|
39
|
+
const rootAttrs = [
|
|
40
|
+
'class="Screen"',
|
|
41
|
+
`bounds="[0,0][${screenWidth},${screenHeight}]"`,
|
|
42
|
+
'clickable="false"',
|
|
43
|
+
'focusable="false"',
|
|
44
|
+
'focused="false"',
|
|
45
|
+
'enabled="true"',
|
|
46
|
+
];
|
|
47
|
+
lines.push(` <node ${rootAttrs.join(' ')}>`);
|
|
48
|
+
emitChildren(screen, { x: 0, y: 0 }, false, lines, 2);
|
|
49
|
+
lines.push(' </node>');
|
|
50
|
+
lines.push('</hierarchy>');
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
function emitChildren(parent, offset, parentIsRowListItem, lines, depth) {
|
|
54
|
+
// A RowListItem's trailing Group duplicates the item it wraps.
|
|
55
|
+
const children = parentIsRowListItem && parent.children.length > 1
|
|
56
|
+
? parent.children.slice(0, -1)
|
|
57
|
+
: parent.children;
|
|
58
|
+
for (const child of children)
|
|
59
|
+
emitNode(child, offset, parentIsRowListItem, lines, depth);
|
|
60
|
+
}
|
|
61
|
+
function emitNode(node, parentOffset, parentIsRowListItem, lines, depth) {
|
|
62
|
+
// SceneGraph doesn't render an invisible node or anything beneath it, but ECP
|
|
63
|
+
// still reports the subtree with real bounds. Keeping them would let
|
|
64
|
+
// assert-visible match a hidden element and assert-not-visible fail on one.
|
|
65
|
+
if (node.attrs['visible'] === 'false')
|
|
66
|
+
return;
|
|
67
|
+
const opacity = parseFloat(node.attrs['opacity'] ?? '100');
|
|
68
|
+
if (!isNaN(opacity) && opacity <= 0)
|
|
69
|
+
return;
|
|
70
|
+
// ECP reports the SceneGraph type in `subtype`; the element name is only a
|
|
71
|
+
// fallback, and for a generic `RenderableNode` it says nothing at all.
|
|
72
|
+
const subtype = node.attrs['subtype'] || (node.tag === 'RenderableNode' ? 'Group' : node.tag);
|
|
73
|
+
const translation = parseNumericArray(node.attrs['translation'], 2);
|
|
74
|
+
const bounds = parseNumericArray(node.attrs['bounds'], 4);
|
|
75
|
+
// Offset this node's children inherit.
|
|
76
|
+
let nodeOffset = parentOffset;
|
|
77
|
+
if (subtype === 'MarkupGrid' && parentIsRowListItem) {
|
|
78
|
+
if (bounds)
|
|
79
|
+
nodeOffset = { x: bounds[0] + parentOffset.x, y: bounds[1] + parentOffset.y };
|
|
80
|
+
}
|
|
81
|
+
else if (translation) {
|
|
82
|
+
nodeOffset = { x: translation[0] + parentOffset.x, y: translation[1] + parentOffset.y };
|
|
83
|
+
}
|
|
84
|
+
const focusable = node.attrs['focusable'] === 'true';
|
|
85
|
+
const focused = node.attrs['focused'] === 'true';
|
|
86
|
+
const attrs = [`class="${(0, xml_js_1.xmlEscape)(subtype)}"`];
|
|
87
|
+
if (node.attrs['name'])
|
|
88
|
+
attrs.push(`resource-id="${(0, xml_js_1.xmlEscape)(node.attrs['name'])}"`);
|
|
89
|
+
if (node.attrs['text'])
|
|
90
|
+
attrs.push(`text="${(0, xml_js_1.xmlEscape)(node.attrs['text'])}"`);
|
|
91
|
+
if (bounds) {
|
|
92
|
+
const x1 = Math.round(bounds[0] + parentOffset.x);
|
|
93
|
+
const y1 = Math.round(bounds[1] + parentOffset.y);
|
|
94
|
+
attrs.push(`bounds="[${x1},${y1}][${x1 + Math.round(bounds[2])},${y1 + Math.round(bounds[3])}]"`);
|
|
95
|
+
}
|
|
96
|
+
attrs.push(`clickable="${focusable}"`, `focusable="${focusable}"`);
|
|
97
|
+
attrs.push(`focused="${focused}"`, `selected="${focused}"`, 'enabled="true"');
|
|
98
|
+
const indent = ' '.repeat(depth);
|
|
99
|
+
if (node.children.length === 0) {
|
|
100
|
+
lines.push(`${indent}<node ${attrs.join(' ')} />`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
lines.push(`${indent}<node ${attrs.join(' ')}>`);
|
|
104
|
+
emitChildren(node, nodeOffset, node.tag === 'RowListItem', lines, depth + 1);
|
|
105
|
+
lines.push(`${indent}</node>`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parses a Roku numeric array attribute (`{0, 0, 1920, 1080}`), or null if it
|
|
109
|
+
* doesn't hold at least `minSize` values — callers index it positionally, so a
|
|
110
|
+
* short array is no more usable than a missing one.
|
|
111
|
+
*/
|
|
112
|
+
function parseNumericArray(value, minSize) {
|
|
113
|
+
if (!value)
|
|
114
|
+
return null;
|
|
115
|
+
const cleaned = value.replace(/[{}]/g, '').trim();
|
|
116
|
+
if (!cleaned)
|
|
117
|
+
return null;
|
|
118
|
+
const parsed = cleaned.split(',').map((p) => parseFloat(p.trim()));
|
|
119
|
+
if (parsed.length < minSize || parsed.some((v) => isNaN(v)))
|
|
120
|
+
return null;
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.rokuPassword = rokuPassword;
|
|
7
|
+
exports.discoverRokuDevices = discoverRokuDevices;
|
|
8
|
+
exports.describe = describe;
|
|
9
|
+
exports.isPortOpen = isPortOpen;
|
|
10
|
+
exports.parseSsdpLocation = parseSsdpLocation;
|
|
11
|
+
/**
|
|
12
|
+
* Discovers Roku devices on the local network. Two best-effort methods:
|
|
13
|
+
* 1. `CONDUCTOR_ROKU_HOST` — a manually pinned device IP/hostname (always checked).
|
|
14
|
+
* 2. SSDP multicast (`M-SEARCH` with `ST: roku:ecp`) — opt-in via
|
|
15
|
+
* `CONDUCTOR_ROKU_DISCOVERY`, because the scan multicasts on the LAN and adds
|
|
16
|
+
* ~1s to every device listing.
|
|
17
|
+
*/
|
|
18
|
+
const dgram_1 = __importDefault(require("dgram"));
|
|
19
|
+
const net_1 = __importDefault(require("net"));
|
|
20
|
+
const verbose_js_1 = require("../../verbose.js");
|
|
21
|
+
const ecp_client_js_1 = require("./ecp-client.js");
|
|
22
|
+
const SSDP_ADDRESS = '239.255.255.250';
|
|
23
|
+
const SSDP_PORT = 1900;
|
|
24
|
+
const SSDP_TIMEOUT_MS = 1000;
|
|
25
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
26
|
+
/** The dev-mode password used for screenshots, from the environment. */
|
|
27
|
+
function rokuPassword() {
|
|
28
|
+
return process.env.CONDUCTOR_ROKU_PASSWORD ?? '';
|
|
29
|
+
}
|
|
30
|
+
function discoveryEnabled() {
|
|
31
|
+
const v = (process.env.CONDUCTOR_ROKU_DISCOVERY ?? '').toLowerCase();
|
|
32
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
33
|
+
}
|
|
34
|
+
/** All Roku devices reachable right now: the pinned env-var host plus any SSDP hits. */
|
|
35
|
+
async function discoverRokuDevices() {
|
|
36
|
+
const hosts = new Set();
|
|
37
|
+
const pinned = process.env.CONDUCTOR_ROKU_HOST?.trim();
|
|
38
|
+
if (pinned)
|
|
39
|
+
hosts.add(pinned);
|
|
40
|
+
if (discoveryEnabled()) {
|
|
41
|
+
for (const host of await ssdpScan())
|
|
42
|
+
hosts.add(host);
|
|
43
|
+
}
|
|
44
|
+
const described = await Promise.all([...hosts].map((host) => describe(host)));
|
|
45
|
+
return described.filter((d) => d !== null);
|
|
46
|
+
}
|
|
47
|
+
/** Resolve a host into a described device, or null if it isn't reachable. */
|
|
48
|
+
async function describe(host) {
|
|
49
|
+
if (!(await isPortOpen(host, ecp_client_js_1.DEFAULT_ECP_PORT))) {
|
|
50
|
+
(0, verbose_js_1.log)(`roku: device at ${host} is not reachable on port ${ecp_client_js_1.DEFAULT_ECP_PORT}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const info = await new ecp_client_js_1.RokuEcpClient(host).getDeviceInfo();
|
|
54
|
+
return {
|
|
55
|
+
host,
|
|
56
|
+
modelName: info?.modelName ?? 'Roku Device',
|
|
57
|
+
friendlyName: info?.friendlyName || host,
|
|
58
|
+
serialNumber: info?.serialNumber ?? '',
|
|
59
|
+
softwareVersion: info?.softwareVersion ?? '',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Quick TCP probe so an offline pinned host fails in ~500ms, not a full HTTP timeout. */
|
|
63
|
+
function isPortOpen(host, port) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const socket = new net_1.default.Socket();
|
|
66
|
+
const done = (result) => {
|
|
67
|
+
socket.destroy();
|
|
68
|
+
resolve(result);
|
|
69
|
+
};
|
|
70
|
+
socket.setTimeout(PROBE_TIMEOUT_MS);
|
|
71
|
+
socket.once('connect', () => done(true));
|
|
72
|
+
socket.once('timeout', () => done(false));
|
|
73
|
+
socket.once('error', () => done(false));
|
|
74
|
+
socket.connect(port, host);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** SSDP M-SEARCH for `roku:ecp` targets; resolves to the responding hosts. */
|
|
78
|
+
function ssdpScan() {
|
|
79
|
+
const request = Buffer.from([
|
|
80
|
+
'M-SEARCH * HTTP/1.1',
|
|
81
|
+
`HOST: ${SSDP_ADDRESS}:${SSDP_PORT}`,
|
|
82
|
+
'MAN: "ssdp:discover"',
|
|
83
|
+
'ST: roku:ecp',
|
|
84
|
+
'MX: 1',
|
|
85
|
+
'',
|
|
86
|
+
'',
|
|
87
|
+
].join('\r\n'));
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const hosts = new Set();
|
|
90
|
+
const socket = dgram_1.default.createSocket({ type: 'udp4', reuseAddr: true });
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = () => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
try {
|
|
98
|
+
socket.close();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* already closed */
|
|
102
|
+
}
|
|
103
|
+
resolve([...hosts]);
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(finish, SSDP_TIMEOUT_MS);
|
|
106
|
+
socket.on('message', (msg) => {
|
|
107
|
+
const host = parseSsdpLocation(msg.toString('utf-8'));
|
|
108
|
+
if (host)
|
|
109
|
+
hosts.add(host);
|
|
110
|
+
});
|
|
111
|
+
socket.on('error', (err) => {
|
|
112
|
+
(0, verbose_js_1.log)(`roku: SSDP scan failed: ${err.message}`);
|
|
113
|
+
finish();
|
|
114
|
+
});
|
|
115
|
+
socket.bind(() => {
|
|
116
|
+
socket.send(request, SSDP_PORT, SSDP_ADDRESS, (err) => {
|
|
117
|
+
if (err) {
|
|
118
|
+
(0, verbose_js_1.log)(`roku: SSDP send failed: ${err.message}`);
|
|
119
|
+
finish();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Extract the device host from an SSDP response's `LOCATION: http://<ip>:8060/` header. */
|
|
126
|
+
function parseSsdpLocation(response) {
|
|
127
|
+
const line = response.split(/\r?\n/).find((l) => /^location:/i.test(l));
|
|
128
|
+
if (!line)
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
return new URL(line.slice(line.indexOf(':') + 1).trim()).hostname || null;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|