@ciphore/radiocli 0.2.3 → 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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  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 +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,63 @@
1
+ /** Environment overrides affect this invocation, never the saved settings. */
2
+ export function resolveTerminalCapabilities(env = process.env, evidence = {}) {
3
+ const dumb = env.TERM?.toLowerCase() === 'dumb';
4
+ const screenReader = evidence.screenReader === true || env.INK_SCREEN_READER === 'true' || flag(env.RADIOCLI_SCREEN_READER) === true;
5
+ const interactive = evidence.isTTY !== false && !dumb;
6
+ const asciiOverride = flag(env.RADIOCLI_ASCII);
7
+ const unicodeOverride = flag(env.RADIOCLI_UNICODE);
8
+ const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(value => value?.trim())?.trim();
9
+ // An explicit ASCII request wins when both overrides are enabled. A bare
10
+ // C/POSIX locale is a known constraint; SSH and missing locale data are not.
11
+ const unicode = dumb ? false
12
+ : asciiOverride === true ? false
13
+ : unicodeOverride === true ? true
14
+ : asciiOverride === false ? true
15
+ : unicodeOverride === false ? false
16
+ : locale === 'C' || locale === 'POSIX' ? false : !evidence.asciiMode;
17
+ const colorLevel = dumb || screenReader || Boolean(env.NO_COLOR) ? 0 : resolveColorLevel(env, evidence);
18
+ return {
19
+ unicode,
20
+ colorLevel,
21
+ screenReader,
22
+ reduceMotion: Boolean(evidence.reduceMotion) || !interactive || screenReader || env.RADIOCLI_DISABLE_ANIMATION === '1' || env.RADIO_ATLAS_DISABLE_ANIMATION === '1',
23
+ interactive
24
+ };
25
+ }
26
+ function resolveColorLevel(env, evidence) {
27
+ const forced = env.FORCE_COLOR;
28
+ if (forced === '0' || forced === 'false')
29
+ return 0;
30
+ if (forced === '2')
31
+ return 2;
32
+ if (forced === '3')
33
+ return 3;
34
+ if (forced === '' || forced === '1' || forced === 'true')
35
+ return 1;
36
+ if (evidence.isTTY === false)
37
+ return 0;
38
+ // COLORTERM=truecolor/24bit and direct-color TERM values are explicit
39
+ // capability declarations. Some terminals expose those declarations while
40
+ // Node's getColorDepth() still reports 8 bits, so honor them before the
41
+ // conservative stream probe to avoid silently quantizing RGB themes.
42
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '') || /(?:direct|truecolor)/i.test(env.TERM ?? ''))
43
+ return 3;
44
+ if (evidence.colorDepth !== undefined) {
45
+ return evidence.colorDepth <= 1 ? 0 : evidence.colorDepth <= 4 ? 1 : evidence.colorDepth <= 8 ? 2 : 3;
46
+ }
47
+ if (/256color/i.test(env.TERM ?? ''))
48
+ return 2;
49
+ if (env.TERM)
50
+ return 1;
51
+ // Preserve the existing rich display unless the environment supplies a
52
+ // concrete constraint. Native callers can pass stdout.getColorDepth().
53
+ return 3;
54
+ }
55
+ function flag(value) {
56
+ if (value === undefined)
57
+ return undefined;
58
+ if (/^(1|true|yes|on)$/i.test(value))
59
+ return true;
60
+ if (/^(0|false|no|off)$/i.test(value))
61
+ return false;
62
+ return undefined;
63
+ }
@@ -0,0 +1,223 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { posix, win32 } from 'node:path';
3
+ import { resolveCommandDetails } from './executables.js';
4
+ import { identifyPlatform, nativeAdapters } from './runtime.js';
5
+ import { powershellCommand } from './shell.js';
6
+ import { launchEnvironment, nodeLaunchCommand, waitForLaunch } from './launch-command.js';
7
+ import { identifyOmarchy } from './omarchy.js';
8
+ const unixTerminals = [
9
+ { name: 'ghostty', args: ['-e'] }, { name: 'wezterm', args: ['start', '--always-new-process', '--'] },
10
+ { name: 'kitty', args: ['-e'] }, { name: 'gnome-terminal', args: ['--'] }, { name: 'konsole', args: ['-e'] },
11
+ { name: 'xfce4-terminal', args: ['-x'] }, { name: 'x-terminal-emulator', args: ['-e'], shell: true },
12
+ { name: 'alacritty', args: ['-e'] }, { name: 'foot', args: ['--'], display: 'wayland' },
13
+ { name: 'mate-terminal', args: ['-x'] }, { name: 'qterminal', args: ['-e'], shell: true },
14
+ { name: 'terminator', args: ['-x'] }, { name: 'tilix', args: ['-e'] },
15
+ { name: 'xterm', args: ['-e'], display: 'x11' }, { name: 'uxterm', args: ['-e'], display: 'x11' }
16
+ ];
17
+ const macTerminals = ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'];
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);
21
+ const host = identifyPlatform({ platform, env });
22
+ const adapter = nativeAdapters(host).terminal;
23
+ const unsupported = `${host.id === 'unknown' ? platform : host.id}:unsupported`;
24
+ const configured = env.RADIOCLI_ALARM_TERMINAL?.trim();
25
+ if (adapter === 'macos') {
26
+ if (configured && macTerminals.includes(configured))
27
+ return configured;
28
+ const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase();
29
+ for (const name of ['iterm', 'wezterm', 'ghostty', 'kitty'])
30
+ if (program.includes(name))
31
+ return `darwin:${name}`;
32
+ return 'darwin:apple-terminal';
33
+ }
34
+ if (adapter === 'windows') {
35
+ if (configured === 'win32:windows-terminal' || configured === 'win32:console')
36
+ return configured;
37
+ return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console';
38
+ }
39
+ if (adapter !== 'unix' || !hasDisplay(env))
40
+ return unsupported;
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) {
47
+ const separator = configured.indexOf(':');
48
+ const prefix = separator < 0 ? '' : configured.slice(0, separator);
49
+ if (prefix === host.id || prefix === 'linux') {
50
+ const path = resolveUnixTerminal(configured.slice(separator + 1), env, resolve);
51
+ return path ? `${prefix}:${path}` : unsupported;
52
+ }
53
+ }
54
+ const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim();
55
+ const selected = requested && resolveUnixTerminal(requested, env, resolve);
56
+ if (selected)
57
+ return `${host.id}:${selected}`;
58
+ if (nativeOmarchy())
59
+ return omarchyTerminal;
60
+ for (const terminal of unixTerminals) {
61
+ const path = resolveUnixTerminal(terminal.name, env, resolve);
62
+ if (path)
63
+ return `${host.id}:${path}`;
64
+ }
65
+ return unsupported;
66
+ }
67
+ export function createTerminalLaunch(options) {
68
+ const platform = options.platform ?? process.platform;
69
+ const env = options.env ?? process.env;
70
+ const resolve = options.resolve ?? resolver(platform, env);
71
+ const terminal = detectGraphicalTerminal(platform, env, options.resolve, options.omarchy);
72
+ const values = [options.nodePath, ...options.args];
73
+ if (values.some(value => value.includes('\0')))
74
+ throw new Error('Terminal command values cannot contain NUL bytes.');
75
+ const environment = launchEnvironment(env, { platform });
76
+ const direct = nodeLaunchCommand(options.nodePath, options.args, environment);
77
+ if (terminal.endsWith(':unsupported')) {
78
+ if (nativeAdapters(identifyPlatform({ platform, env })).terminal === 'unix' && !hasDisplay(env))
79
+ throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.');
80
+ throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.');
81
+ }
82
+ if (terminal === 'darwin:apple-terminal' || terminal === 'darwin:iterm') {
83
+ const command = `${direct.map(shellQuote).join(' ')}${options.closeOnExit ? '; exit' : ''}`;
84
+ return { terminal, command: '/usr/bin/osascript', args: terminal === 'darwin:apple-terminal' ? appleTerminalScript(command) : iTermScript(command) };
85
+ }
86
+ if (terminal === 'darwin:wezterm')
87
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct] };
88
+ if (terminal === 'darwin:ghostty')
89
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'Ghostty', '--args', '-e', ...direct] };
90
+ if (terminal === 'darwin:kitty')
91
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'kitty', '--args', '--detach', ...direct] };
92
+ if (terminal.startsWith('win32:')) {
93
+ const systemRoot = env.SystemRoot ?? env.WINDIR;
94
+ const powershell = resolve('powershell.exe') ?? (systemRoot ? resolve(win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) : undefined) ?? resolve('pwsh.exe');
95
+ if (!powershell)
96
+ throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.');
97
+ // PowerShell 5 does not preserve arbitrary native argv quoting. Only a
98
+ // fixed quote-free Node program and encoded JSON cross that boundary.
99
+ const args = powershellCommand(direct, {}, { keepOpen: !options.closeOnExit });
100
+ if (terminal === 'win32:console')
101
+ return { terminal, command: powershell, ...newWindowsConsole(powershell, args) };
102
+ const wt = resolve('wt.exe') ?? (env.LOCALAPPDATA ? resolve(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'wt.exe')) : undefined);
103
+ if (!wt)
104
+ throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.');
105
+ return { terminal, command: wt, args: ['-w', 'new', 'new-tab', '--title', options.title ?? 'RadioCLI', powershell, ...args] };
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
+ }
115
+ const executable = terminal.slice(terminal.indexOf(':') + 1);
116
+ const spec = unixTerminals.find(item => item.name === posix.basename(executable));
117
+ if (!spec)
118
+ throw new Error('The saved graphical terminal is unsupported.');
119
+ // QTerminal reparses only its first -e argument, then appends argv literally.
120
+ // The alternatives alias can select QTerminal too, so both use the fixed
121
+ // POSIX shell boundary. This also preserves argv for ordinary -e terminals.
122
+ if (spec.shell) {
123
+ const sh = resolve('/bin/sh');
124
+ if (!sh)
125
+ throw new Error('The POSIX shell required by this terminal is unavailable.');
126
+ return { terminal, command: executable, args: [...spec.args, sh, '-c', direct.map(shellQuote).join(' ')] };
127
+ }
128
+ return { terminal, command: executable, args: [...spec.args, ...direct] };
129
+ }
130
+ /** Request acceptance is separate from the caller's TUI/session verification. */
131
+ export async function launchTerminalCommand(options) {
132
+ const plan = createTerminalLaunch(options);
133
+ // PowerShell can exit without executing its script under DETACHED_PROCESS.
134
+ // Keep this short-lived bootstrap attached until it creates the independent
135
+ // console. The new console, not its bootstrap, owns the interactive handles.
136
+ // https://github.com/nodejs/node/issues/51018
137
+ const consoleBootstrap = plan.terminal === 'win32:console';
138
+ const child = (options.spawn ?? spawn)(plan.command, plan.args, { env: { ...(options.env ?? process.env), ...plan.environment }, detached: !consoleBootstrap, stdio: 'ignore', windowsHide: 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 });
142
+ return plan.terminal;
143
+ }
144
+ function resolver(platform, env) { return command => resolveCommandDetails(command, { platform, env }).path ?? undefined; }
145
+ function hasDisplay(env) { return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); }
146
+ function resolveUnixTerminal(input, env, resolve) {
147
+ const spec = unixTerminals.find(item => item.name === posix.basename(input).toLowerCase());
148
+ if (!spec || spec.display === 'x11' && !env.DISPLAY?.trim() || spec.display === 'wayland' && !env.WAYLAND_DISPLAY?.trim())
149
+ return undefined;
150
+ const path = resolve(input);
151
+ return path && unixTerminals.some(item => item.name === posix.basename(path)) ? path : undefined;
152
+ }
153
+ function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
154
+ function newWindowsConsole(powershell, args) {
155
+ const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND';
156
+ // The transient launcher has NUL stdio. CreateProcessW explicitly creates
157
+ // new console buffers without inheriting those handles; STARTF_USESTDHANDLES
158
+ // must stay unset. ShellExecute-based Start-Process does not provide this
159
+ // handle contract. Only fixed flags and encoded data enter the argument string.
160
+ // https://learn.microsoft.com/en-us/windows/console/creation-of-a-console
161
+ // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
162
+ const native = String.raw `
163
+ using System;
164
+ using System.ComponentModel;
165
+ using System.Runtime.InteropServices;
166
+ using System.Text;
167
+ public static class RadioCliConsole {
168
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
169
+ private struct StartupInfo {
170
+ public uint cb;
171
+ public string lpReserved, lpDesktop, lpTitle;
172
+ public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
173
+ public short wShowWindow, cbReserved2;
174
+ public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
175
+ }
176
+ [StructLayout(LayoutKind.Sequential)]
177
+ private struct ProcessInformation {
178
+ public IntPtr hProcess, hThread;
179
+ public uint dwProcessId, dwThreadId;
180
+ }
181
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
182
+ [return: MarshalAs(UnmanagedType.Bool)]
183
+ private static extern bool CreateProcessW(string application, StringBuilder commandLine,
184
+ IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
185
+ uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startup,
186
+ out ProcessInformation process);
187
+ [DllImport("kernel32.dll", SetLastError = true)]
188
+ [return: MarshalAs(UnmanagedType.Bool)]
189
+ private static extern bool CloseHandle(IntPtr handle);
190
+ public static void Launch(string application, string arguments) {
191
+ var commandLine = new StringBuilder("\"" + application + "\" " + arguments);
192
+ if (commandLine.Length >= 32767) throw new ArgumentException("The Windows console command exceeds the native command-line limit.");
193
+ var startup = new StartupInfo();
194
+ startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo));
195
+ startup.dwFlags = 0x00000001; // STARTF_USESHOWWINDOW only; no inherited standard handles.
196
+ startup.wShowWindow = 1; // SW_SHOWNORMAL
197
+ ProcessInformation process;
198
+ if (!CreateProcessW(application, commandLine, IntPtr.Zero, IntPtr.Zero, false,
199
+ 0x00000010, IntPtr.Zero, null, ref startup, out process)) { // CREATE_NEW_CONSOLE
200
+ throw new Win32Exception(Marshal.GetLastWin32Error());
201
+ }
202
+ CloseHandle(process.hThread);
203
+ CloseHandle(process.hProcess);
204
+ }
205
+ }`;
206
+ const script = [
207
+ "$ErrorActionPreference='Stop'",
208
+ "$ProgressPreference='SilentlyContinue'",
209
+ `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`,
210
+ `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`,
211
+ `Add-Type -TypeDefinition '${native.replaceAll("'", "''")}'`,
212
+ "[RadioCliConsole]::Launch([string]$radiocliConsole.command, [string]::Join(' ', [string[]]$radiocliConsole.args))"
213
+ ].join(';');
214
+ // Only this short-lived outer process needs the handoff. Encoding the inner
215
+ // PowerShell command again exceeds Windows' command-line limit for ordinary
216
+ // deep install paths. Clear the private variable before creating the console.
217
+ return {
218
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')],
219
+ environment: { [key]: JSON.stringify({ command: powershell, args }) }
220
+ };
221
+ }
222
+ function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
223
+ function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
@@ -1,26 +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 './command.js';
4
+ import { resolveCommandDetails } from '../platform/executables.js';
5
+ import { networkPolicy } from '../platform/network.js';
6
+ import { isAirPlayPlatformSupported } from './backend-install.js';
7
+ import { probeAirPlayReadiness } from './airplay-readiness.js';
8
+ import { runAirPlayCommand } from './airplay-process.js';
5
9
  const defaultTimeoutMs = 2500;
6
10
  const defaultMaxDevices = 12;
7
11
  const defaultLookupConcurrency = 4;
8
12
  const defaultMaxOutputBytes = 64 * 1024;
9
- export async function discoverAirPlayDevices({ platform = process.platform, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
10
- if (platform !== 'darwin' || !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)) {
11
15
  return [];
12
16
  }
13
- 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(() => '');
14
41
  const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices));
15
42
  const devices = await mapWithConcurrency(instances, Math.max(1, lookupConcurrency), async (instance) => {
16
- 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(() => '');
17
44
  const device = parseRaopLookupOutput(instance, lookupOutput);
18
- return device ? enrichAirPlayDeviceHost(device) : null;
45
+ return device ? enrichAirPlayDeviceHost(device, timeoutMs) : null;
19
46
  });
20
47
  return devices.filter((device) => Boolean(device));
21
48
  }
22
- async function enrichAirPlayDeviceHost(device) {
23
- 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));
24
55
  if (addresses.length === 0) {
25
56
  return device;
26
57
  }
@@ -60,6 +91,82 @@ export function parseRaopBrowseOutput(output) {
60
91
  }
61
92
  return [...instances];
62
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
+ }
63
170
  export function parseRaopLookupOutput(instance, output) {
64
171
  const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/);
65
172
  if (!reachable?.[1] || !reachable[2]) {
@@ -74,20 +181,20 @@ export function parseRaopLookupOutput(instance, output) {
74
181
  const txtLine = output
75
182
  .split('\n')
76
183
  .map(line => line.trim())
77
- .find(line => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(line));
184
+ .find(line => /\b(?:cn|sf|pk|pw)=/.test(line));
78
185
  const txt = txtLine
79
186
  ? txtLine.split(/\s+/).map(entry => sanitizeDnsSdText(entry, 512)).filter(Boolean).slice(0, 64)
80
187
  : [];
81
188
  const txtMap = txtRecordMap(txt);
82
189
  const sf = txtMap.get('sf');
83
- const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0;
190
+ const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) || 0 : 0;
84
191
  return {
85
192
  id,
86
193
  name: displayNameForRaopInstance(id),
87
194
  host,
88
195
  port,
89
196
  txt,
90
- requiresPassword: sfValue !== 0 && sfValue !== 0x4,
197
+ requiresPassword: txtMap.get('pw')?.toLowerCase() === 'true' || (sfValue !== 0 && sfValue !== 0x4),
91
198
  airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
92
199
  };
93
200
  }
@@ -107,7 +214,32 @@ function txtRecordMap(txt) {
107
214
  return map;
108
215
  }
109
216
  function unescapeDnsSdName(value) {
110
- 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
+ }
111
243
  }
112
244
  function normalizeHost(value) {
113
245
  const host = sanitizeDnsSdText(unescapeDnsSdName(value), 253).replace(/\.$/, '');
@@ -124,53 +256,6 @@ function sanitizeDnsSdText(value, maxBytes) {
124
256
  }
125
257
  return cleaned.trim();
126
258
  }
127
- function runDnsSd(args, timeoutMs, maxOutputBytes) {
128
- return new Promise(resolve => {
129
- const child = spawn('dns-sd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
130
- let output = '';
131
- let killedForLimit = false;
132
- let settled = false;
133
- const finish = () => {
134
- if (settled)
135
- return;
136
- settled = true;
137
- clearTimeout(timer);
138
- clearTimeout(forceTimer);
139
- resolve(output);
140
- };
141
- const timer = setTimeout(() => {
142
- child.kill('SIGTERM');
143
- setTimeout(() => {
144
- if (child.exitCode === null)
145
- child.kill('SIGKILL');
146
- }, 250).unref();
147
- }, timeoutMs);
148
- const forceTimer = setTimeout(finish, timeoutMs + 750);
149
- const appendOutput = (chunk) => {
150
- if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) {
151
- if (!killedForLimit) {
152
- killedForLimit = true;
153
- child.kill('SIGTERM');
154
- }
155
- return;
156
- }
157
- const remaining = maxOutputBytes - Buffer.byteLength(output, 'utf8');
158
- output += chunk.toString('utf8').slice(0, remaining);
159
- };
160
- child.stdout.on('data', chunk => {
161
- appendOutput(chunk);
162
- });
163
- child.stderr.on('data', chunk => {
164
- appendOutput(chunk);
165
- });
166
- child.once('error', () => {
167
- finish();
168
- });
169
- child.once('exit', () => {
170
- finish();
171
- });
172
- });
173
- }
174
259
  async function mapWithConcurrency(items, concurrency, map) {
175
260
  const results = [];
176
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
+ });