@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,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, 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 }) {
4
5
  return useCallback(async (rawCommand) => {
5
6
  const trimmed = rawCommand.trim();
6
7
  if (!trimmed) {
@@ -19,16 +20,18 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
19
20
  setMessage('Usage: :country <name or code>');
20
21
  return;
21
22
  }
23
+ const origin = screenRef.current;
22
24
  const availableCountries = countries.length > 0 ? countries : await providers.countries();
23
25
  if (countries.length === 0) {
24
26
  setCountries(availableCountries);
25
27
  }
26
28
  const match = availableCountries.find(country => `${country.code} ${country.name}`.toLowerCase().includes(value.toLowerCase()));
27
29
  if (!match) {
28
- setMessage(`Country not found: ${value}`);
30
+ if (screenRef.current === origin)
31
+ setMessage(`Country not found: ${value}`);
29
32
  return;
30
33
  }
31
- await loadCountry(match);
34
+ await loadCountry(match, screenRef.current === origin);
32
35
  return;
33
36
  }
34
37
  if (name === 'codec') {
@@ -62,20 +65,20 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
62
65
  }
63
66
  if (name === 'location') {
64
67
  const enabled = value === 'on' || value === 'true' || value === '1';
65
- updateSettings({ enableNearbyLocation: enabled });
68
+ await updateSettings({ enableNearbyLocation: enabled });
66
69
  setMessage(`Nearby location lookup ${enabled ? 'enabled' : 'disabled'}.`);
67
70
  return;
68
71
  }
69
72
  if (name === 'timeout') {
70
73
  const seconds = Number(value);
71
74
  if (Number.isFinite(seconds)) {
72
- updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
75
+ await updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
73
76
  }
74
77
  return;
75
78
  }
76
79
  if (name === 'skip') {
77
80
  const enabled = value !== 'off' && value !== 'false' && value !== '0';
78
- updateSettings({ skipBrokenStreams: enabled });
81
+ await updateSettings({ skipBrokenStreams: enabled });
79
82
  return;
80
83
  }
81
84
  if (name === 'learn' || name === 'bind' || name === 'key') {
@@ -98,10 +101,10 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
98
101
  }
99
102
  if (name === 'airplay-code' || name === 'airplay-passcode') {
100
103
  if (!value.trim()) {
101
- go('airplay-code');
104
+ openAirPlayCode();
102
105
  return;
103
106
  }
104
- const result = player.submitAirPlayPasscode(value.trim());
107
+ const result = await player.submitAirPlayPasscode(value.trim());
105
108
  if (result.message) {
106
109
  setMessage(result.message);
107
110
  }
@@ -138,7 +141,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
138
141
  }
139
142
  if (name === 'export' || name === 'backup') {
140
143
  try {
141
- const backupPath = store.exportBackup(value || undefined);
144
+ const backupPath = await store.exportBackup(value || undefined);
142
145
  setMessage(`Backup exported: ${backupPath}`);
143
146
  }
144
147
  catch (error) {
@@ -146,21 +149,43 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
146
149
  }
147
150
  return;
148
151
  }
149
- if (name === 'import' || name === 'restore') {
152
+ if (name === 'import') {
150
153
  if (!value.trim()) {
151
- setMessage('Usage: :import <backup.json>');
154
+ setMessage('Usage: :import <stream-url> [station name]');
152
155
  return;
153
156
  }
154
157
  try {
155
- const result = store.importBackup(value);
158
+ const [url = '', ...nameParts] = rest;
159
+ setMessage('Reading stream metadata...');
160
+ const result = await importStreamUrl(url, nameParts.join(' ') || undefined);
161
+ const nextLibrary = await persistLibrary(() => store.addImported([result.station]));
162
+ if (!nextLibrary)
163
+ return;
164
+ openLibrary();
165
+ setMessage(result.warning
166
+ ? `Imported ${result.station.name}. ${result.warning}`
167
+ : `Imported ${result.station.name}${result.station.codec ? ` · ${result.station.codec}` : ''}${result.station.bitrate ? ` · ${result.station.bitrate} kbps` : ''}.`);
168
+ }
169
+ catch (error) {
170
+ setMessage(error instanceof Error ? error.message : 'Could not import the radio stream.');
171
+ }
172
+ return;
173
+ }
174
+ if (name === 'restore') {
175
+ if (!value.trim()) {
176
+ setMessage('Usage: :restore <backup.json>');
177
+ return;
178
+ }
179
+ try {
180
+ const result = await store.importBackup(value);
156
181
  settingsRef.current = result.state.settings;
157
182
  setLibrary(result.state);
158
183
  setMessage(result.safetyBackupPath
159
- ? `Backup imported. Previous library saved to ${result.safetyBackupPath}`
160
- : `Backup imported: ${result.sourcePath}`);
184
+ ? `Backup restored. Previous library saved to ${result.safetyBackupPath}`
185
+ : `Backup restored: ${result.sourcePath}`);
161
186
  }
162
187
  catch (error) {
163
- setMessage(error instanceof Error ? error.message : 'Could not import the RadioCLI backup.');
188
+ setMessage(error instanceof Error ? error.message : 'Could not restore the RadioCLI backup.');
164
189
  }
165
190
  return;
166
191
  }
@@ -169,7 +194,8 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
169
194
  return;
170
195
  }
171
196
  if (name === 'stop') {
172
- setLibrary(store.finishActiveListeningSession());
197
+ cancelPendingTune();
198
+ persistLibrary(() => store.finishActiveListeningSession());
173
199
  await player.stop();
174
200
  return;
175
201
  }
@@ -190,25 +216,30 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
190
216
  : name === 'motion'
191
217
  ? 'reduceMotion'
192
218
  : 'transparentBackground';
193
- const enabled = value === '' ? !settingsRef.current[key] : value === 'on' || value === 'true' || value === '1';
194
- updateSettings({ [key]: enabled });
195
- 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'}.`);
196
223
  return;
197
224
  }
198
225
  setMessage(`Unknown command: ${name}`);
199
226
  }, [
200
227
  beginLearningTransportKey,
228
+ cancelPendingTune,
201
229
  countries,
202
230
  go,
203
231
  loadCountry,
204
232
  openAirPlaySettings,
233
+ openAirPlayCode,
205
234
  openLibrary,
235
+ persistLibrary,
206
236
  player,
207
237
  playingStation,
208
238
  providers,
209
239
  resetLearnedTransportKeys,
210
240
  runSearch,
211
241
  screen,
242
+ screenRef,
212
243
  selectedStation,
213
244
  setCountries,
214
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',
@@ -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 &&
@@ -95,9 +96,10 @@ export function installUpdate(command = updateCommandForInstall().command) {
95
96
  });
96
97
  }
97
98
  export function updateShellForPlatform(platform, command) {
99
+ // Login profiles can replace PATH and select a different package-manager installation.
98
100
  return platform === 'win32'
99
101
  ? { command: 'cmd.exe', args: ['/d', '/s', '/c', command] }
100
- : { command: 'sh', args: ['-lc', command] };
102
+ : { command: 'sh', args: ['-c', command] };
101
103
  }
102
104
  export function compareSemver(left, right) {
103
105
  const leftParts = semverParts(left);
@@ -113,16 +115,6 @@ export function compareSemver(left, right) {
113
115
  function registryLatestUrl(packageName) {
114
116
  return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
115
117
  }
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
118
  function semverParts(version) {
127
119
  const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? '';
128
120
  const [major = '0', minor = '0', patch = '0'] = normalized.split('.');
@@ -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.3",
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();