@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
@@ -0,0 +1,380 @@
1
+ import { resolve4, resolve6 } from 'node:dns/promises';
2
+ import { request as requestHttps } from 'node:https';
3
+ import { parseJsonAsync } from './json-worker.js';
4
+ const maxResolvedResponseBytes = 128 * 1024 * 1024;
5
+ export function networkPolicy(env = process.env) {
6
+ return { offline: env.RADIOCLI_OFFLINE === '1', lowBandwidth: env.RADIOCLI_LOW_BANDWIDTH === '1' };
7
+ }
8
+ /** A direct connection must never silently bypass an operator-configured proxy. */
9
+ export function allowsDirectDnsFallback(env = process.env, url) {
10
+ if (url && bypassesProxy(new URL(url), env))
11
+ return true;
12
+ return !['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']
13
+ .some(name => Boolean(env[name]?.trim()));
14
+ }
15
+ /** Configuration preflight only: no network request or proxy credentials. */
16
+ export function networkDiagnostic() {
17
+ const policy = networkPolicy();
18
+ if (policy.offline)
19
+ return { ...policy, status: 'offline', message: 'Public directory and update requests are disabled; cached data and direct stream URLs remain usable.' };
20
+ try {
21
+ validateProxyConfiguration(new URL('http://example.invalid'));
22
+ validateProxyConfiguration(new URL('https://example.invalid'));
23
+ return { ...policy, status: 'configured', message: 'Network configuration accepted; reachability and TLS are checked per request. External player networking is separate.' };
24
+ }
25
+ catch (error) {
26
+ return { ...policy, status: 'unavailable', message: error instanceof Error ? error.message : 'Network configuration is invalid.' };
27
+ }
28
+ }
29
+ /** Keep the request deadline active through body consumption, including errors. */
30
+ export async function withExternalResponse(url, { timeoutMs, init = {}, fetchImpl = fetch, maxResponseBytes = maxResolvedResponseBytes }, consume) {
31
+ if (networkPolicy().offline)
32
+ throw new Error('RadioCLI is offline (RADIOCLI_OFFLINE=1).');
33
+ validateProxyConfiguration(new URL(url));
34
+ init.signal?.throwIfAborted();
35
+ const controller = new AbortController();
36
+ const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
37
+ let onAbort;
38
+ const aborted = new Promise((_resolve, reject) => {
39
+ onAbort = () => reject(signal.reason);
40
+ signal.addEventListener('abort', onAbort, { once: true });
41
+ });
42
+ const timeout = setTimeout(() => controller.abort(new Error(`Network request timed out after ${timeoutMs} ms.`)), timeoutMs);
43
+ let response;
44
+ const work = async () => {
45
+ response = await fetchImpl(url, { ...init, signal });
46
+ try {
47
+ // A custom fetch can resolve after the deadline without observing abort.
48
+ signal.throwIfAborted();
49
+ response = boundedResponse(response, maxResponseBytes, signal);
50
+ return await consume(response);
51
+ }
52
+ finally {
53
+ cancelBody(response);
54
+ }
55
+ };
56
+ try {
57
+ return await Promise.race([work(), aborted]);
58
+ }
59
+ finally {
60
+ clearTimeout(timeout);
61
+ signal.removeEventListener('abort', onAbort);
62
+ controller.abort();
63
+ if (response)
64
+ cancelBody(response);
65
+ }
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
+ }
133
+ /**
134
+ * HTTPS fetch fallback for systems where libc getaddrinfo() reports EAI_AGAIN
135
+ * but Node's DNS-protocol resolver can still reach the configured nameserver.
136
+ * The original hostname remains the HTTP Host and TLS SNI identity, so normal
137
+ * certificate-chain and hostname verification remain enabled.
138
+ */
139
+ export async function fetchHttpsWithSystemResolver(input, init = {}, runtime = {}) {
140
+ const url = new URL(input);
141
+ if (url.protocol !== 'https:')
142
+ throw new Error('The DNS fallback only supports HTTPS requests.');
143
+ if (url.username || url.password)
144
+ throw new Error('The DNS fallback does not accept URL credentials.');
145
+ const method = (init.method ?? 'GET').toUpperCase();
146
+ if (method !== 'GET' && method !== 'HEAD')
147
+ throw new Error('The DNS fallback only supports GET and HEAD requests.');
148
+ if (init.body)
149
+ throw new Error('The DNS fallback does not accept a request body.');
150
+ init.signal?.throwIfAborted();
151
+ const addresses = await resolveHttpsAddresses(url.hostname, init.signal, runtime);
152
+ let lastError = null;
153
+ for (const address of addresses) {
154
+ try {
155
+ return await requestResolvedHttps(url, address, init, runtime, method);
156
+ }
157
+ catch (error) {
158
+ if (init.signal?.aborted)
159
+ throw init.signal.reason;
160
+ lastError = error instanceof Error ? error : new Error(String(error));
161
+ }
162
+ }
163
+ throw lastError ?? new Error(`DNS did not return an address for ${url.hostname}.`);
164
+ }
165
+ async function resolveHttpsAddresses(hostname, signal, runtime) {
166
+ let lastErrors = [];
167
+ for (let attempt = 0; attempt < 2; attempt += 1) {
168
+ const results = await raceAbort(Promise.allSettled([
169
+ (runtime.resolve4 ?? resolve4)(hostname),
170
+ (runtime.resolve6 ?? resolve6)(hostname)
171
+ ]), signal);
172
+ const addresses = [
173
+ ...(results[0].status === 'fulfilled' ? results[0].value.map(address => ({ address, family: 4 })) : []),
174
+ ...(results[1].status === 'fulfilled' ? results[1].value.map(address => ({ address, family: 6 })) : [])
175
+ ];
176
+ if (addresses.length > 0)
177
+ return dedupeAddresses(addresses);
178
+ lastErrors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
179
+ if (attempt === 1 || !lastErrors.some(isTemporaryDnsError))
180
+ break;
181
+ await abortableDelay(100, signal);
182
+ }
183
+ const lastError = lastErrors.find(value => value instanceof Error);
184
+ throw lastError instanceof Error ? lastError : new Error(`DNS did not return an address for ${hostname}.`);
185
+ }
186
+ function requestResolvedHttps(url, address, init, runtime, method) {
187
+ return new Promise((resolve, reject) => {
188
+ const headers = new Headers(init.headers);
189
+ if (!headers.has('host'))
190
+ headers.set('host', url.host);
191
+ if (!headers.has('accept-encoding'))
192
+ headers.set('accept-encoding', 'identity');
193
+ const request = (runtime.request ?? requestHttps)({
194
+ hostname: address.address,
195
+ family: address.family,
196
+ port: url.port ? Number(url.port) : 443,
197
+ path: `${url.pathname}${url.search}`,
198
+ method,
199
+ headers: Object.fromEntries(headers.entries()),
200
+ servername: url.hostname,
201
+ signal: init.signal ?? undefined,
202
+ ca: runtime.ca,
203
+ agent: false
204
+ }, response => {
205
+ const chunks = [];
206
+ let bytes = 0;
207
+ response.on('data', (chunk) => {
208
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
209
+ bytes += buffer.length;
210
+ if (bytes > maxResolvedResponseBytes) {
211
+ response.destroy(new Error('HTTPS response exceeded the 128 MiB safety limit.'));
212
+ return;
213
+ }
214
+ chunks.push(buffer);
215
+ });
216
+ response.once('error', reject);
217
+ response.once('aborted', () => reject(new Error('HTTPS response ended before completion.')));
218
+ response.once('end', () => {
219
+ const responseHeaders = new Headers();
220
+ for (const [name, value] of Object.entries(response.headers)) {
221
+ if (Array.isArray(value)) {
222
+ for (const item of value)
223
+ responseHeaders.append(name, item);
224
+ }
225
+ else if (value !== undefined) {
226
+ responseHeaders.set(name, value);
227
+ }
228
+ }
229
+ const status = response.statusCode ?? 500;
230
+ const body = method === 'HEAD' || status === 204 || status === 205 || status === 304
231
+ ? null
232
+ : Buffer.concat(chunks);
233
+ resolve(new Response(body, {
234
+ status,
235
+ statusText: response.statusMessage,
236
+ headers: responseHeaders
237
+ }));
238
+ });
239
+ });
240
+ request.once('error', reject);
241
+ request.end();
242
+ });
243
+ }
244
+ function dedupeAddresses(addresses) {
245
+ const seen = new Set();
246
+ return addresses.filter(({ address, family }) => {
247
+ const key = `${family}:${address}`;
248
+ if (seen.has(key))
249
+ return false;
250
+ seen.add(key);
251
+ return true;
252
+ });
253
+ }
254
+ function isTemporaryDnsError(error) {
255
+ return error instanceof Error && 'code' in error && error.code === 'EAI_AGAIN';
256
+ }
257
+ function raceAbort(promise, signal) {
258
+ if (!signal)
259
+ return promise;
260
+ signal.throwIfAborted();
261
+ return new Promise((resolve, reject) => {
262
+ const onAbort = () => reject(signal.reason);
263
+ signal.addEventListener('abort', onAbort, { once: true });
264
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
265
+ });
266
+ }
267
+ function abortableDelay(ms, signal) {
268
+ if (!signal)
269
+ return new Promise(resolve => setTimeout(resolve, ms));
270
+ signal.throwIfAborted();
271
+ return new Promise((resolve, reject) => {
272
+ const timeout = setTimeout(done, ms);
273
+ const onAbort = () => done(signal.reason);
274
+ signal.addEventListener('abort', onAbort, { once: true });
275
+ function done(error) {
276
+ clearTimeout(timeout);
277
+ signal.removeEventListener('abort', onAbort);
278
+ if (error)
279
+ reject(error);
280
+ else
281
+ resolve();
282
+ }
283
+ });
284
+ }
285
+ function cancelBody(response) {
286
+ // Cancellation is best-effort and must not extend the request deadline. A
287
+ // locked native body is interrupted by the request's AbortController instead.
288
+ if (response.body && !response.body.locked)
289
+ void response.body.cancel().catch(() => { });
290
+ }
291
+ function validateProxyConfiguration(url) {
292
+ if (bypassesProxy(url, process.env))
293
+ return;
294
+ const http = proxyVariable('http_proxy', 'HTTP_PROXY');
295
+ const https = proxyVariable('https_proxy', 'HTTPS_PROXY');
296
+ for (const proxy of [http, https]) {
297
+ if (!proxy.value)
298
+ continue;
299
+ let parsed;
300
+ try {
301
+ if (/[\r\n]/.test(proxy.value))
302
+ throw new Error();
303
+ parsed = new URL(proxy.value);
304
+ decodeURIComponent(parsed.username);
305
+ decodeURIComponent(parsed.password);
306
+ }
307
+ catch {
308
+ // URL parse errors can contain credentials. Do not attach the raw cause.
309
+ throw new Error(`${proxy.name} contains an invalid proxy URL.`);
310
+ }
311
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
312
+ throw new Error(`${proxy.name} uses an unsupported proxy protocol. This Node HTTP client supports HTTP(S) proxies; SOCKS proxies are unavailable.`);
313
+ }
314
+ }
315
+ // Native fetch falls back to HTTP_PROXY for HTTPS when HTTPS_PROXY is unset.
316
+ const selected = url.protocol === 'https:' && https.value ? https : http;
317
+ if (!selected.value) {
318
+ const all = proxyVariable('all_proxy', 'ALL_PROXY');
319
+ if (all.value)
320
+ throw new Error(`${all.name} is not supported by this Node HTTP client. Configure HTTP_PROXY or HTTPS_PROXY with an HTTP(S) proxy; SOCKS proxies are unavailable.`);
321
+ return;
322
+ }
323
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
324
+ if (major < 24 && !(major === 22 && minor >= 21)) {
325
+ throw new Error('Native HTTP(S) proxy support requires Node 22.21+ or Node 24+.');
326
+ }
327
+ let enabled = process.env.NODE_USE_ENV_PROXY === '1';
328
+ const environmentFlags = (process.env.NODE_OPTIONS ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
329
+ for (const raw of [...environmentFlags, ...process.execArgv]) {
330
+ const flag = raw.replace(/^["']|["']$/g, '');
331
+ if (flag === '--use-env-proxy')
332
+ enabled = true;
333
+ if (flag === '--no-use-env-proxy')
334
+ enabled = false;
335
+ }
336
+ if (!enabled) {
337
+ throw new Error(`${selected.name} is configured. Start RadioCLI with NODE_USE_ENV_PROXY=1 to enable the native HTTP(S) proxy.`);
338
+ }
339
+ // Node configures its dispatcher at startup. Do not mutate proxy environment
340
+ // variables or install a global dispatcher here; local IPC has a direct agent.
341
+ }
342
+ function bypassesProxy(url, env) {
343
+ const value = env.no_proxy !== undefined ? env.no_proxy : env.NO_PROXY;
344
+ if (!value?.trim())
345
+ return false;
346
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
347
+ const port = url.port || (url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '');
348
+ return value.split(',').some(raw => {
349
+ let entry = raw.trim().toLowerCase();
350
+ if (!entry)
351
+ return false;
352
+ if (entry === '*')
353
+ return true;
354
+ let entryPort = '';
355
+ if (entry.startsWith('[')) {
356
+ const end = entry.indexOf(']');
357
+ if (end < 0)
358
+ return false;
359
+ entryPort = entry.slice(end + 1).replace(/^:/, '');
360
+ entry = entry.slice(1, end);
361
+ }
362
+ else {
363
+ const colon = entry.lastIndexOf(':');
364
+ if (colon > -1 && /^\d+$/.test(entry.slice(colon + 1))) {
365
+ entryPort = entry.slice(colon + 1);
366
+ entry = entry.slice(0, colon);
367
+ }
368
+ }
369
+ if (entryPort && entryPort !== port)
370
+ return false;
371
+ const suffix = entry.replace(/^\*\./, '.');
372
+ return suffix.startsWith('.')
373
+ ? hostname === suffix.slice(1) || hostname.endsWith(suffix)
374
+ : hostname === suffix;
375
+ });
376
+ }
377
+ function proxyVariable(lower, upper) {
378
+ const name = process.env[lower] !== undefined ? lower : upper;
379
+ return { name, value: process.env[name] };
380
+ }
@@ -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
+ }