@ciphore/radiocli 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. 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
- export function detectGraphicalTerminal(platform = process.platform, env = process.env, resolve = resolver(platform, env)) {
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
- if (configured) {
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
- await waitForLaunch(child, { waitForExit: consoleBootstrap });
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 { spawn } from 'node:child_process';
1
+ import { isIP } from 'node:net';
2
2
  import { lookup } from 'node:dns/promises';
3
3
  import { networkInterfaces } from 'node:os';
4
- import { commandExists } from '../platform/executables.js';
4
+ import { resolveCommandDetails } from '../platform/executables.js';
5
5
  import { networkPolicy } from '../platform/network.js';
6
- import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
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 || !nativeAdapters(identifyPlatform({ platform })).airPlay || !commandExists('dns-sd')) {
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 browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
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 runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
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
- const addresses = await lookup(device.host, { all: true, family: 4 }).catch(() => []);
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 => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(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
- return value.replace(/\\(\d{3})/g, (_match, code) => String.fromCharCode(Number(code)));
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 vulnerablePackages = blockingAirPlaySenderPackages(sender, lookupPackage);
19
- const warningPackages = warningAirPlaySenderPackages(sender, lookupPackage);
20
- if (vulnerablePackages.length > 0) {
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: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`,
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: warningPackages.length > 0
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
- const packageJsonPath = paths
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);