@ciphore/radiocli 0.2.3 → 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.
Files changed (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -10,7 +10,11 @@ import { playbackBackendCapabilities } from '../../player/backend-install.js';
10
10
  import { airPlayReceiverSettingValue } from '../airplay-settings.js';
11
11
  import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
12
12
  import { updateStatusText } from '../../update-check.js';
13
- export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
13
+ import { useDisplay } from '../display-context.js';
14
+ import { toAsciiSafe } from '../ascii.js';
15
+ export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
16
+ const { ascii } = useDisplay();
17
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
14
18
  const accent = themeAccent(theme);
15
19
  const lineWidth = Math.max(32, width - 4);
16
20
  const group = settingsGroup(page);
@@ -33,12 +37,12 @@ export function SettingsScreen({ page = 'root', selected, settings, appVersion,
33
37
  const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
34
38
  const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
35
39
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: group ? `Settings › ${group.label}` : 'Choose a category', width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: item => item, render: (item, _index, active) => {
36
- const label = settingLabel(item, updateCheck, appVersion);
40
+ const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
37
41
  const value = page === 'root'
38
42
  ? settingsRootValue(item)
39
43
  : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
40
- return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] }));
41
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
44
+ return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(truncate(label, labelWidth)) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: a(truncate(value ?? '', valueWidth)) }) })] }));
45
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
42
46
  }
43
47
  function settingsMenuWindow(items, selected, height) {
44
48
  const reservedRows = 6;
@@ -111,13 +115,16 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
111
115
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
112
116
  case 'Export preferences and library':
113
117
  return 'JSON backup';
114
- case 'Import preferences and library':
118
+ case 'Restore preferences and library':
115
119
  return 'restore JSON backup';
116
120
  default:
117
121
  return undefined;
118
122
  }
119
123
  }
120
- export function settingLabel(item, updateCheck, currentVersion) {
124
+ export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
125
+ if (item === 'AirPlay receiver' && !airPlaySupported) {
126
+ return 'AirPlay receiver (macOS only)';
127
+ }
121
128
  if (item === 'Check for updates') {
122
129
  return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
123
130
  }
@@ -145,7 +152,7 @@ function settingDetail(input) {
145
152
  if (item === 'Automatically check for updates') {
146
153
  return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
147
154
  }
148
- if (item === 'Export preferences and library' || item === 'Import preferences and library') {
155
+ if (item === 'Export preferences and library' || item === 'Restore preferences and library') {
149
156
  return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
150
157
  }
151
158
  if (item === 'Share favorite votes with Radio Browser') {
@@ -6,6 +6,7 @@ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '..
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { truncate } from '../format.js';
9
+ import { toAsciiSafe } from '../ascii.js';
9
10
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
11
  const dayLabelWidth = 5;
11
12
  // Two terminal columns are approximately one row high in common monospace
@@ -15,6 +16,7 @@ const heatmapCell = ' ';
15
16
  const heatmapGap = '';
16
17
  export function StatsScreen({ library, theme, width, height }) {
17
18
  const { panel: panelBackground, ascii } = useDisplay();
19
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
18
20
  const stats = computeListeningStats(library.activity.sessions);
19
21
  const contentWidth = Math.max(20, width - 4);
20
22
  const graph = buildContributionGraph(stats.days, contentWidth, height);
@@ -24,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
24
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
25
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
26
28
  const compact = height < 30;
27
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, "aria-hidden": true, children: [_jsx(Text, { color: textMuted, children: a('Less · ') }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
28
30
  }
29
31
  function renderContributionCells(cells, graphColors, cellGap) {
30
32
  const runs = [];
@@ -1,65 +1,93 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { resolveCommand } from '../player/command.js';
2
+ import { resolveCommand } from '../platform/executables.js';
3
3
  import { safeExternalHttpUrl } from '../safety.js';
4
- // The command used to open a URL in the platform's default handler.
5
- export function openExternalCommand(platform = process.platform) {
6
- if (platform === 'darwin') {
7
- return { command: 'open', args: [] };
8
- }
9
- if (platform === 'win32') {
10
- // Direct invocation avoids cmd.exe interpreting URL query characters.
11
- return { command: 'explorer', args: [] };
12
- }
13
- return { command: 'xdg-open', args: [] };
14
- }
15
- // Candidate clipboard tools per platform, in priority order. pbcopy/clip ship
16
- // with macOS/Windows; Linux/BSD rely on whichever of these is installed.
17
- export function clipboardCommands(platform = process.platform) {
18
- if (platform === 'darwin') {
19
- return [{ command: 'pbcopy', args: [] }];
20
- }
21
- if (platform === 'win32') {
22
- return [{ command: 'clip', args: [] }];
23
- }
24
- return [
25
- { command: 'wl-copy', args: [] },
26
- { command: 'xclip', args: ['-selection', 'clipboard'] },
27
- { command: 'xsel', args: ['--clipboard', '--input'] }
28
- ];
29
- }
30
- export function openExternal(url, platform = process.platform) {
4
+ import { browserCommands, clipboardCandidates, hasGraphicalSession } from '../platform/desktop.js';
5
+ import { identifyPlatform } from '../platform/runtime.js';
6
+ import { waitForLaunch } from '../platform/launch-command.js';
7
+ export async function openExternal(url, platform = process.platform, deps = {}) {
31
8
  const safeUrl = safeExternalHttpUrl(url);
32
- if (!safeUrl) {
9
+ const env = deps.env ?? process.env;
10
+ const host = identifyPlatform({ platform, env });
11
+ if (!safeUrl || !hasGraphicalSession(host, env))
33
12
  return false;
13
+ for (const { command, args } of browserCommands(host)) {
14
+ const executable = (deps.resolve ?? resolveCommand)(command);
15
+ if (!executable)
16
+ continue;
17
+ try {
18
+ // xdg-open may run for the browser's entire lifetime. Observe startup
19
+ // failure, then let the desktop application own its detached process.
20
+ const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], { stdio: 'ignore', env, detached: true, windowsHide: true });
21
+ await waitForLaunch(child);
22
+ return true;
23
+ }
24
+ catch { /* Try the next available opener after a launch failure. */ }
34
25
  }
35
- const { command, args } = openExternalCommand(platform);
36
- try {
37
- const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], { stdio: 'ignore', detached: true });
38
- child.on('error', () => undefined);
39
- child.unref();
40
- return true;
41
- }
42
- catch {
43
- // Opening a browser is best-effort; never crash the TUI over it.
26
+ return false;
27
+ }
28
+ export async function copyToClipboard(text, platform = process.platform, deps = {}) {
29
+ const env = deps.env ?? process.env;
30
+ const host = identifyPlatform({ platform, env });
31
+ if (!hasGraphicalSession(host, env))
44
32
  return false;
33
+ for (const { command, args } of clipboardCandidates(host)) {
34
+ const executable = (deps.resolve ?? resolveCommand)(command);
35
+ if (executable && await runHelper(executable, args, text, deps))
36
+ return true;
45
37
  }
38
+ return false;
46
39
  }
47
- export function copyToClipboard(text, platform = process.platform) {
48
- for (const { command, args } of clipboardCommands(platform)) {
49
- const resolved = resolveCommand(command);
50
- if (!resolved) {
51
- continue;
52
- }
40
+ function runHelper(command, args, input, deps) {
41
+ return new Promise(resolve => {
42
+ let child;
43
+ let settled = false;
44
+ let stopping = false;
45
+ let forceTimer;
46
+ let cleanupTimer;
47
+ const signal = (value) => {
48
+ try {
49
+ child?.kill(value);
50
+ }
51
+ catch { /* The failed action remains false; cleanup is still bounded. */ }
52
+ };
53
+ const finish = (ok) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ clearTimeout(timer);
58
+ clearTimeout(forceTimer);
59
+ clearTimeout(cleanupTimer);
60
+ resolve(ok);
61
+ };
62
+ const stop = () => {
63
+ if (settled || stopping)
64
+ return;
65
+ stopping = true;
66
+ forceTimer = setTimeout(() => signal('SIGKILL'), 250);
67
+ cleanupTimer = setTimeout(() => {
68
+ // Even an uninterruptible OS process must not keep RadioCLI alive.
69
+ child?.unref();
70
+ finish(false);
71
+ }, 1_000);
72
+ child?.stdin?.destroy();
73
+ signal('SIGTERM');
74
+ };
75
+ const timer = setTimeout(stop, deps.timeoutMs ?? 5000);
53
76
  try {
54
- const child = spawn(resolved, args, { stdio: ['pipe', 'ignore', 'ignore'] });
55
- child.on('error', () => undefined);
56
- child.stdin.write(text);
57
- child.stdin.end();
58
- return true;
77
+ child = (deps.spawn ?? spawn)(command, args, { stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true });
78
+ child.on('error', () => { if (child?.pid)
79
+ stop();
80
+ else
81
+ finish(false); });
82
+ child.once('close', code => finish(!stopping && code === 0));
83
+ child.stdin?.on('error', stop);
84
+ child.stdin?.end(input);
59
85
  }
60
86
  catch {
61
- continue;
87
+ if (child?.pid)
88
+ stop();
89
+ else
90
+ finish(false);
62
91
  }
63
- }
64
- return false;
92
+ });
65
93
  }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { resolveTerminalCapabilities } from '../platform/terminal.js';
4
+ /**
5
+ * Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR
6
+ * is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host
7
+ * at 16 or 256 colors. Set the instance's level before the first Ink render.
8
+ * Resolve from Ink itself so nested dependency installations work as well.
9
+ */
10
+ export async function configureTerminalRenderer(env = process.env, evidence = {}) {
11
+ const terminal = resolveTerminalCapabilities(env, evidence);
12
+ const inkRequire = createRequire(import.meta.resolve('ink'));
13
+ const { default: chalk } = await import(pathToFileURL(inkRequire.resolve('chalk')).href);
14
+ chalk.level = terminal.colorLevel;
15
+ return terminal;
16
+ }
@@ -180,6 +180,40 @@ export function useAlarmTui(params) {
180
180
  }
181
181
  await refreshRuntime();
182
182
  }, [refreshRuntime, service, setMessage]);
183
+ const deleteAlarm = useCallback(async (alarm) => {
184
+ let phase = 'disable';
185
+ let message;
186
+ try {
187
+ // Persist the safe state before touching native jobs. A failed cleanup
188
+ // can then be retried without another write in the error handler.
189
+ const disabled = store.toggleAlarm(alarm.id, false);
190
+ const disabledSnapshot = store.snapshot();
191
+ alarmStateRef.current = disabledSnapshot.alarms;
192
+ setLibrary(disabledSnapshot);
193
+ phase = 'native';
194
+ await service.remove(disabled);
195
+ phase = 'definition';
196
+ store.removeAlarm(alarm.id);
197
+ const snapshot = store.snapshot();
198
+ alarmStateRef.current = snapshot.alarms;
199
+ setLibrary(snapshot);
200
+ setSelected(value => clamp(value, snapshot.alarms.length + 1));
201
+ message = `Deleted ${alarm.label}.`;
202
+ }
203
+ catch (error) {
204
+ message = phase === 'disable'
205
+ ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.`
206
+ : phase === 'native'
207
+ ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.`
208
+ : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`;
209
+ }
210
+ finally {
211
+ busyAlarmIdsRef.current.delete(alarm.id);
212
+ setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
213
+ }
214
+ await refreshRuntime();
215
+ setMessage(message);
216
+ }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
183
217
  const saveDraft = useCallback(() => {
184
218
  if (!draft || savingRef.current)
185
219
  return;
@@ -343,26 +377,7 @@ export function useAlarmTui(params) {
343
377
  setDeletingId(undefined);
344
378
  busyAlarmIdsRef.current.add(alarm.id);
345
379
  setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
346
- void service.remove(alarm).then(() => {
347
- if (store.getAlarm(alarm.id))
348
- store.removeAlarm(alarm.id);
349
- const snapshot = store.snapshot();
350
- alarmStateRef.current = snapshot.alarms;
351
- setLibrary(snapshot);
352
- setSelected(value => clamp(value, snapshot.alarms.length + 1));
353
- setMessage(`Deleted ${alarm.label}.`);
354
- }).catch(error => {
355
- const existing = store.getAlarm(alarm.id);
356
- if (existing) {
357
- const disabled = store.toggleAlarm(alarm.id, false);
358
- const snapshot = store.snapshot();
359
- alarmStateRef.current = snapshot.alarms;
360
- setLibrary(snapshot);
361
- setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`);
362
- }
363
- else
364
- setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`);
365
- }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); });
380
+ void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`));
366
381
  return true;
367
382
  }
368
383
  if (input !== 'x') {
@@ -677,7 +692,7 @@ export function useAlarmTui(params) {
677
692
  return true;
678
693
  }
679
694
  return false;
680
- }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
695
+ }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
681
696
  const openActive = useCallback(() => {
682
697
  if (!activeAlarms.length) {
683
698
  setMessage('No alarm is currently playing.');
@@ -1,11 +1,15 @@
1
- import { useEffect } from 'react';
1
+ import { useCallback, useEffect } from 'react';
2
2
  import { useInput } from 'ink';
3
3
  import { homeItems, settingsItemsForPage, settingsPageForRootItem } from './screen-items.js';
4
- import { applyTextInput, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } from './app-state.js';
4
+ import { applyTextInput, favoriteTarget, isEditableInput, isPlainPrintableInput, isSearchFavoriteShortcut, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldTuneSearchResult, shouldToggleNearbyLocationShortcut } from './app-state.js';
5
5
  import { parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta } from './terminal-mouse.js';
6
6
  import { completeCommand } from './help-content.js';
7
7
  import { commitImmediateSelection } from './selection-state.js';
8
+ import { sanitizeTerminalText } from '../safety.js';
8
9
  export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, cancelPendingAutoSkip, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, confirmCtrlCExit, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openSettingsPage, closeSettingsPage, openScreen, handleAlarmInput, openAlarmForStation, openActiveAlarms, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, repairMcpIntegrations, setAgentIntegrationEnabled, runSearch, saveLearnedTransportKey, screen, settingsPage, searchQuery, selectedRef, selectedStationForInput, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleAgentSetting, toggleNearbyLocation, toggleDirectoryVoting, toggleRadioGarden, toggleSkipBrokenStreams, updateFromSettings }) {
10
+ const reportInputError = useCallback((error) => {
11
+ setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
12
+ }, [setMessage]);
9
13
  const commitSelection = (next) => {
10
14
  commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen));
11
15
  };
@@ -15,7 +19,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
15
19
  commitSelection(selectedRef.current + delta);
16
20
  };
17
21
  useEffect(() => {
18
- const onData = (data) => {
22
+ const handleData = (data) => {
19
23
  const rawInput = String(data);
20
24
  if (capturingTransportAction) {
21
25
  if (rawInput === '\u001B') {
@@ -63,6 +67,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
63
67
  togglePause();
64
68
  }
65
69
  };
70
+ const onData = (data) => {
71
+ try {
72
+ handleData(data);
73
+ }
74
+ catch (error) {
75
+ reportInputError(error);
76
+ }
77
+ };
66
78
  stdin.on('data', onData);
67
79
  return () => {
68
80
  stdin.off('data', onData);
@@ -78,6 +90,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
78
90
  lastRawTransportAtRef,
79
91
  playAdjacent,
80
92
  player,
93
+ reportInputError,
81
94
  moveExploreCursorToCell,
82
95
  saveLearnedTransportKey,
83
96
  screen,
@@ -89,7 +102,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
89
102
  stdin,
90
103
  togglePause
91
104
  ]);
92
- useInput((input, key) => {
105
+ const handleInput = async (input, key) => {
93
106
  if (key.ctrl && input === 'c') {
94
107
  confirmCtrlCExit();
95
108
  return;
@@ -105,9 +118,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
105
118
  }
106
119
  if (commandMode) {
107
120
  if (key.return) {
108
- void executeCommand(commandText);
109
121
  setCommandText('');
110
122
  setCommandMode(false);
123
+ await executeCommand(commandText);
111
124
  return;
112
125
  }
113
126
  if (key.escape) {
@@ -128,17 +141,21 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
128
141
  if (handleAlarmInput(input, key)) {
129
142
  return;
130
143
  }
131
- // An active text field owns its keystrokes before app-wide shortcuts. In
132
- // particular, a normal "a" in a station query must not invoke the global
133
- // schedule-this-station shortcut and leave Search for the alarm editor.
144
+ // An active text field owns ordinary keystrokes before app-wide shortcuts.
145
+ // Dedicated modified shortcuts are handled here so plain query characters
146
+ // never trigger actions or leave Search.
134
147
  if (screen === 'search' && editingSearch) {
148
+ if (isSearchFavoriteShortcut(input, key)) {
149
+ toggleFavorite(favoriteTarget(screen, selectedStationForInput(), playingStation));
150
+ return;
151
+ }
135
152
  if (key.return) {
136
153
  const inputStation = selectedStationForInput();
137
- if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && inputStation) {
138
- void playStation(inputStation);
154
+ if (inputStation && shouldTuneSearchResult(searchQuery, lastSubmittedSearchRef.current)) {
155
+ await playStation(inputStation);
139
156
  }
140
157
  else {
141
- void runSearch();
158
+ await runSearch();
142
159
  }
143
160
  return;
144
161
  }
@@ -240,7 +257,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
240
257
  if (input.startsWith(':')) {
241
258
  const seed = input.slice(1).replace(/[\r\n]+$/g, '');
242
259
  if (/[\r\n]/.test(input)) {
243
- void executeCommand(seed);
260
+ await executeCommand(seed);
244
261
  }
245
262
  else {
246
263
  setCommandMode(true);
@@ -372,11 +389,11 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
372
389
  return;
373
390
  }
374
391
  if (input === 'O') {
375
- openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation));
392
+ await openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation));
376
393
  return;
377
394
  }
378
395
  if (input === 'y') {
379
- void copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation));
396
+ await copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation));
380
397
  return;
381
398
  }
382
399
  if (input === ' ') {
@@ -418,7 +435,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
418
435
  if (screen === 'countries') {
419
436
  const country = filteredCountries[selectedRef.current];
420
437
  if (country) {
421
- void loadCountry(country);
438
+ await loadCountry(country);
422
439
  }
423
440
  return;
424
441
  }
@@ -483,10 +500,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
483
500
  toggleSetting('automaticUpdateChecks');
484
501
  }
485
502
  else if (item === 'Allow local agent control') {
486
- void setAgentIntegrationEnabled();
503
+ await setAgentIntegrationEnabled();
487
504
  }
488
505
  else if (item === 'Install or repair MCP integrations') {
489
- void repairMcpIntegrations();
506
+ await repairMcpIntegrations();
490
507
  }
491
508
  else if (item === 'Open TUI for agent playback') {
492
509
  toggleAgentSetting('openUiOnPlay');
@@ -498,12 +515,12 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
498
515
  setCommandText('export ');
499
516
  setCommandMode(true);
500
517
  }
501
- else if (item === 'Import preferences and library') {
502
- setCommandText('import ');
518
+ else if (item === 'Restore preferences and library') {
519
+ setCommandText('restore ');
503
520
  setCommandMode(true);
504
521
  }
505
522
  else if (item === 'Check for updates') {
506
- void updateFromSettings();
523
+ await updateFromSettings();
507
524
  }
508
525
  else if (item === 'Refresh provider health') {
509
526
  refreshProviderHealth();
@@ -530,15 +547,18 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
530
547
  if (screen === 'map') {
531
548
  const country = filteredCountries[selectedRef.current];
532
549
  if (country) {
533
- void loadCountry(country);
550
+ await loadCountry(country);
534
551
  }
535
552
  return;
536
553
  }
537
554
  const inputStation = selectedStationForInput();
538
555
  if (inputStation) {
539
- void playStation(inputStation);
556
+ await playStation(inputStation);
540
557
  }
541
558
  }
559
+ };
560
+ useInput((input, key) => {
561
+ void handleInput(input, key).catch(reportInputError);
542
562
  });
543
563
  }
544
564
  function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from 'react';
2
2
  import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
- export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
3
+ import { importStreamUrl } from '../streams/import-stream.js';
4
+ export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, persistLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
4
5
  return useCallback(async (rawCommand) => {
5
6
  const trimmed = rawCommand.trim();
6
7
  if (!trimmed) {
@@ -146,9 +147,31 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
146
147
  }
147
148
  return;
148
149
  }
149
- if (name === 'import' || name === 'restore') {
150
+ if (name === 'import') {
150
151
  if (!value.trim()) {
151
- setMessage('Usage: :import <backup.json>');
152
+ setMessage('Usage: :import <stream-url> [station name]');
153
+ return;
154
+ }
155
+ try {
156
+ const [url = '', ...nameParts] = rest;
157
+ setMessage('Reading stream metadata...');
158
+ const result = await importStreamUrl(url, nameParts.join(' ') || undefined);
159
+ const nextLibrary = persistLibrary(() => store.addImported([result.station]));
160
+ if (!nextLibrary)
161
+ return;
162
+ openLibrary();
163
+ setMessage(result.warning
164
+ ? `Imported ${result.station.name}. ${result.warning}`
165
+ : `Imported ${result.station.name}${result.station.codec ? ` · ${result.station.codec}` : ''}${result.station.bitrate ? ` · ${result.station.bitrate} kbps` : ''}.`);
166
+ }
167
+ catch (error) {
168
+ setMessage(error instanceof Error ? error.message : 'Could not import the radio stream.');
169
+ }
170
+ return;
171
+ }
172
+ if (name === 'restore') {
173
+ if (!value.trim()) {
174
+ setMessage('Usage: :restore <backup.json>');
152
175
  return;
153
176
  }
154
177
  try {
@@ -156,11 +179,11 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
156
179
  settingsRef.current = result.state.settings;
157
180
  setLibrary(result.state);
158
181
  setMessage(result.safetyBackupPath
159
- ? `Backup imported. Previous library saved to ${result.safetyBackupPath}`
160
- : `Backup imported: ${result.sourcePath}`);
182
+ ? `Backup restored. Previous library saved to ${result.safetyBackupPath}`
183
+ : `Backup restored: ${result.sourcePath}`);
161
184
  }
162
185
  catch (error) {
163
- setMessage(error instanceof Error ? error.message : 'Could not import the RadioCLI backup.');
186
+ setMessage(error instanceof Error ? error.message : 'Could not restore the RadioCLI backup.');
164
187
  }
165
188
  return;
166
189
  }
@@ -169,7 +192,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
169
192
  return;
170
193
  }
171
194
  if (name === 'stop') {
172
- setLibrary(store.finishActiveListeningSession());
195
+ persistLibrary(() => store.finishActiveListeningSession());
173
196
  await player.stop();
174
197
  return;
175
198
  }
@@ -203,6 +226,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
203
226
  loadCountry,
204
227
  openAirPlaySettings,
205
228
  openLibrary,
229
+ persistLibrary,
206
230
  player,
207
231
  playingStation,
208
232
  providers,
@@ -1,15 +1,16 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { appVersion } from './version.js';
4
+ import { networkPolicy, withExternalResponse } from './platform/network.js';
4
5
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
5
6
  const UPDATE_PACKAGE_NAME = '@ciphore/radiocli';
6
7
  export async function checkForUpdate({ currentVersion = appVersion(), fetchImpl = fetch, now = new Date(), packageName = UPDATE_PACKAGE_NAME, timeoutMs = 3000 } = {}) {
7
8
  try {
8
- const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs);
9
- if (!response.ok) {
10
- throw new Error(`npm registry returned ${response.status}`);
11
- }
12
- const parsed = await response.json();
9
+ const parsed = await withExternalResponse(registryLatestUrl(packageName), { fetchImpl, timeoutMs }, async (response) => {
10
+ if (!response.ok)
11
+ throw new Error(`npm registry returned ${response.status}`);
12
+ return await response.json();
13
+ });
13
14
  const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined;
14
15
  if (!latestVersion) {
15
16
  throw new Error('npm registry response did not include a version');
@@ -31,7 +32,7 @@ export async function checkForUpdate({ currentVersion = appVersion(), fetchImpl
31
32
  }
32
33
  }
33
34
  export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
34
- if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') {
35
+ if (!automaticUpdateChecksAllowed()) {
35
36
  return false;
36
37
  }
37
38
  if (!updateCheck?.checkedAt) {
@@ -41,7 +42,7 @@ export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
41
42
  return !Number.isFinite(checkedAt) || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
42
43
  }
43
44
  export function automaticUpdateChecksAllowed(enabled = true) {
44
- return enabled && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true';
45
+ return enabled && !networkPolicy().offline && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true';
45
46
  }
46
47
  export function updateAvailableForVersion(updateCheck, currentVersion = updateCheck?.currentVersion) {
47
48
  return Boolean(updateCheck?.updateAvailable &&
@@ -113,16 +114,6 @@ export function compareSemver(left, right) {
113
114
  function registryLatestUrl(packageName) {
114
115
  return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
115
116
  }
116
- async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
117
- const controller = new AbortController();
118
- const timer = setTimeout(() => controller.abort(), timeoutMs);
119
- try {
120
- return await fetchImpl(url, { signal: controller.signal });
121
- }
122
- finally {
123
- clearTimeout(timer);
124
- }
125
- }
126
117
  function semverParts(version) {
127
118
  const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? '';
128
119
  const [major = '0', minor = '0', patch = '0'] = normalized.split('.');