@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
|
@@ -1,81 +1,32 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
2
|
import { createServer } from 'node:net';
|
|
4
3
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const configured = env.RADIOCLI_ALARM_TERMINAL?.trim();
|
|
9
|
-
if (configured && validDescriptor(configured, platform))
|
|
10
|
-
return configured;
|
|
11
|
-
if (platform === 'darwin') {
|
|
12
|
-
const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase();
|
|
13
|
-
if (program.includes('iterm'))
|
|
14
|
-
return 'darwin:iterm';
|
|
15
|
-
if (program.includes('wezterm'))
|
|
16
|
-
return 'darwin:wezterm';
|
|
17
|
-
if (program.includes('ghostty'))
|
|
18
|
-
return 'darwin:ghostty';
|
|
19
|
-
if (program.includes('kitty'))
|
|
20
|
-
return 'darwin:kitty';
|
|
21
|
-
return 'darwin:apple-terminal';
|
|
22
|
-
}
|
|
23
|
-
if (platform === 'win32')
|
|
24
|
-
return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console';
|
|
25
|
-
if (platform === 'linux') {
|
|
26
|
-
const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim();
|
|
27
|
-
const resolved = requested ? resolveExecutable(requested, env) : undefined;
|
|
28
|
-
if (resolved && linuxTerminals.has(basename(resolved)))
|
|
29
|
-
return `linux:${resolved}`;
|
|
30
|
-
for (const command of ['ghostty', 'wezterm', 'kitty', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator']) {
|
|
31
|
-
const path = resolveExecutable(command, env);
|
|
32
|
-
if (path)
|
|
33
|
-
return `linux:${path}`;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return `${platform}:unsupported`;
|
|
37
|
-
}
|
|
4
|
+
import { listenLoopback } from '../platform/loopback.js';
|
|
5
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
6
|
+
import { detectGraphicalTerminal, launchTerminalCommand } from '../platform/terminals.js';
|
|
38
7
|
export async function openAlarmControls(options) {
|
|
39
8
|
if (options.hasLiveTui?.())
|
|
40
9
|
return { opened: false, terminal: 'existing-tui', message: 'An existing RadioCLI TUI will show the ringing controls.' };
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...directArgs]));
|
|
53
|
-
else if (terminal === 'darwin:ghostty')
|
|
54
|
-
await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...directArgs]));
|
|
55
|
-
else if (terminal === 'darwin:kitty')
|
|
56
|
-
await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...directArgs]));
|
|
57
|
-
else if (terminal === 'win32:windows-terminal')
|
|
58
|
-
await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm', options.nodePath, options.cliPath]));
|
|
59
|
-
else if (terminal === 'win32:console')
|
|
60
|
-
await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm', 'cmd.exe', '/k', windowsCommand(options.nodePath, options.cliPath, env.RADIOCLI_HOME)]));
|
|
61
|
-
else if (terminal.startsWith('linux:')) {
|
|
62
|
-
const executable = terminal.slice('linux:'.length);
|
|
63
|
-
if (!linuxTerminals.has(basename(executable)))
|
|
64
|
-
throw new Error('Saved Linux terminal is not supported.');
|
|
65
|
-
const name = basename(executable);
|
|
66
|
-
const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
|
|
67
|
-
await launched(launch(executable, [...prefix, ...directArgs]));
|
|
10
|
+
const terminal = await launchTerminalCommand({ ...options, args: [options.cliPath], title: 'RadioCLI Alarm' });
|
|
11
|
+
if (!options.hasLiveTui)
|
|
12
|
+
return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls; TUI startup is unverified.' };
|
|
13
|
+
const deadline = Date.now() + (options.timeoutMs ?? 8_000);
|
|
14
|
+
while (true) {
|
|
15
|
+
if (options.hasLiveTui())
|
|
16
|
+
return { opened: true, requested: true, terminal, message: 'RadioCLI alarm controls are ready in the saved terminal.' };
|
|
17
|
+
const remaining = deadline - Date.now();
|
|
18
|
+
if (remaining <= 0)
|
|
19
|
+
return { opened: false, requested: true, terminal, message: 'Requested RadioCLI alarm controls, but the TUI did not become ready before verification timed out. It may still be starting.' };
|
|
20
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
|
68
21
|
}
|
|
69
|
-
else
|
|
70
|
-
throw new Error('No supported graphical terminal was found. Open radiocli manually and press ! for alarm controls.');
|
|
71
|
-
return { opened: true, terminal, message: 'Opened RadioCLI alarm controls in the saved terminal.' };
|
|
72
22
|
}
|
|
73
23
|
/** Ask for macOS Automation access while the user is configuring the alarm. */
|
|
74
24
|
export async function prepareAlarmTerminalAccess(options = {}) {
|
|
75
25
|
const platform = options.platform ?? process.platform;
|
|
76
|
-
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
if (nativeAdapters(identifyPlatform({ platform, env })).terminal !== 'macos')
|
|
77
28
|
return;
|
|
78
|
-
const terminal =
|
|
29
|
+
const terminal = detectGraphicalTerminal(platform, env, options.resolve);
|
|
79
30
|
const application = terminal === 'darwin:apple-terminal' ? 'Terminal' : terminal === 'darwin:iterm' ? 'iTerm' : undefined;
|
|
80
31
|
if (!application)
|
|
81
32
|
return;
|
|
@@ -94,88 +45,77 @@ export async function verifyAlarmTerminalLaunch(options = {}) {
|
|
|
94
45
|
const platform = options.platform ?? process.platform;
|
|
95
46
|
const env = options.env ?? process.env;
|
|
96
47
|
await prepareAlarmTerminalAccess(options);
|
|
97
|
-
const terminal =
|
|
48
|
+
const terminal = detectGraphicalTerminal(platform, env, options.resolve);
|
|
98
49
|
if (terminal.endsWith(':unsupported'))
|
|
99
50
|
throw new Error('No supported graphical terminal was found for automatic alarm controls.');
|
|
100
51
|
const token = randomBytes(24).toString('base64url');
|
|
101
52
|
const server = createServer();
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
53
|
+
const address = await listenLoopback(server);
|
|
54
|
+
const script = "const n=require('node:net');const s=n.connect(Number(process.argv[1]),process.argv[3],()=>s.end(process.argv[2]));s.on('error',()=>process.exit(2));";
|
|
55
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
56
|
+
const args = ['-e', script, String(address.port), token, address.host];
|
|
57
|
+
const sockets = new Set();
|
|
58
|
+
const received = new Promise((resolve, reject) => {
|
|
59
|
+
server.on('connection', socket => {
|
|
60
|
+
sockets.add(socket);
|
|
61
|
+
socket.once('close', () => sockets.delete(socket));
|
|
62
|
+
let value = '';
|
|
63
|
+
socket.setEncoding('utf8');
|
|
64
|
+
socket.on('data', chunk => {
|
|
65
|
+
value += chunk;
|
|
66
|
+
if (value.length > token.length) {
|
|
67
|
+
socket.destroy();
|
|
68
|
+
reject(new Error('The terminal verification response was not authentic.'));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
socket.on('end', () => value === token ? resolve() : reject(new Error('The terminal verification response was not authentic.')));
|
|
72
|
+
socket.on('error', reject);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// A callback can fail while the launcher is still reporting its own result.
|
|
76
|
+
// Keep the rejection observed until it is awaited below.
|
|
77
|
+
void received.catch(() => undefined);
|
|
113
78
|
try {
|
|
114
|
-
|
|
115
|
-
await launched(launch('/usr/bin/osascript', appleTerminalScript(command)));
|
|
116
|
-
else if (terminal === 'darwin:iterm')
|
|
117
|
-
await launched(launch('/usr/bin/osascript', iTermScript(command)));
|
|
118
|
-
else if (terminal === 'darwin:wezterm')
|
|
119
|
-
await launched(launch('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
|
|
120
|
-
else if (terminal === 'darwin:ghostty')
|
|
121
|
-
await launched(launch('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
|
|
122
|
-
else if (terminal === 'darwin:kitty')
|
|
123
|
-
await launched(launch('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
|
|
124
|
-
else if (terminal === 'win32:windows-terminal')
|
|
125
|
-
await launched(launch('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI Alarm Verification', ...direct]));
|
|
126
|
-
else if (terminal === 'win32:console')
|
|
127
|
-
await launched(launch('cmd.exe', ['/d', '/c', 'start', 'RadioCLI Alarm Verification', 'cmd.exe', '/c', windowsCommandArgs(direct)]));
|
|
128
|
-
else if (terminal.startsWith('linux:')) {
|
|
129
|
-
const executable = terminal.slice(6);
|
|
130
|
-
const name = basename(executable);
|
|
131
|
-
const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal' ? ['--'] : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
|
|
132
|
-
await launched(launch(executable, [...prefix, ...direct]));
|
|
133
|
-
}
|
|
134
|
-
else
|
|
135
|
-
throw new Error('The saved terminal is not supported for alarm controls.');
|
|
79
|
+
await launchTerminalCommand({ ...options, nodePath, args, title: 'RadioCLI Alarm Verification', closeOnExit: true });
|
|
136
80
|
await withTimeout(received, options.timeoutMs ?? 8_000, 'The terminal opened but did not connect back to RadioCLI.');
|
|
137
81
|
return terminal;
|
|
138
82
|
}
|
|
139
83
|
finally {
|
|
84
|
+
for (const socket of sockets)
|
|
85
|
+
socket.destroy();
|
|
140
86
|
await new Promise(resolve => server.close(() => resolve()));
|
|
141
87
|
}
|
|
142
88
|
}
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
return ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'].includes(value);
|
|
146
|
-
if (platform === 'win32')
|
|
147
|
-
return value === 'win32:windows-terminal' || value === 'win32:console';
|
|
148
|
-
if (platform === 'linux' && value.startsWith('linux:'))
|
|
149
|
-
return linuxTerminals.has(basename(value.slice(6)));
|
|
150
|
-
return false;
|
|
89
|
+
function spawnAttached(command, args) {
|
|
90
|
+
return spawn(command, [...args], { stdio: 'ignore', windowsHide: true });
|
|
151
91
|
}
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
92
|
+
function completed(child, timeoutMs) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (work) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
work();
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
child.kill('SIGTERM');
|
|
104
|
+
finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.')));
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
child.once('error', error => finish(() => reject(error)));
|
|
107
|
+
child.once('close', code => finish(() => resolve(code ?? 1)));
|
|
108
|
+
});
|
|
161
109
|
}
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
function completed(child, timeoutMs) { return new Promise((resolve, reject) => { let settled = false; const finish = (work) => { if (settled)
|
|
174
|
-
return; settled = true; clearTimeout(timer); work(); }; const timer = setTimeout(() => { child.kill('SIGTERM'); finish(() => reject(new Error('Timed out waiting for the macOS Automation permission response.'))); }, timeoutMs); child.once('error', error => finish(() => reject(error))); child.once('close', code => finish(() => resolve(code ?? 1))); }); }
|
|
175
|
-
async function withTimeout(promise, milliseconds, message) { let timer; try {
|
|
176
|
-
return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]);
|
|
110
|
+
async function withTimeout(promise, milliseconds, message) {
|
|
111
|
+
let timer;
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
114
|
+
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
115
|
+
})]);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
177
121
|
}
|
|
178
|
-
finally {
|
|
179
|
-
if (timer)
|
|
180
|
-
clearTimeout(timer);
|
|
181
|
-
} }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { platformPaths } from '../platform/paths.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
export function registerTuiPresence(root = runtimeDirectory(), pid = process.pid) {
|
|
5
5
|
const directory = join(root, 'tui');
|
|
@@ -26,9 +26,7 @@ export function hasLiveTui(root = runtimeDirectory(), alive = processAlive) {
|
|
|
26
26
|
}
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
|
-
function runtimeDirectory() {
|
|
30
|
-
return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
|
|
31
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
|
|
29
|
+
function runtimeDirectory() { return platformPaths().runtime; }
|
|
32
30
|
function processAlive(pid) { try {
|
|
33
31
|
process.kill(pid, 0);
|
|
34
32
|
return true;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export const listenerCommands = ['play', 'pause', 'resume', 'stop', 'next', 'previous', 'volume', 'mute', 'unmute', 'now', 'status', 'search', 'countries', 'favorites', 'recent', 'favorite', 'unfavorite', 'preset', 'presets', 'stats', 'completion'];
|
|
2
|
+
const commonFlags = ['--json', '--plain', '--quiet', '-q', '--no-input', '--no-color'];
|
|
3
|
+
const filterFlags = ['--country', '--language', '--codec', '--min-bitrate', '--limit'];
|
|
4
|
+
const commandOptions = {
|
|
5
|
+
play: ['--favorites', '--recent', '--popular', '--pick', '--source', '--random', '--keep-playing', ...filterFlags],
|
|
6
|
+
search: ['--pick', ...filterFlags],
|
|
7
|
+
favorites: ['--pick', '--limit'], recent: ['--pick', '--limit'],
|
|
8
|
+
countries: ['--limit'], now: ['--watch'], status: ['--watch']
|
|
9
|
+
};
|
|
10
|
+
const booleans = new Set([...commonFlags, '--favorites', '--recent', '--popular', '--pick', '--watch', '--random', '--keep-playing']);
|
|
11
|
+
export function isListenerCommand(value) {
|
|
12
|
+
return listenerCommands.includes(value);
|
|
13
|
+
}
|
|
14
|
+
export function parseListenerArgs(command, args) {
|
|
15
|
+
const options = new Map();
|
|
16
|
+
const words = [];
|
|
17
|
+
const allowed = new Set([...commonFlags, ...commandOptions[command] ?? []]);
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
const arg = args[index];
|
|
20
|
+
if (arg === '--') {
|
|
21
|
+
words.push(...args.slice(index + 1));
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if (!arg.startsWith('-')) {
|
|
25
|
+
words.push(arg);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const equal = arg.indexOf('=');
|
|
29
|
+
const key = equal < 0 ? arg : arg.slice(0, equal);
|
|
30
|
+
if (!allowed.has(key))
|
|
31
|
+
throw new Error(`Unknown option: ${key}\nRun radiocli ${command} --help.`);
|
|
32
|
+
if (options.has(key))
|
|
33
|
+
throw new Error(`Option ${key} was supplied more than once.`);
|
|
34
|
+
if (booleans.has(key)) {
|
|
35
|
+
if (equal >= 0)
|
|
36
|
+
throw new Error(`${key} does not take a value.`);
|
|
37
|
+
options.set(key, true);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const value = equal >= 0 ? arg.slice(equal + 1) : args[++index];
|
|
41
|
+
if (!value || value.startsWith('--'))
|
|
42
|
+
throw new Error(`${key} requires a value. Run radiocli ${command} --help.`);
|
|
43
|
+
options.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const flags = { json: options.has('--json'), plain: options.has('--plain'), quiet: options.has('--quiet') || options.has('-q'), noInput: options.has('--no-input'), noColor: options.has('--no-color') };
|
|
47
|
+
if (flags.json && flags.plain)
|
|
48
|
+
throw new Error('Choose either --json or --plain.');
|
|
49
|
+
if ((options.has('--pick') || options.has('--watch')) && (flags.json || flags.plain || flags.quiet || flags.noInput)) {
|
|
50
|
+
throw new Error('--pick and --watch require interactive output. Omit --json, --plain, --quiet, and --no-input.');
|
|
51
|
+
}
|
|
52
|
+
const sources = ['--favorites', '--recent', '--popular', '--source'].filter(option => options.has(option));
|
|
53
|
+
if (sources.length > 1)
|
|
54
|
+
throw new Error('Choose one station queue: --favorites, --recent, --popular, or --source.');
|
|
55
|
+
if (sources[0] && sources[0] !== '--source')
|
|
56
|
+
options.set('--source', sources[0] === '--favorites' ? 'favorite' : sources[0].slice(2));
|
|
57
|
+
if (options.get('--source') === 'favorites')
|
|
58
|
+
options.set('--source', 'favorite');
|
|
59
|
+
return { command, words, options, flags };
|
|
60
|
+
}
|
|
61
|
+
export function integerOption(value, fallback, label, minimum, maximum) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return fallback;
|
|
64
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value) || Number(value) < minimum || Number(value) > maximum)
|
|
65
|
+
throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
|
|
66
|
+
return Number(value);
|
|
67
|
+
}
|
|
68
|
+
const help = {
|
|
69
|
+
play: `play [station name|provider:id|preset] [options]
|
|
70
|
+
Start background playback, or control the open TUI. With no station, continue
|
|
71
|
+
the current station or play your most recent station when nothing is selected.
|
|
72
|
+
Paused playback resumes; playing audio continues uninterrupted.
|
|
73
|
+
Exact saved names and presets work offline.
|
|
74
|
+
Ambiguous names open a numbered chooser only in an interactive terminal.
|
|
75
|
+
|
|
76
|
+
--favorites Play from your favorites
|
|
77
|
+
--recent Play from your recently played stations
|
|
78
|
+
--popular Play from the directory's popular stations
|
|
79
|
+
--random Pick randomly from the chosen queue or search
|
|
80
|
+
--pick Choose a station by number
|
|
81
|
+
--keep-playing Keep an already playing or paused station
|
|
82
|
+
--country CODE --language NAME --codec NAME --min-bitrate KBPS
|
|
83
|
+
--limit 1-100 Search result count (default 20)
|
|
84
|
+
|
|
85
|
+
--source recent|favorites|popular|country also works; favorite is accepted
|
|
86
|
+
for compatibility. Explicit queue or filter options select a station anew.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
radiocli play jazz
|
|
90
|
+
radiocli pause
|
|
91
|
+
radiocli play
|
|
92
|
+
radiocli play "Groove Salad"
|
|
93
|
+
radiocli play --favorites --random
|
|
94
|
+
radiocli play --country JP
|
|
95
|
+
radiocli play work`,
|
|
96
|
+
search: `search <query> [options]
|
|
97
|
+
Find stations. Terminal tables mark favorites with *; piped output is TSV.
|
|
98
|
+
--pick Choose a result and start background playback
|
|
99
|
+
--country CODE Filter by two-letter country code
|
|
100
|
+
--language NAME Filter by language
|
|
101
|
+
--codec NAME Filter by audio codec
|
|
102
|
+
--min-bitrate KBPS Minimum bitrate (0-10000)
|
|
103
|
+
--limit 1-100 Maximum results (default 20)
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
radiocli search jazz --country US --pick
|
|
107
|
+
radiocli search jazz --json
|
|
108
|
+
radiocli search jazz --plain`,
|
|
109
|
+
now: `now [--watch]
|
|
110
|
+
Show station, track metadata, output, playback state, and volume.
|
|
111
|
+
--watch updates a single terminal line. Ctrl+C exits the view; audio continues.
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
radiocli now
|
|
115
|
+
radiocli now --watch
|
|
116
|
+
radiocli now --json`,
|
|
117
|
+
status: `status [--watch]
|
|
118
|
+
Alias for radiocli now. Use --json for the current playback snapshot.`,
|
|
119
|
+
favorites: `favorites [--limit 1-100] [--pick]
|
|
120
|
+
List favorite stations, or select one to play with --pick.
|
|
121
|
+
Save the active station with radiocli favorite.`,
|
|
122
|
+
recent: `recent [--limit 1-100] [--pick]
|
|
123
|
+
List recently played stations, newest first. Use --pick to tune one again.`,
|
|
124
|
+
favorite: `favorite [station name|provider:id|preset]
|
|
125
|
+
Save a station to favorites. With no argument, save the active station.
|
|
126
|
+
Example: radiocli favorite "Groove Salad"`,
|
|
127
|
+
unfavorite: `unfavorite [station name|provider:id|preset]
|
|
128
|
+
Remove a saved favorite. With no argument, use the active station.`,
|
|
129
|
+
preset: `preset <save|list|remove> [name] [station name|provider:id|preset]
|
|
130
|
+
save NAME [STATION] saves a shortcut; omit STATION to save the active station.
|
|
131
|
+
Saving an existing name replaces its station. Names are case-insensitive,
|
|
132
|
+
1-40 letters, numbers, hyphens, or underscores, starting with a letter/number.
|
|
133
|
+
remove NAME removes only the shortcut. Presets are included in library backups.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
radiocli preset save work
|
|
137
|
+
radiocli preset save jazz "Example FM"
|
|
138
|
+
radiocli play work
|
|
139
|
+
radiocli preset remove work`,
|
|
140
|
+
presets: `presets
|
|
141
|
+
List saved station shortcuts. Alias for radiocli preset list.`,
|
|
142
|
+
countries: `countries [--limit 1-100]
|
|
143
|
+
Show the most popular countries (default 30).
|
|
144
|
+
Example: radiocli play --country JP`,
|
|
145
|
+
volume: `volume <0-100>
|
|
146
|
+
Set playback volume, or save it for the next session when playback is closed.
|
|
147
|
+
Example: radiocli volume 40`,
|
|
148
|
+
pause: `pause
|
|
149
|
+
Pause the active station. Use radiocli play to continue.`,
|
|
150
|
+
resume: `resume
|
|
151
|
+
Resume paused playback. radiocli play also resumes, and can restart after stop.`,
|
|
152
|
+
stop: `stop
|
|
153
|
+
Stop ordinary playback and close its background player. Alarms are separate.`,
|
|
154
|
+
next: `next
|
|
155
|
+
Play the next station in the current search, favorites, or recent queue.`,
|
|
156
|
+
previous: `previous
|
|
157
|
+
Play the previous station in the current queue.`,
|
|
158
|
+
mute: `mute
|
|
159
|
+
Mute active playback. Use radiocli unmute to restore sound.`,
|
|
160
|
+
unmute: `unmute
|
|
161
|
+
Restore sound without changing the saved volume.`,
|
|
162
|
+
stats: `stats
|
|
163
|
+
Show listening time, sessions, streaks, and your most-listened station.
|
|
164
|
+
Example: radiocli stats --json`,
|
|
165
|
+
completion: `completion <bash|zsh|fish|powershell>
|
|
166
|
+
Print a shell completion script. No shell configuration is changed.
|
|
167
|
+
|
|
168
|
+
Bash: source <(radiocli completion bash)
|
|
169
|
+
Zsh: source <(radiocli completion zsh) # after compinit
|
|
170
|
+
Fish: radiocli completion fish | source
|
|
171
|
+
PowerShell: radiocli completion powershell | Out-String | Invoke-Expression
|
|
172
|
+
|
|
173
|
+
Add the matching line to your shell profile to enable it in future sessions.`
|
|
174
|
+
};
|
|
175
|
+
export function listenerHelp(command) {
|
|
176
|
+
return `RadioCLI · ${command}\n\nUsage: radiocli ${help[command]}\n\nOutput options:\n --json Structured JSON\n --plain Stable tab-separated text without decoration\n --quiet, -q Suppress successful output\n --no-input Never prompt; use an exact station ID for ambiguous matches\n --no-color Disable color\n --help, -h Show this help\n`;
|
|
177
|
+
}
|
|
178
|
+
export function completionCandidates(words, presets) {
|
|
179
|
+
const current = words.at(-1) ?? '';
|
|
180
|
+
const command = words[0] ?? '';
|
|
181
|
+
const previous = words.at(-2);
|
|
182
|
+
let candidates;
|
|
183
|
+
if (words.length <= 1)
|
|
184
|
+
candidates = [...listenerCommands, 'help', 'version', 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm'];
|
|
185
|
+
else if (command === 'help')
|
|
186
|
+
candidates = ['--all', ...listenerCommands, 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm', 'version'];
|
|
187
|
+
else if (command === '--help' || command === '-h')
|
|
188
|
+
candidates = ['--all'];
|
|
189
|
+
else if (previous === '--source')
|
|
190
|
+
candidates = ['recent', 'favorites', 'favorite', 'popular', 'country'];
|
|
191
|
+
else if (previous === '--country')
|
|
192
|
+
candidates = ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN'];
|
|
193
|
+
else if (previous === '--codec')
|
|
194
|
+
candidates = ['MP3', 'AAC', 'OGG', 'FLAC'];
|
|
195
|
+
else if (previous === '--language')
|
|
196
|
+
candidates = ['english', 'spanish', 'french', 'german', 'japanese'];
|
|
197
|
+
else if (previous === '--limit')
|
|
198
|
+
candidates = ['10', '20', '50', '100'];
|
|
199
|
+
else if (previous === '--min-bitrate')
|
|
200
|
+
candidates = ['64', '128', '192', '320'];
|
|
201
|
+
else if (command === 'completion')
|
|
202
|
+
candidates = ['bash', 'zsh', 'fish', 'powershell'];
|
|
203
|
+
else if (command === 'preset' && words.length === 2)
|
|
204
|
+
candidates = ['save', 'list', 'remove'];
|
|
205
|
+
else if (command === 'volume')
|
|
206
|
+
candidates = ['0', '20', '40', '60', '80', '100'];
|
|
207
|
+
else if (isListenerCommand(command))
|
|
208
|
+
candidates = [...commonFlags, '--help', ...commandOptions[command] ?? [], ...(['play', 'favorite', 'unfavorite', 'preset'].includes(command) ? presets : [])];
|
|
209
|
+
else
|
|
210
|
+
candidates = ['--help'];
|
|
211
|
+
return [...new Set(candidates)].filter(candidate => candidate.startsWith(current));
|
|
212
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function completionScript(shell) {
|
|
2
|
+
if (shell === 'bash')
|
|
3
|
+
return `# RadioCLI Bash completion
|
|
4
|
+
_radiocli_complete() {
|
|
5
|
+
local candidate
|
|
6
|
+
COMPREPLY=()
|
|
7
|
+
while IFS= read -r candidate; do
|
|
8
|
+
COMPREPLY+=("$candidate")
|
|
9
|
+
done < <(radiocli _complete "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)
|
|
10
|
+
}
|
|
11
|
+
complete -F _radiocli_complete radiocli
|
|
12
|
+
`;
|
|
13
|
+
if (shell === 'zsh')
|
|
14
|
+
return `# RadioCLI Zsh completion (load after compinit)
|
|
15
|
+
_radiocli_complete() {
|
|
16
|
+
local -a candidates
|
|
17
|
+
candidates=("\${(@f)$(radiocli _complete "\${words[@]:1:$((CURRENT - 1))}" 2>/dev/null)}")
|
|
18
|
+
compadd -- "\${candidates[@]}"
|
|
19
|
+
}
|
|
20
|
+
compdef _radiocli_complete radiocli
|
|
21
|
+
`;
|
|
22
|
+
if (shell === 'fish')
|
|
23
|
+
return `# RadioCLI Fish completion
|
|
24
|
+
function __radiocli_candidates
|
|
25
|
+
set -l words (commandline -opc)
|
|
26
|
+
set -e words[1]
|
|
27
|
+
set -l current (commandline -ct)
|
|
28
|
+
radiocli _complete $words "$current" 2>/dev/null
|
|
29
|
+
end
|
|
30
|
+
complete -c radiocli -f -a '(__radiocli_candidates)'
|
|
31
|
+
`;
|
|
32
|
+
if (shell === 'powershell')
|
|
33
|
+
return `# RadioCLI PowerShell completion
|
|
34
|
+
Register-ArgumentCompleter -Native -CommandName radiocli -ScriptBlock {
|
|
35
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
36
|
+
$words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text })
|
|
37
|
+
if (!$wordToComplete) { $words += '' }
|
|
38
|
+
& radiocli _complete @words 2>$null | ForEach-Object {
|
|
39
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
throw new Error('Choose a completion shell: bash, zsh, fish, or powershell. Run radiocli completion --help.');
|
|
44
|
+
}
|