@feralfile/cli 2.0.3 → 2.1.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/config.json.example +5 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/device.js +66 -7
- package/dist/src/commands/find.js +165 -98
- package/dist/src/commands/helpers/device-discovery.js +23 -3
- package/dist/src/commands/helpers/device-removal.js +40 -0
- package/dist/src/commands/play.js +3 -3
- package/dist/src/commands/sign.js +1 -1
- package/dist/src/config.js +55 -1
- package/dist/src/utilities/device-lookup.js +3 -2
- package/dist/src/utilities/device-upsert.js +19 -5
- package/dist/src/utilities/ff1-device.js +127 -25
- package/dist/src/utilities/ff1-relayer.js +105 -0
- package/dist/src/utilities/functions.js +2 -2
- package/dist/src/utilities/handoff-client.js +135 -0
- package/dist/src/utilities/handoff-crypto.js +132 -0
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/playlist-builder.js +2 -2
- package/dist/src/utilities/ssh-access.js +1 -5
- package/dist/src/utilities/topic-store.js +33 -0
- package/docs/CONFIGURATION.md +31 -2
- package/docs/PROJECT_SPEC.md +6 -0
- package/docs/README.md +4 -3
- package/package.json +5 -2
- package/dist/src/utilities/marketplace-url.js +0 -443
package/dist/src/config.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.getBrowserConfig = getBrowserConfig;
|
|
|
10
10
|
exports.getPlaylistConfig = getPlaylistConfig;
|
|
11
11
|
exports.getFeedConfig = getFeedConfig;
|
|
12
12
|
exports.getFF1DeviceConfig = getFF1DeviceConfig;
|
|
13
|
+
exports.getFF1RelayerConfig = getFF1RelayerConfig;
|
|
13
14
|
exports.validateConfig = validateConfig;
|
|
14
15
|
exports.createSampleConfig = createSampleConfig;
|
|
15
16
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -188,7 +189,7 @@ function getFeedConfig() {
|
|
|
188
189
|
* @returns {Array<Object>} returns.devices - Array of configured FF1 devices
|
|
189
190
|
* @returns {string} returns.devices[].host - Device host URL
|
|
190
191
|
* @returns {string} [returns.devices[].apiKey] - Optional device API key
|
|
191
|
-
* @returns {string} [returns.devices[].
|
|
192
|
+
* @returns {string} [returns.devices[].id] - Optional stable physical device ID
|
|
192
193
|
* @returns {string} [returns.devices[].name] - Optional device name
|
|
193
194
|
*/
|
|
194
195
|
function getFF1DeviceConfig() {
|
|
@@ -198,6 +199,53 @@ function getFF1DeviceConfig() {
|
|
|
198
199
|
devices: (0, config_placeholders_1.configuredFF1Devices)(ff1Devices.devices || []),
|
|
199
200
|
};
|
|
200
201
|
}
|
|
202
|
+
const DEFAULT_FF1_RELAYER_URL = 'https://tv-cast-coordination.autonomy-system.workers.dev';
|
|
203
|
+
/**
|
|
204
|
+
* Get the FF1 relayer endpoint using repository config priority.
|
|
205
|
+
*
|
|
206
|
+
* `config.json` wins over environment variables, which win over the production
|
|
207
|
+
* default. The API key remains optional because the signed topic is the
|
|
208
|
+
* per-device credential; it exists only for deployments that retain a separate
|
|
209
|
+
* cloud-edge compatibility gate.
|
|
210
|
+
*/
|
|
211
|
+
function getFF1RelayerConfig() {
|
|
212
|
+
return resolveFF1RelayerConfig(getConfig());
|
|
213
|
+
}
|
|
214
|
+
/** Resolve and validate the effective FF1 relayer configuration. */
|
|
215
|
+
function resolveFF1RelayerConfig(config) {
|
|
216
|
+
const configured = config.ff1Relayer;
|
|
217
|
+
if (configured !== undefined &&
|
|
218
|
+
(configured === null || typeof configured !== 'object' || Array.isArray(configured))) {
|
|
219
|
+
throw new Error('ff1Relayer must be an object');
|
|
220
|
+
}
|
|
221
|
+
const fields = (configured || {});
|
|
222
|
+
if (fields.baseUrl !== undefined && typeof fields.baseUrl !== 'string') {
|
|
223
|
+
throw new Error('ff1Relayer.baseUrl must be a string');
|
|
224
|
+
}
|
|
225
|
+
if (fields.apiKey !== undefined && typeof fields.apiKey !== 'string') {
|
|
226
|
+
throw new Error('ff1Relayer.apiKey must be a string');
|
|
227
|
+
}
|
|
228
|
+
const baseUrl = (fields.baseUrl ||
|
|
229
|
+
process.env.FF1_RELAYER_URL ||
|
|
230
|
+
DEFAULT_FF1_RELAYER_URL)
|
|
231
|
+
.trim()
|
|
232
|
+
.replace(/\/$/, '');
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = new URL(baseUrl);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL');
|
|
239
|
+
}
|
|
240
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
241
|
+
throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL');
|
|
242
|
+
}
|
|
243
|
+
const apiKey = (fields.apiKey || process.env.FF1_RELAYER_API_KEY)?.trim();
|
|
244
|
+
return {
|
|
245
|
+
baseUrl,
|
|
246
|
+
apiKey: apiKey || undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
201
249
|
/**
|
|
202
250
|
* Validate configuration
|
|
203
251
|
*
|
|
@@ -247,6 +295,12 @@ function validateConfig() {
|
|
|
247
295
|
}
|
|
248
296
|
}
|
|
249
297
|
}
|
|
298
|
+
try {
|
|
299
|
+
resolveFF1RelayerConfig(config);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
errors.push(error.message);
|
|
303
|
+
}
|
|
250
304
|
return {
|
|
251
305
|
valid: errors.length === 0,
|
|
252
306
|
errors,
|
|
@@ -25,10 +25,11 @@ exports.findExistingDeviceEntry = findExistingDeviceEntry;
|
|
|
25
25
|
* the default when prompting, rather than falling back to the raw mDNS label.
|
|
26
26
|
*/
|
|
27
27
|
function findExistingDeviceEntry(existingDevices, newHost, discoveredName, discoveredId, discoveredAddresses) {
|
|
28
|
+
const sameDeviceId = (left, right) => Boolean(left && right && left.trim().toUpperCase() === right.trim().toUpperCase());
|
|
28
29
|
// 1. mDNS device ID — stable hardware identity, checked before URL so stale
|
|
29
30
|
// host entries for other devices at the same IP do not shadow the result.
|
|
30
31
|
if (discoveredId) {
|
|
31
|
-
const byId = existingDevices.find((d) => d.id
|
|
32
|
+
const byId = existingDevices.find((d) => sameDeviceId(d.id, discoveredId));
|
|
32
33
|
if (byId) {
|
|
33
34
|
return byId;
|
|
34
35
|
}
|
|
@@ -69,7 +70,7 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
|
|
|
69
70
|
// the stored entry has an id; the address is the best identity signal available.
|
|
70
71
|
if (discoveredAddresses && discoveredAddresses.length > 0) {
|
|
71
72
|
const byDiscoveredAddress = existingDevices.find((d) => {
|
|
72
|
-
if (d.id && discoveredId && d.id
|
|
73
|
+
if (d.id && discoveredId && !sameDeviceId(d.id, discoveredId)) {
|
|
73
74
|
return false; // different physical devices — do not conflate
|
|
74
75
|
}
|
|
75
76
|
try {
|
|
@@ -36,7 +36,17 @@ function applyPatch(existing, patch) {
|
|
|
36
36
|
else {
|
|
37
37
|
addresses = existing.addresses; // same host, no new IPs: keep stored set
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
const updated = { ...existing, ...patch };
|
|
40
|
+
// Rebuild the row from supported fields so an old plaintext topicID cannot
|
|
41
|
+
// survive a normal device update. Topics now belong exclusively in the OS
|
|
42
|
+
// credential vault under the stable device ID.
|
|
43
|
+
return {
|
|
44
|
+
host: updated.host,
|
|
45
|
+
name: updated.name,
|
|
46
|
+
id: updated.id,
|
|
47
|
+
apiKey: updated.apiKey,
|
|
48
|
+
addresses,
|
|
49
|
+
};
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* Insert or update a device in the configured device list.
|
|
@@ -56,7 +66,11 @@ function upsertDevice(existingDevices, newDevice,
|
|
|
56
66
|
* heuristics below are skipped and this row is updated directly. */
|
|
57
67
|
matchedIndex) {
|
|
58
68
|
const devices = [...existingDevices];
|
|
59
|
-
const
|
|
69
|
+
const normalizedNewDevice = {
|
|
70
|
+
...newDevice,
|
|
71
|
+
id: newDevice.id?.trim().toUpperCase(),
|
|
72
|
+
};
|
|
73
|
+
const patch = withoutUndefined(normalizedNewDevice);
|
|
60
74
|
// Case 0: caller already resolved the match — update directly.
|
|
61
75
|
if (matchedIndex !== undefined && matchedIndex >= 0 && matchedIndex < devices.length) {
|
|
62
76
|
const isSameHost = devices[matchedIndex].host === newDevice.host;
|
|
@@ -64,8 +78,8 @@ matchedIndex) {
|
|
|
64
78
|
return { devices, updated: isSameHost };
|
|
65
79
|
}
|
|
66
80
|
// Case 1: same mDNS device ID — update in-place even when host changed.
|
|
67
|
-
if (
|
|
68
|
-
const sameIdIndex = devices.findIndex((d) => d.id ===
|
|
81
|
+
if (normalizedNewDevice.id) {
|
|
82
|
+
const sameIdIndex = devices.findIndex((d) => d.id?.trim().toUpperCase() === normalizedNewDevice.id);
|
|
69
83
|
if (sameIdIndex !== -1) {
|
|
70
84
|
const isSameHost = devices[sameIdIndex].host === newDevice.host;
|
|
71
85
|
devices[sameIdIndex] = applyPatch(devices[sameIdIndex], patch);
|
|
@@ -79,7 +93,7 @@ matchedIndex) {
|
|
|
79
93
|
return { devices, updated: true };
|
|
80
94
|
}
|
|
81
95
|
// Case 3: same name, different host — replace in-place to preserve array order.
|
|
82
|
-
// Spread existing entry first so
|
|
96
|
+
// Spread existing entry first so non-secret device metadata survives a host change.
|
|
83
97
|
const staleNameIndex = devices.findIndex((d) => d.name === newDevice.name);
|
|
84
98
|
if (staleNameIndex !== -1) {
|
|
85
99
|
devices[staleNameIndex] = applyPatch(devices[staleNameIndex], patch);
|
|
@@ -41,13 +41,27 @@ exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
|
|
|
41
41
|
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
42
42
|
exports.sendPlaylistToDevice = sendPlaylistToDevice;
|
|
43
43
|
const logger = __importStar(require("../logger"));
|
|
44
|
+
const config_1 = require("../config");
|
|
44
45
|
const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
46
|
+
const ff1_relayer_1 = require("./ff1-relayer");
|
|
47
|
+
const topic_store_1 = require("./topic-store");
|
|
45
48
|
const SEND_RETRY_ATTEMPTS = 3;
|
|
46
49
|
const SEND_RETRY_BASE_DELAY_MS = 750;
|
|
47
50
|
const RATE_LIMIT_STATUS = 429;
|
|
48
51
|
// Cap how long a device-supplied Retry-After can stall the CLI, so a buggy or
|
|
49
52
|
// hostile device cannot pin a command for minutes/hours.
|
|
50
53
|
const MAX_RETRY_DELAY_MS = 30_000;
|
|
54
|
+
/** Find an FF1 `ok` flag through the nested cast response envelopes. */
|
|
55
|
+
function nestedCastOk(value) {
|
|
56
|
+
if (!value || typeof value !== 'object') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const record = value;
|
|
60
|
+
if (typeof record.ok === 'boolean') {
|
|
61
|
+
return record.ok;
|
|
62
|
+
}
|
|
63
|
+
return nestedCastOk(record.message);
|
|
64
|
+
}
|
|
51
65
|
/**
|
|
52
66
|
* Sleep for a short duration before retrying transient network errors.
|
|
53
67
|
*
|
|
@@ -162,14 +176,21 @@ function parseRetryAfterMs(headerValue) {
|
|
|
162
176
|
* deviceName: 'Living Room Display'
|
|
163
177
|
* });
|
|
164
178
|
*/
|
|
165
|
-
async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
179
|
+
async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
|
|
180
|
+
const dependencies = {
|
|
181
|
+
fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis),
|
|
182
|
+
getTopicIdFn: overrides.getTopicIdFn ?? topic_store_1.getTopicId,
|
|
183
|
+
getRelayerConfigFn: overrides.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
|
|
184
|
+
waitFn: overrides.waitFn ?? waitForRetry,
|
|
185
|
+
noticeFn: overrides.noticeFn ?? logger.always,
|
|
186
|
+
};
|
|
166
187
|
let device;
|
|
167
188
|
try {
|
|
168
189
|
// Validate input
|
|
169
190
|
if (!playlist || typeof playlist !== 'object') {
|
|
170
191
|
return {
|
|
171
192
|
success: false,
|
|
172
|
-
error: 'Invalid playlist: must provide a valid
|
|
193
|
+
error: 'Invalid playlist: must provide a valid DP-1 playlist object',
|
|
173
194
|
};
|
|
174
195
|
}
|
|
175
196
|
const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
|
|
@@ -180,7 +201,9 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
180
201
|
};
|
|
181
202
|
}
|
|
182
203
|
device = resolved.device;
|
|
183
|
-
const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist'
|
|
204
|
+
const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist', {
|
|
205
|
+
fetchFn: dependencies.fetchFn,
|
|
206
|
+
});
|
|
184
207
|
if (!compatibility.compatible) {
|
|
185
208
|
return {
|
|
186
209
|
success: false,
|
|
@@ -189,12 +212,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
189
212
|
};
|
|
190
213
|
}
|
|
191
214
|
logger.info(`Sending playlist to FF1 device: ${device.host}`);
|
|
192
|
-
|
|
193
|
-
let apiUrl = `${device.host}/api/cast`;
|
|
194
|
-
if (device.topicID && device.topicID.trim() !== '') {
|
|
195
|
-
apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
|
|
196
|
-
logger.debug(`Using topicID: ${device.topicID}`);
|
|
197
|
-
}
|
|
215
|
+
const apiUrl = `${device.host}/api/cast`;
|
|
198
216
|
// Wrap playlist in required structure
|
|
199
217
|
const requestBody = {
|
|
200
218
|
command: 'displayPlaylist',
|
|
@@ -221,7 +239,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
221
239
|
let lastBodyIssue = null;
|
|
222
240
|
for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) {
|
|
223
241
|
try {
|
|
224
|
-
response = await
|
|
242
|
+
response = await dependencies.fetchFn(apiUrl, {
|
|
225
243
|
method: 'POST',
|
|
226
244
|
headers,
|
|
227
245
|
body: JSON.stringify(requestBody),
|
|
@@ -234,7 +252,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
234
252
|
const retryDelay = Math.min(retryAfterMs ?? SEND_RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
|
|
235
253
|
logger.warn(`Device rate-limited the command (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
|
|
236
254
|
response = null;
|
|
237
|
-
await
|
|
255
|
+
await dependencies.waitFn(retryDelay);
|
|
238
256
|
continue;
|
|
239
257
|
}
|
|
240
258
|
// Non-2xx (other than 429 handled above): surface via the post-loop
|
|
@@ -263,7 +281,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
263
281
|
const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
|
|
264
282
|
logger.warn(`${lastBodyIssue} (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
|
|
265
283
|
response = null;
|
|
266
|
-
await
|
|
284
|
+
await dependencies.waitFn(retryDelay);
|
|
267
285
|
continue;
|
|
268
286
|
}
|
|
269
287
|
// Out of attempts; let the post-loop handler report the body issue.
|
|
@@ -277,17 +295,22 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
277
295
|
const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
|
|
278
296
|
logger.warn(`Transient network error while sending playlist (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}): ${error.message}`);
|
|
279
297
|
logger.debug(`Retrying playlist send in ${retryDelay}ms`);
|
|
280
|
-
await
|
|
298
|
+
await dependencies.waitFn(retryDelay);
|
|
281
299
|
}
|
|
282
300
|
}
|
|
283
301
|
if (!response) {
|
|
284
302
|
const deviceLabel = device.name || device.host;
|
|
285
|
-
return {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
303
|
+
return fallbackToRelayer({
|
|
304
|
+
playlist,
|
|
305
|
+
device,
|
|
306
|
+
dependencies,
|
|
307
|
+
lanFailure: {
|
|
308
|
+
success: false,
|
|
309
|
+
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
310
|
+
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
311
|
+
'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
291
314
|
}
|
|
292
315
|
// Check response status
|
|
293
316
|
if (!response.ok) {
|
|
@@ -321,6 +344,13 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
321
344
|
'wait a few seconds and try again.',
|
|
322
345
|
};
|
|
323
346
|
}
|
|
347
|
+
if (nestedCastOk(responseData) === false) {
|
|
348
|
+
return {
|
|
349
|
+
success: false,
|
|
350
|
+
error: `Device "${device.name || device.host}" rejected the display command`,
|
|
351
|
+
details: JSON.stringify(responseData),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
324
354
|
logger.info('Successfully sent playlist to FF1 device');
|
|
325
355
|
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
326
356
|
return {
|
|
@@ -329,6 +359,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
329
359
|
deviceName: device.name || device.host,
|
|
330
360
|
response: responseData,
|
|
331
361
|
message: 'Playlist successfully sent to FF1 device',
|
|
362
|
+
transport: 'lan',
|
|
332
363
|
};
|
|
333
364
|
}
|
|
334
365
|
catch (error) {
|
|
@@ -336,12 +367,17 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
336
367
|
logger.error(`Error sending playlist to device: ${errorMessage}`);
|
|
337
368
|
if (device && isTransientDeviceNetworkError(error)) {
|
|
338
369
|
const deviceLabel = device.name || device.host;
|
|
339
|
-
return {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
370
|
+
return fallbackToRelayer({
|
|
371
|
+
playlist,
|
|
372
|
+
device,
|
|
373
|
+
dependencies,
|
|
374
|
+
lanFailure: {
|
|
375
|
+
success: false,
|
|
376
|
+
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
377
|
+
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
378
|
+
'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
|
|
379
|
+
},
|
|
380
|
+
});
|
|
345
381
|
}
|
|
346
382
|
return {
|
|
347
383
|
success: false,
|
|
@@ -349,3 +385,69 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
349
385
|
};
|
|
350
386
|
}
|
|
351
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Fall back to the relayer only after LAN reachability is exhausted.
|
|
390
|
+
*
|
|
391
|
+
* Topic lookup is keyed by the stable physical device ID and happens only at
|
|
392
|
+
* the fallback boundary, keeping the credential out of config and normal LAN
|
|
393
|
+
* requests. Application/HTTP errors do not enter this path.
|
|
394
|
+
*/
|
|
395
|
+
async function fallbackToRelayer(input) {
|
|
396
|
+
if (!input.device.id) {
|
|
397
|
+
return {
|
|
398
|
+
...input.lanFailure,
|
|
399
|
+
details: `${input.lanFailure.details} Pair the relayer with: ff-cli device pair ${input.device.name ?? '<device>'}`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
let topicId;
|
|
403
|
+
try {
|
|
404
|
+
topicId = input.dependencies.getTopicIdFn(input.device.id);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
return {
|
|
408
|
+
...input.lanFailure,
|
|
409
|
+
details: `${input.lanFailure.details} Secure topic storage is unavailable: ${error.message}`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (!topicId) {
|
|
413
|
+
return {
|
|
414
|
+
...input.lanFailure,
|
|
415
|
+
details: `${input.lanFailure.details} Pair this device for relayer fallback with: ff-cli device pair ${input.device.name ?? input.device.id}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
input.dependencies.noticeFn(`Notice: LAN device is unreachable; falling back to the FF1 relayer for ${input.device.name ?? input.device.id}.`);
|
|
419
|
+
let relayer;
|
|
420
|
+
try {
|
|
421
|
+
relayer = input.dependencies.getRelayerConfigFn();
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return {
|
|
425
|
+
success: false,
|
|
426
|
+
error: 'Invalid FF1 relayer configuration',
|
|
427
|
+
details: error.message,
|
|
428
|
+
transport: 'relayer',
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const result = await (0, ff1_relayer_1.sendPlaylistViaRelayer)({
|
|
432
|
+
...relayer,
|
|
433
|
+
topicId,
|
|
434
|
+
playlist: input.playlist,
|
|
435
|
+
fetchFn: input.dependencies.fetchFn,
|
|
436
|
+
});
|
|
437
|
+
if (!result.success) {
|
|
438
|
+
return {
|
|
439
|
+
success: false,
|
|
440
|
+
error: result.error,
|
|
441
|
+
details: result.details,
|
|
442
|
+
transport: 'relayer',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
device: relayer.baseUrl,
|
|
448
|
+
deviceName: input.device.name ?? input.device.id,
|
|
449
|
+
response: result.response,
|
|
450
|
+
message: 'Playlist successfully sent through the FF1 relayer',
|
|
451
|
+
transport: 'relayer',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sendPlaylistViaRelayer = sendPlaylistViaRelayer;
|
|
4
|
+
function nestedOk(value) {
|
|
5
|
+
if (!value || typeof value !== 'object') {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const record = value;
|
|
9
|
+
if (typeof record.ok === 'boolean') {
|
|
10
|
+
return record.ok;
|
|
11
|
+
}
|
|
12
|
+
return nestedOk(record.message);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* sendPlaylistViaRelayer sends a display command through the mobile-compatible
|
|
16
|
+
* FF1 relayer contract.
|
|
17
|
+
*
|
|
18
|
+
* The result preserves the relayer's HTTP failure status before interpreting
|
|
19
|
+
* any response body. Successful responses must contain JSON when non-empty;
|
|
20
|
+
* non-2xx responses may use plain text or HTML without obscuring the original
|
|
21
|
+
* gateway failure.
|
|
22
|
+
*
|
|
23
|
+
* @param input - Relayer endpoint, optional compatibility key, paired topic,
|
|
24
|
+
* playlist, and optional fetch implementation.
|
|
25
|
+
* @returns A structured result for handled request failures, HTTP statuses,
|
|
26
|
+
* and JSON parsing errors.
|
|
27
|
+
*/
|
|
28
|
+
async function sendPlaylistViaRelayer(input) {
|
|
29
|
+
const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL('/api/cast', input.baseUrl);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
error: 'Invalid FF1 relayer configuration',
|
|
38
|
+
details: 'ff1Relayer.baseUrl must be a valid HTTP(S) URL',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
url.searchParams.set('topicID', input.topicId);
|
|
42
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
43
|
+
if (input.apiKey) {
|
|
44
|
+
headers['API-KEY'] = input.apiKey;
|
|
45
|
+
}
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetchFn(url, {
|
|
49
|
+
// The relayer accepts GET or POST with the same body. Node's standards-
|
|
50
|
+
// compliant fetch rejects GET bodies, so CLI uses POST while preserving
|
|
51
|
+
// the mobile request shape and topic query parameter.
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers,
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
command: 'displayPlaylist',
|
|
56
|
+
request: {
|
|
57
|
+
dp1_call: input.playlist,
|
|
58
|
+
intent: { action: 'now_display' },
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: 'Could not reach the FF1 relayer',
|
|
67
|
+
details: error.message,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const bodyText = (await response.text()).trim();
|
|
71
|
+
// HTTP status is authoritative for gateway failures. Parse only successful
|
|
72
|
+
// responses so an HTML/plain-text 401 cannot hide the actionable missing-key
|
|
73
|
+
// diagnosis behind a secondary JSON parsing error.
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const needsCompatibilityKey = response.status === 401 && !input.apiKey;
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
error: `FF1 relayer returned HTTP ${response.status}`,
|
|
79
|
+
details: needsCompatibilityKey
|
|
80
|
+
? 'This relayer deployment still requires its optional FF1_RELAYER_API_KEY compatibility gate.'
|
|
81
|
+
: bodyText || response.statusText,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
let data = {};
|
|
85
|
+
if (bodyText) {
|
|
86
|
+
try {
|
|
87
|
+
data = JSON.parse(bodyText);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: `FF1 relayer returned non-JSON HTTP ${response.status}`,
|
|
93
|
+
details: bodyText.slice(0, 200),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (nestedOk(data) === false) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: 'FF1 rejected the relayer display command',
|
|
101
|
+
details: bodyText,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { success: true, response: data };
|
|
105
|
+
}
|
|
@@ -259,7 +259,7 @@ async function verifyAddresses(params) {
|
|
|
259
259
|
* @returns {Array<Object>} returns.devices - Array of device configurations
|
|
260
260
|
* @returns {string} returns.devices[].name - Device name
|
|
261
261
|
* @returns {string} returns.devices[].host - Device host URL
|
|
262
|
-
* @returns {string} [returns.devices[].
|
|
262
|
+
* @returns {string} [returns.devices[].id] - Optional physical device ID
|
|
263
263
|
* @returns {string} [returns.error] - Error message if no devices configured
|
|
264
264
|
* @example
|
|
265
265
|
* const result = await getConfiguredDevices();
|
|
@@ -291,7 +291,7 @@ async function getConfiguredDevices() {
|
|
|
291
291
|
const devices = deviceConfig.devices.map((d) => ({
|
|
292
292
|
name: d.name || d.host,
|
|
293
293
|
host: d.host,
|
|
294
|
-
|
|
294
|
+
id: d.id,
|
|
295
295
|
}));
|
|
296
296
|
return {
|
|
297
297
|
success: true,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TopicHandoffReceiver = void 0;
|
|
4
|
+
const handoff_crypto_1 = require("./handoff-crypto");
|
|
5
|
+
const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com';
|
|
6
|
+
function brokerBaseUrl(value) {
|
|
7
|
+
return (value || process.env.FF1_HANDOFF_URL || DEFAULT_HANDOFF_URL).replace(/\/$/, '');
|
|
8
|
+
}
|
|
9
|
+
async function responseJson(response, operation) {
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new Error(`${operation} failed with HTTP ${response.status}`);
|
|
12
|
+
}
|
|
13
|
+
return response.json();
|
|
14
|
+
}
|
|
15
|
+
function parseCreated(value) {
|
|
16
|
+
const record = value;
|
|
17
|
+
if (!record ||
|
|
18
|
+
typeof record.channelId !== 'string' ||
|
|
19
|
+
!record.channelId.startsWith('ch_') ||
|
|
20
|
+
typeof record.minterToken !== 'string' ||
|
|
21
|
+
typeof record.shortCode !== 'string' ||
|
|
22
|
+
!/^\d{6}$/.test(record.shortCode) ||
|
|
23
|
+
typeof record.expiresAt !== 'string') {
|
|
24
|
+
throw new Error('handoff channel response is invalid');
|
|
25
|
+
}
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
function parsePoll(value, channelId) {
|
|
29
|
+
const record = value;
|
|
30
|
+
if (record?.channelId !== channelId || !Array.isArray(record.messages)) {
|
|
31
|
+
throw new Error('handoff poll response is invalid');
|
|
32
|
+
}
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
function parseTopicPayload(value, channelId, expectedDeviceId) {
|
|
36
|
+
const payload = value;
|
|
37
|
+
if (payload?.v !== 1 ||
|
|
38
|
+
payload.type !== 'ff1_topic_handoff' ||
|
|
39
|
+
payload.channelId !== channelId ||
|
|
40
|
+
typeof payload.deviceId !== 'string' ||
|
|
41
|
+
payload.deviceId.trim().toUpperCase() !== expectedDeviceId.trim().toUpperCase() ||
|
|
42
|
+
typeof payload.topicId !== 'string' ||
|
|
43
|
+
payload.topicId.trim().length === 0 ||
|
|
44
|
+
typeof payload.sentAt !== 'string' ||
|
|
45
|
+
Number.isNaN(Date.parse(payload.sentAt))) {
|
|
46
|
+
throw new Error('received topic handoff does not match the selected FF1 device');
|
|
47
|
+
}
|
|
48
|
+
return payload;
|
|
49
|
+
}
|
|
50
|
+
/** One short-lived CLI-side channel waiting for a mobile topic transfer. */
|
|
51
|
+
class TopicHandoffReceiver {
|
|
52
|
+
baseUrl;
|
|
53
|
+
channelId;
|
|
54
|
+
shortCode;
|
|
55
|
+
verificationCode;
|
|
56
|
+
expiresAt;
|
|
57
|
+
minterToken;
|
|
58
|
+
privateKey;
|
|
59
|
+
fetchFn;
|
|
60
|
+
constructor(baseUrl, channelId, shortCode, verificationCode, expiresAt, minterToken, privateKey, fetchFn) {
|
|
61
|
+
this.baseUrl = baseUrl;
|
|
62
|
+
this.channelId = channelId;
|
|
63
|
+
this.shortCode = shortCode;
|
|
64
|
+
this.verificationCode = verificationCode;
|
|
65
|
+
this.expiresAt = expiresAt;
|
|
66
|
+
this.minterToken = minterToken;
|
|
67
|
+
this.privateKey = privateKey;
|
|
68
|
+
this.fetchFn = fetchFn;
|
|
69
|
+
}
|
|
70
|
+
/** Ask the handoff broker for a one-time six-digit pairing channel. */
|
|
71
|
+
static async create(options = {}) {
|
|
72
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
|
73
|
+
const baseUrl = brokerBaseUrl(options.baseUrl);
|
|
74
|
+
const keyPair = await (0, handoff_crypto_1.generateHandoffKeyPair)();
|
|
75
|
+
const publicKey = await (0, handoff_crypto_1.exportHandoffPublicJwk)(keyPair.publicKey);
|
|
76
|
+
const response = await fetchFn(new URL('/v1/channels', baseUrl), {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
algorithm: handoff_crypto_1.HANDOFF_ALGORITHM,
|
|
81
|
+
minterPublicKeyJwk: publicKey,
|
|
82
|
+
idleTtlSeconds: 300,
|
|
83
|
+
shortCodeRequested: true,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const created = parseCreated(await responseJson(response, 'handoff channel creation'));
|
|
87
|
+
const verificationCode = (0, handoff_crypto_1.handoffVerificationCode)({
|
|
88
|
+
channelId: created.channelId,
|
|
89
|
+
minterPublicKeyJwk: publicKey,
|
|
90
|
+
});
|
|
91
|
+
return new TopicHandoffReceiver(baseUrl, created.channelId, created.shortCode, verificationCode, created.expiresAt, created.minterToken, keyPair.privateKey, fetchFn);
|
|
92
|
+
}
|
|
93
|
+
/** Poll until mobile sends a valid encrypted topic for the selected device. */
|
|
94
|
+
async receiveTopic(options) {
|
|
95
|
+
const deadline = Date.now() + (options.maxWaitMs ?? 300_000);
|
|
96
|
+
let afterSeq = 0;
|
|
97
|
+
while (Date.now() <= deadline) {
|
|
98
|
+
const url = new URL(`/v1/channels/${encodeURIComponent(this.channelId)}/messages`, this.baseUrl);
|
|
99
|
+
url.searchParams.set('afterSeq', String(afterSeq));
|
|
100
|
+
const response = await this.fetchFn(url, {
|
|
101
|
+
headers: { Authorization: `Bearer ${this.minterToken}` },
|
|
102
|
+
});
|
|
103
|
+
const poll = parsePoll(await responseJson(response, 'handoff poll'), this.channelId);
|
|
104
|
+
for (const message of poll.messages) {
|
|
105
|
+
afterSeq = Math.max(afterSeq, message.seq);
|
|
106
|
+
if (message.sender !== 'browser' ||
|
|
107
|
+
message.recipient !== 'minter' ||
|
|
108
|
+
!message.senderPublicKeyJwk) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const plaintext = await (0, handoff_crypto_1.decryptHandoffMessage)({
|
|
112
|
+
privateKey: this.privateKey,
|
|
113
|
+
peerPublicKeyJwk: message.senderPublicKeyJwk,
|
|
114
|
+
channelId: this.channelId,
|
|
115
|
+
seq: message.seq,
|
|
116
|
+
message,
|
|
117
|
+
});
|
|
118
|
+
return parseTopicPayload(plaintext, this.channelId, options.expectedDeviceId);
|
|
119
|
+
}
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 1000));
|
|
121
|
+
}
|
|
122
|
+
throw new Error('Timed out waiting for the mobile app topic transfer');
|
|
123
|
+
}
|
|
124
|
+
/** Close the broker channel and invalidate its participant credentials. */
|
|
125
|
+
async close() {
|
|
126
|
+
const response = await this.fetchFn(new URL(`/v1/channels/${encodeURIComponent(this.channelId)}`, this.baseUrl), {
|
|
127
|
+
method: 'DELETE',
|
|
128
|
+
headers: { Authorization: `Bearer ${this.minterToken}` },
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok && response.status !== 404 && response.status !== 410) {
|
|
131
|
+
throw new Error(`handoff channel close failed with HTTP ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.TopicHandoffReceiver = TopicHandoffReceiver;
|