@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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/assert-not-visible.js +4 -1
  3. package/dist/commands/assert-visible.js +5 -2
  4. package/dist/commands/back.js +2 -1
  5. package/dist/commands/capture-ui.js +7 -3
  6. package/dist/commands/clipboard.js +9 -0
  7. package/dist/commands/copy-text-from.js +2 -1
  8. package/dist/commands/crashes.js +5 -0
  9. package/dist/commands/delete-device.js +5 -0
  10. package/dist/commands/download-app.js +4 -0
  11. package/dist/commands/erase-text.js +4 -1
  12. package/dist/commands/focused.js +5 -2
  13. package/dist/commands/foreground-app.js +4 -1
  14. package/dist/commands/gestures.js +7 -0
  15. package/dist/commands/hide-keyboard.js +5 -0
  16. package/dist/commands/inspect.js +10 -3
  17. package/dist/commands/install-app.js +5 -0
  18. package/dist/commands/launch-app.js +3 -2
  19. package/dist/commands/list-apps.js +5 -0
  20. package/dist/commands/list-devices.js +12 -0
  21. package/dist/commands/memory.js +5 -0
  22. package/dist/commands/press-key.js +15 -0
  23. package/dist/commands/profile.js +4 -0
  24. package/dist/commands/screenshot.js +5 -2
  25. package/dist/commands/scroll-until-visible.js +5 -2
  26. package/dist/commands/scroll.js +4 -1
  27. package/dist/commands/start-device.js +27 -3
  28. package/dist/commands/stop-app.js +2 -1
  29. package/dist/commands/stop-device.js +12 -1
  30. package/dist/commands/swipe.js +4 -1
  31. package/dist/commands/tap.js +3 -2
  32. package/dist/commands/uninstall-app.js +4 -0
  33. package/dist/daemon/server.js +14 -4
  34. package/dist/drivers/bootstrap.js +10 -0
  35. package/dist/drivers/flow-runner.js +16 -3
  36. package/dist/drivers/roku/app-ui-parser.js +122 -0
  37. package/dist/drivers/roku/discovery.js +136 -0
  38. package/dist/drivers/roku/ecp-client.js +396 -0
  39. package/dist/drivers/roku/key-mapping.js +67 -0
  40. package/dist/drivers/roku.js +237 -0
  41. package/dist/drivers/vega/page-source-parser.js +5 -118
  42. package/dist/drivers/xml.js +128 -0
  43. package/dist/enum-options.js +3 -1
  44. package/dist/index.js +1 -1
  45. package/dist/runner.js +32 -0
  46. package/package.json +1 -1
  47. package/skills/conductor-device-interact/SKILL.md +4 -3
  48. package/skills/conductor-device-setup/SKILL.md +35 -2
  49. 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);
@@ -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 || driver instanceof vega_js_1.VegaDriver) {
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) {
@@ -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 emits uiautomator-style XML, so it reuses the Android resolver.
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
  }
@@ -177,8 +177,9 @@ let _driverStartError = null;
177
177
  async function ensureDriverRunning() {
178
178
  if (_restartInProgress || !_driverStarted)
179
179
  return;
180
- // Vega has no driver process/port to health-check — control is host-side via the CLI.
181
- if (driverPlatform === 'vega')
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 non-vega platform. Sets `_driverStarted` /
491
- * `_driverStartError`. Extracted so the vega path can skip it entirely.
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
- : 'android';
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 vega all expose eraseAllText.
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
+ }