@ciphore/radiocli 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- 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>`;
|
package/dist/platform/network.js
CHANGED
|
@@ -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.',
|
package/dist/platform/runtime.js
CHANGED
|
@@ -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;
|