@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.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- 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
|
@@ -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 {
|
|
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
|
+
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 => /\
|
|
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
|
-
|
|
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
|
+
});
|