@ciphore/radiocli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { rightToLeftPattern, strongDirectionPattern } from './bidi-ranges.js';
2
+ /** First-strong direction for single-line text sanitized by sanitizeTerminalText. */
3
+ export function textDirection(text) {
4
+ // UAX #9 P2/P3: numbers, combining marks, emoji and punctuation generally
5
+ // don't establish direction. Inspect the whole name, never the moving slice.
6
+ // Pinned bidi classes avoid relying on locale or the host's ICU version.
7
+ const first = strongDirectionPattern.exec(text)?.[0];
8
+ return first && rightToLeftPattern.test(first) ? 'rtl' : 'ltr';
9
+ }
@@ -3,7 +3,12 @@ import { applyTextInput, clamp, isEditableInput } from './app-state.js';
3
3
  import { adjustTime, alarmEditorFields, alarmInputFromDraft, alarmTextFields, cycleWeekdays, defaultAlarmDraft, draftFromAlarm, toggleWeekday, validateAlarmDraft } from './alarm-editor.js';
4
4
  import { alarmPickerChoices } from './screens/AlarmsScreen.js';
5
5
  export function useAlarmTui(params) {
6
- const { service, store, library, setLibrary, screen, selected, setSelected, go, setMessage, playingStation, previewStation } = params;
6
+ const { service, store, library, setLibrary, screen, screenRef, selected, selectedRef, setSelected: setSelectionState, go, setMessage, playingStation, previewStation } = params;
7
+ const setSelected = useCallback(next => {
8
+ const value = typeof next === 'function' ? next(selectedRef.current) : next;
9
+ selectedRef.current = value;
10
+ setSelectionState(value);
11
+ }, [selectedRef, setSelectionState]);
7
12
  const [draft, setDraft] = useState(null);
8
13
  const [editingField, setEditingField] = useState(false);
9
14
  const [editorControl, setEditorControl] = useState(null);
@@ -38,8 +43,6 @@ export function useAlarmTui(params) {
38
43
  const activeSelectedKeyRef = useRef(undefined);
39
44
  const pendingActiveActionsRef = useRef(new Set());
40
45
  const acknowledgedActiveKeysRef = useRef(new Set());
41
- const screenRef = useRef(screen);
42
- screenRef.current = screen;
43
46
  const alarmsRef = useRef(library.alarms);
44
47
  alarmsRef.current = library.alarms;
45
48
  const visibleFields = useMemo(() => alarmEditorFields.filter(field => {
@@ -67,14 +70,16 @@ export function useAlarmTui(params) {
67
70
  for (const key of pendingActiveActionsRef.current)
68
71
  if (!liveKeys.has(key))
69
72
  pendingActiveActionsRef.current.delete(key);
70
- activeAlarmsRef.current = sessions;
71
- setActiveAlarms(sessions);
73
+ if (JSON.stringify(activeAlarmsRef.current) !== JSON.stringify(sessions)) {
74
+ activeAlarmsRef.current = sessions;
75
+ setActiveAlarms(sessions);
76
+ }
72
77
  if (sessions.length > 0 && activeCountRef.current === 0 && screenRef.current !== 'alarm-ringing') {
73
78
  returnScreenRef.current = screenRef.current;
74
79
  go('alarm-ringing', { resetSelection: true, clearMessage: false });
75
80
  }
76
81
  activeCountRef.current = sessions.length;
77
- }, [go]);
82
+ }, [go, screenRef]);
78
83
  const refreshActive = useCallback(async () => {
79
84
  if (activePollingRef.current)
80
85
  return;
@@ -180,7 +185,41 @@ export function useAlarmTui(params) {
180
185
  }
181
186
  await refreshRuntime();
182
187
  }, [refreshRuntime, service, setMessage]);
183
- const saveDraft = useCallback(() => {
188
+ const deleteAlarm = useCallback(async (alarm) => {
189
+ let phase = 'disable';
190
+ let message;
191
+ try {
192
+ // Persist the safe state before touching native jobs. A failed cleanup
193
+ // can then be retried without another write in the error handler.
194
+ const disabled = await store.toggleAlarm(alarm.id, false);
195
+ const disabledSnapshot = store.snapshot();
196
+ alarmStateRef.current = disabledSnapshot.alarms;
197
+ setLibrary(disabledSnapshot);
198
+ phase = 'native';
199
+ await service.remove(disabled);
200
+ phase = 'definition';
201
+ await store.removeAlarm(alarm.id);
202
+ const snapshot = store.snapshot();
203
+ alarmStateRef.current = snapshot.alarms;
204
+ setLibrary(snapshot);
205
+ setSelected(value => clamp(value, snapshot.alarms.length + 1));
206
+ message = `Deleted ${alarm.label}.`;
207
+ }
208
+ catch (error) {
209
+ message = phase === 'disable'
210
+ ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.`
211
+ : phase === 'native'
212
+ ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.`
213
+ : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`;
214
+ }
215
+ finally {
216
+ busyAlarmIdsRef.current.delete(alarm.id);
217
+ setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
218
+ }
219
+ await refreshRuntime();
220
+ setMessage(message);
221
+ }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
222
+ const saveDraft = useCallback(async () => {
184
223
  if (!draft || savingRef.current)
185
224
  return;
186
225
  const error = validateAlarmDraft(draft);
@@ -193,7 +232,7 @@ export function useAlarmTui(params) {
193
232
  setSaving(true);
194
233
  try {
195
234
  const input = alarmInputFromDraft(draft);
196
- const alarm = draft.id ? store.updateAlarm(draft.id, input) : store.addAlarm(input);
235
+ const alarm = draft.id ? await store.updateAlarm(draft.id, input) : await store.addAlarm(input);
197
236
  setLibrary(store.snapshot());
198
237
  setValidationError(null);
199
238
  go('alarms', { resetSelection: true, clearMessage: false });
@@ -212,7 +251,7 @@ export function useAlarmTui(params) {
212
251
  setSaving(false);
213
252
  }, [draft, go, setLibrary, setMessage, store, syncPersisted]);
214
253
  const editSelected = useCallback(() => {
215
- const alarm = alarmStateRef.current[selected - 1];
254
+ const alarm = alarmStateRef.current[selectedRef.current - 1];
216
255
  if (!alarm)
217
256
  return;
218
257
  setDraft(draftFromAlarm(alarm));
@@ -226,7 +265,7 @@ export function useAlarmTui(params) {
226
265
  setValidationError(null);
227
266
  setSelected(0);
228
267
  go('alarm-editor', { resetSelection: true });
229
- }, [go, library.alarms, selected, setSelected]);
268
+ }, [go, selectedRef, setSelected]);
230
269
  const runVerification = useCallback(() => {
231
270
  if (verificationRunningRef.current)
232
271
  return;
@@ -243,6 +282,9 @@ export function useAlarmTui(params) {
243
282
  void service.verifySetup(alarm, library.settings, update => setVerification(update)).then(report => { setVerification(report); setMessage(report.state === 'passed' ? 'Alarm setup verified. This machine completed every rehearsal step.' : report.state === 'warning' ? 'Alarm setup works, with limitations shown in the verification report.' : 'Alarm setup verification found blockers. Review the failed steps before relying on it.'); }).catch(error => { setVerification(current => ({ ...current ?? initial, state: 'failed', finishedAt: new Date().toISOString(), steps: [...(current?.steps ?? []), { id: 'internal', label: 'Verification runner', state: 'failed', detail: messageOf(error), critical: true }] })); setMessage(`Alarm setup verification failed: ${messageOf(error)}`); }).finally(() => { verificationRunningRef.current = false; void refreshRuntime(); });
244
283
  }, [library.settings, refreshRuntime, service, setMessage, store]);
245
284
  const handleInput = useCallback((input, key) => {
285
+ const screen = screenRef.current;
286
+ const selected = selectedRef.current;
287
+ const editorField = visibleFields[clamp(selected, visibleFields.length - 1)] ?? 'label';
246
288
  if (!['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'].includes(screen))
247
289
  return false;
248
290
  if (key.scrollDelta) {
@@ -321,11 +363,7 @@ export function useAlarmTui(params) {
321
363
  return true;
322
364
  }
323
365
  if (input === ' ' && alarm) {
324
- const updated = store.toggleAlarm(alarm.id);
325
- const snapshot = store.snapshot();
326
- alarmStateRef.current = snapshot.alarms;
327
- setLibrary(snapshot);
328
- void syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`);
366
+ void (async () => { const updated = await store.toggleAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`));
329
367
  return true;
330
368
  }
331
369
  if (input === 'x' && alarm) {
@@ -343,26 +381,7 @@ export function useAlarmTui(params) {
343
381
  setDeletingId(undefined);
344
382
  busyAlarmIdsRef.current.add(alarm.id);
345
383
  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(); });
384
+ void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`));
366
385
  return true;
367
386
  }
368
387
  if (input !== 'x') {
@@ -393,11 +412,7 @@ export function useAlarmTui(params) {
393
412
  return true;
394
413
  }
395
414
  if (input === 'g' && alarm) {
396
- const updated = store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } });
397
- const snapshot = store.snapshot();
398
- alarmStateRef.current = snapshot.alarms;
399
- setLibrary(snapshot);
400
- void syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`);
415
+ void (async () => { const updated = await store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } }); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`));
401
416
  return true;
402
417
  }
403
418
  if (input === 'b' || key.escape) {
@@ -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, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screenRef, selectedRef, 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.');
@@ -689,7 +704,7 @@ export function useAlarmTui(params) {
689
704
  activeSelectedKeyRef.current = activeAlarms[0]?.key;
690
705
  setActiveSelected(0);
691
706
  go('alarm-ringing', { resetSelection: true, clearMessage: false });
692
- }, [activeAlarms, go, setMessage]);
707
+ }, [activeAlarms, go, screenRef, setMessage]);
693
708
  return { draft, editorField, editingField, editorControl, timeSegment, weekdayIndex, validationError, saving, pickerChoices, pickerFallback, runtime, verification, activeAlarms, activeSelected, snoozeMinutes, deletingId, busyAlarmIds, itemCount(current) { if (current === 'alarms')
694
709
  return library.alarms.length + 2; if (current === 'alarm-editor')
695
710
  return visibleFields.length; if (current === 'alarm-picker')
@@ -1,21 +1,26 @@
1
- import { useEffect } from 'react';
2
- import { useInput } from 'ink';
1
+ import { useCallback, useEffect } from 'react';
2
+ import { useInput, usePaste } 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
- import { completeCommand } from './help-content.js';
6
+ import { completeCommand, commandNamesForAirPlay } from './help-content.js';
7
7
  import { commitImmediateSelection } from './selection-state.js';
8
- 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 }) {
8
+ import { sanitizeTerminalText } from '../safety.js';
9
+ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, airPlaySupported, 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, screenRef, settingsPageRef, 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
- commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen));
14
+ commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screenRef.current));
11
15
  };
12
16
  const moveSelection = (delta) => {
13
- if (hasStationSelection(screen))
17
+ if (hasStationSelection(screenRef.current))
14
18
  cancelPendingAutoSkip();
15
19
  commitSelection(selectedRef.current + delta);
16
20
  };
17
21
  useEffect(() => {
18
- const onData = (data) => {
22
+ const handleData = (data) => {
23
+ const screen = screenRef.current;
19
24
  const rawInput = String(data);
20
25
  if (capturingTransportAction) {
21
26
  if (rawInput === '\u001B') {
@@ -63,6 +68,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
63
68
  togglePause();
64
69
  }
65
70
  };
71
+ const onData = (data) => {
72
+ try {
73
+ handleData(data);
74
+ }
75
+ catch (error) {
76
+ reportInputError(error);
77
+ }
78
+ };
66
79
  stdin.on('data', onData);
67
80
  return () => {
68
81
  stdin.off('data', onData);
@@ -78,9 +91,11 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
78
91
  lastRawTransportAtRef,
79
92
  playAdjacent,
80
93
  player,
94
+ reportInputError,
81
95
  moveExploreCursorToCell,
82
96
  saveLearnedTransportKey,
83
97
  screen,
98
+ screenRef,
84
99
  selectedRef,
85
100
  setCapturingTransportAction,
86
101
  setMessage,
@@ -89,7 +104,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
89
104
  stdin,
90
105
  togglePause
91
106
  ]);
92
- useInput((input, key) => {
107
+ const handleInput = async (input, key) => {
108
+ const screen = screenRef.current;
109
+ const settingsPage = settingsPageRef.current;
93
110
  if (key.ctrl && input === 'c') {
94
111
  confirmCtrlCExit();
95
112
  return;
@@ -100,14 +117,17 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
100
117
  if (capturingTransportAction) {
101
118
  return;
102
119
  }
103
- if (Date.now() - lastRawTransportAtRef.current < 50) {
120
+ // Suppress only the mouse/media event already handled by the raw listener.
121
+ // Other keys in the same chunk (or immediately afterward) must still work.
122
+ const editingPlainText = isPlainPrintableInput(input) && (commandMode || (screen === 'search' && editingSearch) || ((screen === 'countries' || screen === 'map') && editingCountryFilter));
123
+ if (!editingPlainText && Date.now() - lastRawTransportAtRef.current < 50 && isRawInputEcho(input, key, settingsRef.current)) {
104
124
  return;
105
125
  }
106
126
  if (commandMode) {
107
127
  if (key.return) {
108
- void executeCommand(commandText);
109
128
  setCommandText('');
110
129
  setCommandMode(false);
130
+ await executeCommand(commandText);
111
131
  return;
112
132
  }
113
133
  if (key.escape) {
@@ -117,7 +137,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
117
137
  }
118
138
  if (key.tab) {
119
139
  // Complete the command name only while still typing it (no args yet).
120
- setCommandText(value => (/\s/.test(value) ? value : completeCommand(value)));
140
+ setCommandText(value => (/\s/.test(value) ? value : completeCommand(value, commandNamesForAirPlay(airPlaySupported))));
121
141
  return;
122
142
  }
123
143
  if (isEditableInput(input, key)) {
@@ -128,17 +148,21 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
128
148
  if (handleAlarmInput(input, key)) {
129
149
  return;
130
150
  }
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.
151
+ // An active text field owns ordinary keystrokes before app-wide shortcuts.
152
+ // Dedicated modified shortcuts are handled here so plain query characters
153
+ // never trigger actions or leave Search.
134
154
  if (screen === 'search' && editingSearch) {
155
+ if (isSearchFavoriteShortcut(input, key)) {
156
+ toggleFavorite(favoriteTarget(screen, selectedStationForInput(), playingStation));
157
+ return;
158
+ }
135
159
  if (key.return) {
136
160
  const inputStation = selectedStationForInput();
137
- if (searchQuery.trim() && searchQuery.trim() === lastSubmittedSearchRef.current && inputStation) {
138
- void playStation(inputStation);
161
+ if (inputStation && shouldTuneSearchResult(searchQuery, lastSubmittedSearchRef.current)) {
162
+ await playStation(inputStation);
139
163
  }
140
164
  else {
141
- void runSearch();
165
+ await runSearch();
142
166
  }
143
167
  return;
144
168
  }
@@ -240,7 +264,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
240
264
  if (input.startsWith(':')) {
241
265
  const seed = input.slice(1).replace(/[\r\n]+$/g, '');
242
266
  if (/[\r\n]/.test(input)) {
243
- void executeCommand(seed);
267
+ await executeCommand(seed);
244
268
  }
245
269
  else {
246
270
  setCommandMode(true);
@@ -372,11 +396,11 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
372
396
  return;
373
397
  }
374
398
  if (input === 'O') {
375
- openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation));
399
+ await openStationHomepage(favoriteTarget(screen, selectedStationForInput(), playingStation));
376
400
  return;
377
401
  }
378
402
  if (input === 'y') {
379
- void copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation));
403
+ await copyStationUrl(favoriteTarget(screen, selectedStationForInput(), playingStation));
380
404
  return;
381
405
  }
382
406
  if (input === ' ') {
@@ -418,12 +442,12 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
418
442
  if (screen === 'countries') {
419
443
  const country = filteredCountries[selectedRef.current];
420
444
  if (country) {
421
- void loadCountry(country);
445
+ await loadCountry(country);
422
446
  }
423
447
  return;
424
448
  }
425
449
  if (screen === 'settings') {
426
- const item = settingsItemsForPage(settingsPage)[selectedRef.current];
450
+ const item = settingsItemsForPage(settingsPage, airPlaySupported)[selectedRef.current];
427
451
  if (settingsPage === 'root') {
428
452
  const targetPage = settingsPageForRootItem(item);
429
453
  if (targetPage) {
@@ -483,10 +507,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
483
507
  toggleSetting('automaticUpdateChecks');
484
508
  }
485
509
  else if (item === 'Allow local agent control') {
486
- void setAgentIntegrationEnabled();
510
+ await setAgentIntegrationEnabled();
487
511
  }
488
512
  else if (item === 'Install or repair MCP integrations') {
489
- void repairMcpIntegrations();
513
+ await repairMcpIntegrations();
490
514
  }
491
515
  else if (item === 'Open TUI for agent playback') {
492
516
  toggleAgentSetting('openUiOnPlay');
@@ -498,12 +522,12 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
498
522
  setCommandText('export ');
499
523
  setCommandMode(true);
500
524
  }
501
- else if (item === 'Import preferences and library') {
502
- setCommandText('import ');
525
+ else if (item === 'Restore preferences and library') {
526
+ setCommandText('restore ');
503
527
  setCommandMode(true);
504
528
  }
505
529
  else if (item === 'Check for updates') {
506
- void updateFromSettings();
530
+ await updateFromSettings();
507
531
  }
508
532
  else if (item === 'Refresh provider health') {
509
533
  refreshProviderHealth();
@@ -530,17 +554,47 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
530
554
  if (screen === 'map') {
531
555
  const country = filteredCountries[selectedRef.current];
532
556
  if (country) {
533
- void loadCountry(country);
557
+ await loadCountry(country);
534
558
  }
535
559
  return;
536
560
  }
537
561
  const inputStation = selectedStationForInput();
538
562
  if (inputStation) {
539
- void playStation(inputStation);
563
+ await playStation(inputStation);
540
564
  }
541
565
  }
566
+ };
567
+ useInput((input, key) => {
568
+ // Ink preserves non-escape text chunks for paste, including repeated Tab
569
+ // bytes. Only a chunk consisting entirely of Tabs represents navigation.
570
+ if (/^\t{2,}$/.test(input)) {
571
+ for (let index = 0; index < input.length; index += 1) {
572
+ void handleInput('', { ...key, tab: true }).catch(reportInputError);
573
+ }
574
+ return;
575
+ }
576
+ void handleInput(input, key).catch(reportInputError);
577
+ });
578
+ // Keep bracketed paste on its own channel so pasted Tabs stay text. The
579
+ // existing text/command handling still receives each complete pasted string.
580
+ usePaste(input => {
581
+ void handleInput(input, pasteInputKey).catch(reportInputError);
542
582
  });
543
583
  }
584
+ const pasteInputKey = {
585
+ upArrow: false, downArrow: false, leftArrow: false, rightArrow: false,
586
+ pageDown: false, pageUp: false, home: false, end: false,
587
+ return: false, escape: false, ctrl: false, shift: false, tab: false,
588
+ backspace: false, delete: false, meta: false,
589
+ super: false, hyper: false, capsLock: false, numLock: false
590
+ };
591
+ function isRawInputEcho(input, key, settings) {
592
+ return Boolean(mediaTransportActionForInput(input, settings.mediaKeys) ||
593
+ mediaTransportActionForInput(`\u001B${input}`, settings.mediaKeys) ||
594
+ ((key.shift || key.ctrl || key.meta) && (key.leftArrow || key.rightArrow)) ||
595
+ /^\[<\d+;\d+;\d+[Mm]$/.test(input) ||
596
+ input.startsWith('[M'));
597
+ }
544
598
  function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
545
599
  if (commandMode || editingCountryFilter) {
546
600
  return false;