@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.
Files changed (53) 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 +21 -4
  18. package/dist/commands/launch-app.js +3 -2
  19. package/dist/commands/list-apps.js +16 -0
  20. package/dist/commands/list-devices.js +37 -0
  21. package/dist/commands/memory.js +5 -0
  22. package/dist/commands/press-key.js +36 -3
  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 +25 -3
  30. package/dist/commands/swipe.js +8 -3
  31. package/dist/commands/tap.js +3 -2
  32. package/dist/commands/uninstall-app.js +4 -0
  33. package/dist/daemon/input-backends.js +26 -1
  34. package/dist/daemon/log-collector.js +6 -0
  35. package/dist/daemon/server.js +54 -11
  36. package/dist/drivers/bootstrap.js +263 -4
  37. package/dist/drivers/devicectl.js +243 -0
  38. package/dist/drivers/flow-runner.js +26 -4
  39. package/dist/drivers/ios.js +96 -4
  40. package/dist/drivers/roku/app-ui-parser.js +122 -0
  41. package/dist/drivers/roku/discovery.js +136 -0
  42. package/dist/drivers/roku/ecp-client.js +396 -0
  43. package/dist/drivers/roku/key-mapping.js +67 -0
  44. package/dist/drivers/roku.js +237 -0
  45. package/dist/drivers/vega/page-source-parser.js +5 -118
  46. package/dist/drivers/xml.js +128 -0
  47. package/dist/enum-options.js +3 -1
  48. package/dist/index.js +1 -1
  49. package/dist/runner.js +48 -8
  50. package/package.json +1 -1
  51. package/skills/conductor-device-interact/SKILL.md +14 -3
  52. package/skills/conductor-device-setup/SKILL.md +62 -2
  53. package/skills/conductor-profiler/SKILL.md +1 -1
@@ -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
- await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
244
- await this.simctl(['uninstall', deviceId, bundleId]);
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
+ }