@ciphore/radiocli 0.2.4 → 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 (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  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 +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -26,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
26
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
27
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
28
28
  const compact = height < 30;
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" })] })] })] }));
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), _jsxs(Text, { children: [_jsx(Text, { color: textMuted, children: "Active days: " }), _jsxs(Text, { color: themeAccent(theme), children: [stats.activeDays, "/", stats.totalTrackedDays] })] })] }), _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" })] })] })] }));
30
30
  }
31
31
  function renderContributionCells(cells, graphColors, cellGap) {
32
32
  const runs = [];
@@ -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;
@@ -186,14 +191,14 @@ export function useAlarmTui(params) {
186
191
  try {
187
192
  // Persist the safe state before touching native jobs. A failed cleanup
188
193
  // can then be retried without another write in the error handler.
189
- const disabled = store.toggleAlarm(alarm.id, false);
194
+ const disabled = await store.toggleAlarm(alarm.id, false);
190
195
  const disabledSnapshot = store.snapshot();
191
196
  alarmStateRef.current = disabledSnapshot.alarms;
192
197
  setLibrary(disabledSnapshot);
193
198
  phase = 'native';
194
199
  await service.remove(disabled);
195
200
  phase = 'definition';
196
- store.removeAlarm(alarm.id);
201
+ await store.removeAlarm(alarm.id);
197
202
  const snapshot = store.snapshot();
198
203
  alarmStateRef.current = snapshot.alarms;
199
204
  setLibrary(snapshot);
@@ -214,7 +219,7 @@ export function useAlarmTui(params) {
214
219
  await refreshRuntime();
215
220
  setMessage(message);
216
221
  }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
217
- const saveDraft = useCallback(() => {
222
+ const saveDraft = useCallback(async () => {
218
223
  if (!draft || savingRef.current)
219
224
  return;
220
225
  const error = validateAlarmDraft(draft);
@@ -227,7 +232,7 @@ export function useAlarmTui(params) {
227
232
  setSaving(true);
228
233
  try {
229
234
  const input = alarmInputFromDraft(draft);
230
- 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);
231
236
  setLibrary(store.snapshot());
232
237
  setValidationError(null);
233
238
  go('alarms', { resetSelection: true, clearMessage: false });
@@ -246,7 +251,7 @@ export function useAlarmTui(params) {
246
251
  setSaving(false);
247
252
  }, [draft, go, setLibrary, setMessage, store, syncPersisted]);
248
253
  const editSelected = useCallback(() => {
249
- const alarm = alarmStateRef.current[selected - 1];
254
+ const alarm = alarmStateRef.current[selectedRef.current - 1];
250
255
  if (!alarm)
251
256
  return;
252
257
  setDraft(draftFromAlarm(alarm));
@@ -260,7 +265,7 @@ export function useAlarmTui(params) {
260
265
  setValidationError(null);
261
266
  setSelected(0);
262
267
  go('alarm-editor', { resetSelection: true });
263
- }, [go, library.alarms, selected, setSelected]);
268
+ }, [go, selectedRef, setSelected]);
264
269
  const runVerification = useCallback(() => {
265
270
  if (verificationRunningRef.current)
266
271
  return;
@@ -277,6 +282,9 @@ export function useAlarmTui(params) {
277
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(); });
278
283
  }, [library.settings, refreshRuntime, service, setMessage, store]);
279
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';
280
288
  if (!['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'].includes(screen))
281
289
  return false;
282
290
  if (key.scrollDelta) {
@@ -355,11 +363,7 @@ export function useAlarmTui(params) {
355
363
  return true;
356
364
  }
357
365
  if (input === ' ' && alarm) {
358
- const updated = store.toggleAlarm(alarm.id);
359
- const snapshot = store.snapshot();
360
- alarmStateRef.current = snapshot.alarms;
361
- setLibrary(snapshot);
362
- 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)}`));
363
367
  return true;
364
368
  }
365
369
  if (input === 'x' && alarm) {
@@ -408,11 +412,7 @@ export function useAlarmTui(params) {
408
412
  return true;
409
413
  }
410
414
  if (input === 'g' && alarm) {
411
- const updated = store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } });
412
- const snapshot = store.snapshot();
413
- alarmStateRef.current = snapshot.alarms;
414
- setLibrary(snapshot);
415
- 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)}`));
416
416
  return true;
417
417
  }
418
418
  if (input === 'b' || key.escape) {
@@ -692,7 +692,7 @@ export function useAlarmTui(params) {
692
692
  return true;
693
693
  }
694
694
  return false;
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]);
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]);
696
696
  const openActive = useCallback(() => {
697
697
  if (!activeAlarms.length) {
698
698
  setMessage('No alarm is currently playing.');
@@ -704,7 +704,7 @@ export function useAlarmTui(params) {
704
704
  activeSelectedKeyRef.current = activeAlarms[0]?.key;
705
705
  setActiveSelected(0);
706
706
  go('alarm-ringing', { resetSelection: true, clearMessage: false });
707
- }, [activeAlarms, go, setMessage]);
707
+ }, [activeAlarms, go, screenRef, setMessage]);
708
708
  return { draft, editorField, editingField, editorControl, timeSegment, weekdayIndex, validationError, saving, pickerChoices, pickerFallback, runtime, verification, activeAlarms, activeSelected, snoozeMinutes, deletingId, busyAlarmIds, itemCount(current) { if (current === 'alarms')
709
709
  return library.alarms.length + 2; if (current === 'alarm-editor')
710
710
  return visibleFields.length; if (current === 'alarm-picker')
@@ -1,25 +1,26 @@
1
1
  import { useCallback, useEffect } from 'react';
2
- import { useInput } from 'ink';
2
+ import { useInput, usePaste } from 'ink';
3
3
  import { homeItems, settingsItemsForPage, settingsPageForRootItem } from './screen-items.js';
4
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
8
  import { sanitizeTerminalText } from '../safety.js';
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 }) {
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
10
  const reportInputError = useCallback((error) => {
11
11
  setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
12
12
  }, [setMessage]);
13
13
  const commitSelection = (next) => {
14
- commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screen));
14
+ commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screenRef.current));
15
15
  };
16
16
  const moveSelection = (delta) => {
17
- if (hasStationSelection(screen))
17
+ if (hasStationSelection(screenRef.current))
18
18
  cancelPendingAutoSkip();
19
19
  commitSelection(selectedRef.current + delta);
20
20
  };
21
21
  useEffect(() => {
22
22
  const handleData = (data) => {
23
+ const screen = screenRef.current;
23
24
  const rawInput = String(data);
24
25
  if (capturingTransportAction) {
25
26
  if (rawInput === '\u001B') {
@@ -94,6 +95,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
94
95
  moveExploreCursorToCell,
95
96
  saveLearnedTransportKey,
96
97
  screen,
98
+ screenRef,
97
99
  selectedRef,
98
100
  setCapturingTransportAction,
99
101
  setMessage,
@@ -103,6 +105,8 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
103
105
  togglePause
104
106
  ]);
105
107
  const handleInput = async (input, key) => {
108
+ const screen = screenRef.current;
109
+ const settingsPage = settingsPageRef.current;
106
110
  if (key.ctrl && input === 'c') {
107
111
  confirmCtrlCExit();
108
112
  return;
@@ -113,7 +117,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
113
117
  if (capturingTransportAction) {
114
118
  return;
115
119
  }
116
- 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)) {
117
124
  return;
118
125
  }
119
126
  if (commandMode) {
@@ -130,7 +137,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
130
137
  }
131
138
  if (key.tab) {
132
139
  // Complete the command name only while still typing it (no args yet).
133
- setCommandText(value => (/\s/.test(value) ? value : completeCommand(value)));
140
+ setCommandText(value => (/\s/.test(value) ? value : completeCommand(value, commandNamesForAirPlay(airPlaySupported))));
134
141
  return;
135
142
  }
136
143
  if (isEditableInput(input, key)) {
@@ -440,7 +447,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
440
447
  return;
441
448
  }
442
449
  if (screen === 'settings') {
443
- const item = settingsItemsForPage(settingsPage)[selectedRef.current];
450
+ const item = settingsItemsForPage(settingsPage, airPlaySupported)[selectedRef.current];
444
451
  if (settingsPage === 'root') {
445
452
  const targetPage = settingsPageForRootItem(item);
446
453
  if (targetPage) {
@@ -558,8 +565,35 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
558
565
  }
559
566
  };
560
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
+ }
561
576
  void handleInput(input, key).catch(reportInputError);
562
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);
582
+ });
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'));
563
597
  }
564
598
  function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
565
599
  if (commandMode || editingCountryFilter) {
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from 'react';
2
2
  import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
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
+ export function useCommandExecutor({ beginLearningTransportKey, cancelPendingTune, countries, go, loadCountry, openAirPlaySettings, openAirPlayCode, openLibrary, persistLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, screenRef, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
5
5
  return useCallback(async (rawCommand) => {
6
6
  const trimmed = rawCommand.trim();
7
7
  if (!trimmed) {
@@ -20,16 +20,18 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
20
20
  setMessage('Usage: :country <name or code>');
21
21
  return;
22
22
  }
23
+ const origin = screenRef.current;
23
24
  const availableCountries = countries.length > 0 ? countries : await providers.countries();
24
25
  if (countries.length === 0) {
25
26
  setCountries(availableCountries);
26
27
  }
27
28
  const match = availableCountries.find(country => `${country.code} ${country.name}`.toLowerCase().includes(value.toLowerCase()));
28
29
  if (!match) {
29
- setMessage(`Country not found: ${value}`);
30
+ if (screenRef.current === origin)
31
+ setMessage(`Country not found: ${value}`);
30
32
  return;
31
33
  }
32
- await loadCountry(match);
34
+ await loadCountry(match, screenRef.current === origin);
33
35
  return;
34
36
  }
35
37
  if (name === 'codec') {
@@ -63,20 +65,20 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
63
65
  }
64
66
  if (name === 'location') {
65
67
  const enabled = value === 'on' || value === 'true' || value === '1';
66
- updateSettings({ enableNearbyLocation: enabled });
68
+ await updateSettings({ enableNearbyLocation: enabled });
67
69
  setMessage(`Nearby location lookup ${enabled ? 'enabled' : 'disabled'}.`);
68
70
  return;
69
71
  }
70
72
  if (name === 'timeout') {
71
73
  const seconds = Number(value);
72
74
  if (Number.isFinite(seconds)) {
73
- updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
75
+ await updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
74
76
  }
75
77
  return;
76
78
  }
77
79
  if (name === 'skip') {
78
80
  const enabled = value !== 'off' && value !== 'false' && value !== '0';
79
- updateSettings({ skipBrokenStreams: enabled });
81
+ await updateSettings({ skipBrokenStreams: enabled });
80
82
  return;
81
83
  }
82
84
  if (name === 'learn' || name === 'bind' || name === 'key') {
@@ -99,10 +101,10 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
99
101
  }
100
102
  if (name === 'airplay-code' || name === 'airplay-passcode') {
101
103
  if (!value.trim()) {
102
- go('airplay-code');
104
+ openAirPlayCode();
103
105
  return;
104
106
  }
105
- const result = player.submitAirPlayPasscode(value.trim());
107
+ const result = await player.submitAirPlayPasscode(value.trim());
106
108
  if (result.message) {
107
109
  setMessage(result.message);
108
110
  }
@@ -139,7 +141,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
139
141
  }
140
142
  if (name === 'export' || name === 'backup') {
141
143
  try {
142
- const backupPath = store.exportBackup(value || undefined);
144
+ const backupPath = await store.exportBackup(value || undefined);
143
145
  setMessage(`Backup exported: ${backupPath}`);
144
146
  }
145
147
  catch (error) {
@@ -156,7 +158,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
156
158
  const [url = '', ...nameParts] = rest;
157
159
  setMessage('Reading stream metadata...');
158
160
  const result = await importStreamUrl(url, nameParts.join(' ') || undefined);
159
- const nextLibrary = persistLibrary(() => store.addImported([result.station]));
161
+ const nextLibrary = await persistLibrary(() => store.addImported([result.station]));
160
162
  if (!nextLibrary)
161
163
  return;
162
164
  openLibrary();
@@ -175,7 +177,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
175
177
  return;
176
178
  }
177
179
  try {
178
- const result = store.importBackup(value);
180
+ const result = await store.importBackup(value);
179
181
  settingsRef.current = result.state.settings;
180
182
  setLibrary(result.state);
181
183
  setMessage(result.safetyBackupPath
@@ -192,6 +194,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
192
194
  return;
193
195
  }
194
196
  if (name === 'stop') {
197
+ cancelPendingTune();
195
198
  persistLibrary(() => store.finishActiveListeningSession());
196
199
  await player.stop();
197
200
  return;
@@ -213,18 +216,21 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
213
216
  : name === 'motion'
214
217
  ? 'reduceMotion'
215
218
  : 'transparentBackground';
216
- const enabled = value === '' ? !settingsRef.current[key] : value === 'on' || value === 'true' || value === '1';
217
- updateSettings({ [key]: enabled });
218
- setMessage(`${name} ${enabled ? 'on' : 'off'}.`);
219
+ const saved = await updateSettings(value === ''
220
+ ? current => ({ [key]: !current[key] })
221
+ : { [key]: value === 'on' || value === 'true' || value === '1' });
222
+ setMessage(`${name} ${saved.settings[key] ? 'on' : 'off'}.`);
219
223
  return;
220
224
  }
221
225
  setMessage(`Unknown command: ${name}`);
222
226
  }, [
223
227
  beginLearningTransportKey,
228
+ cancelPendingTune,
224
229
  countries,
225
230
  go,
226
231
  loadCountry,
227
232
  openAirPlaySettings,
233
+ openAirPlayCode,
228
234
  openLibrary,
229
235
  persistLibrary,
230
236
  player,
@@ -233,6 +239,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
233
239
  resetLearnedTransportKeys,
234
240
  runSearch,
235
241
  screen,
242
+ screenRef,
236
243
  selectedStation,
237
244
  setCountries,
238
245
  setFilters,
@@ -11,7 +11,7 @@ export function buildGallop(pulse, width, height, theme) {
11
11
  const frame = frames[frameIndex];
12
12
  const usableW = w > 8 ? w - 4 : w;
13
13
  const usableH = h > 4 ? h - 2 : h;
14
- const scale = Math.min(usableW * 2 / horseStrideWidth, usableH * 4 / horseStrideHeight);
14
+ const scale = Math.min(usableW * 2 / horseStrideWidth, usableH * 4 / horseStrideHeight) * 0.9;
15
15
  const dotsW = Math.max(1, Math.floor(horseStrideWidth * scale));
16
16
  const dotsH = Math.max(1, Math.floor(horseStrideHeight * scale));
17
17
  const left = Math.floor((w - Math.ceil(dotsW / 2)) / 2) * 2;
@@ -65,9 +65,10 @@ export const receiverStyleRegistry = [
65
65
  { id: 'crossfade', maxRows: 14, minRows: 7 },
66
66
  ];
67
67
  export const receiverStyleNames = receiverStyleRegistry.map(style => style.id);
68
- export const defaultReceiverStyle = 'gallop-fine';
68
+ export const defaultReceiverStyle = 'ultracode';
69
69
  export const receiverStyleMetadata = Object.fromEntries(receiverStyleRegistry.map(style => [style.id, style]));
70
70
  const retiredReceiverStyleNames = [
71
+ 'synthwave-sun', 'neon-alley', 'safari-dusk', 'hot-air-balloons', 'deep-sea-whale', 'meteor-shower', 'volcano', 'desert-highway', 'firefly-grove', 'cherry-blossom', 'event-horizon', 'lighthouse',
71
72
  'duplex-wave', 'color-drift', 'undertow', 'cube-tide', 'parallax-slabs', 'voxel-swell',
72
73
  'dot-current', 'flip-matrix', 'raster-harp',
73
74
  'spin-lattice', 'packet-storm', 'spark-chamber',
@@ -96,9 +96,10 @@ export function installUpdate(command = updateCommandForInstall().command) {
96
96
  });
97
97
  }
98
98
  export function updateShellForPlatform(platform, command) {
99
+ // Login profiles can replace PATH and select a different package-manager installation.
99
100
  return platform === 'win32'
100
101
  ? { command: 'cmd.exe', args: ['/d', '/s', '/c', command] }
101
- : { command: 'sh', args: ['-lc', command] };
102
+ : { command: 'sh', args: ['-c', command] };
102
103
  }
103
104
  export function compareSemver(left, right) {
104
105
  const leftParts = semverParts(left);
@@ -34,7 +34,9 @@ Earth vector data is public domain.
34
34
 
35
35
  ## node-airtunes2
36
36
 
37
- AirPlay support is provided as an optional integration with `node-airtunes2`
38
- 2.5.0, distributed under the GNU Affero General Public License v3. Its source,
39
- license text, and attribution are available in the installed dependency and at
40
- <https://github.com/bertrandda/node-airtunes2>.
37
+ AirPlay support uses a source copy of `node-airtunes2` 2.5.0, distributed under
38
+ the GNU Affero General Public License v3, with RadioCLI's native Ed25519 security
39
+ fix. The original attribution, license, complete native source, changes and
40
+ archive provenance ship in `vendor/node-airtunes2/`. Runtime dependencies remain
41
+ optional. See `vendor/node-airtunes2/RADIOCLI_PATCH.md` and the original project
42
+ at <https://github.com/bertrandda/node-airtunes2>.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,16 @@
33
33
  "SECURITY.md",
34
34
  "CONTRIBUTING.md",
35
35
  "CODE_OF_CONDUCT.md",
36
- "docs"
36
+ "docs",
37
+ "scripts/build-airplay-native.mjs",
38
+ "vendor/node-airtunes2/lib",
39
+ "vendor/node-airtunes2/src",
40
+ "vendor/node-airtunes2/package.json",
41
+ "vendor/node-airtunes2/binding.gyp",
42
+ "vendor/node-airtunes2/LICENSE",
43
+ "vendor/node-airtunes2/README.md",
44
+ "vendor/node-airtunes2/RADIOCLI_PATCH.md",
45
+ "vendor/node-airtunes2/UPSTREAM.json"
37
46
  ],
38
47
  "bin": {
39
48
  "radiocli": "dist/cli.js"
@@ -47,7 +56,7 @@
47
56
  "check:whitespace": "node scripts/whitespace-check.mjs",
48
57
  "check:exports": "knip",
49
58
  "check:package": "publint",
50
- "audit:runtime": "node scripts/audit-ci.mjs --omit=dev --omit=optional --audit-level=high",
59
+ "audit:runtime": "node scripts/audit-ci.mjs --omit=dev --include=optional --audit-level=low",
51
60
  "audit:docs": "node scripts/audit-ci.mjs --prefix=apps/docs --omit=dev --audit-level=high",
52
61
  "lint": "npm run check:unused && npm run check:whitespace && npm run check:exports",
53
62
  "docs:dev": "npm --prefix apps/docs run dev",
@@ -56,27 +65,29 @@
56
65
  "start": "node dist/cli.js",
57
66
  "test": "vitest run",
58
67
  "verify": "npm run check && npm run lint && npm run test && npm run build && npm run smoke:data",
59
- "verify:release": "npm run verify && npm run smoke:playback && npm run pack:check && npm run fresh:check && npm run check:package",
68
+ "verify:release": "node scripts/verify-release.mjs",
60
69
  "prepack": "npm run build",
61
- "prepublishOnly": "npm run check && npm run lint && npm run test && npm run smoke:data && npm run fresh:check && npm run check:package",
70
+ "prepublishOnly": "npm run verify:release",
62
71
  "pack:check": "npm pack --dry-run",
63
72
  "smoke:data": "tsx src/smoke/data-smoke.ts",
64
73
  "smoke:mcp": "node scripts/mcp-smoke.mjs",
65
74
  "smoke:playback": "tsx src/smoke/playback-smoke.ts",
75
+ "smoke:network": "tsx src/smoke/network-playback-smoke.ts",
76
+ "smoke:cli": "tsx src/smoke/cli-smoke.ts",
66
77
  "demo:script": "node scripts/demo-script.mjs",
67
78
  "demo:assets": "node scripts/capture-demo-assets.mjs",
68
- "fresh:check": "node scripts/fresh-check.mjs"
79
+ "fresh:check": "node scripts/fresh-check.mjs",
80
+ "postinstall": "node scripts/build-airplay-native.mjs"
69
81
  },
70
82
  "dependencies": {
83
+ "@homebridge/dbus-native": "^0.7.9",
71
84
  "@modelcontextprotocol/server": "^2.0.0",
72
85
  "ink": "^7.0.5",
73
86
  "jsonc-parser": "^3.3.1",
74
87
  "react": "^19.2.6",
88
+ "string-width": "8.2.1",
75
89
  "zod": "^4.4.3"
76
90
  },
77
- "overrides": {
78
- "protobufjs": "^7.5.8"
79
- },
80
91
  "devDependencies": {
81
92
  "@types/node": "^22.15.0",
82
93
  "@types/react": "^19.2.15",
@@ -85,12 +96,24 @@
85
96
  "publint": "^0.3.21",
86
97
  "tsx": "^4.22.3",
87
98
  "typescript": "^6.0.3",
88
- "vitest": "^4.1.7"
99
+ "vitest": "^4.1.11"
89
100
  },
90
101
  "engines": {
91
102
  "node": ">=22"
92
103
  },
93
104
  "optionalDependencies": {
94
- "node-airtunes2": "2.5.0"
105
+ "@noble/ed25519": "1.7.5",
106
+ "async": "3.2.6",
107
+ "axlsign": "git+https://github.com/ciderapp/curve25519-js.git#cae68782564b83ffa1c7c757e40a3944cb140290",
108
+ "big-integer": "1.6.52",
109
+ "bplist-creator": "0.1.1",
110
+ "bplist-parser": "0.3.2",
111
+ "chacha-js": "git+https://github.com/ciderapp/chacha20poly1305.git#f85941b8f5c62843a1459758cc3dbabad9f849cb",
112
+ "curve25519-js": "0.0.4",
113
+ "fast-srp-hap": "2.0.4",
114
+ "js-sha1": "0.7.0",
115
+ "lodash": "4.18.1",
116
+ "python-struct": "1.1.3",
117
+ "node-gyp": "12.3.0"
95
118
  }
96
119
  }
@@ -0,0 +1,22 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ import {createRequire} from 'node:module';
3
+ import {fileURLToPath} from 'node:url';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ /** Optional Linux optimization. The sender retains its upstream JS fallback. */
8
+ export function buildAirPlayNative({platform = process.platform, env = process.env, resolve = name => require.resolve(name), execute = spawnSync} = {}) {
9
+ const omitted = Object.entries(env).find(([name]) => name.toLowerCase() === 'npm_config_omit')?.[1] ?? '';
10
+ if (platform !== 'linux' || omitted.split(/[\s,]+/).includes('optional')) return 'skipped';
11
+ let entry;
12
+ try { entry = resolve('node-gyp/bin/node-gyp.js'); } catch { return 'unavailable'; }
13
+ try {
14
+ const result = execute(process.execPath, [entry, 'rebuild'], {
15
+ cwd: fileURLToPath(new URL('../vendor/node-airtunes2/', import.meta.url)), env,
16
+ shell: false, windowsHide: true, timeout: 120_000, stdio: 'ignore'
17
+ });
18
+ return result.status === 0 && !result.error ? 'built' : 'fallback';
19
+ } catch { return 'fallback'; }
20
+ }
21
+
22
+ if (process.argv[1] === fileURLToPath(import.meta.url)) buildAirPlayNative();