@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
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ROKU_ECP_KEYS = void 0;
4
+ exports.rokuEcpKey = rokuEcpKey;
5
+ exports.rokuSwipeKey = rokuSwipeKey;
6
+ /** Canonical key name (as listed in `press-key`) → ECP key. */
7
+ exports.ROKU_ECP_KEYS = {
8
+ 'Remote Dpad Up': 'Up',
9
+ 'Remote Dpad Down': 'Down',
10
+ 'Remote Dpad Left': 'Left',
11
+ 'Remote Dpad Right': 'Right',
12
+ 'Remote Dpad Center': 'Select',
13
+ Enter: 'Select',
14
+ Return: 'Select',
15
+ Back: 'Back',
16
+ Escape: 'Back',
17
+ Backspace: 'Backspace',
18
+ Delete: 'Backspace',
19
+ Home: 'Home',
20
+ 'Remote Media Play Pause': 'Play',
21
+ 'Remote Media Stop': 'Play', // Roku uses Play as a toggle
22
+ 'Remote Media Fast Forward': 'Fwd',
23
+ 'Remote Media Rewind': 'Rev',
24
+ 'Remote Media Next': 'Fwd',
25
+ 'Remote Media Previous': 'Rev',
26
+ 'Remote Menu': 'Info', // The * (options) button is Roku's menu
27
+ 'Remote Info': 'Info',
28
+ 'Remote Instant Replay': 'InstantReplay',
29
+ 'Remote Search': 'Search',
30
+ Search: 'Search',
31
+ Power: 'PowerOff',
32
+ VolumeUp: 'VolumeUp',
33
+ VolumeDown: 'VolumeDown',
34
+ 'Remote Button A': 'A',
35
+ 'Remote Button B': 'B',
36
+ };
37
+ /**
38
+ * Key names reach us in several spellings — `Remote Dpad Up` from the CLI,
39
+ * `REMOTE DPAD UP` from flow YAML, `VOLUME_UP` from the underscore convention —
40
+ * so lookups compare on letters and digits alone.
41
+ */
42
+ function normalizeKeyName(name) {
43
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, '');
44
+ }
45
+ const BY_NORMALIZED = new Map(Object.entries(exports.ROKU_ECP_KEYS).map(([name, key]) => [normalizeKeyName(name), key]));
46
+ /** ECP key for a key name, matched loosely; undefined if unsupported. */
47
+ function rokuEcpKey(name) {
48
+ return BY_NORMALIZED.get(normalizeKeyName(name));
49
+ }
50
+ /**
51
+ * D-pad key for a swipe. A swipe drags the content, so it reveals what lies on the
52
+ * far side: swiping up brings up what is *below*, which on a focus-driven UI is a
53
+ * move down. Every direction inverts — matching Vega and web, where swiping up
54
+ * increases the scroll offset.
55
+ */
56
+ function rokuSwipeKey(direction) {
57
+ switch (direction) {
58
+ case 'up':
59
+ return 'Down';
60
+ case 'down':
61
+ return 'Up';
62
+ case 'left':
63
+ return 'Right';
64
+ case 'right':
65
+ return 'Left';
66
+ }
67
+ }