@feralfile/cli 3.0.0 → 4.0.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 (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
@@ -37,21 +37,36 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  };
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
- exports.parseRetryAfterMs = parseRetryAfterMs;
40
+ exports.parseRetryAfterMs = exports.isTransientDeviceNetworkError = void 0;
42
41
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
42
+ exports.publicControlCode = publicControlCode;
43
43
  const http_1 = require("./http");
44
44
  const logger = __importStar(require("../logger"));
45
45
  const config_1 = require("../config");
46
46
  const ff1_compatibility_1 = require("./ff1-compatibility");
47
47
  const ff1_relayer_1 = require("./ff1-relayer");
48
48
  const topic_store_1 = require("./topic-store");
49
+ const secure_transport_1 = require("./control/secure-transport");
50
+ const control_errors_1 = require("./control/control-errors");
51
+ const device_network_1 = require("./device-network");
52
+ Object.defineProperty(exports, "isTransientDeviceNetworkError", { enumerable: true, get: function () { return device_network_1.isTransientDeviceNetworkError; } });
53
+ Object.defineProperty(exports, "parseRetryAfterMs", { enumerable: true, get: function () { return device_network_1.parseRetryAfterMs; } });
49
54
  const SEND_RETRY_ATTEMPTS = 3;
50
55
  const SEND_RETRY_BASE_DELAY_MS = 750;
51
56
  const RATE_LIMIT_STATUS = 429;
52
57
  // Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
53
58
  // hostile device cannot pin a command for minutes/hours.
54
59
  const MAX_RETRY_DELAY_MS = 30_000;
60
+ // PUBLIC_CONTROL_CODES are the ff1-control-v1 §4.4 public errors a secured
61
+ // FF1 can answer a plaintext cast with (LAN 401/403, relayer RPC message).
62
+ const PUBLIC_CONTROL_CODES = new Set([
63
+ 'bad_envelope',
64
+ 'unknown_controller',
65
+ 'bad_signature',
66
+ 'enrollment_required',
67
+ 'envelope_required',
68
+ 'locked',
69
+ ]);
55
70
  /** Find an FF1 `ok` flag through the nested cast response envelopes. */
56
71
  function nestedCastOk(value) {
57
72
  if (!value || typeof value !== 'object') {
@@ -74,78 +89,6 @@ function waitForRetry(delayMs) {
74
89
  setTimeout(resolve, delayMs);
75
90
  });
76
91
  }
77
- /**
78
- * isTransientDeviceNetworkError returns true when a send failure is likely temporary.
79
- *
80
- * This classifier intentionally targets resolver and route-level failures that are
81
- * common on local mDNS/Wi-Fi environments. Permanent command errors should surface
82
- * immediately without retry loops.
83
- *
84
- * @param {unknown} error - Error thrown by fetch
85
- * @returns {boolean} True when retrying is likely to recover
86
- */
87
- function isTransientDeviceNetworkError(error) {
88
- if (!(error instanceof Error)) {
89
- return false;
90
- }
91
- const networkCodes = new Set([
92
- 'EHOSTUNREACH',
93
- 'ENETUNREACH',
94
- 'ENOTFOUND',
95
- 'EAI_AGAIN',
96
- 'ETIMEDOUT',
97
- 'ECONNREFUSED',
98
- 'ECONNRESET',
99
- ]);
100
- const message = error.message || '';
101
- const messageLooksTransient = message.includes('fetch failed') ||
102
- message.includes('getaddrinfo') ||
103
- message.includes('EHOSTUNREACH') ||
104
- message.includes('ENOTFOUND') ||
105
- message.includes('ETIMEDOUT') ||
106
- message.includes('network timeout') ||
107
- message.includes('No route to host');
108
- if (messageLooksTransient) {
109
- return true;
110
- }
111
- const causeCode = error.cause &&
112
- typeof error.cause === 'object' &&
113
- 'code' in error.cause &&
114
- typeof error.cause.code === 'string'
115
- ? error.cause.code
116
- : undefined;
117
- return Boolean(causeCode && networkCodes.has(causeCode));
118
- }
119
- /**
120
- * parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
121
- *
122
- * Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
123
- * HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
124
- * header is absent or unparseable, so callers can fall back to their own
125
- * backoff. A past date clamps to 0 (retry immediately).
126
- *
127
- * @param {string | null | undefined} headerValue - Raw Retry-After header value
128
- * @returns {number | undefined} Delay in milliseconds, or undefined if unknown
129
- */
130
- function parseRetryAfterMs(headerValue) {
131
- if (!headerValue) {
132
- return undefined;
133
- }
134
- const trimmed = headerValue.trim();
135
- if (/^\d+$/.test(trimmed)) {
136
- return Number(trimmed) * 1000;
137
- }
138
- // A valid HTTP-date always contains letters (month/day names, or the ISO
139
- // 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
140
- // Date.parse would otherwise misinterpret as a date.
141
- if (/[a-zA-Z]/.test(trimmed)) {
142
- const dateMs = Date.parse(trimmed);
143
- if (!Number.isNaN(dateMs)) {
144
- return Math.max(0, dateMs - Date.now());
145
- }
146
- }
147
- return undefined;
148
- }
149
92
  /**
150
93
  * Send a DP1 playlist to an FF1 device using the cast API
151
94
  *
@@ -186,6 +129,8 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
186
129
  getRelayerConfigFn: overrides.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
187
130
  waitFn: overrides.waitFn ?? waitForRetry,
188
131
  noticeFn: overrides.noticeFn ?? logger.always,
132
+ controllerStore: overrides.controllerStore,
133
+ nowFn: overrides.nowFn,
189
134
  };
190
135
  let device;
191
136
  try {
@@ -204,16 +149,30 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
204
149
  };
205
150
  }
206
151
  device = resolved.device;
152
+ // A stored controller file means this FF1 was paired under the control
153
+ // protocol (ff1-control-v1): every command goes as a secure envelope and
154
+ // the legacy plaintext path below is never used for it.
155
+ const secure = (0, secure_transport_1.findSecureController)(device, dependencies.controllerStore);
156
+ if (secure.failure) {
157
+ return { success: false, error: secure.failure.error, details: secure.failure.details };
158
+ }
207
159
  const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist', {
208
160
  fetchFn: dependencies.fetchFn,
161
+ controller: secure.record
162
+ ? { record: secure.record, deps: secureDeps(dependencies) }
163
+ : undefined,
209
164
  });
210
165
  if (!compatibility.compatible) {
211
166
  return {
212
167
  success: false,
213
168
  error: compatibility.error || 'FF1 OS does not support playlist casting',
214
- details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
169
+ details: compatibility.details ??
170
+ (compatibility.version ? `Detected version ${compatibility.version}` : undefined),
215
171
  };
216
172
  }
173
+ if (secure.record) {
174
+ return sendPlaylistSecurely(playlist, device, secure.record, dependencies);
175
+ }
217
176
  logger.info(`Sending playlist to FF1 device: ${device.host}`);
218
177
  const apiUrl = `${device.host}/api/cast`;
219
178
  // Wrap playlist in required structure
@@ -251,7 +210,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
251
210
  // Treat it as transient: honor Retry-After when present, otherwise back
252
211
  // off, and retry within the bounded attempt budget.
253
212
  if (response.status === RATE_LIMIT_STATUS && attempt < SEND_RETRY_ATTEMPTS) {
254
- const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
213
+ const retryAfterMs = (0, device_network_1.parseRetryAfterMs)(response.headers.get('retry-after'));
255
214
  const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
256
215
  logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
257
216
  response = null;
@@ -291,7 +250,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
291
250
  break;
292
251
  }
293
252
  catch (error) {
294
- const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && isTransientDeviceNetworkError(error);
253
+ const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && (0, device_network_1.isTransientDeviceNetworkError)(error);
295
254
  if (!shouldRetry) {
296
255
  throw error;
297
256
  }
@@ -320,6 +279,11 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
320
279
  const errorText = await response.text();
321
280
  logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
322
281
  logger.debug(`Error details: ${errorText}`);
282
+ const publicCode = publicControlCode(response.status, errorText);
283
+ if (publicCode) {
284
+ const failure = (0, control_errors_1.describeControlCode)(publicCode, device.name || device.id || device.host);
285
+ return { success: false, error: failure.error, details: failure.details };
286
+ }
323
287
  if (response.status === RATE_LIMIT_STATUS) {
324
288
  const deviceLabel = device.name || device.host;
325
289
  return {
@@ -368,7 +332,7 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
368
332
  catch (error) {
369
333
  const errorMessage = error.message;
370
334
  logger.error(`Error sending playlist to device: ${errorMessage}`);
371
- if (device && isTransientDeviceNetworkError(error)) {
335
+ if (device && (0, device_network_1.isTransientDeviceNetworkError)(error)) {
372
336
  const deviceLabel = device.name || device.host;
373
337
  return fallbackToRelayer({
374
338
  playlist,
@@ -388,6 +352,117 @@ async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
388
352
  };
389
353
  }
390
354
  }
355
+ /**
356
+ * secureDeps forwards the delivery seams to the secure transport.
357
+ *
358
+ * @param dependencies - Resolved delivery dependencies
359
+ * @returns Secure transport dependencies
360
+ */
361
+ function secureDeps(dependencies) {
362
+ return {
363
+ fetchFn: dependencies.fetchFn,
364
+ getTopicIdFn: dependencies.getTopicIdFn,
365
+ getRelayerConfigFn: dependencies.getRelayerConfigFn,
366
+ waitFn: dependencies.waitFn,
367
+ noticeFn: dependencies.noticeFn,
368
+ controllerStore: dependencies.controllerStore,
369
+ nowFn: dependencies.nowFn,
370
+ };
371
+ }
372
+ /**
373
+ * sendPlaylistSecurely delivers displayPlaylist as a secure envelope with the
374
+ * same retry budget and LAN-then-relayer fallback as the plaintext path. The
375
+ * `req` is the unchanged plaintext request object (§3.3).
376
+ *
377
+ * @param playlist - DP-1 playlist
378
+ * @param device - Device with a stored controller record
379
+ * @param record - Enrolled controller record
380
+ * @param dependencies - Delivery seams
381
+ * @returns Send result
382
+ */
383
+ async function sendPlaylistSecurely(playlist, device, record, dependencies) {
384
+ logger.info(`Sending playlist to FF1 device (secure): ${device.host}`);
385
+ const result = await (0, secure_transport_1.runSecureCommand)({
386
+ device: device,
387
+ record,
388
+ cmd: 'displayPlaylist',
389
+ req: { dp1_call: playlist, intent: { action: 'now_display' } },
390
+ relayerFallback: true,
391
+ retry: true,
392
+ }, secureDeps(dependencies));
393
+ const deviceName = device.name || device.host;
394
+ if (!result.success) {
395
+ return {
396
+ success: false,
397
+ error: result.error,
398
+ details: result.details,
399
+ transport: result.transport,
400
+ };
401
+ }
402
+ if (nestedCastOk(result.response) === false) {
403
+ return {
404
+ success: false,
405
+ error: `Device "${deviceName}" rejected the display command`,
406
+ details: JSON.stringify(result.response),
407
+ transport: result.transport,
408
+ };
409
+ }
410
+ const viaRelayer = result.transport === 'relayer';
411
+ return {
412
+ success: true,
413
+ device: device.host,
414
+ deviceName,
415
+ response: result.response,
416
+ message: viaRelayer
417
+ ? 'Playlist successfully sent through the FF1 relayer'
418
+ : 'Playlist successfully sent to FF1 device',
419
+ transport: result.transport,
420
+ };
421
+ }
422
+ /**
423
+ * publicControlCode extracts a control-protocol public error (§4.4) from a
424
+ * LAN 401/403, e.g. a secured FF1 refusing plaintext with
425
+ * `enrollment_required`. Other bodies return undefined.
426
+ *
427
+ * @param status - HTTP status
428
+ * @param body - Response text
429
+ * @returns The code, or undefined
430
+ */
431
+ function publicControlCode(status, body) {
432
+ if (status !== 401 && status !== 403) {
433
+ return undefined;
434
+ }
435
+ try {
436
+ const code = JSON.parse(body)?.error?.code;
437
+ return typeof code === 'string' && PUBLIC_CONTROL_CODES.has(code) ? code : undefined;
438
+ }
439
+ catch {
440
+ return undefined;
441
+ }
442
+ }
443
+ /**
444
+ * plaintextRefusalCode finds a control-protocol refusal in a relayer reply:
445
+ * the flat plaintext denial `{ok:false,error:"<code>",command,message}` or the
446
+ * public-error shape `{error:{code}}`, at any of the relayer's nesting levels.
447
+ *
448
+ * @param response - Parsed relayer response
449
+ * @returns A §4.4 code, or undefined
450
+ */
451
+ function plaintextRefusalCode(response) {
452
+ let current = response;
453
+ for (let depth = 0; depth < 3 && current && typeof current === 'object'; depth += 1) {
454
+ const record = current;
455
+ const error = record.error;
456
+ const code = record.ok === false && typeof error === 'string'
457
+ ? error
458
+ : error?.code;
459
+ if (typeof code === 'string' && PUBLIC_CONTROL_CODES.has(code)) {
460
+ return code;
461
+ }
462
+ current = record.message;
463
+ }
464
+ return undefined;
465
+ }
391
466
  /**
392
467
  * Fall back to the relayer only after LAN reachability is exhausted.
393
468
  *
@@ -437,6 +512,14 @@ async function fallbackToRelayer(input) {
437
512
  playlist: input.playlist,
438
513
  fetchFn: input.dependencies.fetchFn,
439
514
  });
515
+ // A secured FF1 refuses a plaintext relayer cast it will not run
516
+ // (ff1-control-v1 §4.4/§7.2) with a flat `{ok:false,error:"<code>"}` RPC
517
+ // message; map it to pairing guidance instead of a bare rejection.
518
+ const refusal = plaintextRefusalCode(result.response);
519
+ if (refusal) {
520
+ const failure = (0, control_errors_1.describeControlCode)(refusal, input.device.name ?? input.device.id);
521
+ return { success: false, error: failure.error, details: failure.details, transport: 'relayer' };
522
+ }
440
523
  if (!result.success) {
441
524
  return {
442
525
  success: false,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sendPlaylistViaRelayer = sendPlaylistViaRelayer;
4
+ exports.sendCastViaRelayer = sendCastViaRelayer;
4
5
  const http_1 = require("./http");
5
6
  function nestedOk(value) {
6
7
  if (!value || typeof value !== 'object') {
@@ -27,6 +28,37 @@ function nestedOk(value) {
27
28
  * and JSON parsing errors.
28
29
  */
29
30
  async function sendPlaylistViaRelayer(input) {
31
+ const result = await sendCastViaRelayer({
32
+ ...input,
33
+ body: {
34
+ command: 'displayPlaylist',
35
+ request: {
36
+ dp1_call: input.playlist,
37
+ intent: { action: 'now_display' },
38
+ },
39
+ },
40
+ });
41
+ if (result.success && nestedOk(result.response) === false) {
42
+ return {
43
+ success: false,
44
+ error: 'FF1 rejected the relayer display command',
45
+ details: JSON.stringify(result.response),
46
+ response: result.response,
47
+ };
48
+ }
49
+ return result;
50
+ }
51
+ /**
52
+ * sendCastViaRelayer posts one cast body to the relayer topic endpoint and
53
+ * returns the parsed JSON response without interpreting the device's answer.
54
+ * It is shared by the legacy plaintext Play path and the secure `ff1Envelope`
55
+ * path (ff1-control-v1 §10: the envelope rides the same topic cast).
56
+ *
57
+ * @param input - Relayer endpoint, optional compatibility key, paired topic,
58
+ * cast body, and optional fetch implementation.
59
+ * @returns The parsed relayer response, or a structured failure.
60
+ */
61
+ async function sendCastViaRelayer(input) {
30
62
  // Production default rides the shared deadline (#101 review).
31
63
  const fetchFn = input.fetchFn ?? http_1.defaultDeadlineFetch;
32
64
  let url;
@@ -53,13 +85,7 @@ async function sendPlaylistViaRelayer(input) {
53
85
  // the mobile request shape and topic query parameter.
54
86
  method: 'POST',
55
87
  headers,
56
- body: JSON.stringify({
57
- command: 'displayPlaylist',
58
- request: {
59
- dp1_call: input.playlist,
60
- intent: { action: 'now_display' },
61
- },
62
- }),
88
+ body: JSON.stringify(input.body),
63
89
  });
64
90
  }
65
91
  catch (error) {
@@ -78,6 +104,7 @@ async function sendPlaylistViaRelayer(input) {
78
104
  return {
79
105
  success: false,
80
106
  error: `FF1 relayer returned HTTP ${response.status}`,
107
+ status: response.status,
81
108
  details: needsCompatibilityKey
82
109
  ? 'This relayer deployment still requires its optional FF1_RELAYER_API_KEY compatibility gate.'
83
110
  : bodyText || response.statusText,
@@ -96,12 +123,5 @@ async function sendPlaylistViaRelayer(input) {
96
123
  };
97
124
  }
98
125
  }
99
- if (nestedOk(data) === false) {
100
- return {
101
- success: false,
102
- error: 'FF1 rejected the relayer display command',
103
- details: bodyText,
104
- };
105
- }
106
126
  return { success: true, response: data };
107
127
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TopicHandoffReceiver = void 0;
3
+ exports.HandoffReceiver = void 0;
4
+ exports.parseHandoffPayload = parseHandoffPayload;
4
5
  const http_1 = require("./http");
5
6
  const handoff_crypto_1 = require("./handoff-crypto");
6
7
  const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com';
@@ -33,6 +34,19 @@ function parsePoll(value, channelId) {
33
34
  }
34
35
  return record;
35
36
  }
37
+ /**
38
+ * parseHandoffPayload accepts a v1 topic handoff for the selected device and
39
+ * rejects everything else, including a retired v2 controller handoff.
40
+ *
41
+ * @param value - Decrypted handoff plaintext
42
+ * @param channelId - This receiver's channel
43
+ * @param expectedDeviceId - The configured device's stable ID
44
+ * @returns The validated payload
45
+ * @throws {Error} When the payload is for another channel or device, or malformed
46
+ */
47
+ function parseHandoffPayload(value, channelId, expectedDeviceId) {
48
+ return parseTopicPayload(value, channelId, expectedDeviceId);
49
+ }
36
50
  function parseTopicPayload(value, channelId, expectedDeviceId) {
37
51
  const payload = value;
38
52
  if (payload?.v !== 1 ||
@@ -48,8 +62,8 @@ function parseTopicPayload(value, channelId, expectedDeviceId) {
48
62
  }
49
63
  return payload;
50
64
  }
51
- /** One short-lived CLI-side channel waiting for a mobile topic transfer. */
52
- class TopicHandoffReceiver {
65
+ /** One short-lived CLI-side channel waiting for a mobile topic handoff. */
66
+ class HandoffReceiver {
53
67
  baseUrl;
54
68
  channelId;
55
69
  shortCode;
@@ -90,10 +104,10 @@ class TopicHandoffReceiver {
90
104
  channelId: created.channelId,
91
105
  minterPublicKeyJwk: publicKey,
92
106
  });
93
- return new TopicHandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
107
+ return new HandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
94
108
  }
95
- /** Poll until mobile sends a valid encrypted topic for the selected device. */
96
- async receiveTopic(options) {
109
+ /** Poll until mobile sends a valid encrypted topic handoff for the selected device. */
110
+ async receiveHandoff(options) {
97
111
  const deadline = Date.now() + (options.maxWaitMs ?? 300_000);
98
112
  let afterSeq = 0;
99
113
  while (Date.now() <= deadline) {
@@ -117,11 +131,11 @@ class TopicHandoffReceiver {
117
131
  seq: message.seq,
118
132
  message,
119
133
  });
120
- return parseTopicPayload(plaintext, this.channelId, options.expectedDeviceId);
134
+ return parseHandoffPayload(plaintext, this.channelId, options.expectedDeviceId);
121
135
  }
122
136
  await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 1000));
123
137
  }
124
- throw new Error('Timed out waiting for the mobile app topic transfer');
138
+ throw new Error('Timed out waiting for the mobile app handoff');
125
139
  }
126
140
  /** Close the broker channel and invalidate its participant credentials. */
127
141
  async close() {
@@ -134,4 +148,4 @@ class TopicHandoffReceiver {
134
148
  }
135
149
  }
136
150
  }
137
- exports.TopicHandoffReceiver = TopicHandoffReceiver;
151
+ exports.HandoffReceiver = HandoffReceiver;
@@ -64,9 +64,9 @@ const user_agent_1 = require("./user-agent");
64
64
  const NEORT_API_BASE = 'https://api.neort.io/v1';
65
65
  const NEORT_CDN_BASE = 'https://d32h66pp7fue57.cloudfront.net';
66
66
  /**
67
- * Resolve a Neort art id to a playable asset URL plus title/artist metadata.
67
+ * Resolve a Neort art id to a displayable asset URL plus title/artist metadata.
68
68
  *
69
- * @throws When the id is unknown, the art is private, or no playable asset
69
+ * @throws When the id is unknown, the art is private, or no displayable asset
70
70
  * can be constructed from the response.
71
71
  */
72
72
  async function resolveNeortArt(id) {
@@ -90,7 +90,7 @@ async function resolveNeortArt(id) {
90
90
  ? `${NEORT_CDN_BASE}/artPreview/${data.resourceFileName}`
91
91
  : thumbnailUrl;
92
92
  if (!assetUrl) {
93
- throw new Error(`Neort: art "${id}" has no playable asset (resourceFileName and thumbnail are both empty).`);
93
+ throw new Error(`Neort: art "${id}" has no displayable asset (resourceFileName and thumbnail are both empty).`);
94
94
  }
95
95
  return {
96
96
  id: data.id,
@@ -629,7 +629,7 @@ function detectMimeType(url) {
629
629
  * e.g. extensionless IPFS gateway URLs, where without this hint a video
630
630
  * would be misclassified as a static image and stamped a fixed duration.
631
631
  * @param {boolean} [options.asWebPage] - Time the item as a web page whatever
632
- * the URL's extension says (`play --web`)
632
+ * the URL's extension says (`display --web`)
633
633
  * @returns {Object} DP1 playlist item
634
634
  */
635
635
  function buildUrlItem(url, duration, options = {}) {
@@ -669,13 +669,13 @@ function buildUrlItem(url, duration, options = {}) {
669
669
  .license('open')
670
670
  // offChainURI has no provenance.uri in the DP-1 schema — only type.
671
671
  .provenance(new ProvenanceBuilder().type('offChainURI'));
672
- // A URL handed to `play` that isn't a recognized media file is treated as an
672
+ // A URL handed to `display` that isn't a recognized media file is treated as an
673
673
  // interactive web page: the FF1 renders unknown URLs in a sandboxed iframe,
674
674
  // so an explicit caller hint aside, "unknown" means web, not static image.
675
675
  // This routes such items through the open-ended (no-duration) timing path
676
676
  // instead of the static-image default.
677
677
  const mimeHint = options.mimeType || (detectMimeType(sourceUrl) === '' ? 'text/html' : undefined);
678
- // `play --web` says the URL is a page whatever its path looks like. Timing
678
+ // `display --web` says the URL is a page whatever its path looks like. Timing
679
679
  // checks the URL extension before the MIME hint, so a page served at
680
680
  // /page.mp4 would otherwise be timed as video (no duration, loop false);
681
681
  // leaving the URL out of the timing hints makes the page timing win.
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.castPlaylist = castPlaylist;
4
4
  /**
5
- * DP-1 signature role used when signing a playlist for playback.
5
+ * DP-1 signature role used when signing a playlist for display.
6
6
  *
7
7
  * A cast never passes through a feed, so a document signed here carries no `feed` signature and its own
8
8
  * entry is the only one a player could judge. DP-1 §7.1.1 rule 1 has players verify a `feed` or `curator`
@@ -26,15 +26,15 @@ const { signPlaylist } = require('./playlist-signer');
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const { sendPlaylistToDevice } = require('./ff1-device');
28
28
  /**
29
- * Shared "(sign →) verify → send to device" sequence used by `play` and
30
- * `find --play`.
29
+ * Shared "(sign →) verify → send to device" sequence used by `display` and
30
+ * `find --display`.
31
31
  *
32
32
  * This is the single source of truth for getting a playlist onto an FF1: the
33
33
  * verification gate, the synthesized-media auto-sign (rebuilding the
34
34
  * `signatures[]` envelope), and the device transport. Callers own their
35
35
  * console output and map the returned `stage`/`error` to their own messages —
36
36
  * keeping delivery behavior consistent without coupling the two commands'
37
- * UX. Previously this logic was duplicated across `play`, `find`, and the
37
+ * UX. Previously this logic was duplicated across `display`, `find`, and the
38
38
  * (now-removed) chat send path, and drifted between them.
39
39
  */
40
40
  async function castPlaylist(playlist, options = {}) {
@@ -50,7 +50,7 @@ async function castPlaylist(playlist, options = {}) {
50
50
  signed: false,
51
51
  verified: false,
52
52
  stage: 'sign',
53
- error: 'Cannot sign playlist for playback: no playlist signing key is configured',
53
+ error: 'Cannot sign playlist for display: no playlist signing key is configured',
54
54
  };
55
55
  }
56
56
  try {
@@ -69,7 +69,7 @@ async function castPlaylist(playlist, options = {}) {
69
69
  signed: false,
70
70
  verified: false,
71
71
  stage: 'sign',
72
- error: `Failed to sign playlist for playback: ${error.message}`,
72
+ error: `Failed to sign playlist for display: ${error.message}`,
73
73
  };
74
74
  }
75
75
  }
@@ -141,7 +141,7 @@ async function loadPlaylistSource(source) {
141
141
  }
142
142
  /**
143
143
  * buildMediaPlaySource wraps a URL in a synthesized single-item playlist, the
144
- * shape `play` sends for a direct media file or web page.
144
+ * shape `display` sends for a direct media file or web page.
145
145
  *
146
146
  * @param {string} url - Trimmed http(s) URL
147
147
  * @param {Object} [options] - Item options
@@ -174,11 +174,11 @@ function looksLikePlaylistUrl(url) {
174
174
  }
175
175
  }
176
176
  /**
177
- * refusalForLoadFailure returns the error `play` must stop with when a URL it
177
+ * refusalForLoadFailure returns the error `display` must stop with when a URL it
178
178
  * tried to load is known not to exist, or null when it may still be sent.
179
179
  *
180
180
  * Wrapping a URL that does not exist sends the device an error page and
181
- * reports "Playing", the silent wrong result an agent trusting exit codes
181
+ * reports "Displaying", the silent wrong result an agent trusting exit codes
182
182
  * cannot catch. These are refused:
183
183
  * - HTTP 404 or 410: nothing is there.
184
184
  * - DNS failure (ENOTFOUND): the host does not exist. A temporary resolver
@@ -206,7 +206,7 @@ function refusalForLoadFailure(url, failure) {
206
206
  return null;
207
207
  }
208
208
  /**
209
- * noteForLoadFailure returns the line `play` prints when it sends a URL it
209
+ * noteForLoadFailure returns the line `display` prints when it sends a URL it
210
210
  * could not read itself, or undefined when the URL was read in full and is
211
211
  * simply not a playlist (an ordinary page or media file).
212
212
  *
@@ -221,11 +221,11 @@ function noteForLoadFailure(failure) {
221
221
  return `ff-cli could not read this URL itself (${failure?.message}); sending it to the device anyway.`;
222
222
  }
223
223
  /**
224
- * checkWebPageUrl fetches a URL that `play --web` is about to send, refusing
224
+ * checkWebPageUrl fetches a URL that `display --web` is about to send, refusing
225
225
  * one that does not exist and returning a note when it could not be read.
226
226
  *
227
227
  * `--web` skips the playlist probe, so without this check a typo or a dead
228
- * link handed to `--web` (the hint `play` prints for marketplace pages) would
228
+ * link handed to `--web` (the hint `display` prints for marketplace pages) would
229
229
  * be cast as an error page with a successful exit. Only the status line is
230
230
  * needed; the body is cancelled unread. The `.json` rule does not apply: the
231
231
  * caller said this is a page.
@@ -259,7 +259,7 @@ async function checkWebPageUrl(url) {
259
259
  return noteForLoadFailure(failure);
260
260
  }
261
261
  /**
262
- * Resolve a `play` command argument to a playable source.
262
+ * Resolve a `display` command argument to a source it can send.
263
263
  *
264
264
  * Files are always treated as playlists. URLs are tried as playlists first;
265
265
  * a URL whose body is not a playlist (an ordinary web page, a video, an
@@ -95,7 +95,7 @@ const INDEXER_CHAIN_TO_CAIP = {
95
95
  * Raster could not be reached at the network level (connect failure, broken
96
96
  * IPv6 path, timeout). Distinct from API-level errors so callers that hold
97
97
  * usable token coordinates can degrade gracefully instead of dying — Raster
98
- * only enriches a find, it is not required to build a playable playlist.
98
+ * only enriches a find, it is not required to build a playlist to display.
99
99
  */
100
100
  class RasterUnreachableError extends Error {
101
101
  constructor(message) {