@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.
- package/README.md +20 -17
- package/config.json.example +1 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/device.js +84 -39
- package/dist/src/commands/enrich.js +2 -2
- package/dist/src/commands/find.js +71 -37
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/play.js +39 -26
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/config.js +162 -6
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/playlist-builder.js +3 -3
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +7 -7
- package/dist/src/utilities/raster-client.js +1 -1
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/docs/CONFIGURATION.md +270 -28
- package/docs/EXAMPLES.md +31 -31
- package/docs/PROJECT_SPEC.md +32 -19
- package/docs/README.md +113 -36
- 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 =
|
|
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.
|
|
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.
|
|
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
|
|
52
|
-
class
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 (`
|
|
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 `
|
|
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
|
-
// `
|
|
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
|
|
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 `
|
|
30
|
-
* `find --
|
|
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 `
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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 "
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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
|
|
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) {
|