@ciphore/radiocli 0.2.4 → 0.2.6
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/CHANGELOG.md +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -4,6 +4,7 @@ import { resolveCommandDetails } from './executables.js';
|
|
|
4
4
|
import { identifyPlatform, nativeAdapters } from './runtime.js';
|
|
5
5
|
import { powershellCommand } from './shell.js';
|
|
6
6
|
import { launchEnvironment, nodeLaunchCommand, waitForLaunch } from './launch-command.js';
|
|
7
|
+
import { identifyOmarchy } from './omarchy.js';
|
|
7
8
|
const unixTerminals = [
|
|
8
9
|
{ name: 'ghostty', args: ['-e'] }, { name: 'wezterm', args: ['start', '--always-new-process', '--'] },
|
|
9
10
|
{ name: 'kitty', args: ['-e'] }, { name: 'gnome-terminal', args: ['--'] }, { name: 'konsole', args: ['-e'] },
|
|
@@ -14,7 +15,9 @@ const unixTerminals = [
|
|
|
14
15
|
{ name: 'xterm', args: ['-e'], display: 'x11' }, { name: 'uxterm', args: ['-e'], display: 'x11' }
|
|
15
16
|
];
|
|
16
17
|
const macTerminals = ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'];
|
|
17
|
-
|
|
18
|
+
const omarchyTerminal = 'linux:omarchy-launch-tui';
|
|
19
|
+
export function detectGraphicalTerminal(platform = process.platform, env = process.env, resolveCommand, omarchy) {
|
|
20
|
+
const resolve = resolveCommand ?? resolver(platform, env);
|
|
18
21
|
const host = identifyPlatform({ platform, env });
|
|
19
22
|
const adapter = nativeAdapters(host).terminal;
|
|
20
23
|
const unsupported = `${host.id === 'unknown' ? platform : host.id}:unsupported`;
|
|
@@ -35,7 +38,12 @@ export function detectGraphicalTerminal(platform = process.platform, env = proce
|
|
|
35
38
|
}
|
|
36
39
|
if (adapter !== 'unix' || !hasDisplay(env))
|
|
37
40
|
return unsupported;
|
|
38
|
-
|
|
41
|
+
const nativeOmarchy = () => identifyOmarchy({ ...omarchy, platform, env, ...(resolveCommand ? { resolve } : {}) }).terminal.available;
|
|
42
|
+
if (configured === omarchyTerminal) {
|
|
43
|
+
if (nativeOmarchy())
|
|
44
|
+
return omarchyTerminal;
|
|
45
|
+
}
|
|
46
|
+
else if (configured) {
|
|
39
47
|
const separator = configured.indexOf(':');
|
|
40
48
|
const prefix = separator < 0 ? '' : configured.slice(0, separator);
|
|
41
49
|
if (prefix === host.id || prefix === 'linux') {
|
|
@@ -47,6 +55,8 @@ export function detectGraphicalTerminal(platform = process.platform, env = proce
|
|
|
47
55
|
const selected = requested && resolveUnixTerminal(requested, env, resolve);
|
|
48
56
|
if (selected)
|
|
49
57
|
return `${host.id}:${selected}`;
|
|
58
|
+
if (nativeOmarchy())
|
|
59
|
+
return omarchyTerminal;
|
|
50
60
|
for (const terminal of unixTerminals) {
|
|
51
61
|
const path = resolveUnixTerminal(terminal.name, env, resolve);
|
|
52
62
|
if (path)
|
|
@@ -58,7 +68,7 @@ export function createTerminalLaunch(options) {
|
|
|
58
68
|
const platform = options.platform ?? process.platform;
|
|
59
69
|
const env = options.env ?? process.env;
|
|
60
70
|
const resolve = options.resolve ?? resolver(platform, env);
|
|
61
|
-
const terminal = detectGraphicalTerminal(platform, env, resolve);
|
|
71
|
+
const terminal = detectGraphicalTerminal(platform, env, options.resolve, options.omarchy);
|
|
62
72
|
const values = [options.nodePath, ...options.args];
|
|
63
73
|
if (values.some(value => value.includes('\0')))
|
|
64
74
|
throw new Error('Terminal command values cannot contain NUL bytes.');
|
|
@@ -94,6 +104,14 @@ export function createTerminalLaunch(options) {
|
|
|
94
104
|
throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.');
|
|
95
105
|
return { terminal, command: wt, args: ['-w', 'new', 'new-tab', '--title', options.title ?? 'RadioCLI', powershell, ...args] };
|
|
96
106
|
}
|
|
107
|
+
if (terminal === omarchyTerminal) {
|
|
108
|
+
if (!posix.isAbsolute(options.nodePath))
|
|
109
|
+
throw new Error('The Omarchy launcher requires an absolute Node executable path.');
|
|
110
|
+
const command = resolve('/usr/share/omarchy/bin/omarchy-launch-tui') ?? resolve('omarchy-launch-tui');
|
|
111
|
+
if (!command || !posix.isAbsolute(command) || /[\0\r\n]/.test(command))
|
|
112
|
+
throw new Error('The Omarchy TUI launcher is unavailable.');
|
|
113
|
+
return { terminal, command, args: ['--app-id=org.radiocli.RadioCLI', ...direct] };
|
|
114
|
+
}
|
|
97
115
|
const executable = terminal.slice(terminal.indexOf(':') + 1);
|
|
98
116
|
const spec = unixTerminals.find(item => item.name === posix.basename(executable));
|
|
99
117
|
if (!spec)
|
|
@@ -118,7 +136,9 @@ export async function launchTerminalCommand(options) {
|
|
|
118
136
|
// https://github.com/nodejs/node/issues/51018
|
|
119
137
|
const consoleBootstrap = plan.terminal === 'win32:console';
|
|
120
138
|
const child = (options.spawn ?? spawn)(plan.command, plan.args, { env: { ...(options.env ?? process.env), ...plan.environment }, detached: !consoleBootstrap, stdio: 'ignore', windowsHide: consoleBootstrap });
|
|
121
|
-
|
|
139
|
+
// The console bootstrap compiles a small Win32 bridge with Add-Type. Cold
|
|
140
|
+
// PowerShell/.NET startup on ARM64 needs more time than ordinary launchers.
|
|
141
|
+
await waitForLaunch(child, { waitForExit: consoleBootstrap, exitTimeoutMs: consoleBootstrap ? 30_000 : undefined });
|
|
122
142
|
return plan.terminal;
|
|
123
143
|
}
|
|
124
144
|
function resolver(platform, env) { return command => resolveCommandDetails(command, { platform, env }).path ?? undefined; }
|
|
@@ -1,28 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
2
|
import { lookup } from 'node:dns/promises';
|
|
3
3
|
import { networkInterfaces } from 'node:os';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
5
5
|
import { networkPolicy } from '../platform/network.js';
|
|
6
|
-
import {
|
|
6
|
+
import { isAirPlayPlatformSupported } from './backend-install.js';
|
|
7
|
+
import { probeAirPlayReadiness } from './airplay-readiness.js';
|
|
8
|
+
import { runAirPlayCommand } from './airplay-process.js';
|
|
7
9
|
const defaultTimeoutMs = 2500;
|
|
8
10
|
const defaultMaxDevices = 12;
|
|
9
11
|
const defaultLookupConcurrency = 4;
|
|
10
12
|
const defaultMaxOutputBytes = 64 * 1024;
|
|
11
|
-
export async function discoverAirPlayDevices({ platform = process.platform, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
|
|
12
|
-
if (networkPolicy().offline || !
|
|
13
|
+
export async function discoverAirPlayDevices({ platform = process.platform, env = process.env, release, resolve = command => resolveCommandDetails(command, { platform, env }).path ?? undefined, hasCommand = command => Boolean(resolve(command)), run = runAirPlayCommand, readiness = probeAirPlayReadiness, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
|
|
14
|
+
if (networkPolicy(env).offline || !isAirPlayPlatformSupported(platform, env, release)) {
|
|
13
15
|
return [];
|
|
14
16
|
}
|
|
15
|
-
const
|
|
17
|
+
const tool = platform === 'linux' ? 'avahi-browse' : 'dns-sd';
|
|
18
|
+
const executable = hasCommand(tool) ? resolve(tool) : undefined;
|
|
19
|
+
if (!executable) {
|
|
20
|
+
if (platform === 'linux')
|
|
21
|
+
throw new Error('AirPlay discovery requires avahi-browse. Run radiocli doctor.');
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
timeoutMs = Math.min(defaultTimeoutMs, Math.max(1, timeoutMs));
|
|
25
|
+
maxOutputBytes = Math.min(defaultMaxOutputBytes, Math.max(1, maxOutputBytes));
|
|
26
|
+
maxDevices = Math.min(defaultMaxDevices, Math.max(0, Math.floor(maxDevices)));
|
|
27
|
+
if (platform === 'linux') {
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
const health = await readiness({ platform, env, release, resolve, hasCommand, run });
|
|
30
|
+
if (!health.ready)
|
|
31
|
+
throw new Error(health.message);
|
|
32
|
+
const remaining = deadline - Date.now();
|
|
33
|
+
if (remaining <= 0)
|
|
34
|
+
throw new Error('AirPlay readiness check timed out. Run radiocli doctor.');
|
|
35
|
+
const result = await run(executable, ['--parsable', '--resolve', '--terminate', '--no-db-lookup', '_raop._tcp'], remaining, maxOutputBytes, { env });
|
|
36
|
+
if (result.code !== 0 && !result.timedOut && !result.truncated)
|
|
37
|
+
throw new Error('Avahi discovery failed. Check that the Avahi service is running with radiocli doctor.');
|
|
38
|
+
return parseAvahiBrowseOutput(result.output, maxDevices);
|
|
39
|
+
}
|
|
40
|
+
const browseOutput = await run(executable, ['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes, { env }).then(result => result.output).catch(() => '');
|
|
16
41
|
const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices));
|
|
17
42
|
const devices = await mapWithConcurrency(instances, Math.max(1, lookupConcurrency), async (instance) => {
|
|
18
|
-
const lookupOutput = await
|
|
43
|
+
const lookupOutput = await run(executable, ['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes, { env }).then(result => result.output).catch(() => '');
|
|
19
44
|
const device = parseRaopLookupOutput(instance, lookupOutput);
|
|
20
|
-
return device ? enrichAirPlayDeviceHost(device) : null;
|
|
45
|
+
return device ? enrichAirPlayDeviceHost(device, timeoutMs) : null;
|
|
21
46
|
});
|
|
22
47
|
return devices.filter((device) => Boolean(device));
|
|
23
48
|
}
|
|
24
|
-
async function enrichAirPlayDeviceHost(device) {
|
|
25
|
-
|
|
49
|
+
async function enrichAirPlayDeviceHost(device, timeoutMs) {
|
|
50
|
+
let timer;
|
|
51
|
+
const addresses = await Promise.race([
|
|
52
|
+
lookup(device.host, { all: true, family: 4 }).catch(() => []),
|
|
53
|
+
new Promise(resolve => { timer = setTimeout(() => resolve([]), timeoutMs); })
|
|
54
|
+
]).finally(() => clearTimeout(timer));
|
|
26
55
|
if (addresses.length === 0) {
|
|
27
56
|
return device;
|
|
28
57
|
}
|
|
@@ -62,6 +91,82 @@ export function parseRaopBrowseOutput(output) {
|
|
|
62
91
|
}
|
|
63
92
|
return [...instances];
|
|
64
93
|
}
|
|
94
|
+
/** avahi-browse -pr emits resolved IPv4 and IPv6 records separately. The
|
|
95
|
+
* bundled sender uses udp4, so an IPv6-only record is deliberately not offered. */
|
|
96
|
+
export function parseAvahiBrowseOutput(output, maxDevices = defaultMaxDevices) {
|
|
97
|
+
const devices = new Map();
|
|
98
|
+
const local = localIpv4Addresses();
|
|
99
|
+
for (const line of output.slice(0, defaultMaxOutputBytes).split('\n')) {
|
|
100
|
+
const fields = avahiFields(line);
|
|
101
|
+
if (fields.length !== 10 || fields[0] !== '=' || fields[2] !== 'IPv4' || fields[4] !== '_raop._tcp' || fields[5] !== 'local')
|
|
102
|
+
continue;
|
|
103
|
+
const host = fields[7];
|
|
104
|
+
const port = Number(fields[8]);
|
|
105
|
+
if (isIP(host) !== 4 || host === '0.0.0.0' || Number(host.split('.')[0]) >= 224 || !/^\d+$/.test(fields[8]) || port < 1 || port > 65535)
|
|
106
|
+
continue;
|
|
107
|
+
const id = sanitizeDnsSdText(unescapeDnsSdName(fields[3]), 256);
|
|
108
|
+
if (!id)
|
|
109
|
+
continue;
|
|
110
|
+
const txt = avahiTxt(fields[9]);
|
|
111
|
+
if (!txt)
|
|
112
|
+
continue;
|
|
113
|
+
const values = txtRecordMap(txt);
|
|
114
|
+
const sf = Number.parseInt((values.get('sf') ?? '0').replace(/^0x/i, ''), 16) || 0;
|
|
115
|
+
const device = {
|
|
116
|
+
id, name: displayNameForRaopInstance(id), host, port, txt,
|
|
117
|
+
local: local.has(host),
|
|
118
|
+
requiresPassword: values.get('pw')?.toLowerCase() === 'true' || (sf !== 0 && sf !== 4),
|
|
119
|
+
airplay2: Boolean(values.get('pk') || values.get('vv') === '2' || values.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
|
|
120
|
+
};
|
|
121
|
+
const prior = devices.get(id);
|
|
122
|
+
if (!prior || (isLoopbackAddress(prior.host) && !isLoopbackAddress(host)))
|
|
123
|
+
devices.set(id, device);
|
|
124
|
+
}
|
|
125
|
+
return [...devices.values()].slice(0, Math.min(defaultMaxDevices, Math.max(0, Math.floor(maxDevices))));
|
|
126
|
+
}
|
|
127
|
+
function avahiFields(line) {
|
|
128
|
+
const fields = [];
|
|
129
|
+
let start = 0;
|
|
130
|
+
for (let index = 0; index < line.length && fields.length < 9; index += 1) {
|
|
131
|
+
if (line[index] === '\\') {
|
|
132
|
+
index += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (line[index] === ';') {
|
|
136
|
+
fields.push(line.slice(start, index));
|
|
137
|
+
start = index + 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
fields.push(line.slice(start));
|
|
141
|
+
return fields;
|
|
142
|
+
}
|
|
143
|
+
function avahiTxt(value) {
|
|
144
|
+
const entries = [];
|
|
145
|
+
let index = 0;
|
|
146
|
+
while (index < value.length) {
|
|
147
|
+
while (/\s/.test(value[index] ?? '') && index < value.length)
|
|
148
|
+
index += 1;
|
|
149
|
+
if (index === value.length)
|
|
150
|
+
break;
|
|
151
|
+
if (value[index] !== '"' || entries.length === 64)
|
|
152
|
+
return null;
|
|
153
|
+
const start = ++index;
|
|
154
|
+
while (index < value.length && value[index] !== '"') {
|
|
155
|
+
if (value[index] === '\\')
|
|
156
|
+
index += 1;
|
|
157
|
+
index += 1;
|
|
158
|
+
}
|
|
159
|
+
if (index === value.length)
|
|
160
|
+
return null;
|
|
161
|
+
const entry = sanitizeDnsSdText(unescapeDnsSdName(value.slice(start, index)), 512);
|
|
162
|
+
if (entry)
|
|
163
|
+
entries.push(entry);
|
|
164
|
+
index += 1;
|
|
165
|
+
if (index < value.length && !/\s/.test(value[index]))
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return entries;
|
|
169
|
+
}
|
|
65
170
|
export function parseRaopLookupOutput(instance, output) {
|
|
66
171
|
const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/);
|
|
67
172
|
if (!reachable?.[1] || !reachable[2]) {
|
|
@@ -76,20 +181,20 @@ export function parseRaopLookupOutput(instance, output) {
|
|
|
76
181
|
const txtLine = output
|
|
77
182
|
.split('\n')
|
|
78
183
|
.map(line => line.trim())
|
|
79
|
-
.find(line => /\
|
|
184
|
+
.find(line => /\b(?:cn|sf|pk|pw)=/.test(line));
|
|
80
185
|
const txt = txtLine
|
|
81
186
|
? txtLine.split(/\s+/).map(entry => sanitizeDnsSdText(entry, 512)).filter(Boolean).slice(0, 64)
|
|
82
187
|
: [];
|
|
83
188
|
const txtMap = txtRecordMap(txt);
|
|
84
189
|
const sf = txtMap.get('sf');
|
|
85
|
-
const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0;
|
|
190
|
+
const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) || 0 : 0;
|
|
86
191
|
return {
|
|
87
192
|
id,
|
|
88
193
|
name: displayNameForRaopInstance(id),
|
|
89
194
|
host,
|
|
90
195
|
port,
|
|
91
196
|
txt,
|
|
92
|
-
requiresPassword: sfValue !== 0 && sfValue !== 0x4,
|
|
197
|
+
requiresPassword: txtMap.get('pw')?.toLowerCase() === 'true' || (sfValue !== 0 && sfValue !== 0x4),
|
|
93
198
|
airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
|
|
94
199
|
};
|
|
95
200
|
}
|
|
@@ -109,7 +214,32 @@ function txtRecordMap(txt) {
|
|
|
109
214
|
return map;
|
|
110
215
|
}
|
|
111
216
|
function unescapeDnsSdName(value) {
|
|
112
|
-
|
|
217
|
+
const bytes = [];
|
|
218
|
+
for (let index = 0; index < value.length;) {
|
|
219
|
+
const match = /^\\(\d{3})/.exec(value.slice(index));
|
|
220
|
+
if (match) {
|
|
221
|
+
const byte = Number(match[1]);
|
|
222
|
+
if (byte > 255)
|
|
223
|
+
return '';
|
|
224
|
+
bytes.push(byte);
|
|
225
|
+
index += 4;
|
|
226
|
+
}
|
|
227
|
+
else if (value[index] === '\\' && index + 1 < value.length) {
|
|
228
|
+
bytes.push(...Buffer.from(value[index + 1]));
|
|
229
|
+
index += 2;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const character = String.fromCodePoint(value.codePointAt(index));
|
|
233
|
+
bytes.push(...Buffer.from(character));
|
|
234
|
+
index += character.length;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
113
243
|
}
|
|
114
244
|
function normalizeHost(value) {
|
|
115
245
|
const host = sanitizeDnsSdText(unescapeDnsSdName(value), 253).replace(/\.$/, '');
|
|
@@ -126,53 +256,6 @@ function sanitizeDnsSdText(value, maxBytes) {
|
|
|
126
256
|
}
|
|
127
257
|
return cleaned.trim();
|
|
128
258
|
}
|
|
129
|
-
function runDnsSd(args, timeoutMs, maxOutputBytes) {
|
|
130
|
-
return new Promise(resolve => {
|
|
131
|
-
const child = spawn('dns-sd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
132
|
-
let output = '';
|
|
133
|
-
let killedForLimit = false;
|
|
134
|
-
let settled = false;
|
|
135
|
-
const finish = () => {
|
|
136
|
-
if (settled)
|
|
137
|
-
return;
|
|
138
|
-
settled = true;
|
|
139
|
-
clearTimeout(timer);
|
|
140
|
-
clearTimeout(forceTimer);
|
|
141
|
-
resolve(output);
|
|
142
|
-
};
|
|
143
|
-
const timer = setTimeout(() => {
|
|
144
|
-
child.kill('SIGTERM');
|
|
145
|
-
setTimeout(() => {
|
|
146
|
-
if (child.exitCode === null)
|
|
147
|
-
child.kill('SIGKILL');
|
|
148
|
-
}, 250).unref();
|
|
149
|
-
}, timeoutMs);
|
|
150
|
-
const forceTimer = setTimeout(finish, timeoutMs + 750);
|
|
151
|
-
const appendOutput = (chunk) => {
|
|
152
|
-
if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) {
|
|
153
|
-
if (!killedForLimit) {
|
|
154
|
-
killedForLimit = true;
|
|
155
|
-
child.kill('SIGTERM');
|
|
156
|
-
}
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const remaining = maxOutputBytes - Buffer.byteLength(output, 'utf8');
|
|
160
|
-
output += chunk.toString('utf8').slice(0, remaining);
|
|
161
|
-
};
|
|
162
|
-
child.stdout.on('data', chunk => {
|
|
163
|
-
appendOutput(chunk);
|
|
164
|
-
});
|
|
165
|
-
child.stderr.on('data', chunk => {
|
|
166
|
-
appendOutput(chunk);
|
|
167
|
-
});
|
|
168
|
-
child.once('error', () => {
|
|
169
|
-
finish();
|
|
170
|
-
});
|
|
171
|
-
child.once('exit', () => {
|
|
172
|
-
finish();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
259
|
async function mapWithConcurrency(items, concurrency, map) {
|
|
177
260
|
const results = [];
|
|
178
261
|
let nextIndex = 0;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
2
|
+
const airPlayUnsupportedMessage = 'AirPlay output is experimental on macOS and native Linux and is not supported in this environment.';
|
|
3
|
+
const linuxAirPlayDisabledMessage = 'Experimental Linux AirPlay is disabled for this library. Run radiocli setup --airplay to enable it.';
|
|
4
|
+
export const airPlaySetupRequiredMessage = 'AirPlay setup required. Run radiocli setup --airplay, then radiocli doctor.';
|
|
5
|
+
export function isAirPlayPlatformSupported(platform = process.platform, env, release) {
|
|
6
|
+
const native = platform === process.platform;
|
|
7
|
+
return nativeAdapters(identifyPlatform({ platform, env: env ?? (native ? process.env : {}), release: release ?? (native ? undefined : '') })).airPlay;
|
|
8
|
+
}
|
|
9
|
+
/** Application consent is independent of optional dependencies and receiver reachability. */
|
|
10
|
+
export function airPlayPolicy(settings = {}, { platform = process.platform, env, release } = {}) {
|
|
11
|
+
const eligible = isAirPlayPlatformSupported(platform, env, release);
|
|
12
|
+
const enabled = eligible && (platform === 'darwin' || settings.linuxAirPlayEnabled === true);
|
|
13
|
+
const state = !eligible ? 'unsupported' : !enabled ? 'disabled' : 'enabled';
|
|
14
|
+
return { eligible, enabled, state, message: !eligible ? airPlayUnsupportedMessage : !enabled ? linuxAirPlayDisabledMessage : 'Experimental AirPlay is enabled.' };
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Short, bounded probes. Only stdout is returned; protocol diagnostics stay private. */
|
|
3
|
+
export const runAirPlayCommand = (command, args, timeoutMs, maxOutputBytes, options) => new Promise(resolve => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
let totalBytes = 0;
|
|
7
|
+
let settled = false;
|
|
8
|
+
let child;
|
|
9
|
+
const finish = (code, timedOut = false, truncated = false) => {
|
|
10
|
+
if (settled)
|
|
11
|
+
return;
|
|
12
|
+
settled = true;
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
resolve({ output: Buffer.concat(chunks, bytes).toString('utf8'), code, timedOut, truncated });
|
|
15
|
+
};
|
|
16
|
+
const stop = () => {
|
|
17
|
+
if (settled || !child)
|
|
18
|
+
return;
|
|
19
|
+
// A dedicated POSIX process group includes ordinary helper descendants.
|
|
20
|
+
// Closing inherited pipes and releasing the child handle also bounds the
|
|
21
|
+
// parent on Windows, or if a descendant escaped into a separate session.
|
|
22
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
25
|
+
}
|
|
26
|
+
catch { /* The group may already have exited. */ }
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
child.kill('SIGKILL');
|
|
30
|
+
}
|
|
31
|
+
catch { /* Still release our handles if termination fails. */ }
|
|
32
|
+
child.stdout?.destroy();
|
|
33
|
+
child.stderr?.destroy();
|
|
34
|
+
child.unref();
|
|
35
|
+
};
|
|
36
|
+
const timer = setTimeout(() => { stop(); finish(null, true); }, Math.max(1, timeoutMs));
|
|
37
|
+
try {
|
|
38
|
+
child = spawn(command, args, { detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...(options?.env ?? process.env), LC_ALL: 'C' } });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
finish(null);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const append = (chunk, stdout) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
const remaining = Math.max(0, maxOutputBytes - totalBytes);
|
|
48
|
+
totalBytes += chunk.length;
|
|
49
|
+
if (stdout && remaining > 0) {
|
|
50
|
+
const kept = chunk.subarray(0, remaining);
|
|
51
|
+
chunks.push(kept);
|
|
52
|
+
bytes += kept.length;
|
|
53
|
+
}
|
|
54
|
+
if (totalBytes > maxOutputBytes) {
|
|
55
|
+
stop();
|
|
56
|
+
finish(null, false, true);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
child.stdout?.on('data', (chunk) => append(chunk, true));
|
|
60
|
+
child.stderr?.on('data', (chunk) => append(chunk, false));
|
|
61
|
+
child.once('error', () => { stop(); finish(null); });
|
|
62
|
+
child.once('close', code => finish(code));
|
|
63
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
2
|
+
import { sessionBusSocket } from '../platform/mpris.js';
|
|
3
|
+
import { isAirPlayPlatformSupported } from './backend-install.js';
|
|
4
|
+
import { runAirPlayCommand } from './airplay-process.js';
|
|
5
|
+
import { probeAirPlaySender } from './airplay-sender-health.js';
|
|
6
|
+
/** Does not browse the network, activate services, or open a receiver. */
|
|
7
|
+
export async function probeAirPlayReadiness({ platform = process.platform, env = process.env, release, resolve = command => resolveCommandDetails(command, { platform, env }).path ?? undefined, hasCommand = command => Boolean(resolve(command)), run = runAirPlayCommand, sender = probeAirPlaySender } = {}) {
|
|
8
|
+
const eligible = isAirPlayPlatformSupported(platform, env, release);
|
|
9
|
+
const discoveryExecutable = eligible && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd');
|
|
10
|
+
const ffmpeg = eligible && hasCommand('ffmpeg');
|
|
11
|
+
const busctl = eligible && platform === 'linux' && hasCommand('busctl') ? resolve('busctl') : undefined;
|
|
12
|
+
let discoveryService = 'unverified';
|
|
13
|
+
const senderResult = eligible ? sender() : Promise.resolve({ loaded: false, message: 'AirPlay is not supported in this environment.' });
|
|
14
|
+
if (!discoveryExecutable)
|
|
15
|
+
discoveryService = 'unavailable';
|
|
16
|
+
else if (platform === 'linux' && busctl) {
|
|
17
|
+
const socket = sessionBusSocket(env.DBUS_SYSTEM_BUS_ADDRESS ?? 'unix:path=/run/dbus/system_bus_socket');
|
|
18
|
+
if (!socket)
|
|
19
|
+
discoveryService = 'unavailable';
|
|
20
|
+
else {
|
|
21
|
+
// --system still honors executable/network transport overrides. Pass only
|
|
22
|
+
// one validated Unix address and remove all inherited D-Bus fallbacks.
|
|
23
|
+
const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`;
|
|
24
|
+
const childEnv = { ...env };
|
|
25
|
+
for (const key of Object.keys(childEnv))
|
|
26
|
+
if (key.startsWith('DBUS_'))
|
|
27
|
+
delete childEnv[key];
|
|
28
|
+
const result = await run(busctl, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], 600, 4096, { env: childEnv });
|
|
29
|
+
discoveryService = result.code === 0 && /^i\s+2\s*$/.test(result.output.trim()) && !result.truncated && !result.timedOut ? 'ready' : 'unavailable';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const senderLoaded = (await senderResult).loaded;
|
|
33
|
+
const ready = eligible && discoveryExecutable && ffmpeg && senderLoaded && discoveryService !== 'unavailable';
|
|
34
|
+
const message = !eligible ? 'AirPlay is experimental on macOS and native Linux; this environment is not supported.'
|
|
35
|
+
: !discoveryExecutable ? `AirPlay discovery requires ${platform === 'linux' ? 'avahi-browse' : 'dns-sd'}.`
|
|
36
|
+
: discoveryService === 'unavailable' ? 'The Avahi discovery service is not ready. Local playback remains available.'
|
|
37
|
+
: !ffmpeg ? 'AirPlay playback requires FFmpeg.'
|
|
38
|
+
: !senderLoaded ? 'The optional AirPlay sender could not load. Local playback remains available.'
|
|
39
|
+
: discoveryService === 'unverified' ? 'AirPlay prerequisites are present; the discovery service and receiver connection must still be verified.'
|
|
40
|
+
: 'Experimental AirPlay prerequisites are ready; select a receiver to verify playback.';
|
|
41
|
+
return { eligible, discoveryExecutable, discoveryService, senderLoaded, ffmpeg, ready, message };
|
|
42
|
+
}
|
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
+
import { runAirPlayCommand } from './airplay-process.js';
|
|
5
|
+
import { airPlaySenderPackageJson } from './airplay-sender-package.js';
|
|
4
6
|
const require = createRequire(import.meta.url);
|
|
5
7
|
const airPlaySenderPackageName = 'node-airtunes2';
|
|
8
|
+
let senderProbe;
|
|
9
|
+
/** Explicit diagnostics/discovery only: loading native code stays outside the TUI. */
|
|
10
|
+
export async function probeAirPlaySender(options = {}) {
|
|
11
|
+
const health = options.health ?? airPlaySenderHealth();
|
|
12
|
+
if (!health.safe)
|
|
13
|
+
return { loaded: false, message: health.message };
|
|
14
|
+
const key = `${process.execPath}:${health.packageName}:${health.version}`;
|
|
15
|
+
if (!options.run && senderProbe?.key === key && senderProbe.until > Date.now())
|
|
16
|
+
return senderProbe.result;
|
|
17
|
+
const result = (async () => {
|
|
18
|
+
const code = [
|
|
19
|
+
"const r = require('node:module').createRequire(process.argv[1]);",
|
|
20
|
+
"try { const s = r('./');",
|
|
21
|
+
"if (typeof s !== 'function' || typeof s.prototype.add !== 'function' || typeof s.prototype.stopAll !== 'function') process.exit(1);",
|
|
22
|
+
// The Linux binding is otherwise loaded only when constructing a receiver.
|
|
23
|
+
// Match the sender's JS fallback on a normal require failure, but contain
|
|
24
|
+
// native crashes here before advertising a loadable sender. No sockets.
|
|
25
|
+
"if (process.platform === 'linux') { try { r('./build/Release/airtunes'); } catch {} }",
|
|
26
|
+
'process.exit(0); } catch { process.exit(1); }'
|
|
27
|
+
].join('\n');
|
|
28
|
+
const probe = await (options.run ?? runAirPlayCommand)(process.execPath, ['-e', code, airPlaySenderPackageJson], Math.min(1500, Math.max(1, options.timeoutMs ?? 1500)), 4096);
|
|
29
|
+
const loaded = probe.code === 0 && !probe.timedOut && !probe.truncated;
|
|
30
|
+
return { loaded, message: loaded ? 'The optional AirPlay sender loaded in an isolated process.' : 'The optional AirPlay sender could not load. Local playback remains available; run radiocli doctor.' };
|
|
31
|
+
})();
|
|
32
|
+
if (!options.run)
|
|
33
|
+
senderProbe = { key, until: Date.now() + 30_000, result };
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
6
36
|
export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
7
37
|
const sender = lookupPackage(airPlaySenderPackageName);
|
|
8
38
|
if (!sender) {
|
|
@@ -15,17 +45,21 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
15
45
|
warningPackages: []
|
|
16
46
|
};
|
|
17
47
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
const missingPackages = (sender.dependencies ?? []).filter(name => !lookupPackage(name, [sender.root]));
|
|
49
|
+
if (missingPackages.length > 0) {
|
|
50
|
+
return { available: false, safe: false, packageName: sender.name, version: sender.version,
|
|
51
|
+
message: `Optional AirPlay sender dependencies are not installed: ${missingPackages.join(', ')}. Local playback remains available.`,
|
|
52
|
+
vulnerablePackages: [], warningPackages: [] };
|
|
53
|
+
}
|
|
54
|
+
if (!sender.nativeEd25519) {
|
|
21
55
|
return {
|
|
22
56
|
available: true,
|
|
23
57
|
safe: false,
|
|
24
58
|
packageName: sender.name,
|
|
25
59
|
version: sender.version,
|
|
26
|
-
message: `
|
|
27
|
-
vulnerablePackages,
|
|
28
|
-
warningPackages
|
|
60
|
+
message: `AirPlay sender ${sender.name}@${sender.version} is missing the native Ed25519 security fix.`,
|
|
61
|
+
vulnerablePackages: [`${sender.name}@${sender.version}`],
|
|
62
|
+
warningPackages: []
|
|
29
63
|
};
|
|
30
64
|
}
|
|
31
65
|
return {
|
|
@@ -33,34 +67,18 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
33
67
|
safe: true,
|
|
34
68
|
packageName: sender.name,
|
|
35
69
|
version: sender.version,
|
|
36
|
-
message:
|
|
37
|
-
? `Bundled AirPlay sender ${sender.name}@${sender.version} is available with warnings: ${warningPackages.join(', ')}.`
|
|
38
|
-
: `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`,
|
|
70
|
+
message: `AirPlay sender ${sender.name}@${sender.version} uses native Ed25519 with its optional runtime dependencies installed.`,
|
|
39
71
|
vulnerablePackages: [],
|
|
40
|
-
warningPackages
|
|
72
|
+
warningPackages: []
|
|
41
73
|
};
|
|
42
74
|
}
|
|
43
|
-
function blockingAirPlaySenderPackages(sender, lookupPackage) {
|
|
44
|
-
const packagePaths = [sender.root];
|
|
45
|
-
const vulnerable = [];
|
|
46
|
-
const protobuf = lookupPackage('protobufjs', packagePaths);
|
|
47
|
-
if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
|
|
48
|
-
vulnerable.push(`protobufjs@${protobuf.version}`);
|
|
49
|
-
}
|
|
50
|
-
return vulnerable;
|
|
51
|
-
}
|
|
52
|
-
function warningAirPlaySenderPackages(sender, lookupPackage) {
|
|
53
|
-
const packagePaths = [sender.root];
|
|
54
|
-
const vulnerable = [];
|
|
55
|
-
const elliptic = lookupPackage('elliptic', packagePaths);
|
|
56
|
-
if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
|
|
57
|
-
vulnerable.push(`elliptic@${elliptic.version}`);
|
|
58
|
-
}
|
|
59
|
-
return vulnerable;
|
|
60
|
-
}
|
|
61
75
|
function lookupInstalledPackage(packageName, paths) {
|
|
62
76
|
try {
|
|
63
|
-
|
|
77
|
+
// Package exports can hide package.json while exposing the runtime entry.
|
|
78
|
+
if (paths && packageName !== airPlaySenderPackageName) {
|
|
79
|
+
return { name: packageName, version: 'installed', root: dirname(require.resolve(packageName, { paths })) };
|
|
80
|
+
}
|
|
81
|
+
const packageJsonPath = packageName === airPlaySenderPackageName ? airPlaySenderPackageJson : paths
|
|
64
82
|
? require.resolve(`${packageName}/package.json`, { paths })
|
|
65
83
|
: require.resolve(`${packageName}/package.json`);
|
|
66
84
|
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
@@ -70,29 +88,11 @@ function lookupInstalledPackage(packageName, paths) {
|
|
|
70
88
|
return {
|
|
71
89
|
name: parsed.name ?? packageName,
|
|
72
90
|
version: parsed.version,
|
|
73
|
-
root: dirname(packageJsonPath)
|
|
91
|
+
root: dirname(packageJsonPath),
|
|
92
|
+
...(packageName === airPlaySenderPackageName ? { nativeEd25519: parsed.radiocliLegacyPairing === 'node:crypto', dependencies: Object.keys(parsed.dependencies ?? {}) } : {})
|
|
74
93
|
};
|
|
75
94
|
}
|
|
76
95
|
catch {
|
|
77
96
|
return null;
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
|
-
function compareSemver(left, right) {
|
|
81
|
-
const leftParts = semverParts(left);
|
|
82
|
-
const rightParts = semverParts(right);
|
|
83
|
-
for (let index = 0; index < 3; index += 1) {
|
|
84
|
-
const delta = leftParts[index] - rightParts[index];
|
|
85
|
-
if (delta !== 0) {
|
|
86
|
-
return delta;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return 0;
|
|
90
|
-
}
|
|
91
|
-
function semverParts(version) {
|
|
92
|
-
const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/);
|
|
93
|
-
return [
|
|
94
|
-
Number.parseInt(major, 10) || 0,
|
|
95
|
-
Number.parseInt(minor, 10) || 0,
|
|
96
|
-
Number.parseInt(patch, 10) || 0
|
|
97
|
-
];
|
|
98
|
-
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// The same relative location works from src/player and the packaged dist/player.
|
|
4
|
+
export const airPlaySenderPackageJson = fileURLToPath(new URL('../../vendor/node-airtunes2/package.json', import.meta.url));
|
|
5
|
+
export const airPlaySenderRoot = dirname(airPlaySenderPackageJson);
|