@ciphore/radiocli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to RadioCLI are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.4] - 2026-05-31
11
+
12
+ ### Added
13
+
14
+ - Added a dedicated AirPlay receiver picker and AirPlay Code screen for
15
+ passcode-protected receivers.
16
+
17
+ ### Changed
18
+
19
+ - Replaced silent AirPlay target cycling with a dedicated receiver picker in
20
+ Settings, clearer Audio output labels, immediate output switching while a
21
+ station is playing, and explicit AirPlay receiver selection before tuning.
22
+ - AirPlay is now an explicit current-session output instead of a persisted
23
+ default. Restarting RadioCLI returns to automatic local playback while keeping
24
+ the last receiver available for the next manual AirPlay switch.
25
+ - Bundled the AirPlay sender bridge and updated the Homebrew formula template to
26
+ install FFmpeg, so macOS users do not need manual AirPlay package setup.
27
+
28
+ ### Fixed
29
+
30
+ - Fixed same-session AirPlay station switching so the worker retunes the active
31
+ receiver, resets its sender buffer, and keeps the UI in buffering until the
32
+ new stream is actually ready.
33
+ - Fixed AirPlay output failures so `skip broken streams` no longer walks
34
+ through the library when the receiver is missing, off, local, or unavailable.
35
+ - Fixed library selection after playing a station that moves to the top of
36
+ recents.
37
+ - Patched the bundled AirPlay sender for modern macOS AirPlay 2 receivers that
38
+ return digest-protected setup responses or empty first setup bodies.
39
+
40
+ ## [0.1.3] - 2026-05-30
41
+
42
+ ### Added
43
+
44
+ - Experimental macOS AirPlay playback backend with Bonjour receiver discovery,
45
+ Settings target selection, `:airplay-code` passcode entry, worker-based stream
46
+ forwarding, and active receiver labels in the playback footer.
47
+
48
+ ### Changed
49
+
50
+ - `ffplay` is now treated as a playback-only fallback: the UI labels it as
51
+ `ffplay fallback`, `radiocli doctor` reports limited controls, and unsupported
52
+ pause, mute, volume, and media-key actions explain that `mpv` is required.
53
+
54
+ ### Security
55
+
56
+ - AirPlay sender support is worker-isolated and blocks high-risk sender
57
+ dependency advisories before the backend is advertised.
58
+ - Hardened AirPlay discovery and worker messaging with bounded payloads,
59
+ sanitized receiver data, passcode validation, and worker shutdown on startup
60
+ timeout.
61
+
8
62
  ## [0.1.2] - 2026-05-30
9
63
 
10
64
  ### Fixed
@@ -26,9 +80,10 @@ Initial public release.
26
80
  countries, world map, nearby, stats, and settings screens.
27
81
  - Radio Browser provider with mirror fallback, durable cache, and tune
28
82
  resolution, plus an experimental, opt-in Radio Garden provider.
29
- - `mpv` playback with `ffplay` fallback, readiness checks, ICY metadata polling,
30
- volume, pause, mute, sleep timer, tune timeouts, and skip-broken-stream
31
- behavior, using Unix sockets on macOS/Linux and named pipes on native Windows.
83
+ - `mpv` playback controls for readiness checks, ICY metadata polling, volume,
84
+ pause, and mute, with `ffplay` playback fallback, sleep timer, tune timeouts,
85
+ and skip-broken-stream behavior, using Unix sockets on macOS/Linux and named
86
+ pipes on native Windows.
32
87
  - Receiver-style Now Playing screen with 50 selectable visualizers and
33
88
  zero-signal frames whenever playback is idle, paused, stopped, or not
34
89
  backend-ready.
@@ -56,5 +111,7 @@ Initial public release.
56
111
  backend/volume that already appear in the header and footer. The reclaimed
57
112
  rows go to the visualizer.
58
113
 
114
+ [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
115
+ [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
59
116
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
60
117
  [0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1
package/CONTRIBUTING.md CHANGED
@@ -12,7 +12,8 @@ npm run build
12
12
  npm run test
13
13
  ```
14
14
 
15
- Install `mpv` for the best playback experience:
15
+ Install `mpv` for playback controls, metadata polling, and the best playback
16
+ experience:
16
17
 
17
18
  ```bash
18
19
  brew install mpv
@@ -57,4 +58,6 @@ checks.
57
58
  ## Issue Triage
58
59
 
59
60
  Playback issues should include station name, country, backend, and
60
- `radiocli check` output when possible.
61
+ `radiocli check` or `radiocli doctor` output when possible. If the backend is
62
+ `ffplay`, pause, mute, volume, and play/pause media-key behavior are expected to
63
+ be limited until `mpv` is installed.
package/README.md CHANGED
@@ -13,8 +13,8 @@ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://rea
13
13
  ## Features
14
14
 
15
15
  - Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
16
- - Tune stations with `mpv` first and `ffplay` fallback when available.
17
- - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
16
+ - Tune stations with `mpv` first for full playback controls, with `ffplay` as a playback-only fallback when available.
17
+ - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
18
18
  - Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
19
19
  - Move previous/next through the exact station list you tuned from, even after navigating to another screen.
20
20
  - Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
@@ -70,7 +70,7 @@ Live public radio from around the world
70
70
  5 Countries · Browse by country list with a world-map toggle
71
71
  6 Nearby · Opt-in approximate location for local stations
72
72
  7 Stats · Listening graph, sessions, streaks, hours
73
- 8 Settings · Playback backend, colors, providers
73
+ 8 Settings · Audio output, colors, providers
74
74
 
75
75
  3 recent · 2 favorites · 1 imported
76
76
 
@@ -137,10 +137,11 @@ Set `NEXT_PUBLIC_SITE_URL` for the canonical public docs URL. Preview builds als
137
137
 
138
138
  Requirements:
139
139
 
140
- - Homebrew on macOS: installs RadioCLI, Node.js, and `mpv`
140
+ - Homebrew on macOS: installs RadioCLI, Node.js, `mpv`, and FFmpeg
141
141
  - npm on macOS, Linux, and Windows: Node.js 22 or newer
142
- - `mpv` for best playback; RadioCLI expects one local playback backend at runtime
143
- - `ffplay` from FFmpeg as an optional fallback
142
+ - `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks
143
+ - FFmpeg on macOS for experimental AirPlay and optional `ffplay` fallback
144
+ - `ffplay` from FFmpeg as an optional playback-only fallback
144
145
 
145
146
  Recommended macOS install:
146
147
 
@@ -149,8 +150,12 @@ brew install ciphore/tap/radiocli
149
150
  radiocli
150
151
  ```
151
152
 
152
- The Homebrew formula depends on `node` and `mpv`, so the native playback backend
153
- comes from the native package manager.
153
+ The Homebrew formula depends on `node`, `mpv`, and FFmpeg, so the native playback
154
+ and AirPlay prerequisites come from the native package manager.
155
+
156
+ AirPlay playback on macOS is experimental, but the sender bridge is bundled with
157
+ RadioCLI. The app discovers AirPlay/RAOP receivers with Bonjour and decodes
158
+ streams with FFmpeg; passcode-protected receivers prompt in the TUI.
154
159
 
155
160
  Universal npm install:
156
161
 
@@ -203,6 +208,11 @@ Windows:
203
208
  winget install --id Gyan.FFmpeg -e
204
209
  ```
205
210
 
211
+ `ffplay` can keep streams playable when `mpv` is not installed, but it does not
212
+ provide reliable pause, mute, volume, media-key, or metadata control. In that
213
+ mode RadioCLI labels the backend as `ffplay fallback`, shows limited-control
214
+ footer text, and `radiocli doctor` reports `controls=limited`.
215
+
206
216
  CI covers command-mode typecheck, tests, builds, package checks, and fresh
207
217
  install smoke checks on Ubuntu, macOS, and Windows.
208
218
 
@@ -257,6 +267,11 @@ RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active,
257
267
  - `+` / `-`: volume.
258
268
  - `q` or `Ctrl+C`: quit cleanly.
259
269
 
270
+ Pause, mute, volume, and play/pause media-key control require `mpv`. When only
271
+ `ffplay` is active, RadioCLI keeps playback and station skipping available but
272
+ labels interactive playback controls as limited and shows an `Install mpv`
273
+ warning instead of pretending the control worked.
274
+
260
275
  Page-specific footer controls:
261
276
 
262
277
  | Screen | Controls |
@@ -269,7 +284,9 @@ Page-specific footer controls:
269
284
  | World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
270
285
  | Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
271
286
  | Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
272
- | Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
287
+ | Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` audio output, `a` AirPlay, `r` health, `b` home |
288
+ | AirPlay | `↑` / `↓` choose, `Enter` select receiver, `c` code entry, `r` refresh, `b` settings |
289
+ | AirPlay Code | type receiver code, `Backspace` edit, `Enter` submit, `Esc` AirPlay |
273
290
  | Stats | `b` home |
274
291
 
275
292
  Other active shortcuts:
@@ -279,7 +296,7 @@ Other active shortcuts:
279
296
  - `/`: edit search or country filter on screens that support it.
280
297
  - `[` / `]`: page through long station and country lists.
281
298
  - `m`: mute.
282
- - `o`: cycle playback backend.
299
+ - `o`: cycle audio output. If a station is playing, RadioCLI retunes it on the new output immediately.
283
300
  - `g`: toggle the experimental Radio Garden adapter.
284
301
  - `l`: toggle nearby location lookup.
285
302
  - `x`: toggle skip-broken-stream behavior.
@@ -292,7 +309,7 @@ Other active shortcuts:
292
309
 
293
310
  When you tune a station from Library, Explore, Search, Countries, or Nearby, that list becomes the playback queue. Previous/next keeps moving through that source list from any screen until you tune from another list.
294
311
 
295
- Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
312
+ Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Previous/next media actions stay app-level; play/pause needs the `mpv` backend. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
296
313
 
297
314
  Explore mouse clicks use terminal mouse reporting while the Explore tab is active. If your terminal or tmux setup does not pass those events through, the WASD cursor controls stay fully available.
298
315
 
@@ -317,6 +334,8 @@ Useful command palette entries:
317
334
  :learn play
318
335
  :learn next
319
336
  :keys reset
337
+ :airplay
338
+ :airplay-code 1234
320
339
  :map
321
340
  :library
322
341
  :stats
@@ -326,6 +345,16 @@ Useful command palette entries:
326
345
 
327
346
  Settings persist display colors and receiver styles without editing config files. The fourteen display colors are green, amber, blue, ruby, ice, teal, violet, copper, cyan, lime, coral, rose, slate, and mono, cycled with `t`. The 50 receiver styles span classic receiver displays, high-resolution braille visuals, and generative motion scenes; cycle them with `v` (see the [Demo](#demo) for the full family breakdown). The stats graph and legend follow the selected display color, and the selected Now Playing style is restored on the next launch.
328
347
 
348
+ On macOS, Settings opens a dedicated AirPlay receiver picker for Bonjour
349
+ receivers. Change Audio output to AirPlay with `o`, open AirPlay with `a` or
350
+ `:airplay`, and choose a visible receiver. If a station is already playing,
351
+ RadioCLI moves it to the selected receiver immediately; switch Audio output back
352
+ to `This device (mpv)` to leave AirPlay. RadioCLI does not auto-pick a receiver.
353
+ AirPlay is a current-session output: restarting RadioCLI returns to automatic
354
+ local playback while remembering the last receiver for the next manual switch.
355
+ If the receiver asks for a code, RadioCLI opens the AirPlay Code screen; you can
356
+ also press `c` from AirPlay settings or use `:airplay-code 1234`.
357
+
329
358
  ## Architecture
330
359
 
331
360
  RadioCLI is split around four seams:
@@ -342,9 +371,9 @@ a station, `ProviderManager.resolve()` follows the provider-specific path, then
342
371
  `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local
343
372
  JSON IPC endpoint, and the configured volume. The endpoint is a Unix socket on
344
373
  macOS/Linux and a named pipe on native Windows. `ffplay` is available as a
345
- simpler fallback, but `mpv` is the intended backend because it handles
346
- redirects, HLS, codecs, metadata, pause, mute, volume, and readiness checks more
347
- reliably than a JavaScript stream client.
374
+ playback-only fallback, but `mpv` is the intended backend because it handles
375
+ redirects, HLS, codecs, metadata, pause, mute, volume, media keys, and readiness
376
+ checks more reliably than a JavaScript stream client.
348
377
 
349
378
  RadioCLI waits for the backend to become ready before marking playback as
350
379
  `playing`. With `mpv`, it polls playback state every 500ms and ICY metadata every
@@ -385,7 +414,7 @@ animation cost.
385
414
 
386
415
  Radio Browser is the primary provider. Its own docs recommend using a speaking user agent, resolving station clicks through `/json/url`, and retrying with other servers when one fails; RadioCLI follows that shape with mirror fallback and durable cache. Explore and Nearby use a cached geotagged Radio Browser atlas, then compute local distance in the app so map movement is not biased toward the most-clicked stations worldwide. Radio Garden support is experimental because the useful endpoints are publicly discoverable but unofficial, and they can be blocked or changed independently of this project.
387
416
 
388
- Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling, using Unix sockets on macOS/Linux and named pipes on Windows.
417
+ Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling, using Unix sockets on macOS/Linux and named pipes on Windows. `ffplay` remains a playback-only fallback and is intentionally labeled with limited controls in the UI and doctor output. Experimental AirPlay output is available on macOS when Bonjour discovery and FFmpeg are available.
389
418
 
390
419
  The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. Current installs store data under RadioCLI paths such as `radiocli.json` and `radiocli-cache.json`. Existing Radio Atlas data is still discovered when a new RadioCLI store does not exist, and legacy `RADIO_ATLAS_HOME` / animation environment variables remain supported as migration fallbacks. New automation should use `RADIOCLI_HOME` and `RADIOCLI_DISABLE_ANIMATION`.
391
420
 
@@ -0,0 +1,174 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { networkInterfaces } from 'node:os';
4
+ import { commandExists } from './command.js';
5
+ const defaultTimeoutMs = 2500;
6
+ const defaultMaxDevices = 12;
7
+ const defaultLookupConcurrency = 4;
8
+ const defaultMaxOutputBytes = 64 * 1024;
9
+ export async function discoverAirPlayDevices({ platform = process.platform, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
10
+ if (platform !== 'darwin' || !commandExists('dns-sd')) {
11
+ return [];
12
+ }
13
+ const browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
14
+ const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices));
15
+ const devices = await mapWithConcurrency(instances, Math.max(1, lookupConcurrency), async (instance) => {
16
+ const lookupOutput = await runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
17
+ const device = parseRaopLookupOutput(instance, lookupOutput);
18
+ return device ? enrichAirPlayDeviceHost(device) : null;
19
+ });
20
+ return devices.filter((device) => Boolean(device));
21
+ }
22
+ async function enrichAirPlayDeviceHost(device) {
23
+ const addresses = await lookup(device.host, { all: true, family: 4 }).catch(() => []);
24
+ if (addresses.length === 0) {
25
+ return device;
26
+ }
27
+ const localAddresses = localIpv4Addresses();
28
+ const preferred = addresses.find(address => !isLoopbackAddress(address.address)) ?? addresses[0];
29
+ if (!preferred) {
30
+ return device;
31
+ }
32
+ return {
33
+ ...device,
34
+ host: preferred.address,
35
+ local: addresses.some(address => localAddresses.has(address.address))
36
+ };
37
+ }
38
+ function localIpv4Addresses() {
39
+ const addresses = new Set(['127.0.0.1']);
40
+ for (const entries of Object.values(networkInterfaces())) {
41
+ for (const entry of entries ?? []) {
42
+ if (entry.family === 'IPv4') {
43
+ addresses.add(entry.address);
44
+ }
45
+ }
46
+ }
47
+ return addresses;
48
+ }
49
+ function isLoopbackAddress(address) {
50
+ return address === '127.0.0.1' || address.startsWith('127.');
51
+ }
52
+ export function parseRaopBrowseOutput(output) {
53
+ const instances = new Set();
54
+ for (const line of output.split('\n')) {
55
+ const match = /\s_raop\._tcp\.\s+(.+)$/.exec(line);
56
+ const instance = sanitizeDnsSdText(unescapeDnsSdName(match?.[1]?.trim() ?? ''), 256);
57
+ if (instance) {
58
+ instances.add(instance);
59
+ }
60
+ }
61
+ return [...instances];
62
+ }
63
+ export function parseRaopLookupOutput(instance, output) {
64
+ const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/);
65
+ if (!reachable?.[1] || !reachable[2]) {
66
+ return null;
67
+ }
68
+ const id = sanitizeDnsSdText(instance, 256);
69
+ const host = normalizeHost(reachable[1]);
70
+ const port = Number(reachable[2]);
71
+ if (!id || !host || !Number.isInteger(port) || port <= 0 || port > 65535) {
72
+ return null;
73
+ }
74
+ const txtLine = output
75
+ .split('\n')
76
+ .map(line => line.trim())
77
+ .find(line => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(line));
78
+ const txt = txtLine
79
+ ? txtLine.split(/\s+/).map(entry => sanitizeDnsSdText(entry, 512)).filter(Boolean).slice(0, 64)
80
+ : [];
81
+ const txtMap = txtRecordMap(txt);
82
+ const sf = txtMap.get('sf');
83
+ const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0;
84
+ return {
85
+ id,
86
+ name: displayNameForRaopInstance(id),
87
+ host,
88
+ port,
89
+ txt,
90
+ requiresPassword: sfValue !== 0 && sfValue !== 0x4,
91
+ airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
92
+ };
93
+ }
94
+ function displayNameForRaopInstance(instance) {
95
+ const atIndex = instance.indexOf('@');
96
+ return sanitizeDnsSdText(atIndex === -1 ? instance : instance.slice(atIndex + 1), 256) || 'AirPlay receiver';
97
+ }
98
+ function txtRecordMap(txt) {
99
+ const map = new Map();
100
+ for (const entry of txt) {
101
+ const equals = entry.indexOf('=');
102
+ if (equals === -1) {
103
+ continue;
104
+ }
105
+ map.set(entry.slice(0, equals), entry.slice(equals + 1));
106
+ }
107
+ return map;
108
+ }
109
+ function unescapeDnsSdName(value) {
110
+ return value.replace(/\\(\d{3})/g, (_match, code) => String.fromCharCode(Number(code)));
111
+ }
112
+ function normalizeHost(value) {
113
+ const host = sanitizeDnsSdText(unescapeDnsSdName(value), 253).replace(/\.$/, '');
114
+ if (!host || /[\s/\\]/.test(host) || !/^[A-Za-z0-9._:%-]+$/.test(host)) {
115
+ return null;
116
+ }
117
+ return host;
118
+ }
119
+ function sanitizeDnsSdText(value, maxBytes) {
120
+ const withoutControls = value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ');
121
+ let cleaned = withoutControls.replace(/\s+/g, ' ').trim();
122
+ while (Buffer.byteLength(cleaned, 'utf8') > maxBytes) {
123
+ cleaned = cleaned.slice(0, -1);
124
+ }
125
+ return cleaned.trim();
126
+ }
127
+ function runDnsSd(args, timeoutMs, maxOutputBytes) {
128
+ return new Promise(resolve => {
129
+ const child = spawn('dns-sd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
130
+ let output = '';
131
+ let killedForLimit = false;
132
+ const timer = setTimeout(() => {
133
+ child.kill('SIGTERM');
134
+ }, timeoutMs);
135
+ const appendOutput = (chunk) => {
136
+ if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) {
137
+ if (!killedForLimit) {
138
+ killedForLimit = true;
139
+ child.kill('SIGTERM');
140
+ }
141
+ return;
142
+ }
143
+ const remaining = maxOutputBytes - Buffer.byteLength(output, 'utf8');
144
+ output += chunk.toString('utf8').slice(0, remaining);
145
+ };
146
+ child.stdout.on('data', chunk => {
147
+ appendOutput(chunk);
148
+ });
149
+ child.stderr.on('data', chunk => {
150
+ appendOutput(chunk);
151
+ });
152
+ child.once('error', () => {
153
+ clearTimeout(timer);
154
+ resolve(output);
155
+ });
156
+ child.once('exit', () => {
157
+ clearTimeout(timer);
158
+ resolve(output);
159
+ });
160
+ });
161
+ }
162
+ async function mapWithConcurrency(items, concurrency, map) {
163
+ const results = [];
164
+ let nextIndex = 0;
165
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
166
+ while (nextIndex < items.length) {
167
+ const index = nextIndex;
168
+ nextIndex += 1;
169
+ results[index] = await map(items[index]);
170
+ }
171
+ });
172
+ await Promise.all(workers);
173
+ return results;
174
+ }
@@ -0,0 +1,98 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname } from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ const airPlaySenderPackageName = 'node-airtunes2';
6
+ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
7
+ const sender = lookupPackage(airPlaySenderPackageName);
8
+ if (!sender) {
9
+ return {
10
+ available: false,
11
+ safe: false,
12
+ packageName: airPlaySenderPackageName,
13
+ message: 'AirPlay sender package is not installed.',
14
+ vulnerablePackages: [],
15
+ warningPackages: []
16
+ };
17
+ }
18
+ const vulnerablePackages = blockingAirPlaySenderPackages(sender, lookupPackage);
19
+ const warningPackages = warningAirPlaySenderPackages(sender, lookupPackage);
20
+ if (vulnerablePackages.length > 0) {
21
+ return {
22
+ available: true,
23
+ safe: false,
24
+ packageName: sender.name,
25
+ version: sender.version,
26
+ message: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`,
27
+ vulnerablePackages,
28
+ warningPackages
29
+ };
30
+ }
31
+ return {
32
+ available: true,
33
+ safe: true,
34
+ packageName: sender.name,
35
+ version: sender.version,
36
+ message: warningPackages.length > 0
37
+ ? `Bundled AirPlay sender ${sender.name}@${sender.version} is available with warnings: ${warningPackages.join(', ')}.`
38
+ : `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`,
39
+ vulnerablePackages: [],
40
+ warningPackages
41
+ };
42
+ }
43
+ function blockingAirPlaySenderPackages(sender, lookupPackage) {
44
+ const packagePaths = [sender.root];
45
+ const vulnerable = [];
46
+ const protobuf = lookupPackage('protobufjs', packagePaths);
47
+ if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
48
+ vulnerable.push(`protobufjs@${protobuf.version}`);
49
+ }
50
+ return vulnerable;
51
+ }
52
+ function warningAirPlaySenderPackages(sender, lookupPackage) {
53
+ const packagePaths = [sender.root];
54
+ const vulnerable = [];
55
+ const elliptic = lookupPackage('elliptic', packagePaths);
56
+ if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
57
+ vulnerable.push(`elliptic@${elliptic.version}`);
58
+ }
59
+ return vulnerable;
60
+ }
61
+ function lookupInstalledPackage(packageName, paths) {
62
+ try {
63
+ const packageJsonPath = paths
64
+ ? require.resolve(`${packageName}/package.json`, { paths })
65
+ : require.resolve(`${packageName}/package.json`);
66
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
67
+ if (!parsed.version) {
68
+ return null;
69
+ }
70
+ return {
71
+ name: parsed.name ?? packageName,
72
+ version: parsed.version,
73
+ root: dirname(packageJsonPath)
74
+ };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function compareSemver(left, right) {
81
+ const leftParts = semverParts(left);
82
+ const rightParts = semverParts(right);
83
+ for (let index = 0; index < 3; index += 1) {
84
+ const delta = leftParts[index] - rightParts[index];
85
+ if (delta !== 0) {
86
+ return delta;
87
+ }
88
+ }
89
+ return 0;
90
+ }
91
+ function semverParts(version) {
92
+ const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/);
93
+ return [
94
+ Number.parseInt(major, 10) || 0,
95
+ Number.parseInt(minor, 10) || 0,
96
+ Number.parseInt(patch, 10) || 0
97
+ ];
98
+ }
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ const require = createRequire(import.meta.url);
4
+ const setupAp2DigestGuard = 'this.status === SETUP_AP2_1 && response.code === 401';
5
+ const responseHeadersMarker = ' headers = response.headers';
6
+ const processDataResponseMarker = ' if (this.status != OPTIONS && this.status != OPTIONS2 && this.mode == 0) {';
7
+ const setupAp2TimingPortMarker = ` let sa1_bplist = bplistParser.parseBuffer(buf7)
8
+ this.eventPort = sa1_bplist[0]['eventPort']
9
+ if (sa1_bplist[0]['timingPort'])
10
+ this.timingDestPort = sa1_bplist[0]['timingPort']
11
+ console.log('timing port ok', sa1_bplist[0]['timingPort'])`;
12
+ const setupAp2TimingPortFallback = ` let sa1_bplist
13
+ try {
14
+ sa1_bplist = buf7.length > 0 ? bplistParser.parseBuffer(buf7) : [{}]
15
+ }
16
+ catch (_) {
17
+ sa1_bplist = [{}]
18
+ }
19
+ this.eventPort = sa1_bplist[0]['eventPort'] || this.eventPort || this.socket.remotePort || 7000
20
+ if (sa1_bplist[0]['timingPort'])
21
+ this.timingDestPort = sa1_bplist[0]['timingPort']
22
+ console.log('timing port ok', sa1_bplist[0]['timingPort'])`;
23
+ const socketDataHandlerMarker = ` var blob = ''
24
+ this.socket.on('data', function (data) {
25
+ if (self.encryptedChannel && self.credentials) {
26
+ // if (self.debug != false) console.log("incoming", data)
27
+ data = self.credentials.decrypt(data)
28
+ }
29
+ self.clearTimeout()
30
+
31
+ /*
32
+ * I wish I could use node's HTTP parser for this...
33
+ * I assume that all responses have empty bodies.
34
+ */
35
+ var rawData = data
36
+ data = data.toString()
37
+
38
+ blob += data
39
+ var endIndex = blob.indexOf('\\r\\n\\r\\n')
40
+
41
+ if (endIndex < 0) {
42
+ return
43
+ }
44
+
45
+ endIndex += 4
46
+
47
+ blob = blob.substring(0, endIndex)
48
+ self.processData(blob, rawData)
49
+
50
+ blob = data.substring(endIndex)
51
+ })`;
52
+ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
53
+ this.socket.on('data', function (data) {
54
+ if (self.encryptedChannel && self.credentials) {
55
+ // if (self.debug != false) console.log("incoming", data)
56
+ data = self.credentials.decrypt(data)
57
+ }
58
+ self.clearTimeout()
59
+
60
+ blob = Buffer.concat([blob, Buffer.from(data)])
61
+ while (blob.length > 0) {
62
+ var endIndex = blob.indexOf('\\r\\n\\r\\n')
63
+
64
+ if (endIndex < 0) {
65
+ return
66
+ }
67
+
68
+ endIndex += 4
69
+ var headers = blob.slice(0, endIndex).toString()
70
+ var lengthMatch = /(?:^|\\r\\n)Content-Length:\\s*(\\d+)/i.exec(headers)
71
+ var contentLength = lengthMatch ? parseInt(lengthMatch[1], 10) : 0
72
+ var messageLength = endIndex + contentLength
73
+
74
+ if (blob.length < messageLength) {
75
+ return
76
+ }
77
+
78
+ var rawData = blob.slice(0, messageLength)
79
+ self.processData(headers, rawData)
80
+
81
+ blob = blob.slice(messageLength)
82
+ }
83
+ })`;
84
+ let installed = false;
85
+ export function installAirPlaySenderPatch(packageName = 'node-airtunes2') {
86
+ if (installed) {
87
+ return true;
88
+ }
89
+ let rtspPath;
90
+ try {
91
+ rtspPath = require.resolve(`${packageName}/lib/rtsp.js`);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ const moduleLoader = require('node:module');
97
+ const originalLoader = moduleLoader._extensions['.js'];
98
+ if (!originalLoader) {
99
+ return false;
100
+ }
101
+ moduleLoader._extensions['.js'] = (module, filename) => {
102
+ if (filename !== rtspPath) {
103
+ originalLoader(module, filename);
104
+ return;
105
+ }
106
+ module._compile(patchAirTunesRtspSource(readFileSync(filename, 'utf8')), filename);
107
+ };
108
+ installed = true;
109
+ return true;
110
+ }
111
+ export function patchAirTunesRtspSource(source) {
112
+ let patched = source;
113
+ if (patched.includes(socketDataHandlerMarker) && !patched.includes('Buffer.concat([blob, Buffer.from(data)])')) {
114
+ patched = patched.replace(socketDataHandlerMarker, bufferedSocketDataHandler);
115
+ }
116
+ if (patched.includes(setupAp2TimingPortMarker) && !patched.includes("this.socket.remotePort || 7000")) {
117
+ patched = patched.replace(setupAp2TimingPortMarker, setupAp2TimingPortFallback);
118
+ }
119
+ if (patched.includes(responseHeadersMarker) && !patched.includes('headers = response.headers || {}')) {
120
+ patched = patched.replace(responseHeadersMarker, ' headers = response.headers || {}');
121
+ }
122
+ if (patched.includes(setupAp2DigestGuard) || !patched.includes(processDataResponseMarker)) {
123
+ return patched;
124
+ }
125
+ const setupAp2DigestRetry = ` if (${setupAp2DigestGuard} && headers['WWW-Authenticate'] && this.password) {
126
+ const auth = headers['WWW-Authenticate']
127
+ const di = {
128
+ realm: parseAuthenticate(auth, 'realm'),
129
+ nonce: parseAuthenticate(auth, 'nonce'),
130
+ username: 'iTunes',
131
+ password: this.password,
132
+ }
133
+ this.sendNextRequest(di)
134
+ return
135
+ }
136
+ `;
137
+ return patched.replace(processDataResponseMarker, `${setupAp2DigestRetry}${processDataResponseMarker}`);
138
+ }