@houwert/conductor 0.29.2 → 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 (50) 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-frames.js +4 -2
  24. package/dist/commands/profile.js +111 -7
  25. package/dist/commands/screenshot.js +5 -2
  26. package/dist/commands/scroll-until-visible.js +5 -2
  27. package/dist/commands/scroll.js +4 -1
  28. package/dist/commands/start-device.js +27 -3
  29. package/dist/commands/stop-app.js +2 -1
  30. package/dist/commands/stop-device.js +12 -1
  31. package/dist/commands/swipe.js +4 -1
  32. package/dist/commands/tap.js +3 -2
  33. package/dist/commands/uninstall-app.js +4 -0
  34. package/dist/daemon/server.js +14 -4
  35. package/dist/drivers/bootstrap.js +10 -0
  36. package/dist/drivers/flow-runner.js +16 -3
  37. package/dist/drivers/roku/app-ui-parser.js +122 -0
  38. package/dist/drivers/roku/discovery.js +136 -0
  39. package/dist/drivers/roku/ecp-client.js +396 -0
  40. package/dist/drivers/roku/key-mapping.js +67 -0
  41. package/dist/drivers/roku.js +237 -0
  42. package/dist/drivers/vega/page-source-parser.js +5 -118
  43. package/dist/drivers/xml.js +128 -0
  44. package/dist/enum-options.js +3 -1
  45. package/dist/index.js +1 -1
  46. package/dist/runner.js +32 -0
  47. package/package.json +1 -1
  48. package/skills/conductor-device-interact/SKILL.md +4 -3
  49. package/skills/conductor-device-setup/SKILL.md +35 -2
  50. package/skills/conductor-profiler/SKILL.md +9 -4
@@ -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
+ }
@@ -0,0 +1,396 @@
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.RokuEcpClient = exports.RokuEcpError = exports.DEFAULT_ECP_PORT = void 0;
7
+ exports.hintForStatus = hintForStatus;
8
+ exports.encodePathSegment = encodePathSegment;
9
+ exports.parseDigestChallenge = parseDigestChallenge;
10
+ exports.md5Hex = md5Hex;
11
+ /**
12
+ * HTTP client for the Roku External Control Protocol (ECP). All Roku device
13
+ * communication goes through a REST API on device port 8060; screenshots go through
14
+ * the developer web server on port 80 (digest auth with the dev-mode password).
15
+ *
16
+ * Requires the device to be in developer mode with ECP network access set to
17
+ * "Permissive" — recent Roku OS versions return 403 on input commands otherwise.
18
+ */
19
+ const crypto_1 = __importDefault(require("crypto"));
20
+ const verbose_js_1 = require("../../verbose.js");
21
+ const utils_js_1 = require("../../utils.js");
22
+ const xml_js_1 = require("../xml.js");
23
+ exports.DEFAULT_ECP_PORT = 8060;
24
+ const DEV_USERNAME = 'rokudev';
25
+ const REQUEST_TIMEOUT_MS = 10000;
26
+ const RETRY_BACKOFF_MS = 50;
27
+ const SCREENSHOT_FORMATS = ['jpg', 'png'];
28
+ const SCREENSHOT_TIMEOUT_MS = 10000;
29
+ const SCREENSHOT_POLL_INTERVAL_MS = 250;
30
+ /** `statusCode` is the status the device answered with, or undefined on a transport failure. */
31
+ class RokuEcpError extends Error {
32
+ constructor(message, statusCode) {
33
+ super(message);
34
+ this.statusCode = statusCode;
35
+ this.name = 'RokuEcpError';
36
+ }
37
+ }
38
+ exports.RokuEcpError = RokuEcpError;
39
+ class RokuEcpClient {
40
+ constructor(host, opts = {}) {
41
+ this.host = host;
42
+ /** RFC 2617 `nc` counts requests sent with one nonce, restarting at 1 for a new one. */
43
+ this.digestNonce = null;
44
+ this.digestNonceCount = 0;
45
+ this.password = opts.password ?? '';
46
+ this.ecpPort = opts.ecpPort ?? exports.DEFAULT_ECP_PORT;
47
+ this.keypressDelayMs = opts.keypressDelayMs ?? 100;
48
+ this.maxRetries = opts.maxRetries ?? 3;
49
+ }
50
+ get baseUrl() {
51
+ return `http://${this.host}:${this.ecpPort}`;
52
+ }
53
+ // ── Key input ───────────────────────────────────────────────────────────────
54
+ async sendKeypress(key) {
55
+ await this.ecpPost(`keypress/${encodePathSegment(key)}`);
56
+ if (this.keypressDelayMs > 0)
57
+ await (0, utils_js_1.sleep)(this.keypressDelayMs);
58
+ }
59
+ async sendKeyDown(key) {
60
+ await this.ecpPost(`keydown/${encodePathSegment(key)}`);
61
+ }
62
+ async sendKeyUp(key) {
63
+ await this.ecpPost(`keyup/${encodePathSegment(key)}`);
64
+ }
65
+ /** Types text character-by-character via ECP `LIT_` keypresses. */
66
+ async sendText(text) {
67
+ for (const char of text) {
68
+ await this.ecpPost(`keypress/${encodePathSegment(`LIT_${char}`)}`);
69
+ if (this.keypressDelayMs > 0)
70
+ await (0, utils_js_1.sleep)(this.keypressDelayMs);
71
+ }
72
+ }
73
+ // ── App lifecycle ───────────────────────────────────────────────────────────
74
+ /**
75
+ * Launches a channel with the caller's parameters and nothing else — no
76
+ * `RTA_LAUNCH` flag, which asks a roku-test-automation channel *not* to restart
77
+ * (the opposite of the cold launch `launchApp` guarantees) and on any other
78
+ * channel is an unexpected parameter riding along with the flow's deep link.
79
+ */
80
+ async launchChannel(channelId, params = {}) {
81
+ const query = Object.entries(params)
82
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
83
+ .join('&');
84
+ await this.ecpPost(query ? `launch/${channelId}?${query}` : `launch/${channelId}`);
85
+ }
86
+ async getActiveApp() {
87
+ const root = await this.ecpGetXml('query/active-app');
88
+ const app = root ? (0, xml_js_1.childElement)(root, 'app') : undefined;
89
+ if (!app)
90
+ return null;
91
+ return {
92
+ id: app.attrs['id'] ?? '',
93
+ title: app.text.trim(),
94
+ type: app.attrs['type'] ?? '',
95
+ version: app.attrs['version'] ?? '',
96
+ };
97
+ }
98
+ async isActiveApp(channelId) {
99
+ return (await this.getActiveApp())?.id === channelId;
100
+ }
101
+ // ── Device info ─────────────────────────────────────────────────────────────
102
+ async getDeviceInfo() {
103
+ const root = await this.ecpGetXml('query/device-info');
104
+ if (!root)
105
+ return null;
106
+ const fields = {};
107
+ for (const child of root.children)
108
+ fields[child.tag] = child.text.trim();
109
+ const uiResolution = fields['ui-resolution'] || '1080p';
110
+ const is1080 = uiResolution.includes('1080');
111
+ return {
112
+ modelName: fields['model-name'] || 'Unknown',
113
+ modelNumber: fields['model-number'] ?? '',
114
+ serialNumber: fields['serial-number'] ?? '',
115
+ softwareVersion: fields['software-version'] ?? '',
116
+ uiResolution,
117
+ friendlyName: fields['friendly-device-name'] || fields['device-name'] || '',
118
+ widthPixels: is1080 ? 1920 : 1280,
119
+ heightPixels: is1080 ? 1080 : 720,
120
+ };
121
+ }
122
+ // ── View hierarchy ──────────────────────────────────────────────────────────
123
+ /** Raw SceneGraph XML from `/query/app-ui`, or null when the query fails. */
124
+ async getAppUIRaw() {
125
+ try {
126
+ const res = await this.executeWithRetry(`${this.baseUrl}/query/app-ui`, { method: 'GET' });
127
+ return await res.text();
128
+ }
129
+ catch (err) {
130
+ // Queries stay tolerant: callers treat null as "hierarchy unavailable".
131
+ (0, verbose_js_1.log)(`roku ecp: GET query/app-ui failed: ${errMessage(err)}`);
132
+ return null;
133
+ }
134
+ }
135
+ // ── Screenshot ──────────────────────────────────────────────────────────────
136
+ /**
137
+ * Captures a screenshot. Two steps: POST `/plugin_inspect` to generate it, then
138
+ * GET `/pkgs/dev.jpg` (or `.png`) to download it.
139
+ *
140
+ * The dev server acknowledges the generation POST before the capture file is
141
+ * written (observed on Roku OS 14), so the download polls until the file's ETag
142
+ * differs from the pre-generation one; on timeout the current file is used — a
143
+ * re-capture of an unchanged screen can legitimately produce identical bytes.
144
+ */
145
+ async takeScreenshot() {
146
+ const previousEtags = new Map();
147
+ for (const format of SCREENSHOT_FORMATS) {
148
+ previousEtags.set(format, await this.screenshotEtag(format));
149
+ }
150
+ await this.generateScreenshot();
151
+ const deadline = Date.now() + SCREENSHOT_TIMEOUT_MS;
152
+ for (;;) {
153
+ const timedOut = Date.now() >= deadline;
154
+ for (const format of SCREENSHOT_FORMATS) {
155
+ // Cache-bust with a timestamp so no intermediary replays an old capture.
156
+ const url = `http://${this.host}/pkgs/dev.${format}?time=${Date.now()}`;
157
+ try {
158
+ const res = await this.digestFetch(url, { method: 'GET' });
159
+ if (!res.ok)
160
+ continue;
161
+ const etag = res.headers.get('etag');
162
+ const previous = previousEtags.get(format) ?? null;
163
+ const isFresh = previous === null || etag === null || etag !== previous;
164
+ if (isFresh || timedOut) {
165
+ if (!isFresh) {
166
+ (0, verbose_js_1.log)(`roku ecp: screenshot ETag unchanged after ${SCREENSHOT_TIMEOUT_MS}ms; using current capture`);
167
+ }
168
+ return Buffer.from(await res.arrayBuffer());
169
+ }
170
+ }
171
+ catch (err) {
172
+ (0, verbose_js_1.log)(`roku ecp: failed to download screenshot as ${format}: ${errMessage(err)}`);
173
+ }
174
+ }
175
+ if (timedOut)
176
+ break;
177
+ await (0, utils_js_1.sleep)(SCREENSHOT_POLL_INTERVAL_MS);
178
+ }
179
+ throw new Error(`Failed to capture a screenshot from the Roku device at ${this.host}. ` +
180
+ `Screenshots require the developer-mode password (CONDUCTOR_ROKU_PASSWORD).`);
181
+ }
182
+ /** ETag of the current capture file, or null if none exists (or the server omits it). */
183
+ async screenshotEtag(format) {
184
+ try {
185
+ const res = await this.digestFetch(`http://${this.host}/pkgs/dev.${format}`, {
186
+ method: 'HEAD',
187
+ });
188
+ return res.ok ? res.headers.get('etag') : null;
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ async generateScreenshot() {
195
+ const url = `http://${this.host}/plugin_inspect`;
196
+ // The dev server only runs the form action when the multipart body arrives on an
197
+ // already-authorized request (curl's --digest behavior: an empty-body probe
198
+ // collects the challenge, then the form is sent with Authorization attached up
199
+ // front). Sending the body on the unauthenticated request and retrying returns a
200
+ // 200 whose action silently never ran — so the handshake is explicit here.
201
+ let challenge = null;
202
+ try {
203
+ const probe = await fetchWithTimeout(url, { method: 'POST', body: '' });
204
+ if (probe.status === 401)
205
+ challenge = probe.headers.get('www-authenticate');
206
+ }
207
+ catch (err) {
208
+ throw new Error(`Screenshot generation request to the Roku device at ${this.host} failed: ${errMessage(err)}`);
209
+ }
210
+ // Two quirks, both verified against Roku OS 14 hardware: the empty `archive`
211
+ // field is required (without it the form handler silently does nothing), and
212
+ // parts must carry ONLY a Content-Disposition header — the server's parser
213
+ // ignores parts with a per-part Content-Length. So the body is built by hand.
214
+ const boundary = `----ConductorRokuFormBoundary${process.hrtime.bigint()}`;
215
+ const body = `--${boundary}\r\n` +
216
+ `Content-Disposition: form-data; name="mysubmit"\r\n\r\n` +
217
+ `Screenshot\r\n` +
218
+ `--${boundary}\r\n` +
219
+ `Content-Disposition: form-data; name="archive"\r\n\r\n` +
220
+ `\r\n` +
221
+ `--${boundary}--\r\n`;
222
+ const headers = {
223
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
224
+ };
225
+ const auth = challenge && this.buildDigestHeader(challenge, 'POST', '/plugin_inspect');
226
+ if (auth)
227
+ headers['authorization'] = auth;
228
+ let text;
229
+ try {
230
+ const res = await fetchWithTimeout(url, { method: 'POST', headers, body });
231
+ if (!res.ok) {
232
+ throw new Error(`Screenshot generation failed (HTTP ${res.status}). ` +
233
+ `Check the developer-mode password (CONDUCTOR_ROKU_PASSWORD).`);
234
+ }
235
+ text = await res.text();
236
+ }
237
+ catch (err) {
238
+ if (err instanceof Error && err.message.startsWith('Screenshot generation failed'))
239
+ throw err;
240
+ throw new Error(`Screenshot generation request to the Roku device at ${this.host} failed: ${errMessage(err)}`);
241
+ }
242
+ // The dev server reports the result inside the returned page; anything else
243
+ // means no fresh capture was written to /pkgs/dev.jpg.
244
+ if (!text.includes('Screenshot ok')) {
245
+ (0, verbose_js_1.log)(`roku ecp: plugin_inspect did not confirm: ${text.replace(/\n/g, ' ').slice(0, 300)}`);
246
+ throw new Error(`The Roku device at ${this.host} did not confirm the screenshot ` +
247
+ `(requires a sideloaded dev channel in the foreground).`);
248
+ }
249
+ }
250
+ // ── Connectivity ────────────────────────────────────────────────────────────
251
+ async isReachable() {
252
+ try {
253
+ await fetchWithTimeout(`${this.baseUrl}/`, { method: 'GET' });
254
+ return true;
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ // ── Digest auth ─────────────────────────────────────────────────────────────
261
+ /** Issue a request, answering a 401 digest challenge with a signed retry. */
262
+ async digestFetch(url, init) {
263
+ const first = await fetchWithTimeout(url, init);
264
+ if (first.status !== 401)
265
+ return first;
266
+ const challenge = first.headers.get('www-authenticate');
267
+ if (!challenge)
268
+ return first;
269
+ const auth = this.buildDigestHeader(challenge, init.method ?? 'GET', new URL(url).pathname);
270
+ if (!auth)
271
+ return first;
272
+ return fetchWithTimeout(url, {
273
+ ...init,
274
+ headers: { ...init.headers, authorization: auth },
275
+ });
276
+ }
277
+ nextNonceCount(nonce) {
278
+ if (nonce !== this.digestNonce) {
279
+ this.digestNonce = nonce;
280
+ this.digestNonceCount = 0;
281
+ }
282
+ return ++this.digestNonceCount;
283
+ }
284
+ buildDigestHeader(challengeHeader, method, uri) {
285
+ if (!/^digest /i.test(challengeHeader))
286
+ return null;
287
+ const params = parseDigestChallenge(challengeHeader.replace(/^digest /i, ''));
288
+ const realm = params['realm'];
289
+ const nonce = params['nonce'];
290
+ if (!realm || !nonce)
291
+ return null;
292
+ const qop = params['qop'];
293
+ const nc = this.nextNonceCount(nonce).toString(16).padStart(8, '0');
294
+ const cnonce = (process.hrtime.bigint() & 0xffffffffn).toString(16).padStart(8, '0');
295
+ const ha1 = md5Hex(`${DEV_USERNAME}:${realm}:${this.password}`);
296
+ const ha2 = md5Hex(`${method}:${uri}`);
297
+ const response = qop
298
+ ? md5Hex(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
299
+ : md5Hex(`${ha1}:${nonce}:${ha2}`);
300
+ let header = `Digest username="${DEV_USERNAME}", realm="${realm}", nonce="${nonce}", uri="${uri}"`;
301
+ if (qop)
302
+ header += `, qop=${qop}, nc=${nc}, cnonce="${cnonce}"`;
303
+ return `${header}, response="${response}"`;
304
+ }
305
+ // ── Internal HTTP helpers ───────────────────────────────────────────────────
306
+ /**
307
+ * Issues a state-changing ECP call (input, launch). Throws on failure: a command
308
+ * that never reached the device must fail the flow rather than let it keep
309
+ * asserting against a screen no keypress ever touched.
310
+ */
311
+ async ecpPost(path) {
312
+ await this.executeWithRetry(`${this.baseUrl}/${path}`, {
313
+ method: 'POST',
314
+ headers: { 'content-type': 'text/plain' },
315
+ body: '',
316
+ });
317
+ }
318
+ async ecpGetXml(path) {
319
+ try {
320
+ const res = await this.executeWithRetry(`${this.baseUrl}/${path}`, { method: 'GET' });
321
+ return (0, xml_js_1.parseXml)(await res.text());
322
+ }
323
+ catch (err) {
324
+ (0, verbose_js_1.log)(`roku ecp: GET ${path} failed: ${errMessage(err)}`);
325
+ return null;
326
+ }
327
+ }
328
+ /**
329
+ * Executes a request, retrying transport failures and 5xx responses. The HTTP
330
+ * status survives into the error because it is the detail that matters most —
331
+ * a 403 means ECP access isn't set to Permissive.
332
+ */
333
+ async executeWithRetry(url, init) {
334
+ let lastFailure = null;
335
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
336
+ let failure;
337
+ try {
338
+ const res = await fetchWithTimeout(url, init);
339
+ if (res.ok)
340
+ return res;
341
+ failure = new RokuEcpError(`ECP request to ${url} failed with HTTP ${res.status}.${hintForStatus(res.status)}`, res.status);
342
+ }
343
+ catch (err) {
344
+ failure = new RokuEcpError(`ECP request to ${url} failed: ${errMessage(err)}`);
345
+ }
346
+ lastFailure = failure;
347
+ // 4xx is the device's verdict on this request — a retry re-sends what it
348
+ // already rejected, so report it now instead of after three round trips.
349
+ if (failure.statusCode !== undefined && failure.statusCode < 500)
350
+ break;
351
+ if (attempt < this.maxRetries) {
352
+ (0, verbose_js_1.log)(`roku ecp: ${failure.message} (attempt ${attempt}/${this.maxRetries}). Retrying.`);
353
+ await (0, utils_js_1.sleep)(RETRY_BACKOFF_MS);
354
+ }
355
+ }
356
+ throw lastFailure ?? new RokuEcpError(`ECP request to ${url} failed`);
357
+ }
358
+ }
359
+ exports.RokuEcpClient = RokuEcpClient;
360
+ // ── Standalone helpers (exported for tests) ───────────────────────────────────
361
+ /** Setup advice for the statuses a misconfigured device actually returns. */
362
+ function hintForStatus(status) {
363
+ if (status === 403) {
364
+ return (' The device is refusing ECP commands: set Settings > System > Advanced system ' +
365
+ 'settings > Control by mobile apps > Network access to "Permissive".');
366
+ }
367
+ if (status === 401)
368
+ return ' Check the developer-mode password (CONDUCTOR_ROKU_PASSWORD).';
369
+ return '';
370
+ }
371
+ /**
372
+ * Percent-encode a URL path segment. `encodeURIComponent` leaves `!'()*` alone and
373
+ * ECP would deliver those literally, so they are escaped too — a space must arrive
374
+ * as `%20`, never `+` (`LIT_+` types a plus, not a space).
375
+ */
376
+ function encodePathSegment(value) {
377
+ return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
378
+ }
379
+ function parseDigestChallenge(header) {
380
+ const params = {};
381
+ const re = /(\w+)=(?:"([^"]*)"|([\w/]+))/g;
382
+ let m;
383
+ while ((m = re.exec(header)) !== null) {
384
+ params[m[1]] = m[2] || m[3];
385
+ }
386
+ return params;
387
+ }
388
+ function md5Hex(input) {
389
+ return crypto_1.default.createHash('md5').update(input).digest('hex');
390
+ }
391
+ function errMessage(err) {
392
+ return err instanceof Error ? err.message : String(err);
393
+ }
394
+ function fetchWithTimeout(url, init) {
395
+ return fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
396
+ }