@houwert/conductor 0.29.3 → 0.30.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 +5 -0
- package/dist/commands/launch-app.js +3 -2
- package/dist/commands/list-apps.js +5 -0
- package/dist/commands/list-devices.js +12 -0
- package/dist/commands/memory.js +5 -0
- package/dist/commands/press-key.js +15 -0
- 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 +12 -1
- package/dist/commands/swipe.js +4 -1
- package/dist/commands/tap.js +3 -2
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/daemon/server.js +14 -4
- package/dist/drivers/bootstrap.js +10 -0
- package/dist/drivers/flow-runner.js +16 -3
- 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 +32 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +4 -3
- package/skills/conductor-device-setup/SKILL.md +35 -2
- package/skills/conductor-profiler/SKILL.md +1 -1
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HELP = void 0;
|
|
4
4
|
exports.stopDevice = stopDevice;
|
|
5
5
|
exports.HELP = ` stop-device [<name-or-id>]
|
|
6
|
-
--platform <ios|tvos|android|web|vega> Scope to a single platform
|
|
6
|
+
--platform <ios|tvos|android|web|vega|roku> Scope to a single platform
|
|
7
7
|
--all Stop all booted simulators / running emulators / web sessions`;
|
|
8
8
|
const runner_js_1 = require("../runner.js");
|
|
9
9
|
const sdk_js_1 = require("../android/sdk.js");
|
|
@@ -36,6 +36,7 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
36
36
|
const includeAndroid = !platform || platform === 'android';
|
|
37
37
|
const includeWeb = !platform || platform === 'web';
|
|
38
38
|
const includeVega = !platform || platform === 'vega';
|
|
39
|
+
const includeRoku = !platform || platform === 'roku';
|
|
39
40
|
const stopped = [];
|
|
40
41
|
// ── --all mode ───────────────────────────────────────────────────────────
|
|
41
42
|
if (flags.all) {
|
|
@@ -51,6 +52,8 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
51
52
|
continue;
|
|
52
53
|
if (d.platform === 'vega' && !includeVega)
|
|
53
54
|
continue;
|
|
55
|
+
if (d.platform === 'roku' && !includeRoku)
|
|
56
|
+
continue;
|
|
54
57
|
try {
|
|
55
58
|
if (d.platform === 'ios' || d.platform === 'tvos') {
|
|
56
59
|
await shutdownSimulator(d.id);
|
|
@@ -62,6 +65,10 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
62
65
|
// Vega VVD lifecycle is owned by Amazon's tooling — we only stop our log daemon.
|
|
63
66
|
await (0, client_js_1.stopDaemon)(d.id);
|
|
64
67
|
}
|
|
68
|
+
else if (d.platform === 'roku') {
|
|
69
|
+
// A Roku is physical hardware we never booted; there is nothing to stop.
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
65
72
|
stopped.push({ id: d.id, name: d.name, platform: d.platform });
|
|
66
73
|
}
|
|
67
74
|
catch (e) {
|
|
@@ -104,6 +111,10 @@ async function stopDevice(nameOrId, opts, flags) {
|
|
|
104
111
|
// Vega VVD lifecycle is owned by Amazon's tooling — we only stop our log daemon.
|
|
105
112
|
await (0, client_js_1.stopDaemon)(match.id);
|
|
106
113
|
}
|
|
114
|
+
else if (match.platform === 'roku') {
|
|
115
|
+
(0, output_js_1.printError)(`${match.name} is a physical Roku device — conductor never booted it, so there is nothing to stop.`, opts);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
107
118
|
}
|
|
108
119
|
catch (e) {
|
|
109
120
|
(0, output_js_1.printError)(e instanceof Error ? e.message : String(e), opts);
|
package/dist/commands/swipe.js
CHANGED
|
@@ -13,6 +13,7 @@ const ios_js_1 = require("../drivers/ios.js");
|
|
|
13
13
|
const android_js_1 = require("../drivers/android.js");
|
|
14
14
|
const web_js_1 = require("../drivers/web.js");
|
|
15
15
|
const vega_js_1 = require("../drivers/vega.js");
|
|
16
|
+
const roku_js_1 = require("../drivers/roku.js");
|
|
16
17
|
const utils_js_1 = require("../utils.js");
|
|
17
18
|
function parseCoordPair(s) {
|
|
18
19
|
const [xs, ys] = s.split(',').map((p) => p.trim());
|
|
@@ -71,7 +72,9 @@ async function swipe(direction, opts = {}, sessionName = 'default', flags = {})
|
|
|
71
72
|
}
|
|
72
73
|
await driver.swipe(startX, startY, endX, endY, durationMs);
|
|
73
74
|
}
|
|
74
|
-
else if (driver instanceof android_js_1.AndroidDriver ||
|
|
75
|
+
else if (driver instanceof android_js_1.AndroidDriver ||
|
|
76
|
+
driver instanceof vega_js_1.VegaDriver ||
|
|
77
|
+
driver instanceof roku_js_1.RokuDriver) {
|
|
75
78
|
const { widthPixels: w, heightPixels: h } = await driver.deviceInfo();
|
|
76
79
|
const durationMs = flags.duration ?? 500;
|
|
77
80
|
if (flags.start && flags.end) {
|
package/dist/commands/tap.js
CHANGED
|
@@ -28,6 +28,7 @@ const ios_js_1 = require("../drivers/ios.js");
|
|
|
28
28
|
const android_js_1 = require("../drivers/android.js");
|
|
29
29
|
const web_js_1 = require("../drivers/web.js");
|
|
30
30
|
const vega_js_1 = require("../drivers/vega.js");
|
|
31
|
+
const roku_js_1 = require("../drivers/roku.js");
|
|
31
32
|
const wait_js_1 = require("../drivers/wait.js");
|
|
32
33
|
const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
|
|
33
34
|
const snapshot_store_js_1 = require("../snapshot-store.js");
|
|
@@ -88,8 +89,8 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
|
|
|
88
89
|
else if (driver instanceof web_js_1.WebDriver) {
|
|
89
90
|
el = await (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel);
|
|
90
91
|
}
|
|
91
|
-
else if (driver instanceof vega_js_1.VegaDriver) {
|
|
92
|
-
// Vega
|
|
92
|
+
else if (driver instanceof vega_js_1.VegaDriver || driver instanceof roku_js_1.RokuDriver) {
|
|
93
|
+
// Vega and Roku emit uiautomator-style XML, so they reuse the Android resolver.
|
|
93
94
|
el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
|
|
94
95
|
}
|
|
95
96
|
else if (driver instanceof android_js_1.AndroidDriver) {
|
|
@@ -9,6 +9,7 @@ const ios_js_1 = require("../drivers/ios.js");
|
|
|
9
9
|
const android_js_1 = require("../drivers/android.js");
|
|
10
10
|
const web_js_1 = require("../drivers/web.js");
|
|
11
11
|
const vega_js_1 = require("../drivers/vega.js");
|
|
12
|
+
const roku_js_1 = require("../drivers/roku.js");
|
|
12
13
|
async function uninstallApp(appId, opts = {}, sessionName = 'default') {
|
|
13
14
|
if (!appId) {
|
|
14
15
|
(0, output_js_1.printError)('uninstall-app requires <appId>', opts);
|
|
@@ -24,6 +25,9 @@ async function uninstallApp(appId, opts = {}, sessionName = 'default') {
|
|
|
24
25
|
else if (driver instanceof vega_js_1.VegaDriver) {
|
|
25
26
|
throw new Error('uninstall-app is not supported on vega (Amazon Fire TV)');
|
|
26
27
|
}
|
|
28
|
+
else if (driver instanceof roku_js_1.RokuDriver) {
|
|
29
|
+
throw new Error('uninstall-app is not supported on roku');
|
|
30
|
+
}
|
|
27
31
|
else if (driver instanceof android_js_1.AndroidDriver) {
|
|
28
32
|
await driver.uninstallApp(appId);
|
|
29
33
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -177,8 +177,9 @@ let _driverStartError = null;
|
|
|
177
177
|
async function ensureDriverRunning() {
|
|
178
178
|
if (_restartInProgress || !_driverStarted)
|
|
179
179
|
return;
|
|
180
|
-
// Vega
|
|
181
|
-
|
|
180
|
+
// Vega and Roku have no driver process/port to health-check — control is
|
|
181
|
+
// host-side (the vega CLI) or over the network (Roku ECP).
|
|
182
|
+
if (driverPlatform === 'vega' || driverPlatform === 'roku')
|
|
182
183
|
return;
|
|
183
184
|
let alive;
|
|
184
185
|
if (driverPlatform === 'android') {
|
|
@@ -317,6 +318,9 @@ async function main() {
|
|
|
317
318
|
else if (driverPlatform === 'vega') {
|
|
318
319
|
dlog('vega: no driver process to stop (control is host-side via the CLI)');
|
|
319
320
|
}
|
|
321
|
+
else if (driverPlatform === 'roku') {
|
|
322
|
+
dlog('roku: no driver process to stop (control is ECP over the network)');
|
|
323
|
+
}
|
|
320
324
|
else if (driverPlatform === 'web') {
|
|
321
325
|
dlog('Stopping web driver');
|
|
322
326
|
try {
|
|
@@ -449,6 +453,12 @@ async function main() {
|
|
|
449
453
|
_driverStarted = true;
|
|
450
454
|
dlog('vega: no driver process to start; collecting logs only');
|
|
451
455
|
}
|
|
456
|
+
else if (platform === 'roku') {
|
|
457
|
+
// Roku has neither a driver process nor a log stream to collect, so
|
|
458
|
+
// the daemon has nothing to do — commands drive the device directly.
|
|
459
|
+
_driverStarted = true;
|
|
460
|
+
dlog('roku: no driver process and no device logs; daemon is idle');
|
|
461
|
+
}
|
|
452
462
|
else {
|
|
453
463
|
await startDriverForPlatform(platform);
|
|
454
464
|
}
|
|
@@ -487,8 +497,8 @@ async function main() {
|
|
|
487
497
|
});
|
|
488
498
|
}
|
|
489
499
|
/**
|
|
490
|
-
* Bring up the driver process for a
|
|
491
|
-
* `_driverStartError`. Extracted so the vega
|
|
500
|
+
* Bring up the driver process for a platform that has one. Sets `_driverStarted` /
|
|
501
|
+
* `_driverStartError`. Extracted so the vega and roku paths can skip it entirely.
|
|
492
502
|
*/
|
|
493
503
|
async function startDriverForPlatform(platform) {
|
|
494
504
|
let driverAlive;
|
|
@@ -63,6 +63,11 @@ async function detectPlatform(deviceId) {
|
|
|
63
63
|
_platformCache.set(deviceId, 'vega');
|
|
64
64
|
return 'vega';
|
|
65
65
|
}
|
|
66
|
+
// Roku: "roku:<host>" (e.g. "roku:192.168.1.100")
|
|
67
|
+
if (deviceId === 'roku' || deviceId.startsWith('roku:')) {
|
|
68
|
+
_platformCache.set(deviceId, 'roku');
|
|
69
|
+
return 'roku';
|
|
70
|
+
}
|
|
66
71
|
// Check if it looks like an iOS/tvOS simulator UUID (8-4-4-4-12 hex chars)
|
|
67
72
|
const iosUuidRe = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
|
68
73
|
if (iosUuidRe.test(deviceId)) {
|
|
@@ -174,6 +179,11 @@ async function getDriverPort(platform, deviceId) {
|
|
|
174
179
|
else if (platform === 'vega') {
|
|
175
180
|
port = state.nextVegaPort++;
|
|
176
181
|
}
|
|
182
|
+
else if (platform === 'roku') {
|
|
183
|
+
// Roku is driven entirely over the network (ECP on the device's own port
|
|
184
|
+
// 8060) — there is no host-side driver process, so no port to reserve.
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
177
187
|
else {
|
|
178
188
|
port = state.nextAndroidPort++;
|
|
179
189
|
}
|
|
@@ -21,6 +21,8 @@ const ios_js_1 = require("./ios.js");
|
|
|
21
21
|
const android_js_1 = require("./android.js");
|
|
22
22
|
const web_js_1 = require("./web.js");
|
|
23
23
|
const vega_js_1 = require("./vega.js");
|
|
24
|
+
const roku_js_1 = require("./roku.js");
|
|
25
|
+
const key_mapping_js_1 = require("./roku/key-mapping.js");
|
|
24
26
|
const wait_js_1 = require("./wait.js");
|
|
25
27
|
const direct_ios_selector_js_1 = require("./direct-ios-selector.js");
|
|
26
28
|
const perf_hooks_1 = require("perf_hooks");
|
|
@@ -552,7 +554,9 @@ function getConductorObj(driver, output) {
|
|
|
552
554
|
? 'web'
|
|
553
555
|
: driver instanceof vega_js_1.VegaDriver
|
|
554
556
|
? 'vega'
|
|
555
|
-
:
|
|
557
|
+
: driver instanceof roku_js_1.RokuDriver
|
|
558
|
+
? 'roku'
|
|
559
|
+
: 'android';
|
|
556
560
|
return {
|
|
557
561
|
platform,
|
|
558
562
|
copiedText: output['__copiedText'] ?? '',
|
|
@@ -684,7 +688,7 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
684
688
|
await driver.pressKey('delete');
|
|
685
689
|
}
|
|
686
690
|
else {
|
|
687
|
-
// Android, web, and
|
|
691
|
+
// Android, web, vega, and roku all expose eraseAllText.
|
|
688
692
|
await driver.eraseAllText(n);
|
|
689
693
|
}
|
|
690
694
|
break;
|
|
@@ -825,7 +829,7 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
825
829
|
await driver.back();
|
|
826
830
|
else if (driver instanceof web_js_1.WebDriver)
|
|
827
831
|
await driver.goBack();
|
|
828
|
-
else if (driver instanceof vega_js_1.VegaDriver)
|
|
832
|
+
else if (driver instanceof vega_js_1.VegaDriver || driver instanceof roku_js_1.RokuDriver)
|
|
829
833
|
await driver.back();
|
|
830
834
|
// iOS has no hardware back button — noop
|
|
831
835
|
break;
|
|
@@ -1113,6 +1117,11 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
1113
1117
|
throw new Error(`pressKey: key "${val}" is not supported on vega`);
|
|
1114
1118
|
await driver.pressButton(button);
|
|
1115
1119
|
}
|
|
1120
|
+
else if (driver instanceof roku_js_1.RokuDriver) {
|
|
1121
|
+
if (!(0, key_mapping_js_1.rokuEcpKey)(keyName))
|
|
1122
|
+
throw new Error(`pressKey: key "${val}" is not supported on roku`);
|
|
1123
|
+
await driver.pressKeyNamed(keyName);
|
|
1124
|
+
}
|
|
1116
1125
|
else {
|
|
1117
1126
|
const keycode = ANDROID_KEYCODES[keyName];
|
|
1118
1127
|
if (keycode === undefined)
|
|
@@ -1133,6 +1142,10 @@ async function executeCommandBody(key, val, driver, opts) {
|
|
|
1133
1142
|
else if (driver instanceof vega_js_1.VegaDriver) {
|
|
1134
1143
|
// No reliable keyboard-hide primitive on vega — noop
|
|
1135
1144
|
}
|
|
1145
|
+
else if (driver instanceof roku_js_1.RokuDriver) {
|
|
1146
|
+
// Roku dismisses its on-screen keyboard with Back.
|
|
1147
|
+
await driver.back();
|
|
1148
|
+
}
|
|
1136
1149
|
else {
|
|
1137
1150
|
await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
|
|
1138
1151
|
}
|
|
@@ -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
|
+
}
|