@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -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 +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- 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 +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -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 +80 -56
- 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/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { identifyPlatform } from './runtime.js';
|
|
2
|
+
/** Command plans only. Callers must check the session and observe execution. */
|
|
3
|
+
export function browserCommands(host = identifyPlatform()) {
|
|
4
|
+
// termux-tools opens one URL through Android's VIEW intent.
|
|
5
|
+
if (host.id === 'termux')
|
|
6
|
+
return [{ command: 'termux-open-url', args: [] }];
|
|
7
|
+
if (host.id === 'darwin')
|
|
8
|
+
return [{ command: 'open', args: [] }];
|
|
9
|
+
if (host.id === 'win32')
|
|
10
|
+
return [{ command: 'explorer', args: [] }];
|
|
11
|
+
if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
|
|
12
|
+
return [{ command: 'xdg-open', args: [] }];
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
export function clipboardCandidates(host = identifyPlatform()) {
|
|
16
|
+
// No arguments makes termux-clipboard-set read stdin. Its matching Android
|
|
17
|
+
// API app and permissions are still required; the caller checks execution.
|
|
18
|
+
if (host.id === 'termux')
|
|
19
|
+
return [{ command: 'termux-clipboard-set', args: [] }];
|
|
20
|
+
if (host.id === 'darwin')
|
|
21
|
+
return [{ command: 'pbcopy', args: [] }];
|
|
22
|
+
if (host.id === 'win32')
|
|
23
|
+
return [{ command: 'clip', args: [] }];
|
|
24
|
+
if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
|
|
25
|
+
return [
|
|
26
|
+
{ command: 'wl-copy', args: [] },
|
|
27
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
28
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] }
|
|
29
|
+
];
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
export function hasGraphicalSession(host, env = process.env) {
|
|
33
|
+
if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux')
|
|
34
|
+
return !env.SSH_CONNECTION && !env.SSH_TTY;
|
|
35
|
+
return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
36
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { accessSync, constants,
|
|
2
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { posix, win32 } from 'node:path';
|
|
5
|
+
import { identifyPlatform } from './runtime.js';
|
|
5
6
|
export function commandExists(command) {
|
|
6
7
|
return resolveCommand(command) !== null;
|
|
7
8
|
}
|
|
@@ -12,8 +13,10 @@ export function resolveCommand(command) {
|
|
|
12
13
|
return resolveCommandDetails(command).path;
|
|
13
14
|
}
|
|
14
15
|
export function resolveCommandDetails(command, overrides) {
|
|
16
|
+
const variable = overrideVariable(command);
|
|
17
|
+
const cacheKey = [command, process.env.PATH, process.env.PATHEXT, process.env.PREFIX, process.env.TERMUX_VERSION, variable ? process.env[variable] : undefined].join('\0');
|
|
15
18
|
if (!overrides) {
|
|
16
|
-
const cached = commandCache.get(
|
|
19
|
+
const cached = commandCache.get(cacheKey);
|
|
17
20
|
if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs)
|
|
18
21
|
return cached.resolution;
|
|
19
22
|
}
|
|
@@ -26,11 +29,24 @@ export function resolveCommandDetails(command, overrides) {
|
|
|
26
29
|
registryPaths: overrides?.registryPaths ?? (name => windowsRegistryPaths(name, platform))
|
|
27
30
|
};
|
|
28
31
|
const resolution = resolveUncached(command, options);
|
|
29
|
-
if (!overrides)
|
|
30
|
-
commandCache.
|
|
32
|
+
if (!overrides) {
|
|
33
|
+
if (commandCache.size >= 256)
|
|
34
|
+
commandCache.clear();
|
|
35
|
+
commandCache.set(cacheKey, { resolution, checkedAt: Date.now() });
|
|
36
|
+
}
|
|
31
37
|
return resolution;
|
|
32
38
|
}
|
|
33
39
|
function resolveUncached(command, options) {
|
|
40
|
+
const variable = overrideVariable(command);
|
|
41
|
+
const configured = variable ? options.env[variable] : undefined;
|
|
42
|
+
if (configured !== undefined) {
|
|
43
|
+
const pathApi = options.platform === 'win32' ? win32 : posix;
|
|
44
|
+
if (!configured || /[\0\r\n]/.test(configured) || !pathApi.isAbsolute(configured) || (options.platform === 'win32' && !/\.(exe|com)$/i.test(configured))) {
|
|
45
|
+
return { path: null, discovery: 'configured-path', error: `${variable} must be an absolute native executable path, without shell arguments.` };
|
|
46
|
+
}
|
|
47
|
+
return options.isRunnable(configured) ? { path: configured, discovery: 'configured-path' }
|
|
48
|
+
: { path: null, discovery: 'configured-path', error: `${variable} does not name a runnable file.` };
|
|
49
|
+
}
|
|
34
50
|
const pathMatch = lookupOnPath(command, options);
|
|
35
51
|
if (pathMatch)
|
|
36
52
|
return { path: pathMatch, discovery: 'path' };
|
|
@@ -47,6 +63,9 @@ function resolveUncached(command, options) {
|
|
|
47
63
|
}
|
|
48
64
|
return { path: null, discovery: 'missing' };
|
|
49
65
|
}
|
|
66
|
+
function overrideVariable(command) {
|
|
67
|
+
return { mpv: 'RADIOCLI_MPV_PATH', ffplay: 'RADIOCLI_FFPLAY_PATH', vlc: 'RADIOCLI_VLC_PATH', cvlc: 'RADIOCLI_VLC_PATH', ffmpeg: 'RADIOCLI_FFMPEG_PATH' }[command];
|
|
68
|
+
}
|
|
50
69
|
function lookupOnPath(command, options) {
|
|
51
70
|
if (command.includes('/') || command.includes('\\'))
|
|
52
71
|
return options.isRunnable(command) ? command : null;
|
|
@@ -85,12 +104,11 @@ export function clearCommandCache() {
|
|
|
85
104
|
commandCache.clear();
|
|
86
105
|
}
|
|
87
106
|
function isRunnable(path, platform) {
|
|
88
|
-
if (!existsSync(path))
|
|
89
|
-
return false;
|
|
90
|
-
if (platform === 'win32')
|
|
91
|
-
return true;
|
|
92
107
|
try {
|
|
93
|
-
|
|
108
|
+
if (!statSync(path).isFile())
|
|
109
|
+
return false;
|
|
110
|
+
if (platform !== 'win32')
|
|
111
|
+
accessSync(path, constants.X_OK);
|
|
94
112
|
return true;
|
|
95
113
|
}
|
|
96
114
|
catch {
|
|
@@ -98,9 +116,25 @@ function isRunnable(path, platform) {
|
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
function knownBinaryDirs({ platform, env, home }) {
|
|
119
|
+
if (identifyPlatform({ platform, env }).id === 'termux') {
|
|
120
|
+
// Use the running app's prefix, including alternate Android user installs.
|
|
121
|
+
// Generic /usr paths can refer to a different libc inside a proot environment.
|
|
122
|
+
const prefix = env.PREFIX;
|
|
123
|
+
return prefix && posix.isAbsolute(prefix) && !/[\0\r\n]/.test(prefix) ? [posix.join(prefix, 'bin')] : [];
|
|
124
|
+
}
|
|
101
125
|
if (platform === 'darwin') {
|
|
102
126
|
return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')];
|
|
103
127
|
}
|
|
128
|
+
if (platform === 'freebsd' || platform === 'openbsd')
|
|
129
|
+
return ['/usr/local/bin', '/usr/bin'];
|
|
130
|
+
if (platform === 'netbsd')
|
|
131
|
+
return ['/usr/pkg/bin', '/usr/local/bin', '/usr/bin'];
|
|
132
|
+
if (platform === 'sunos')
|
|
133
|
+
return ['/opt/local/bin', '/usr/pkg/bin', '/usr/local/bin', '/usr/bin'];
|
|
134
|
+
if (platform === 'haiku')
|
|
135
|
+
return [posix.join(home, 'config', 'non-packaged', 'bin'), posix.join(home, 'config', 'bin'), '/boot/system/non-packaged/bin', '/boot/system/bin'];
|
|
136
|
+
if (platform === 'aix')
|
|
137
|
+
return ['/opt/freeware/bin', '/usr/local/bin', '/usr/bin'];
|
|
104
138
|
if (platform === 'win32') {
|
|
105
139
|
const dirs = [];
|
|
106
140
|
if (env.LOCALAPPDATA)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { identifyPlatform, nativeAdapters } from './runtime.js';
|
|
4
|
+
export function mpvIpcPath({ platform = process.platform, directory = tmpdir(), pid = process.pid, timestamp = Date.now() } = {}) {
|
|
5
|
+
if (nativeAdapters(identifyPlatform({ platform })).ipc === 'named-pipe')
|
|
6
|
+
return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
|
|
7
|
+
const path = posix.join(directory, `radiocli-${pid}-${timestamp}.sock`);
|
|
8
|
+
// BSD/macOS sockaddr_un is smaller than Linux's. Count encoded bytes and
|
|
9
|
+
// leave room for the terminator; JavaScript string length is not sufficient.
|
|
10
|
+
if (Buffer.byteLength(path) > 100) {
|
|
11
|
+
throw new Error('The mpv socket path is too long. Select a shorter private TMPDIR and restart RadioCLI.');
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { pathEnvironmentKeys } from './paths.js';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { posix, win32 } from 'node:path';
|
|
4
|
+
const applicationEnvironmentKeys = [
|
|
5
|
+
'RADIOCLI_MPV_PATH',
|
|
6
|
+
'RADIOCLI_FFPLAY_PATH',
|
|
7
|
+
'RADIOCLI_VLC_PATH',
|
|
8
|
+
'RADIOCLI_FFMPEG_PATH',
|
|
9
|
+
'RADIOCLI_OFFLINE',
|
|
10
|
+
'RADIOCLI_LOW_BANDWIDTH'
|
|
11
|
+
];
|
|
12
|
+
const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'];
|
|
13
|
+
/** Persist only application settings and the desktop connection a job needs. */
|
|
14
|
+
export function launchEnvironment(env, options = {}) {
|
|
15
|
+
const result = {};
|
|
16
|
+
const platform = options.platform ?? process.platform;
|
|
17
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
18
|
+
const add = (key, value) => {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return;
|
|
21
|
+
if (/[\r\n\0]/.test(value))
|
|
22
|
+
throw new Error(`${key} cannot contain control characters in a launch environment.`);
|
|
23
|
+
result[key] = value;
|
|
24
|
+
};
|
|
25
|
+
const capturePath = (key, value) => {
|
|
26
|
+
add(key, value);
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return;
|
|
29
|
+
// Empty overrides fall back; empty roots select the invoking directory.
|
|
30
|
+
// Windows rejects an empty USERPROFILE, while HOME is unused there.
|
|
31
|
+
if (value === '' && (key === 'RADIOCLI_HOME' || key === 'RADIO_ATLAS_HOME' || key === 'USERPROFILE' || key === 'HOME' && platform === 'win32'))
|
|
32
|
+
return;
|
|
33
|
+
// Windows root-relative paths also depend on the invoking drive. Fully
|
|
34
|
+
// qualified values need no cwd lookup (which can fail after cwd removal).
|
|
35
|
+
const absolute = path.isAbsolute(value) && (platform !== 'win32' || /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(value));
|
|
36
|
+
if (!absolute)
|
|
37
|
+
add(key, path.resolve(options.cwd ?? process.cwd(), value));
|
|
38
|
+
};
|
|
39
|
+
for (const key of pathEnvironmentKeys)
|
|
40
|
+
capturePath(key, env[key]);
|
|
41
|
+
// On Windows, removing USERPROFILE from a child environment does not
|
|
42
|
+
// reliably erase a differently-cased inherited value. Persist the effective
|
|
43
|
+
// account home so an absent selector cannot be replaced by the launcher.
|
|
44
|
+
if (platform === 'win32' && process.platform === 'win32' && env.USERPROFILE === undefined) {
|
|
45
|
+
capturePath('USERPROFILE', homedir());
|
|
46
|
+
}
|
|
47
|
+
for (const key of applicationEnvironmentKeys)
|
|
48
|
+
add(key, env[key]);
|
|
49
|
+
if (options.includeDesktop)
|
|
50
|
+
for (const key of desktopEnvironmentKeys)
|
|
51
|
+
add(key, env[key]);
|
|
52
|
+
add('RADIOCLI_ALARM_TERMINAL', options.terminal);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/** Keep application argv and approved environment out of native shell syntax. */
|
|
56
|
+
export function nodeLaunchCommand(nodePath, args, environment) {
|
|
57
|
+
if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) {
|
|
58
|
+
throw new Error('Launch command values cannot contain NUL bytes.');
|
|
59
|
+
}
|
|
60
|
+
// This fixed program contains no double quotes, including after expansion.
|
|
61
|
+
// PowerShell 5 and Task Scheduler only receive this program and encoded data.
|
|
62
|
+
// A terminal server or scheduler may have unrelated path overrides. Clear the
|
|
63
|
+
// complete identity before restoring this launch's snapshot, preserving absent
|
|
64
|
+
// selectors and the path layer's existing empty-value/default semantics.
|
|
65
|
+
const keys = [...pathEnvironmentKeys, ...applicationEnvironmentKeys, 'RADIOCLI_ALARM_TERMINAL'].map(key => `'${key}'`).join(',');
|
|
66
|
+
const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const s=new Set([${keys}]);const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>!s.has(process.platform==='win32'?k.toUpperCase():k)));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`;
|
|
67
|
+
return [nodePath, '-e', program, Buffer.from(JSON.stringify({ args, environment }), 'utf8').toString('base64url')];
|
|
68
|
+
}
|
|
69
|
+
/** Process acceptance is separate from application/session readiness. */
|
|
70
|
+
export function waitForLaunch(child, options = {}) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
let acceptedTimer;
|
|
74
|
+
let forceTimer;
|
|
75
|
+
let cleanupTimer;
|
|
76
|
+
let stopError;
|
|
77
|
+
const finish = (error) => {
|
|
78
|
+
if (settled)
|
|
79
|
+
return;
|
|
80
|
+
settled = true;
|
|
81
|
+
clearTimeout(startupTimer);
|
|
82
|
+
if (acceptedTimer)
|
|
83
|
+
clearTimeout(acceptedTimer);
|
|
84
|
+
clearTimeout(forceTimer);
|
|
85
|
+
clearTimeout(cleanupTimer);
|
|
86
|
+
if (error)
|
|
87
|
+
reject(error);
|
|
88
|
+
else
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
const signal = (value) => {
|
|
92
|
+
try {
|
|
93
|
+
child.kill(value);
|
|
94
|
+
}
|
|
95
|
+
catch { /* Retain the launch failure and bounded cleanup. */ }
|
|
96
|
+
};
|
|
97
|
+
const stop = (error) => {
|
|
98
|
+
if (settled || stopError)
|
|
99
|
+
return;
|
|
100
|
+
stopError = error;
|
|
101
|
+
clearTimeout(startupTimer);
|
|
102
|
+
if (acceptedTimer)
|
|
103
|
+
clearTimeout(acceptedTimer);
|
|
104
|
+
forceTimer = setTimeout(() => signal('SIGKILL'), 250);
|
|
105
|
+
cleanupTimer = setTimeout(() => { child.unref(); finish(error); }, 1_000);
|
|
106
|
+
signal('SIGTERM');
|
|
107
|
+
};
|
|
108
|
+
const startupTimer = setTimeout(() => {
|
|
109
|
+
const error = new Error('Process launcher did not report process startup.');
|
|
110
|
+
if (child.pid)
|
|
111
|
+
stop(error);
|
|
112
|
+
else
|
|
113
|
+
finish(error);
|
|
114
|
+
}, 3_000);
|
|
115
|
+
// Keep a listener after acceptance: a late native error must not become an
|
|
116
|
+
// unhandled EventEmitter error in the application that requested the launch.
|
|
117
|
+
child.on('error', error => {
|
|
118
|
+
if (!settled && child.pid)
|
|
119
|
+
stop(error);
|
|
120
|
+
else
|
|
121
|
+
finish(error);
|
|
122
|
+
});
|
|
123
|
+
child.once('close', (code, signal) => finish(stopError ?? (code === 0 ? undefined : new Error(`Process launcher exited with ${code ?? signal ?? 'unknown status'}.`))));
|
|
124
|
+
child.once('spawn', () => {
|
|
125
|
+
if (options.waitForExit) {
|
|
126
|
+
clearTimeout(startupTimer);
|
|
127
|
+
acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
child.unref();
|
|
131
|
+
acceptedTimer = setTimeout(() => finish(), 100);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function isLoopbackHost(value) {
|
|
2
|
+
return value === '127.0.0.1' || value === '::1';
|
|
3
|
+
}
|
|
4
|
+
/** Bind only literal loopback addresses, including hosts without an IPv4 stack. */
|
|
5
|
+
export async function listenLoopback(server) {
|
|
6
|
+
try {
|
|
7
|
+
return await listen(server, '127.0.0.1');
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const code = error.code;
|
|
11
|
+
if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL')
|
|
12
|
+
throw error;
|
|
13
|
+
return listen(server, '::1');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function listen(server, host) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const cleanup = () => {
|
|
19
|
+
server.removeListener('error', failed);
|
|
20
|
+
server.removeListener('listening', ready);
|
|
21
|
+
};
|
|
22
|
+
const failed = (error) => {
|
|
23
|
+
cleanup();
|
|
24
|
+
reject(error);
|
|
25
|
+
};
|
|
26
|
+
const ready = () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
const address = server.address();
|
|
29
|
+
if (!address || typeof address === 'string') {
|
|
30
|
+
reject(new Error('Unable to create a local control endpoint.'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve({ host, port: address.port });
|
|
34
|
+
};
|
|
35
|
+
server.once('error', failed);
|
|
36
|
+
server.once('listening', ready);
|
|
37
|
+
try {
|
|
38
|
+
server.listen(0, host);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
failed(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { resolve4, resolve6 } from 'node:dns/promises';
|
|
2
|
+
import { request as requestHttps } from 'node:https';
|
|
3
|
+
const maxResolvedResponseBytes = 128 * 1024 * 1024;
|
|
4
|
+
export function networkPolicy(env = process.env) {
|
|
5
|
+
return { offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1' };
|
|
6
|
+
}
|
|
7
|
+
/** A direct connection must never silently bypass an operator-configured proxy. */
|
|
8
|
+
export function allowsDirectDnsFallback(env = process.env, url) {
|
|
9
|
+
if (url && bypassesProxy(new URL(url), env))
|
|
10
|
+
return true;
|
|
11
|
+
return !['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']
|
|
12
|
+
.some(name => Boolean(env[name]?.trim()));
|
|
13
|
+
}
|
|
14
|
+
/** Configuration preflight only: no network request or proxy credentials. */
|
|
15
|
+
export function networkDiagnostic() {
|
|
16
|
+
const policy = networkPolicy();
|
|
17
|
+
if (policy.offline)
|
|
18
|
+
return { ...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.' };
|
|
19
|
+
try {
|
|
20
|
+
validateProxyConfiguration(new URL('http://example.invalid'));
|
|
21
|
+
validateProxyConfiguration(new URL('https://example.invalid'));
|
|
22
|
+
return { ...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.' };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return { ...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.' };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Keep the request deadline active through body consumption, including errors. */
|
|
29
|
+
export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImpl = fetch }, consume) {
|
|
30
|
+
if (networkPolicy().offline)
|
|
31
|
+
throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).');
|
|
32
|
+
validateProxyConfiguration(new URL(url));
|
|
33
|
+
init.signal?.throwIfAborted();
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
|
36
|
+
let onAbort;
|
|
37
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
38
|
+
onAbort = () => reject(signal.reason);
|
|
39
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
40
|
+
});
|
|
41
|
+
const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs);
|
|
42
|
+
let response;
|
|
43
|
+
const work = async () => {
|
|
44
|
+
response = await fetchImpl(url, { ...init, signal });
|
|
45
|
+
try {
|
|
46
|
+
// A custom fetch can resolve after the deadline without observing abort.
|
|
47
|
+
signal.throwIfAborted();
|
|
48
|
+
return await consume(response);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
cancelBody(response);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
return await Promise.race([work(), aborted]);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
signal.removeEventListener('abort', onAbort);
|
|
60
|
+
controller.abort();
|
|
61
|
+
if (response)
|
|
62
|
+
cancelBody(response);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN
|
|
67
|
+
* but Node's DNS-protocol resolver can still reach the configured nameserver.
|
|
68
|
+
* The original hostname remains the HTTP Host and TLS SNI identity, so normal
|
|
69
|
+
* certificate-chain and hostname verification remain enabled.
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchHttpsWithSystemResolver(input, init = {}, runtime = {}) {
|
|
72
|
+
const url = new URL(input);
|
|
73
|
+
if (url.protocol !== 'https:')
|
|
74
|
+
throw new Error('The DNS fallback only supports HTTPS requests.');
|
|
75
|
+
if (url.username || url.password)
|
|
76
|
+
throw new Error('The DNS fallback does not accept URL credentials.');
|
|
77
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
78
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
79
|
+
throw new Error('The DNS fallback only supports GET and HEAD requests.');
|
|
80
|
+
if (init.body)
|
|
81
|
+
throw new Error('The DNS fallback does not accept a request body.');
|
|
82
|
+
init.signal?.throwIfAborted();
|
|
83
|
+
const addresses = await resolveHttpsAddresses(url.hostname, init.signal, runtime);
|
|
84
|
+
let lastError = null;
|
|
85
|
+
for (const address of addresses) {
|
|
86
|
+
try {
|
|
87
|
+
return await requestResolvedHttps(url, address, init, runtime, method);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (init.signal?.aborted)
|
|
91
|
+
throw init.signal.reason;
|
|
92
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw lastError ?? new Error(`DNS did not return an address for ${url.hostname}.`);
|
|
96
|
+
}
|
|
97
|
+
async function resolveHttpsAddresses(hostname, signal, runtime) {
|
|
98
|
+
let lastErrors = [];
|
|
99
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
100
|
+
const results = await raceAbort(Promise.allSettled([
|
|
101
|
+
(runtime.resolve4 ?? resolve4)(hostname),
|
|
102
|
+
(runtime.resolve6 ?? resolve6)(hostname)
|
|
103
|
+
]), signal);
|
|
104
|
+
const addresses = [
|
|
105
|
+
...(results[0].status === 'fulfilled' ? results[0].value.map(address => ({ address, family: 4 })) : []),
|
|
106
|
+
...(results[1].status === 'fulfilled' ? results[1].value.map(address => ({ address, family: 6 })) : [])
|
|
107
|
+
];
|
|
108
|
+
if (addresses.length > 0)
|
|
109
|
+
return dedupeAddresses(addresses);
|
|
110
|
+
lastErrors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
|
111
|
+
if (attempt === 1 || !lastErrors.some(isTemporaryDnsError))
|
|
112
|
+
break;
|
|
113
|
+
await abortableDelay(100, signal);
|
|
114
|
+
}
|
|
115
|
+
const lastError = lastErrors.find(value => value instanceof Error);
|
|
116
|
+
throw lastError instanceof Error ? lastError : new Error(`DNS did not return an address for ${hostname}.`);
|
|
117
|
+
}
|
|
118
|
+
function requestResolvedHttps(url, address, init, runtime, method) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const headers = new Headers(init.headers);
|
|
121
|
+
if (!headers.has('host'))
|
|
122
|
+
headers.set('host', url.host);
|
|
123
|
+
if (!headers.has('accept-encoding'))
|
|
124
|
+
headers.set('accept-encoding', 'identity');
|
|
125
|
+
const request = (runtime.request ?? requestHttps)({
|
|
126
|
+
hostname: address.address,
|
|
127
|
+
family: address.family,
|
|
128
|
+
port: url.port ? Number(url.port) : 443,
|
|
129
|
+
path: `${url.pathname}${url.search}`,
|
|
130
|
+
method,
|
|
131
|
+
headers: Object.fromEntries(headers.entries()),
|
|
132
|
+
servername: url.hostname,
|
|
133
|
+
signal: init.signal ?? undefined,
|
|
134
|
+
ca: runtime.ca,
|
|
135
|
+
agent: false
|
|
136
|
+
}, response => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let bytes = 0;
|
|
139
|
+
response.on('data', (chunk) => {
|
|
140
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
141
|
+
bytes += buffer.length;
|
|
142
|
+
if (bytes > maxResolvedResponseBytes) {
|
|
143
|
+
response.destroy(new Error('HTTPS response exceeded the 128 MiB safety limit.'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
chunks.push(buffer);
|
|
147
|
+
});
|
|
148
|
+
response.once('error', reject);
|
|
149
|
+
response.once('aborted', () => reject(new Error('HTTPS response ended before completion.')));
|
|
150
|
+
response.once('end', () => {
|
|
151
|
+
const responseHeaders = new Headers();
|
|
152
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
for (const item of value)
|
|
155
|
+
responseHeaders.append(name, item);
|
|
156
|
+
}
|
|
157
|
+
else if (value !== undefined) {
|
|
158
|
+
responseHeaders.set(name, value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const status = response.statusCode ?? 500;
|
|
162
|
+
const body = method === 'HEAD' || status === 204 || status === 205 || status === 304
|
|
163
|
+
? null
|
|
164
|
+
: Buffer.concat(chunks);
|
|
165
|
+
resolve(new Response(body, {
|
|
166
|
+
status,
|
|
167
|
+
statusText: response.statusMessage,
|
|
168
|
+
headers: responseHeaders
|
|
169
|
+
}));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
request.once('error', reject);
|
|
173
|
+
request.end();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function dedupeAddresses(addresses) {
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
return addresses.filter(({ address, family }) => {
|
|
179
|
+
const key = `${family}:${address}`;
|
|
180
|
+
if (seen.has(key))
|
|
181
|
+
return false;
|
|
182
|
+
seen.add(key);
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function isTemporaryDnsError(error) {
|
|
187
|
+
return error instanceof Error && 'code' in error && error.code === 'EAI_AGAIN';
|
|
188
|
+
}
|
|
189
|
+
function raceAbort(promise, signal) {
|
|
190
|
+
if (!signal)
|
|
191
|
+
return promise;
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const onAbort = () => reject(signal.reason);
|
|
195
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
196
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function abortableDelay(ms, signal) {
|
|
200
|
+
if (!signal)
|
|
201
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
202
|
+
signal.throwIfAborted();
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const timeout = setTimeout(done, ms);
|
|
205
|
+
const onAbort = () => done(signal.reason);
|
|
206
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
207
|
+
function done(error) {
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
signal.removeEventListener('abort', onAbort);
|
|
210
|
+
if (error)
|
|
211
|
+
reject(error);
|
|
212
|
+
else
|
|
213
|
+
resolve();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function cancelBody(response) {
|
|
218
|
+
// Cancellation is best-effort and must not extend the request deadline. A
|
|
219
|
+
// locked native body is interrupted by the request's AbortController instead.
|
|
220
|
+
if (response.body && !response.body.locked)
|
|
221
|
+
void response.body.cancel().catch(() => { });
|
|
222
|
+
}
|
|
223
|
+
function validateProxyConfiguration(url) {
|
|
224
|
+
if (bypassesProxy(url, process.env))
|
|
225
|
+
return;
|
|
226
|
+
const http = proxyVariable('http_proxy', 'HTTP_PROXY');
|
|
227
|
+
const https = proxyVariable('https_proxy', 'HTTPS_PROXY');
|
|
228
|
+
for (const proxy of [http, https]) {
|
|
229
|
+
if (!proxy.value)
|
|
230
|
+
continue;
|
|
231
|
+
let parsed;
|
|
232
|
+
try {
|
|
233
|
+
if (/[\r\n]/.test(proxy.value))
|
|
234
|
+
throw new Error();
|
|
235
|
+
parsed = new URL(proxy.value);
|
|
236
|
+
decodeURIComponent(parsed.username);
|
|
237
|
+
decodeURIComponent(parsed.password);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// URL parse errors can contain credentials. Do not attach the raw cause.
|
|
241
|
+
throw new Error(`${proxy.name} contains an invalid proxy URL.`);
|
|
242
|
+
}
|
|
243
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
244
|
+
throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset.
|
|
248
|
+
const selected = url.protocol === 'https:' && https.value ? https : http;
|
|
249
|
+
if (!selected.value) {
|
|
250
|
+
const all = proxyVariable('all_proxy', 'ALL_PROXY');
|
|
251
|
+
if (all.value)
|
|
252
|
+
throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
|
|
256
|
+
if (major < 24 && !(major === 22 && minor >= 21)) {
|
|
257
|
+
throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.');
|
|
258
|
+
}
|
|
259
|
+
let enabled = process.env.NODE_USE_ENV_PROXY === '1';
|
|
260
|
+
const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
261
|
+
for (const raw of [...environmentFlags, ...process.execArgv]) {
|
|
262
|
+
const flag = raw.replace(/^["']|["']$/g, '');
|
|
263
|
+
if (flag === '--use-env-proxy')
|
|
264
|
+
enabled = true;
|
|
265
|
+
if (flag === '--no-use-env-proxy')
|
|
266
|
+
enabled = false;
|
|
267
|
+
}
|
|
268
|
+
if (!enabled) {
|
|
269
|
+
throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`);
|
|
270
|
+
}
|
|
271
|
+
// Node configures its dispatcher at startup. Do not mutate proxy environment
|
|
272
|
+
// variables or install a global dispatcher here; local IPC has a direct agent.
|
|
273
|
+
}
|
|
274
|
+
function bypassesProxy(url, env) {
|
|
275
|
+
const value = env.no_proxy !== undefined ? env.no_proxy : env.NO_PROXY;
|
|
276
|
+
if (!value?.trim())
|
|
277
|
+
return false;
|
|
278
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
279
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '');
|
|
280
|
+
return value.split(',').some(raw => {
|
|
281
|
+
let entry = raw.trim().toLowerCase();
|
|
282
|
+
if (!entry)
|
|
283
|
+
return false;
|
|
284
|
+
if (entry === '*')
|
|
285
|
+
return true;
|
|
286
|
+
let entryPort = '';
|
|
287
|
+
if (entry.startsWith('[')) {
|
|
288
|
+
const end = entry.indexOf(']');
|
|
289
|
+
if (end < 0)
|
|
290
|
+
return false;
|
|
291
|
+
entryPort = entry.slice(end + 1).replace(/^:/, '');
|
|
292
|
+
entry = entry.slice(1, end);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const colon = entry.lastIndexOf(':');
|
|
296
|
+
if (colon > -1 && /^\d+$/.test(entry.slice(colon + 1))) {
|
|
297
|
+
entryPort = entry.slice(colon + 1);
|
|
298
|
+
entry = entry.slice(0, colon);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (entryPort && entryPort !== port)
|
|
302
|
+
return false;
|
|
303
|
+
const suffix = entry.replace(/^\*\./, '.');
|
|
304
|
+
return suffix.startsWith('.')
|
|
305
|
+
? hostname === suffix.slice(1) || hostname.endsWith(suffix)
|
|
306
|
+
: hostname === suffix;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function proxyVariable(lower, upper) {
|
|
310
|
+
const name = process.env[lower] !== undefined ? lower : upper;
|
|
311
|
+
return { name, value: process.env[name] };
|
|
312
|
+
}
|