@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,13 +1,123 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { parseRadioCommand, parseRadioRequest } from './command-schema.js';
3
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
4
  import { createServer, request } from 'node:http';
4
- import { homedir } from 'node:os';
5
+ import { platformPaths } from '../platform/paths.js';
5
6
  import { dirname, join } from 'node:path';
6
- export async function startRadioSession(handle, filePath = radioSessionPath()) {
7
+ import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
8
+ /** The wire protocol accepts appearance/MCP preferences, never experimental-output consent. */
9
+ export function allowedRadioSessionSettings(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ && Object.keys(value).every(key => ['theme', 'receiverStyle', 'agentControl'].includes(key));
12
+ }
13
+ /** Resolve desktop intent at the owner queue boundary, after earlier commands
14
+ * have completed. In particular, Play/PlayPause must not use a stale snapshot. */
15
+ export function resolveDesktopTransport(action, status) {
16
+ const { playback, station, queue } = status;
17
+ const stopped = ['idle', 'stopped'].includes(playback.state);
18
+ if (action === 'play') {
19
+ if (playback.state === 'paused')
20
+ return { type: 'resume' };
21
+ return stopped && station ? { type: 'resume', restartStopped: true } : null;
22
+ }
23
+ if (action === 'pause')
24
+ return playback.state === 'playing' && playback.backend === 'mpv' ? { type: 'pause' } : null;
25
+ if (action === 'toggle') {
26
+ if (!station || playback.backend !== 'mpv')
27
+ throw new Error('This output cannot toggle pause.');
28
+ return playback.state === 'playing' ? { type: 'pause' } : playback.state === 'paused' ? { type: 'resume' } : stopped ? { type: 'resume', restartStopped: true } : null;
29
+ }
30
+ // Even an idle backend can have an unresolved tune or delayed retry to cancel.
31
+ if (action === 'stop')
32
+ return { type: 'stop', retainSelection: true };
33
+ return station && queue.length > 1 && (stopped || playback.state === 'playing' || playback.state === 'paused')
34
+ ? { type: action, preservePause: true, preserveStop: true } : null;
35
+ }
36
+ /** Agent and desktop controls must use the same owner queue. A failed command
37
+ * cannot strand the queue or allow a later command to overtake an active one. */
38
+ export function serializeRadioCommands(handle, options = {}) {
39
+ let pending = Promise.resolve();
40
+ let tuning = Promise.resolve();
41
+ let admitted = 0;
42
+ const tunes = new Set();
43
+ return (input, permitted) => {
44
+ let command;
45
+ try {
46
+ command = parseRadioCommand(input);
47
+ }
48
+ catch (error) {
49
+ return Promise.reject(error);
50
+ }
51
+ const priority = command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'tui-cancel'
52
+ || command.type === 'desktop-transport' && command.action === 'stop';
53
+ const checkPermission = async () => { if (permitted && !await permitted())
54
+ throw new Error('Desktop media controls are suspended.'); };
55
+ if (options.responsive && command.type === 'status')
56
+ return Promise.resolve().then(async () => { await checkPermission(); return handle(command); });
57
+ if (options.responsive && priority) {
58
+ const interrupted = [...tunes];
59
+ const priorPending = pending;
60
+ const priorTuning = tuning;
61
+ const work = checkPermission().then(() => {
62
+ for (const controller of interrupted)
63
+ controller.abort(new DOMException('Tuning canceled by Stop or alarm.', 'AbortError'));
64
+ return handle(command);
65
+ });
66
+ pending = work.then(() => undefined, () => priorPending);
67
+ tuning = work.then(() => undefined, () => priorTuning);
68
+ return work;
69
+ }
70
+ if (admitted >= (options.limit ?? 128))
71
+ return Promise.reject(new Error('RadioCLI control queue is busy. Status and Stop remain available.'));
72
+ admitted++;
73
+ const replacesTune = ['play', 'airplay-select', 'airplay-local'].includes(command.type);
74
+ const transport = replacesTune || ['pause', 'resume', 'next', 'previous', 'desktop-transport'].includes(command.type);
75
+ const long = options.responsive && (['play', 'resume', 'next', 'previous', 'airplay-select', 'airplay-local'].includes(command.type) || command.type === 'desktop-transport' && ['play', 'next', 'previous'].includes(command.action));
76
+ const replaced = options.responsive && replacesTune ? [...tunes] : [];
77
+ const priorTuning = tuning;
78
+ let executing = false;
79
+ const controller = new AbortController();
80
+ if (options.responsive && transport)
81
+ tunes.add(controller);
82
+ const fence = options.responsive && transport && !replacesTune ? Promise.all([pending, tuning]).then(() => undefined) : pending;
83
+ const start = abortableControl(fence, controller.signal).then(async () => {
84
+ await checkPermission();
85
+ controller.signal.throwIfAborted();
86
+ for (const earlier of replaced)
87
+ earlier.abort(new DOMException('Tuning canceled by a replacement.', 'AbortError'));
88
+ executing = true;
89
+ const result = handle(command, controller.signal);
90
+ return { result };
91
+ });
92
+ const result = abortableControl(start.then(({ result }) => result), controller.signal).finally(() => { admitted--; tunes.delete(controller); });
93
+ // Tune completion is separate from command admission. A later tune replaces
94
+ // an active one, while Stop and status bypass both queues entirely.
95
+ pending = (long ? start : result).then(() => undefined, () => undefined);
96
+ if (long)
97
+ tuning = result.then(() => undefined, () => executing ? undefined : priorTuning);
98
+ // Cancellation is an expected control outcome. Keep the rejection available
99
+ // to the caller without leaking it while another transport action is awaited.
100
+ void result.catch(() => undefined);
101
+ return result;
102
+ };
103
+ }
104
+ function abortableControl(work, signal) {
105
+ if (signal.aborted)
106
+ return Promise.reject(signal.reason);
107
+ return new Promise((resolve, reject) => {
108
+ const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason); };
109
+ signal.addEventListener('abort', abort, { once: true });
110
+ void work.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
111
+ });
112
+ }
113
+ /** Use queue: 'owner' only when handle submits directly to the owner's shared
114
+ * serialized dispatcher. Other callers retain the session's own FIFO. */
115
+ export async function startRadioSession(handle, filePath = radioSessionPath(), options = {}) {
7
116
  const ownerPath = `${filePath}.owner`;
8
117
  acquireOwner(ownerPath);
9
118
  const token = randomBytes(32).toString('hex');
10
119
  let serial = Promise.resolve();
120
+ const requests = new Map();
11
121
  const server = createServer((req, res) => {
12
122
  res.setHeader('content-type', 'application/json');
13
123
  if (req.headers.authorization !== `Bearer ${token}`) {
@@ -20,27 +130,51 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
20
130
  res.end('{}');
21
131
  return;
22
132
  }
23
- const work = serial.then(async () => {
133
+ const accept = async () => {
24
134
  const body = await readBody(req);
25
- return handle(JSON.parse(body));
26
- });
27
- serial = work.then(() => undefined, () => undefined);
135
+ const { command, requestId } = parseRadioRequest(JSON.parse(body));
136
+ const execute = () => handle(command, req.headers['x-radiocli-client'] === 'cli' ? 'cli' : 'agent');
137
+ if (!requestId || command.type === 'status')
138
+ return execute();
139
+ const fingerprint = createHash('sha256').update(JSON.stringify(command)).digest('hex');
140
+ const existing = requests.get(requestId);
141
+ if (existing) {
142
+ if (existing.fingerprint !== fingerprint)
143
+ throw new Error('This request ID was already used for a different command.');
144
+ return existing.result;
145
+ }
146
+ for (const [id, entry] of requests)
147
+ if (entry.settledAt && (Date.now() - entry.settledAt > 300_000 || requests.size >= 256))
148
+ requests.delete(id);
149
+ if (requests.size >= 256) {
150
+ // Stop/preemption are intrinsically idempotent and must remain available
151
+ // even when ordinary requests occupy the entire bounded outcome cache.
152
+ if (command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'desktop-transport' && command.action === 'stop')
153
+ return execute();
154
+ throw new Error('RadioCLI has too many active control requests.');
155
+ }
156
+ const entry = { fingerprint, result: Promise.resolve().then(execute) };
157
+ requests.set(requestId, entry);
158
+ void entry.result.then(() => { entry.settledAt = Date.now(); }, () => { entry.settledAt = Date.now(); });
159
+ return entry.result;
160
+ };
161
+ // An owner shared with desktop controls already serializes accepted work.
162
+ // Submit each authenticated, complete request directly to that queue so a
163
+ // later desktop action cannot overtake an earlier HTTP request here.
164
+ const work = options.queue === 'owner' ? accept() : serial.then(accept);
165
+ if (options.queue !== 'owner')
166
+ serial = work.then(() => undefined, () => undefined);
28
167
  void work.then(result => res.end(JSON.stringify(result))).catch(error => {
29
168
  res.statusCode = 400;
30
169
  res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Radio control failed.' }));
31
170
  });
32
171
  });
33
172
  try {
34
- await new Promise((resolve, reject) => {
35
- server.once('error', reject);
36
- server.listen(0, '127.0.0.1', () => resolve());
37
- });
38
- const address = server.address();
39
- if (!address || typeof address === 'string')
40
- throw new Error('Unable to create RadioCLI control endpoint.');
173
+ const address = await listenLoopback(server);
41
174
  const discovery = {
42
175
  version: 1,
43
- host: '127.0.0.1',
176
+ protocol: 2,
177
+ host: address.host,
44
178
  port: address.port,
45
179
  token,
46
180
  pid: process.pid,
@@ -61,13 +195,14 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
61
195
  throw error;
62
196
  }
63
197
  }
64
- export async function connectRadioSession(filePath = radioSessionPath()) {
198
+ export async function connectRadioSession(filePath = radioSessionPath(), options = {}) {
199
+ options.signal?.throwIfAborted();
65
200
  if (!existsSync(filePath))
66
201
  return null;
67
202
  let discovery;
68
203
  try {
69
204
  discovery = JSON.parse(readFileSync(filePath, 'utf8'));
70
- if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) {
205
+ if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) {
71
206
  throw new Error('invalid');
72
207
  }
73
208
  }
@@ -75,48 +210,72 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
75
210
  rmSync(filePath, { force: true });
76
211
  return null;
77
212
  }
78
- const call = async (command) => {
79
- const payload = JSON.stringify(command);
213
+ const call = async (command, requestId = randomUUID()) => {
214
+ const validated = parseRadioCommand(command);
215
+ const payload = JSON.stringify(discovery.protocol === 2 ? { version: 2, requestId, command: validated } : validated);
80
216
  return new Promise((resolve, reject) => {
81
217
  const req = request({
82
- host: '127.0.0.1',
218
+ host: discovery.host,
219
+ // Local control tokens must never be forwarded by an environment proxy.
220
+ agent: false,
221
+ signal: options.signal,
83
222
  port: discovery.port,
84
223
  path: '/command',
85
224
  method: 'POST',
86
225
  headers: {
87
226
  authorization: `Bearer ${discovery.token}`,
227
+ 'x-radiocli-client': options.source ?? 'agent',
88
228
  'content-type': 'application/json',
89
229
  'content-length': Buffer.byteLength(payload)
90
230
  }
91
231
  }, res => {
92
232
  let text = '';
93
- res.on('data', value => { text += String(value); });
233
+ res.setEncoding('utf8');
234
+ res.on('data', value => { text += String(value); if (Buffer.byteLength(text) > 8 * 1024 * 1024)
235
+ res.destroy(new Error('RadioCLI response too large.')); });
236
+ res.once('error', reject);
94
237
  res.on('end', () => {
95
- if (res.statusCode && res.statusCode < 300)
96
- resolve(JSON.parse(text));
97
- else
98
- reject(new Error(parseError(text)));
238
+ try {
239
+ if (res.statusCode && res.statusCode < 300) {
240
+ const parsed = JSON.parse(text);
241
+ if (typeof parsed.ok !== 'boolean' || typeof parsed.message !== 'string' || !parsed.status?.playback || !Array.isArray(parsed.status.queue))
242
+ throw new Error('Invalid RadioCLI control response.');
243
+ resolve(parsed);
244
+ }
245
+ else
246
+ reject(new Error(parseError(text)));
247
+ }
248
+ catch (error) {
249
+ reject(error);
250
+ }
99
251
  });
100
252
  });
101
253
  req.once('error', reject);
102
- req.setTimeout(15_000, () => req.destroy(new Error('RadioCLI control request timed out.')));
254
+ const timeout = command.type === 'status' ? 5000 : ['stop', 'alarm-preempt'].includes(command.type) ? 15_000 : 120_000;
255
+ req.setTimeout(timeout, () => req.destroy(new Error(`RadioCLI control request timed out. Request ID: ${requestId}. Retry with the same ID to retrieve its outcome.`)));
103
256
  req.end(payload);
104
257
  });
105
258
  };
106
259
  try {
107
260
  await call({ type: 'status' });
261
+ options.signal?.throwIfAborted();
108
262
  return { call, status: async () => (await call({ type: 'status' })).status };
109
263
  }
110
264
  catch {
111
- if (!processAlive(discovery.pid) || discoveryAgeMs(filePath, discovery) > 60_000) {
265
+ options.signal?.throwIfAborted();
266
+ // A slow command does not prove that a playback host is dead. Retain both
267
+ // ownership records while its PID is alive so a second host cannot start.
268
+ if (!processAlive(discovery.pid)) {
112
269
  removeIfOwned(filePath, discovery);
113
270
  removeOwner(`${filePath}.owner`, discovery.pid);
114
271
  }
115
272
  return null;
116
273
  }
117
274
  }
118
- export async function ensureRadioSession(start, timeoutMs = 15_000) {
119
- const existing = await connectRadioSession();
275
+ export async function ensureRadioSession(start, timeoutMs = 15_000, options = {}) {
276
+ options.signal?.throwIfAborted();
277
+ const existing = await connectRadioSession(undefined, options);
278
+ options.signal?.throwIfAborted();
120
279
  if (existing)
121
280
  return existing;
122
281
  const lockPath = `${radioSessionPath()}.launch`;
@@ -124,16 +283,18 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
124
283
  try {
125
284
  acquireOwner(lockPath);
126
285
  ownsLaunch = true;
127
- const appeared = await connectRadioSession();
286
+ const appeared = await connectRadioSession(undefined, options);
287
+ options.signal?.throwIfAborted();
128
288
  if (appeared)
129
289
  return appeared;
130
290
  await start();
131
- return await waitForSession(timeoutMs);
291
+ options.signal?.throwIfAborted();
292
+ return await waitForSession(timeoutMs, options);
132
293
  }
133
294
  catch (error) {
134
295
  if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active'))
135
296
  throw error;
136
- return waitForSession(timeoutMs);
297
+ return waitForSession(timeoutMs, options);
137
298
  }
138
299
  finally {
139
300
  if (ownsLaunch)
@@ -143,22 +304,45 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
143
304
  function radioSessionPath() {
144
305
  return join(runtimeDirectory(), 'agent-session.json');
145
306
  }
146
- async function waitForSession(timeoutMs) {
307
+ /** Optional terminal recovery may fall back to inline playback only when no
308
+ * live or unknown owner could already be using this library's audio session. */
309
+ export function radioSessionMayBeActive() {
310
+ for (const path of [`${radioSessionPath()}.owner`, `${radioSessionPath()}.launch`, radioSessionPath()]) {
311
+ try {
312
+ const text = readFileSync(path, 'utf8');
313
+ const pid = path.endsWith('.json') ? JSON.parse(text).pid : Number(text.trim());
314
+ if (!Number.isSafeInteger(pid) || pid <= 0 || processAlive(pid))
315
+ return true;
316
+ }
317
+ catch (error) {
318
+ if (error.code !== 'ENOENT')
319
+ return true;
320
+ }
321
+ }
322
+ return false;
323
+ }
324
+ async function waitForSession(timeoutMs, options) {
147
325
  const deadline = Date.now() + timeoutMs;
148
326
  while (Date.now() < deadline) {
149
- const client = await connectRadioSession();
327
+ options.signal?.throwIfAborted();
328
+ const client = await connectRadioSession(undefined, options);
329
+ options.signal?.throwIfAborted();
150
330
  if (client)
151
331
  return client;
152
- await new Promise(resolve => setTimeout(resolve, 100));
332
+ await controlPollDelay(options.signal);
153
333
  }
154
334
  throw new Error('RadioCLI was launched but its control session did not become ready.');
155
335
  }
336
+ function controlPollDelay(signal) {
337
+ signal?.throwIfAborted();
338
+ return new Promise((resolve, reject) => {
339
+ const abort = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); reject(signal?.reason); };
340
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, 100);
341
+ signal?.addEventListener('abort', abort, { once: true });
342
+ });
343
+ }
156
344
  function runtimeDirectory() {
157
- if (process.env.RADIOCLI_HOME)
158
- return join(process.env.RADIOCLI_HOME, 'runtime');
159
- if (process.platform === 'win32')
160
- return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime');
161
- return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli');
345
+ return platformPaths().runtime;
162
346
  }
163
347
  function acquireOwner(path) {
164
348
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -214,23 +398,13 @@ function processAlive(pid) {
214
398
  return error.code === 'EPERM';
215
399
  }
216
400
  }
217
- function discoveryAgeMs(path, discovery) {
218
- const created = Date.parse(discovery.createdAt);
219
- if (Number.isFinite(created))
220
- return Math.max(0, Date.now() - created);
221
- try {
222
- return Math.max(0, Date.now() - statSync(path).mtimeMs);
223
- }
224
- catch {
225
- return 0;
226
- }
227
- }
228
401
  function readBody(req) {
229
402
  return new Promise((resolve, reject) => {
230
403
  let body = '';
404
+ req.setEncoding('utf8');
231
405
  req.on('data', value => {
232
406
  body += String(value);
233
- if (body.length > 1_000_000)
407
+ if (Buffer.byteLength(body) > 4 * 1024 * 1024)
234
408
  req.destroy(new Error('Request too large.'));
235
409
  });
236
410
  req.on('end', () => resolve(body));
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { PlayerController } from '../player/player-controller.js';
6
+ import { launchHeadlessHost } from './launcher.js';
7
+ import { platformPaths } from '../platform/paths.js';
8
+ import { storageReadiness } from '../platform/storage.js';
9
+ import { listenLoopback } from '../platform/loopback.js';
10
+ import { ensureRadioSession, radioSessionMayBeActive } from './session.js';
11
+ export async function openTuiSession() {
12
+ if (!radioSessionMayBeActive()) {
13
+ if (process.env.RADIOCLI_TUI_PLAYBACK === 'inline')
14
+ return { warning: 'Playback runs in this terminal. Crash recovery is disabled by RADIOCLI_TUI_PLAYBACK=inline.' };
15
+ if (storageReadiness(join(platformPaths().runtime, 'agent-session.json')).status === 'unavailable') {
16
+ return { warning: 'Playback recovery is unavailable: the runtime directory is not writable. Set RADIOCLI_HOME to a private writable directory.' };
17
+ }
18
+ const probe = createServer();
19
+ try {
20
+ await listenLoopback(probe);
21
+ }
22
+ catch {
23
+ return { warning: 'Playback recovery is unavailable: this environment blocks local loopback control. Playback runs in this terminal.' };
24
+ }
25
+ finally {
26
+ await new Promise(resolve => probe.close(() => resolve()));
27
+ }
28
+ }
29
+ return { session: await TuiSession.connect() };
30
+ }
31
+ /** A terminal is a view of the existing playback owner. Its death must never
32
+ * destroy the owner's IPC connection, queue, timers, or listening history. */
33
+ export class TuiSession {
34
+ client;
35
+ clientId;
36
+ status;
37
+ listeners = new Set();
38
+ timer;
39
+ revision = 0;
40
+ closing;
41
+ closed = false;
42
+ constructor(client, clientId, status) {
43
+ this.client = client;
44
+ this.clientId = clientId;
45
+ this.status = status;
46
+ }
47
+ static async connect() {
48
+ const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../cli.tsx' : '../cli.js', import.meta.url));
49
+ const client = await ensureRadioSession(() => launchHeadlessHost(process.execPath, cli), 15_000, { source: 'cli' });
50
+ const clientId = randomUUID();
51
+ let result;
52
+ try {
53
+ result = await client.call({ type: 'tui-attach', pid: process.pid, clientId });
54
+ }
55
+ catch (error) {
56
+ if (error instanceof Error && /Invalid radio command|Unsupported radio command/i.test(error.message)) {
57
+ throw new Error('The running player predates TUI recovery. Run radiocli stop, then reopen radiocli to use the updated player.');
58
+ }
59
+ throw error;
60
+ }
61
+ if (!result.ok)
62
+ throw new Error(result.message);
63
+ if (!result.status.tui)
64
+ throw new Error('The playback host does not support terminal recovery. Stop the older session and reopen RadioCLI.');
65
+ const session = new TuiSession(client, clientId, result.status);
66
+ session.poll();
67
+ return session;
68
+ }
69
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
70
+ async call(command) {
71
+ if (this.closed)
72
+ throw new Error('RadioCLI terminal session is closed.');
73
+ const result = await this.client.call(command);
74
+ this.revision++;
75
+ this.status = result.status;
76
+ this.emit();
77
+ return result;
78
+ }
79
+ report(error) {
80
+ this.status = { ...this.status, persistenceWarning: error instanceof Error ? error.message : String(error) };
81
+ this.emit();
82
+ }
83
+ disconnect() {
84
+ clearTimeout(this.timer);
85
+ this.closed = true;
86
+ this.listeners.clear();
87
+ }
88
+ close() {
89
+ return this.closing ??= (async () => {
90
+ clearTimeout(this.timer);
91
+ try {
92
+ const result = await this.call({ type: 'tui-detach', clientId: this.clientId });
93
+ if (!result.ok)
94
+ throw new Error(result.message);
95
+ }
96
+ finally {
97
+ this.disconnect();
98
+ }
99
+ })();
100
+ }
101
+ poll() {
102
+ this.timer = setTimeout(() => {
103
+ const revision = this.revision;
104
+ void this.client.status().then(status => {
105
+ if (!this.closed && revision === this.revision && JSON.stringify(status) !== JSON.stringify(this.status)) {
106
+ this.status = status;
107
+ this.emit();
108
+ }
109
+ }).catch(() => {
110
+ if (!this.closed && revision === this.revision) {
111
+ this.status = { ...this.status, playback: { ...this.status.playback, state: 'error', ready: false,
112
+ message: 'Playback host disconnected. Reopen RadioCLI to reconnect.' } };
113
+ this.emit();
114
+ }
115
+ }).finally(() => { if (!this.closed && !this.closing)
116
+ this.poll(); });
117
+ }, 250);
118
+ this.timer.unref();
119
+ }
120
+ emit() { for (const listener of this.listeners)
121
+ listener(); }
122
+ }
123
+ /** Keep synchronous presentation queries local, while every audio mutation
124
+ * reaches the same owner used by CLI, agent, alarm, and desktop controls. */
125
+ export class TuiPlayer extends PlayerController {
126
+ session;
127
+ constructor(session, settings) {
128
+ super(settings);
129
+ this.session = session;
130
+ }
131
+ getState() { return { ...this.session.status.playback }; }
132
+ onChange(listener) {
133
+ listener(this.getState());
134
+ return this.session.subscribe(() => listener(this.getState()));
135
+ }
136
+ onMetadata(listener) {
137
+ let previous;
138
+ return this.session.subscribe(() => {
139
+ const metadata = this.session.status.nowPlaying;
140
+ const key = JSON.stringify(metadata);
141
+ if (metadata && key !== previous) {
142
+ previous = key;
143
+ listener(metadata);
144
+ }
145
+ });
146
+ }
147
+ diagnostics() { return { ...this.session.status.tui.diagnostics }; }
148
+ desktopMediaCompatible() { return this.session.status.tui.desktopMediaCompatible; }
149
+ detectedBackends() { return [...this.session.status.tui.diagnostics.availableBackends]; }
150
+ refreshDetectedBackends() {
151
+ void this.session.call({ type: 'tui-refresh' }).catch(error => this.session.report(error));
152
+ return this.detectedBackends();
153
+ }
154
+ detectedAirPlayDevices() { return [...this.session.status.tui.devices]; }
155
+ airPlaySelectionRequired() { return this.session.status.tui.selectionRequired; }
156
+ async refreshAirPlayDevices() {
157
+ const result = await this.session.call({ type: 'airplay-list' });
158
+ if (!result.ok)
159
+ throw new Error(result.message);
160
+ return result.data ?? [];
161
+ }
162
+ async selectAirPlayDevice(deviceId) { return (await this.session.call({ type: 'tui-output', deviceId })).ok; }
163
+ async submitAirPlayPasscode(code) { return this.control({ type: 'airplay-passcode', code }); }
164
+ async pause() { return this.control({ type: 'pause' }); }
165
+ async resume() { return this.control({ type: 'resume' }); }
166
+ async togglePause() { return this.control({ type: 'tui-toggle-pause' }); }
167
+ async setVolume(volume, options = {}) {
168
+ return this.control({ type: 'set-volume', volume: Math.max(0, Math.min(100, Math.round(volume))), allowStopped: options.allowStopped });
169
+ }
170
+ async adjustVolume(delta) { return this.control({ type: 'tui-adjust-volume', delta }); }
171
+ async setMuted(muted, options = {}) { return this.control({ type: 'set-muted', muted, allowStopped: options.allowStopped }); }
172
+ async toggleMute() { return this.control({ type: 'tui-toggle-muted' }); }
173
+ async stop() { await this.session.call({ type: 'stop', retainSelection: true }); }
174
+ async play() { throw new Error('Terminal playback must use the shared radio engine.'); }
175
+ async control(command) {
176
+ const result = await this.session.call(command);
177
+ return { ok: result.ok, message: result.message };
178
+ }
179
+ }
180
+ export class TuiRadioEngine {
181
+ session;
182
+ constructor(session) {
183
+ this.session = session;
184
+ }
185
+ snapshot() {
186
+ const status = this.session.status;
187
+ return { station: status.station, tuningStation: status.tui?.tuningStation ?? null, queue: [...status.queue],
188
+ playback: { ...status.playback }, nowPlaying: status.nowPlaying ?? null, warning: status.persistenceWarning, message: status.tui?.message };
189
+ }
190
+ onChange(listener) { listener(this.snapshot()); return this.session.subscribe(() => listener(this.snapshot())); }
191
+ clearWarning() { this.session.status.persistenceWarning = undefined; }
192
+ cancel() { void this.session.call({ type: 'tui-cancel' }).catch(error => this.session.report(error)); }
193
+ async tune(station, options = {}) {
194
+ options.signal?.throwIfAborted();
195
+ const abort = () => this.cancel();
196
+ options.signal?.addEventListener('abort', abort, { once: true });
197
+ try {
198
+ const result = await this.session.call({ type: 'play', station, queue: options.queue, startPaused: options.startPaused });
199
+ options.signal?.throwIfAborted();
200
+ if (!result.ok || !result.status.station)
201
+ throw new Error(result.message);
202
+ return result.status.station;
203
+ }
204
+ finally {
205
+ options.signal?.removeEventListener('abort', abort);
206
+ }
207
+ }
208
+ async stop(options = {}) { await this.session.call({ type: 'stop', ...options }); }
209
+ async pause() { const result = await this.session.call({ type: 'pause' }); return { ok: result.ok, message: result.message }; }
210
+ async resume() { const result = await this.session.call({ type: 'resume' }); return { ok: result.ok, message: result.message }; }
211
+ select(station, queue) { void this.session.call({ type: 'tui-select', station, queue }).catch(error => this.session.report(error)); }
212
+ close() { return this.session.close(); }
213
+ }