@ciphore/radiocli 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,356 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createConnection as connectSocket } from 'node:net';
3
+ const objectPath = '/org/mpris/MediaPlayer2';
4
+ const rootInterface = 'org.mpris.MediaPlayer2';
5
+ const playerInterface = `${rootInterface}.Player`;
6
+ const propertiesInterface = 'org.freedesktop.DBus.Properties';
7
+ /** Only local Unix transports are accepted. Never hand a unixexec or TCP
8
+ * address to the dependency's address parser. Node handles abstract sockets. */
9
+ export function sessionBusSocket(address) {
10
+ if (address.length > 4096 || /[\0\r\n]/.test(address))
11
+ return null;
12
+ for (const entry of address.split(';')) {
13
+ if (!entry.startsWith('unix:'))
14
+ continue;
15
+ const values = new Map();
16
+ try {
17
+ for (const part of entry.slice(5).split(',')) {
18
+ const at = part.indexOf('=');
19
+ if (at <= 0)
20
+ throw new Error('Invalid bus address');
21
+ const key = part.slice(0, at);
22
+ if (values.has(key))
23
+ throw new Error('Duplicate bus address key');
24
+ values.set(key, decodeURIComponent(part.slice(at + 1)));
25
+ }
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ const path = values.get('path');
31
+ const abstract = values.get('abstract');
32
+ if (path && !abstract && path.startsWith('/') && !/[\0\r\n]/.test(path) && Buffer.byteLength(path) < 108)
33
+ return path;
34
+ if (abstract && !path && !/[\0\r\n]/.test(abstract) && Buffer.byteLength(abstract) < 107)
35
+ return `\0${abstract}`;
36
+ }
37
+ return null;
38
+ }
39
+ export function mprisServiceName(identity) {
40
+ return `${rootInterface}.radiocli.h${createHash('sha256').update(identity).digest('hex').slice(0, 24)}`;
41
+ }
42
+ export function mprisProperties(snapshot) {
43
+ const { playback, station, queue } = snapshot;
44
+ const active = snapshot.available && playback.ready && ['playing', 'paused'].includes(playback.state);
45
+ const selected = snapshot.available && Boolean(station) && (active || ['idle', 'stopped'].includes(playback.state));
46
+ const pause = selected && playback.backend === 'mpv';
47
+ const metadata = [];
48
+ if (selected && station) {
49
+ metadata.push(['mpris:trackid', ['o', `/org/radiocli/track/t${createHash('sha256').update(`${station.provider}\0${station.id}`).digest('hex').slice(0, 24)}`]]);
50
+ metadata.push(['xesam:title', ['s', publicText(snapshot.title) || publicText(station.name) || 'RadioCLI']]);
51
+ const stationName = publicText(station.name);
52
+ if (stationName)
53
+ metadata.push(['xesam:album', ['s', stationName]]);
54
+ }
55
+ return {
56
+ root: {
57
+ CanQuit: ['b', false], CanRaise: ['b', false], HasTrackList: ['b', false],
58
+ Identity: ['s', 'RadioCLI'], SupportedUriSchemes: ['as', []], SupportedMimeTypes: ['as', []]
59
+ },
60
+ player: {
61
+ PlaybackStatus: ['s', active ? playback.state === 'paused' ? 'Paused' : 'Playing' : 'Stopped'],
62
+ Rate: ['d', 1], Metadata: ['a{sv}', metadata], Volume: ['d', playback.muted ? 0 : Math.max(0, Math.min(1, playback.volume / 100))],
63
+ Position: ['x', 0], MinimumRate: ['d', 1], MaximumRate: ['d', 1],
64
+ CanGoNext: ['b', selected && queue.length > 1], CanGoPrevious: ['b', selected && queue.length > 1],
65
+ CanPlay: ['b', selected], CanPause: ['b', pause], CanSeek: ['b', false], CanControl: ['b', snapshot.available]
66
+ }
67
+ };
68
+ }
69
+ function publicText(value) {
70
+ // Broadcast text is untrusted; URI/path-shaped metadata can contain tokens.
71
+ return (value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').replace(/(?:[a-z][a-z0-9+.-]*:\/\/|\bfile:|\b[a-z]:[\\/]|(?:^|\s)[/~\\])\S*/gi, '').trim().slice(0, 512);
72
+ }
73
+ /** A small, typed MPRIS endpoint. Closing the private connection releases its
74
+ * name, so no ReleaseName race can release a replacement owner's service. */
75
+ export function startMpris(options) {
76
+ const path = sessionBusSocket(options.address);
77
+ let disposed = false;
78
+ let generation = 0;
79
+ let connection;
80
+ let connected = false;
81
+ let registered = false;
82
+ let starting = false;
83
+ let serial = 1;
84
+ let retries = 0;
85
+ let timer;
86
+ let lastProperties = '';
87
+ const pending = new Map();
88
+ const available = () => !disposed && options.snapshot().available;
89
+ const send = (message) => {
90
+ if (connected && connection && !connection.stream.destroyed)
91
+ connection.message({ ...message, serial: serial++ });
92
+ };
93
+ const disconnect = () => {
94
+ generation += 1;
95
+ registered = false;
96
+ starting = false;
97
+ connected = false;
98
+ if (timer)
99
+ clearTimeout(timer);
100
+ timer = undefined;
101
+ for (const request of pending.values()) {
102
+ clearTimeout(request.timer);
103
+ request.reject();
104
+ }
105
+ pending.clear();
106
+ connection?.stream.destroy();
107
+ connection = undefined;
108
+ };
109
+ const retry = () => {
110
+ disconnect();
111
+ if (!available())
112
+ return;
113
+ timer = setTimeout(() => { timer = undefined; void start(); }, Math.min(30_000, 500 * 2 ** Math.min(6, retries++)));
114
+ timer.unref();
115
+ };
116
+ const call = (member, signature = '', body = []) => new Promise((resolve, reject) => {
117
+ if (!connected || !connection || connection.stream.destroyed) {
118
+ reject(new Error('Session bus disconnected.'));
119
+ return;
120
+ }
121
+ const id = serial++;
122
+ const timeout = setTimeout(() => { pending.delete(id); reject(new Error('Session bus did not respond.')); }, 2000);
123
+ timeout.unref();
124
+ pending.set(id, { resolve, reject: () => reject(new Error('Session bus disconnected.')), timer: timeout });
125
+ connection.message({ type: 1, serial: id, path: '/org/freedesktop/DBus', destination: 'org.freedesktop.DBus', interface: 'org.freedesktop.DBus', member, signature, body });
126
+ });
127
+ const error = (request, name, detail) => send({ type: 3, destination: request.sender, replySerial: request.serial, errorName: `org.freedesktop.DBus.Error.${name}`, signature: 's', body: [detail] });
128
+ const reply = (request, signature = '', body = []) => send({ type: 2, destination: request.sender, replySerial: request.serial, signature, body });
129
+ const receive = async (request, current) => {
130
+ if (current !== generation || disposed)
131
+ return;
132
+ if (request.type === 2 || request.type === 3) {
133
+ const handler = request.replySerial === undefined ? undefined : pending.get(request.replySerial);
134
+ if (handler) {
135
+ pending.delete(request.replySerial);
136
+ clearTimeout(handler.timer);
137
+ if (request.type === 2)
138
+ handler.resolve(request.body ?? []);
139
+ else
140
+ handler.reject();
141
+ }
142
+ return;
143
+ }
144
+ if (request.type !== 1 || !registered)
145
+ return;
146
+ if (request.path !== objectPath) {
147
+ error(request, 'UnknownObject', 'Unknown object.');
148
+ return;
149
+ }
150
+ try {
151
+ if (request.interface === 'org.freedesktop.DBus.Introspectable' && request.member === 'Introspect' && !request.signature) {
152
+ reply(request, 's', [introspection]);
153
+ return;
154
+ }
155
+ if (request.interface === 'org.freedesktop.DBus.Peer' && request.member === 'Ping' && !request.signature) {
156
+ reply(request);
157
+ return;
158
+ }
159
+ const properties = mprisProperties(options.snapshot());
160
+ if (request.interface === propertiesInterface) {
161
+ const [iface, property, value] = request.body ?? [];
162
+ const values = iface === rootInterface ? properties.root : iface === playerInterface ? properties.player : null;
163
+ if (!values) {
164
+ error(request, 'UnknownInterface', 'Unknown interface.');
165
+ return;
166
+ }
167
+ if (request.member === 'GetAll' && request.signature === 's') {
168
+ reply(request, 'a{sv}', [Object.entries(values)]);
169
+ return;
170
+ }
171
+ if (typeof property !== 'string' || !Object.hasOwn(values, property)) {
172
+ error(request, 'UnknownProperty', 'Unknown property.');
173
+ return;
174
+ }
175
+ if (request.member === 'Get' && request.signature === 'ss') {
176
+ reply(request, 'v', [values[property]]);
177
+ return;
178
+ }
179
+ if (request.member === 'Set' && request.signature === 'ssv') {
180
+ if (iface === playerInterface && property === 'Rate') {
181
+ const rate = doubleVariant(value);
182
+ if (rate === 1)
183
+ reply(request);
184
+ else if (rate === 0) {
185
+ const result = await options.command({ type: 'desktop-transport', action: 'pause' });
186
+ if (!result.ok)
187
+ throw new Error('Control unavailable.');
188
+ if (current === generation) {
189
+ reply(request);
190
+ refresh();
191
+ }
192
+ }
193
+ else
194
+ error(request, 'NotSupported', 'Live radio plays at its broadcast rate.');
195
+ return;
196
+ }
197
+ if (iface !== playerInterface || property !== 'Volume') {
198
+ error(request, 'PropertyReadOnly', 'Property cannot be changed.');
199
+ return;
200
+ }
201
+ const volume = doubleVariant(value);
202
+ if (volume === null) {
203
+ error(request, 'InvalidArgs', 'Volume must be a finite double.');
204
+ return;
205
+ }
206
+ const result = await options.command({ type: 'set-volume', volume: Math.min(1, Math.max(0, volume)) * 100, unmute: true });
207
+ if (!result.ok)
208
+ throw new Error('Control unavailable.');
209
+ if (current === generation) {
210
+ reply(request);
211
+ refresh();
212
+ }
213
+ return;
214
+ }
215
+ error(request, 'InvalidArgs', 'Invalid properties request.');
216
+ return;
217
+ }
218
+ if (request.interface === playerInterface && (request.member === 'Seek' && request.signature === 'x' || request.member === 'SetPosition' && request.signature === 'ox')) {
219
+ reply(request);
220
+ return;
221
+ }
222
+ if (request.interface !== playerInterface || request.signature) {
223
+ error(request, 'NotSupported', 'This action is not supported for live radio.');
224
+ return;
225
+ }
226
+ let action;
227
+ switch (request.member) {
228
+ case 'Next':
229
+ action = 'next';
230
+ break;
231
+ case 'Previous':
232
+ action = 'previous';
233
+ break;
234
+ case 'Pause':
235
+ action = 'pause';
236
+ break;
237
+ case 'Play':
238
+ action = 'play';
239
+ break;
240
+ case 'PlayPause':
241
+ action = 'toggle';
242
+ break;
243
+ case 'Stop':
244
+ action = 'stop';
245
+ break;
246
+ }
247
+ if (!action) {
248
+ error(request, 'NotSupported', 'This action is currently unavailable.');
249
+ return;
250
+ }
251
+ const result = await options.command({ type: 'desktop-transport', action });
252
+ if (!result.ok)
253
+ throw new Error('Control unavailable.');
254
+ if (current === generation) {
255
+ reply(request);
256
+ refresh();
257
+ }
258
+ }
259
+ catch {
260
+ if (current === generation)
261
+ error(request, 'Failed', 'RadioCLI could not complete the control request.');
262
+ }
263
+ };
264
+ const start = async () => {
265
+ if (!path || !available() || connection || timer || starting)
266
+ return;
267
+ starting = true;
268
+ const current = ++generation;
269
+ try {
270
+ const created = await (options.connect ?? nativeConnection)(path);
271
+ if (disposed || current !== generation || !available()) {
272
+ created.stream.destroy();
273
+ return;
274
+ }
275
+ connection = created;
276
+ created.on('error', () => { if (current === generation)
277
+ retry(); });
278
+ created.on('end', () => { if (current === generation)
279
+ retry(); });
280
+ created.stream.on('close', () => { if (current === generation)
281
+ retry(); });
282
+ created.on('message', message => { void receive(message, current); });
283
+ timer = setTimeout(() => { if (current === generation)
284
+ retry(); }, 2000);
285
+ timer.unref();
286
+ created.once('connect', () => {
287
+ if (current !== generation || connection !== created || !available()) {
288
+ created.stream.destroy();
289
+ return;
290
+ }
291
+ connected = true;
292
+ if (timer)
293
+ clearTimeout(timer);
294
+ timer = undefined;
295
+ void (async () => {
296
+ await call('Hello');
297
+ if (current !== generation)
298
+ return;
299
+ const [result] = await call('RequestName', 'su', [mprisServiceName(options.identity), 4]);
300
+ if (current !== generation)
301
+ return;
302
+ // DO_NOT_QUEUE: never replace or wait in the ownership queue.
303
+ if (result !== 1) {
304
+ retry();
305
+ return;
306
+ }
307
+ registered = true;
308
+ starting = false;
309
+ retries = 0;
310
+ lastProperties = '';
311
+ refresh();
312
+ })().catch(() => { if (current === generation)
313
+ retry(); });
314
+ });
315
+ }
316
+ catch {
317
+ if (current === generation)
318
+ retry();
319
+ }
320
+ };
321
+ const refresh = () => {
322
+ if (!available()) {
323
+ disconnect();
324
+ return;
325
+ }
326
+ if (!registered) {
327
+ void start();
328
+ return;
329
+ }
330
+ const properties = mprisProperties(options.snapshot()).player;
331
+ // Position is not a clock for a live broadcast and does not emit changes.
332
+ const changed = Object.entries(properties).filter(([name]) => name !== 'Position' && name !== 'CanControl');
333
+ const encoded = JSON.stringify(changed);
334
+ if (encoded === lastProperties)
335
+ return;
336
+ lastProperties = encoded;
337
+ send({ type: 4, path: objectPath, interface: propertiesInterface, member: 'PropertiesChanged', signature: 'sa{sv}as', body: [playerInterface, changed, []] });
338
+ };
339
+ void start();
340
+ return { refresh, close() { disposed = true; disconnect(); } };
341
+ }
342
+ async function nativeConnection(path) {
343
+ const module = await import('@homebridge/dbus-native');
344
+ const factory = module.default;
345
+ const stream = connectSocket(path);
346
+ return factory.createConnection({ stream, authMethods: ['EXTERNAL'] });
347
+ }
348
+ function doubleVariant(value) {
349
+ if (!Array.isArray(value) || !Array.isArray(value[0]) || !Array.isArray(value[1]))
350
+ return null;
351
+ const [type] = value[0];
352
+ const [number] = value[1];
353
+ return value[0].length === 1 && type?.type === 'd' && typeof number === 'number' && Number.isFinite(number) ? number : null;
354
+ }
355
+ const propertyXml = (values, writable = []) => Object.entries(values).map(([name, type]) => `<property name="${name}" type="${type}" access="${writable.includes(name) ? 'readwrite' : 'read'}"/>`).join('');
356
+ const introspection = `<node><interface name="org.freedesktop.DBus.Introspectable"><method name="Introspect"><arg direction="out" type="s"/></method></interface><interface name="org.freedesktop.DBus.Peer"><method name="Ping"/></interface><interface name="${propertiesInterface}"><method name="Get"><arg direction="in" type="s"/><arg direction="in" type="s"/><arg direction="out" type="v"/></method><method name="GetAll"><arg direction="in" type="s"/><arg direction="out" type="a{sv}"/></method><method name="Set"><arg direction="in" type="s"/><arg direction="in" type="s"/><arg direction="in" type="v"/></method><signal name="PropertiesChanged"><arg type="s"/><arg type="a{sv}"/><arg type="as"/></signal></interface><interface name="${rootInterface}"><method name="Raise"/><method name="Quit"/>${propertyXml({ CanQuit: 'b', CanRaise: 'b', HasTrackList: 'b', Identity: 's', SupportedUriSchemes: 'as', SupportedMimeTypes: 'as' })}</interface><interface name="${playerInterface}">${['Next', 'Previous', 'Pause', 'PlayPause', 'Stop', 'Play'].map(name => `<method name="${name}"/>`).join('')}<method name="Seek"><arg direction="in" type="x"/></method><method name="SetPosition"><arg direction="in" type="o"/><arg direction="in" type="x"/></method><method name="OpenUri"><arg direction="in" type="s"/></method>${propertyXml({ PlaybackStatus: 's', Rate: 'd', Metadata: 'a{sv}', Volume: 'd', Position: 'x', MinimumRate: 'd', MaximumRate: 'd', CanGoNext: 'b', CanGoPrevious: 'b', CanPlay: 'b', CanPause: 'b', CanSeek: 'b', CanControl: 'b' }, ['Volume', 'Rate'])}</interface></node>`;
@@ -1,5 +1,6 @@
1
1
  import { resolve4, resolve6 } from 'node:dns/promises';
2
2
  import { request as requestHttps } from 'node:https';
3
+ import { parseJsonAsync } from './json-worker.js';
3
4
  const maxResolvedResponseBytes = 128 * 1024 * 1024;
4
5
  export function networkPolicy(env = process.env) {
5
6
  return { offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1' };
@@ -26,7 +27,7 @@ export function networkDiagnostic() {
26
27
  }
27
28
  }
28
29
  /** Keep the request deadline active through body consumption, including errors. */
29
- export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImpl = fetch }, consume) {
30
+ export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImpl = fetch, maxResponseBytes = maxResolvedResponseBytes }, consume) {
30
31
  if (networkPolicy().offline)
31
32
  throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).');
32
33
  validateProxyConfiguration(new URL(url));
@@ -45,6 +46,7 @@ export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImp
45
46
  try {
46
47
  // A custom fetch can resolve after the deadline without observing abort.
47
48
  signal.throwIfAborted();
49
+ response = boundedResponse(response, maxResponseBytes, signal);
48
50
  return await consume(response);
49
51
  }
50
52
  finally {
@@ -62,6 +64,72 @@ export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImp
62
64
  cancelBody(response);
63
65
  }
64
66
  }
67
+ /** Count actual decoded body bytes, including chunked responses and deceptive
68
+ * content lengths. Preserve redirects used by playlist/import consumers. */
69
+ function boundedResponse(response, maxBytes, signal) {
70
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
71
+ throw new Error('Response size limit must be a positive number of bytes.');
72
+ if (!response.body)
73
+ return response;
74
+ const length = Number(response.headers.get('content-length'));
75
+ if (Number.isFinite(length) && length > maxBytes)
76
+ throw responseSizeError(maxBytes);
77
+ const reader = response.body.getReader();
78
+ let bytes = 0;
79
+ let ended = false;
80
+ let controller;
81
+ const cleanup = () => signal.removeEventListener('abort', aborted);
82
+ const aborted = () => {
83
+ if (ended)
84
+ return;
85
+ ended = true;
86
+ cleanup();
87
+ controller.error(signal.reason);
88
+ void reader.cancel(signal.reason).catch(() => undefined);
89
+ };
90
+ const body = new ReadableStream({
91
+ start(value) { controller = value; signal.addEventListener('abort', aborted, { once: true }); },
92
+ async pull(value) {
93
+ try {
94
+ const chunk = await reader.read();
95
+ if (ended)
96
+ return;
97
+ if (chunk.done) {
98
+ ended = true;
99
+ cleanup();
100
+ value.close();
101
+ return;
102
+ }
103
+ bytes += chunk.value.byteLength;
104
+ if (bytes > maxBytes) {
105
+ ended = true;
106
+ cleanup();
107
+ const error = responseSizeError(maxBytes);
108
+ value.error(error);
109
+ void reader.cancel(error).catch(() => undefined);
110
+ return;
111
+ }
112
+ value.enqueue(chunk.value);
113
+ }
114
+ catch (error) {
115
+ if (!ended) {
116
+ ended = true;
117
+ cleanup();
118
+ value.error(error);
119
+ }
120
+ }
121
+ },
122
+ cancel(reason) { ended = true; cleanup(); void reader.cancel(reason).catch(() => undefined); }
123
+ });
124
+ const bounded = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
125
+ for (const key of ['url', 'redirected', 'type'])
126
+ Object.defineProperty(bounded, key, { value: response[key] });
127
+ Object.defineProperty(bounded, 'json', { value: async () => parseJsonAsync(await bounded.text(), signal) });
128
+ return bounded;
129
+ }
130
+ function responseSizeError(maxBytes) {
131
+ return new Error(`HTTP response exceeded the ${maxBytes} byte safety limit.`);
132
+ }
65
133
  /**
66
134
  * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN
67
135
  * but Node's DNS-protocol resolver can still reach the configured nameserver.
@@ -0,0 +1,147 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, constants, fstatSync, openSync, readSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { posix } from 'node:path';
5
+ import { resolveCommandDetails } from './executables.js';
6
+ import { identifyPlatform } from './runtime.js';
7
+ const packagedRoot = '/usr/share/omarchy';
8
+ const maxProbeBytes = 4096;
9
+ const probeTimeoutMs = 500;
10
+ const installationCache = new Map();
11
+ const probeIdentities = new WeakMap();
12
+ let nextProbeIdentity = 0;
13
+ const absent = { kind: 'none', version: null, major: null, source: null, root: null, packageName: null };
14
+ /** Omarchy is desktop evidence, not a replacement for the Linux platform ID. */
15
+ export function identifyOmarchy(options = {}) {
16
+ const platform = options.platform ?? process.platform;
17
+ const env = options.env ?? process.env;
18
+ const remote = Boolean(env.SSH_CONNECTION?.trim() || env.SSH_CLIENT?.trim() || env.SSH_TTY?.trim() || /^(?:1|true|yes)$/i.test(env.XDG_SESSION_REMOTE ?? ''));
19
+ const headless = /^(?:tty|unspecified)$/i.test(env.XDG_SESSION_TYPE ?? '');
20
+ const type = !headless && env.WAYLAND_DISPLAY?.trim() ? 'wayland' : !headless && env.DISPLAY?.trim() ? 'x11' : 'headless';
21
+ const session = { type, remote, localDesktop: !remote && type !== 'headless' };
22
+ const unavailable = { available: false, command: null, missing: [] };
23
+ const injected = Boolean(options.readFile || options.isDirectory || options.run);
24
+ // Cross-platform callers/tests must not inspect the execution host's files.
25
+ if (identifyPlatform({ platform, env }).id !== 'linux' || platform !== process.platform && !injected) {
26
+ return { ...absent, session, terminal: unavailable };
27
+ }
28
+ const resolve = options.resolve ?? (command => resolveCommandDetails(command, { platform, env }).path ?? undefined);
29
+ const home = options.home ?? env.HOME ?? homedir();
30
+ const readFile = options.readFile ?? readSmallFile;
31
+ const isDirectory = options.isDirectory ?? directoryExists;
32
+ const run = options.run ?? probeCommand;
33
+ const now = (options.now ?? Date.now)();
34
+ const key = [home, env.OMARCHY_PATH, env.PATH, options.resolve, readFile, isDirectory, run].map(value => typeof value === 'function' ? probeIdentity(value) : value).join('\0');
35
+ // Cache installation probes, but revalidate launcher executables/session on
36
+ // every request so saved alarm selectors do not retain stale capabilities.
37
+ const cached = installationCache.get(key);
38
+ const installation = cached && now >= cached.checkedAt && now - cached.checkedAt < 5000 ? cached.installation
39
+ : detectInstallation({ env, home, resolve, readFile, isDirectory, run });
40
+ if (installation !== cached?.installation) {
41
+ if (installationCache.size >= 32)
42
+ installationCache.clear();
43
+ installationCache.set(key, { checkedAt: now, installation });
44
+ }
45
+ if (installation.kind !== 'quattro')
46
+ return { ...installation, session, terminal: unavailable };
47
+ const command = resolveAbsolute(resolve, posix.join(packagedRoot, 'bin', 'omarchy-launch-tui')) ?? resolveAbsolute(resolve, 'omarchy-launch-tui');
48
+ // The upstream script invokes these by name. Fallback executable discovery
49
+ // outside PATH cannot establish that its unchanged environment can run them.
50
+ const missing = ['setsid', 'uwsm-app', 'xdg-terminal-exec'].filter(name => !reachableOnPath(resolve, name, env.PATH));
51
+ if (!command)
52
+ missing.unshift('omarchy-launch-tui');
53
+ if (!session.localDesktop || type !== 'wayland')
54
+ missing.push('local-wayland-session');
55
+ if (!env.DBUS_SESSION_BUS_ADDRESS?.trim())
56
+ missing.push('session-bus');
57
+ return { ...installation, session, terminal: { available: missing.length === 0, command, missing } };
58
+ }
59
+ function detectInstallation(options) {
60
+ const { env, home, resolve, readFile, isDirectory, run } = options;
61
+ const configuredRoot = safeAbsolute(env.OMARCHY_PATH);
62
+ // A custom checkout is deliberately kept separate from the packaged release.
63
+ if (configuredRoot && configuredRoot !== packagedRoot && isDirectory(configuredRoot)) {
64
+ const metadata = versionFrom(readFile(posix.join(configuredRoot, 'version')));
65
+ const helper = resolveAbsolute(resolve, posix.join(configuredRoot, 'bin', 'omarchy-version'));
66
+ if (metadata || helper)
67
+ return { kind: metadata?.major === 3 ? 'legacy' : 'development', version: metadata?.version ?? null, major: metadata?.major ?? null, source: metadata ? 'legacy-metadata' : 'helper', root: configuredRoot, packageName: null };
68
+ }
69
+ if (isDirectory(packagedRoot)) {
70
+ const pacman = resolveAbsolute(resolve, 'pacman');
71
+ if (pacman) {
72
+ for (const name of ['omarchy-dev', 'omarchy']) {
73
+ const output = boundedOutput(run(pacman, ['-Q', name], { env, timeout: probeTimeoutMs, maxBuffer: maxProbeBytes }));
74
+ const match = output && new RegExp(`^${name} ([^\\s]+)$`).exec(output);
75
+ if (!match)
76
+ continue;
77
+ const version = versionFrom(match[1]);
78
+ return { kind: name === 'omarchy-dev' ? 'development' : releaseKind(version?.major), version: version?.version ?? match[1], major: version?.major ?? null, source: 'package', root: packagedRoot, packageName: name };
79
+ }
80
+ }
81
+ const helper = resolveAbsolute(resolve, posix.join(packagedRoot, 'bin', 'omarchy-version'));
82
+ const output = helper ? boundedOutput(run(helper, [], { env: { ...env, OMARCHY_PATH: packagedRoot }, timeout: probeTimeoutMs, maxBuffer: maxProbeBytes })) : null;
83
+ const version = versionFrom(output);
84
+ // The packaged helper strips the package name from both omarchy-dev and
85
+ // omarchy. Its numeric output preserves version diagnostics but cannot
86
+ // establish the stable channel needed for automatic Quattro integration.
87
+ return { kind: output?.startsWith('dev') ? 'development' : 'unknown', version: version?.version ?? output, major: version?.major ?? null, source: output ? 'helper' : null, root: packagedRoot, packageName: null };
88
+ }
89
+ const legacyRoot = safeAbsolute(posix.join(home, '.local', 'share', 'omarchy'));
90
+ const metadata = legacyRoot && isDirectory(legacyRoot) ? versionFrom(readFile(posix.join(legacyRoot, 'version'))) : null;
91
+ if (metadata && legacyRoot)
92
+ return { kind: metadata.major === 3 ? 'legacy' : 'development', ...metadata, source: 'legacy-metadata', root: legacyRoot, packageName: null };
93
+ return { ...absent };
94
+ }
95
+ function releaseKind(major) { return major === 4 ? 'quattro' : major !== undefined && major < 4 ? 'legacy' : 'unknown'; }
96
+ function probeIdentity(probe) { const current = probeIdentities.get(probe); if (current !== undefined)
97
+ return current; const next = ++nextProbeIdentity; probeIdentities.set(probe, next); return next; }
98
+ function boundedOutput(value) { const trimmed = value?.trim(); return trimmed && Buffer.byteLength(value, 'utf8') <= maxProbeBytes && !/[\0\r\n]/.test(trimmed) ? trimmed : null; }
99
+ function versionFrom(value) {
100
+ const version = boundedOutput(value);
101
+ const match = version && /^(?:\d+:)?(\d+)\.\d+\.\d+(?:-\d+(?:\.\d+)?)?$/.exec(version);
102
+ return match ? { version: version, major: Number(match[1]) } : null;
103
+ }
104
+ function safeAbsolute(value) { return value && posix.isAbsolute(value) && !/[\0\r\n]/.test(value) ? posix.normalize(value) : null; }
105
+ function resolveAbsolute(resolve, command) { return safeAbsolute(resolve(command)); }
106
+ function reachableOnPath(resolve, command, path) {
107
+ if (path === undefined || /[\0\r\n]/.test(path))
108
+ return false;
109
+ return path.split(':').some(directory => {
110
+ const candidate = posix.resolve(directory || '.', command);
111
+ return resolveAbsolute(resolve, candidate) === candidate;
112
+ });
113
+ }
114
+ function directoryExists(path) { try {
115
+ return statSync(path).isDirectory();
116
+ }
117
+ catch {
118
+ return false;
119
+ } }
120
+ function readSmallFile(path) {
121
+ let descriptor;
122
+ try {
123
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
124
+ const stat = fstatSync(descriptor);
125
+ if (!stat.isFile() || stat.size > maxProbeBytes)
126
+ return null;
127
+ const buffer = Buffer.alloc(maxProbeBytes + 1);
128
+ const count = readSync(descriptor, buffer, 0, buffer.length, 0);
129
+ return count > maxProbeBytes ? null : buffer.toString('utf8', 0, count);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ finally {
135
+ if (descriptor !== undefined)
136
+ closeSync(descriptor);
137
+ }
138
+ }
139
+ function probeCommand(command, args, options) {
140
+ try {
141
+ const result = spawnSync(command, args, { ...options, killSignal: 'SIGKILL', encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
142
+ return result.error || result.status !== 0 ? null : boundedOutput(result.stdout);
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
@@ -1,6 +1,7 @@
1
1
  import { commandExists } from './executables.js';
2
2
  import { powershellCommand } from './shell.js';
3
3
  import { identifyPlatform, readLinuxOsRelease } from './runtime.js';
4
+ import { identifyOmarchy } from './omarchy.js';
4
5
  export const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper', 'pkg', 'pkg_add', 'pkgin', 'termux-pkg', 'pkgman'];
5
6
  /** FreeBSD pkg and Termux pkg have distinct recipes and privilege models. */
6
7
  export function packageManagerProgram(manager) {
@@ -98,7 +99,12 @@ export function packageInstallCommand(manager, component, { elevation = 'sudo' }
98
99
  export function packageManagerNeedsRoot(manager) {
99
100
  return !['brew', 'winget', 'scoop', 'choco', 'termux-pkg', 'pkgman'].includes(manager);
100
101
  }
101
- export function packageManagerNotes(manager, host = identifyPlatform()) {
102
+ export function packageManagerNotes(manager, host = identifyPlatform(), omarchy) {
103
+ if (manager === 'pacman' && host.id === 'linux' && (omarchy ?? identifyOmarchy({ platform: 'linux' })).kind === 'quattro')
104
+ return [
105
+ 'Omarchy Quattro uses the existing Arch mpv and ffmpeg packages. Native terminal launching requires a local Wayland session, a user session bus, and the Omarchy TUI launcher with setsid, uwsm-app, and xdg-terminal-exec.',
106
+ 'If pacman reports stale databases or package conflicts, complete the supported Omarchy update workflow and rerun setup. RadioCLI setup does not upgrade the distribution or bypass package checks.'
107
+ ];
102
108
  if (manager === 'termux-pkg' || (!manager && host.id === 'termux'))
103
109
  return [
104
110
  'Termux pkg runs as the normal app user, without sudo or root. Use --package-manager=termux-pkg to select this recipe explicitly.',
@@ -34,7 +34,7 @@ export function nativeAdapters(host = identifyPlatform()) {
34
34
  if (host.id === 'win32')
35
35
  return { ...portable, scheduler: 'task-scheduler', inhibitor: 'windows', volume: 'windows', terminal: 'windows', ipc: 'named-pipe', posixPermissions: false };
36
36
  if (host.id === 'linux')
37
- return { ...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix' };
37
+ return { ...portable, scheduler: 'systemd', inhibitor: 'logind', volume: 'unix-audio', terminal: 'unix', airPlay: !host.isWsl };
38
38
  if (['freebsd', 'openbsd', 'netbsd'].includes(host.id))
39
39
  return { ...portable, terminal: 'unix' };
40
40
  return portable;