@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,273 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { posix, resolve as absolutePath } from 'node:path';
|
|
3
|
+
import { readSettingsWithoutMigration } from '../storage/read-settings.js';
|
|
4
|
+
import { airPlayPolicy } from '../player/airplay-policy.js';
|
|
5
|
+
import { createSchedulerAdapter } from '../alarms/scheduler.js';
|
|
6
|
+
import { probeAirPlayReadiness } from '../player/airplay-readiness.js';
|
|
7
|
+
import { runAirPlayCommand } from '../player/airplay-process.js';
|
|
8
|
+
import { planMpvDesktopMedia } from '../player/mpv-desktop-media.js';
|
|
9
|
+
import { desktopMediaPolicy } from './desktop-media.js';
|
|
10
|
+
import { resolveCommandDetails } from './executables.js';
|
|
11
|
+
import { mprisServiceName, sessionBusSocket } from './mpris.js';
|
|
12
|
+
import { identifyOmarchy } from './omarchy.js';
|
|
13
|
+
import { nativeAdapters } from './runtime.js';
|
|
14
|
+
import { createTerminalLaunch } from './terminals.js';
|
|
15
|
+
const probeTimeoutMs = 600;
|
|
16
|
+
const maxProbeBytes = 4096;
|
|
17
|
+
const unavailableOwner = { status: 'unverified', message: 'RadioCLI media-service registration could not be checked.' };
|
|
18
|
+
const unavailableBus = { status: 'unverified', message: 'The local session bus could not be checked.' };
|
|
19
|
+
/** Explicit doctor checks only. No library migrations, service activation,
|
|
20
|
+
* terminal launches, MPRIS registration, LAN discovery, or receiver connection. */
|
|
21
|
+
export async function probeNativeIntegrations(options) {
|
|
22
|
+
const { host } = options;
|
|
23
|
+
const platform = host.platform;
|
|
24
|
+
const env = options.env ?? process.env;
|
|
25
|
+
const native = platform === process.platform;
|
|
26
|
+
const resolve = options.resolve ?? (native ? (command) => resolveCommandDetails(command, { platform, env }).path ?? undefined : () => undefined);
|
|
27
|
+
const safeResolve = (command) => attempt(() => resolve(command), undefined);
|
|
28
|
+
const run = options.run ?? runProbe;
|
|
29
|
+
const mayRun = native || Boolean(options.run);
|
|
30
|
+
const path = attempt(options.libraryPath, undefined);
|
|
31
|
+
const saved = readSettingsWithoutMigration(path);
|
|
32
|
+
const preference = readPreference(saved, env);
|
|
33
|
+
const airPlayConsent = airPlayPolicy({ linuxAirPlayEnabled: saved?.linuxAirPlayEnabled === true }, { platform, env, release: host.release });
|
|
34
|
+
const profile = attempt(() => options.omarchy ? options.omarchy()
|
|
35
|
+
: native || host.id !== 'linux' ? identifyOmarchy({ platform, env }) : null, null);
|
|
36
|
+
const omarchy = publicOmarchy(profile, env);
|
|
37
|
+
const policy = mediaPolicy(preference, profile, { ...options, env, resolve: safeResolve }, native);
|
|
38
|
+
const terminalLauncher = terminalReadiness(options, env, safeResolve, native);
|
|
39
|
+
const [bus, audio, airPlay] = await Promise.all([
|
|
40
|
+
sessionBusReadiness(host, env, path, safeResolve, run, mayRun).catch(() => ({ sessionBus: unavailableBus, owner: unavailableOwner })),
|
|
41
|
+
audioReadiness(options, env, safeResolve, run, mayRun).catch(() => ({
|
|
42
|
+
status: 'unverified', backend: null, wpctl: { installed: Boolean(safeResolve('wpctl')), status: 'unverified' },
|
|
43
|
+
pactl: { installed: Boolean(safeResolve('pactl')), status: 'unverified' }, message: 'The optional local audio check could not complete.'
|
|
44
|
+
})),
|
|
45
|
+
Promise.resolve().then(() => options.airPlay ? options.airPlay() : native
|
|
46
|
+
? probeAirPlayReadiness({ platform, env, release: host.release, resolve: safeResolve })
|
|
47
|
+
: unverifiedAirPlay(host)).catch(() => unverifiedAirPlay(host))
|
|
48
|
+
]);
|
|
49
|
+
return { omarchy, desktopMedia: { preference, policy, ...bus }, terminalLauncher, audio, airPlay: {
|
|
50
|
+
...airPlay, enabled: airPlayConsent.enabled, state: airPlayConsent.state, prerequisitesReady: airPlay.ready,
|
|
51
|
+
ready: airPlayConsent.enabled && airPlay.ready,
|
|
52
|
+
message: airPlayConsent.enabled ? airPlay.message : `${airPlayConsent.message} ${airPlay.message}`
|
|
53
|
+
} };
|
|
54
|
+
}
|
|
55
|
+
/** Doctor supplies a bounded read-only runner; scheduler installation and alarm
|
|
56
|
+
* execution keep their existing adapters and lifetimes. */
|
|
57
|
+
export async function probeDoctorScheduler(host, env = process.env) {
|
|
58
|
+
if (host.platform !== process.platform)
|
|
59
|
+
return undefined;
|
|
60
|
+
const failed = { supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.' };
|
|
61
|
+
try {
|
|
62
|
+
const childEnv = probeEnvironment(env);
|
|
63
|
+
const platform = host.platform;
|
|
64
|
+
let userBusAddress;
|
|
65
|
+
let unverified = false;
|
|
66
|
+
if (host.id === 'linux') {
|
|
67
|
+
const fallback = localPath(env.XDG_RUNTIME_DIR) ?? (process.getuid ? `/run/user/${process.getuid()}` : null);
|
|
68
|
+
const socket = sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? (fallback ? `unix:path=${encodeURIComponent(posix.join(fallback, 'bus'))}` : ''));
|
|
69
|
+
if (!socket)
|
|
70
|
+
return { ...failed, name: 'systemd', message: 'The systemd user-manager check requires a supported local Unix session-bus address.' };
|
|
71
|
+
childEnv.DBUS_SESSION_BUS_ADDRESS = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`;
|
|
72
|
+
userBusAddress = childEnv.DBUS_SESSION_BUS_ADDRESS;
|
|
73
|
+
}
|
|
74
|
+
const adapter = createSchedulerAdapter({ platform, env: childEnv, run: async (command, args) => {
|
|
75
|
+
let executable;
|
|
76
|
+
if (userBusAddress) {
|
|
77
|
+
if (command !== 'systemctl' || args.join('\0') !== ['--user', 'show', '--property=Version', '--value'].join('\0'))
|
|
78
|
+
throw new Error('Doctor permits only the read-only user-manager version check.');
|
|
79
|
+
executable = resolveCommandDetails('busctl', { platform, env: childEnv }).path;
|
|
80
|
+
// systemctl can activate the user manager. Without a no-activation
|
|
81
|
+
// query tool, installed scheduler tooling remains unverified.
|
|
82
|
+
if (!executable)
|
|
83
|
+
unverified = true;
|
|
84
|
+
// Older busctl get-property ignores --auto-start=no. The explicit
|
|
85
|
+
// Properties.Get method call applies the message flag consistently.
|
|
86
|
+
args = [`--address=${userBusAddress}`, '--auto-start=no', '--timeout=2', 'call', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', 'org.freedesktop.DBus.Properties', 'Get', 'ss', 'org.freedesktop.systemd1.Manager', 'Version'];
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
executable = resolveCommandDetails(command, { platform, env: childEnv }).path;
|
|
90
|
+
if (!executable)
|
|
91
|
+
return { code: 1, stdout: '', stderr: 'The native scheduler command is unavailable.' };
|
|
92
|
+
// Keep Linux below the adapter's 3-second result deadline; a Windows
|
|
93
|
+
// PowerShell bootstrap needs more time than the short audio/bus probes.
|
|
94
|
+
const result = await runProbe(executable, args, childEnv, platform === 'win32' ? 10_000 : 2_500);
|
|
95
|
+
const ready = usable(result) && (!userBusAddress || /^v\s+s\s+"[^"\r\n]{1,256}"\s*$/.test(result.output.trim()));
|
|
96
|
+
// A headless manager may expose only systemd/private. Failure on a
|
|
97
|
+
// guessed bus address cannot establish that this manager is unavailable.
|
|
98
|
+
if (!ready && userBusAddress && env.DBUS_SESSION_BUS_ADDRESS === undefined)
|
|
99
|
+
unverified = true;
|
|
100
|
+
return ready ? { code: 0, stdout: result.output, stderr: '' }
|
|
101
|
+
: { code: 1, stdout: '', stderr: 'The native scheduler probe failed or exceeded its read-only limits.' };
|
|
102
|
+
} });
|
|
103
|
+
const result = await adapter.probeCapabilities?.();
|
|
104
|
+
return unverified ? undefined : result;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return failed;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function readPreference(saved, env) {
|
|
111
|
+
const override = env.RADIOCLI_DESKTOP_MEDIA;
|
|
112
|
+
if (validPreference(override))
|
|
113
|
+
return { value: override, source: 'environment', status: 'available', message: 'RADIOCLI_DESKTOP_MEDIA selects the preference for this process.' };
|
|
114
|
+
const defaults = { value: 'auto', source: 'default', status: 'available', message: 'No saved desktop media preference; the default is auto.' };
|
|
115
|
+
const unknown = { value: 'auto', source: 'default', status: 'unverified', message: 'The saved desktop media preference could not be safely read; auto is shown as an unverified default.' };
|
|
116
|
+
if (!saved)
|
|
117
|
+
return unknown;
|
|
118
|
+
const value = saved.desktopMediaControls;
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return defaults;
|
|
121
|
+
return validPreference(value) ? { value, source: 'library', status: 'available', message: 'The saved desktop media preference was read without changing the library.' } : unknown;
|
|
122
|
+
}
|
|
123
|
+
function mediaPolicy(preference, profile, options, native) {
|
|
124
|
+
const unverified = { eligible: false, status: 'unverified', message: 'Desktop media policy could not be verified; existing playback is unaffected.' };
|
|
125
|
+
if (preference.status === 'unverified')
|
|
126
|
+
return unverified;
|
|
127
|
+
if (preference.value === 'auto' && options.host.id === 'linux' && !profile)
|
|
128
|
+
return unverified;
|
|
129
|
+
try {
|
|
130
|
+
const policy = desktopMediaPolicy({ desktopMediaControls: preference.value }, {
|
|
131
|
+
platform: options.host.platform, env: options.env,
|
|
132
|
+
omarchy: () => profile,
|
|
133
|
+
mpv: options.mpv ?? (() => native ? planMpvDesktopMedia({ platform: options.host.platform, env: options.env, executable: options.resolve('mpv') })
|
|
134
|
+
: { status: 'unsupported', args: [], reason: 'The selected host mpv configuration has not been inspected.' })
|
|
135
|
+
});
|
|
136
|
+
return { eligible: policy.enabled, status: policy.enabled ? 'ready' : 'unavailable',
|
|
137
|
+
message: policy.enabled ? 'Current policy permits desktop media controls; this does not establish an active media-service owner.' : policy.reason };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return unverified;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function publicOmarchy(profile, env) {
|
|
144
|
+
const known = new Set(['omarchy-launch-tui', 'setsid', 'uwsm-app', 'xdg-terminal-exec', 'local-wayland-session', 'session-bus']);
|
|
145
|
+
const missing = new Set(profile?.terminal.missing.filter(item => known.has(item)) ?? []);
|
|
146
|
+
if (profile?.kind === 'quattro' && !sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''))
|
|
147
|
+
missing.add('session-bus');
|
|
148
|
+
return {
|
|
149
|
+
status: !profile ? 'unverified' : profile.kind === 'none' ? 'absent' : 'detected',
|
|
150
|
+
kind: profile?.kind ?? 'unknown',
|
|
151
|
+
version: profile?.version && /^(?:(?:\d+:)?\d+\.\d+\.\d+(?:[.a-z\d-]*)?|dev(?: \([a-f\d]{7,40}\))?)$/.test(profile.version) && profile.version.length < 100 ? profile.version : null,
|
|
152
|
+
major: profile?.major ?? null,
|
|
153
|
+
source: profile?.source ?? null,
|
|
154
|
+
packageName: profile?.packageName === 'omarchy' || profile?.packageName === 'omarchy-dev' ? profile.packageName : null,
|
|
155
|
+
session: profile ? { type: profile.session.type, remote: profile.session.remote, localDesktop: profile.session.localDesktop } : null,
|
|
156
|
+
terminal: { prerequisitesReady: Boolean(profile?.terminal.available && missing.size === 0), missing: [...missing] }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function terminalReadiness(options, env, resolve, native) {
|
|
160
|
+
const base = { selected: null, prerequisites: 'unverified', launch: 'unverified' };
|
|
161
|
+
if (!native && !options.terminalPlan)
|
|
162
|
+
return { ...base, message: 'Terminal selection on the requested host has not been inspected.' };
|
|
163
|
+
try {
|
|
164
|
+
const plan = options.terminalPlan ? options.terminalPlan()
|
|
165
|
+
: createTerminalLaunch({ platform: options.host.platform, env, resolve, nodePath: process.execPath, args: [] });
|
|
166
|
+
const selected = terminalName(plan.terminal);
|
|
167
|
+
const ready = Boolean(selected && resolve(plan.command) && (selected !== 'linux:omarchy-launch-tui' || sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? '')));
|
|
168
|
+
if (ready && selected?.startsWith('darwin:') && selected !== 'darwin:apple-terminal') {
|
|
169
|
+
return { ...base, selected, message: 'The macOS launch route is selected; the target terminal application has not been inspected, and no window was opened.' };
|
|
170
|
+
}
|
|
171
|
+
return { ...base, selected, prerequisites: ready ? 'ready' : 'unavailable',
|
|
172
|
+
message: ready ? 'A terminal launcher and its prerequisites were found; no window was opened and TUI startup remains unverified.' : 'The selected terminal launcher is unavailable; open RadioCLI in the current terminal.' };
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return { ...base, prerequisites: 'unavailable', message: 'No usable terminal launch plan was found; terminal selection is independent from the background scheduler.' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function terminalName(selector) {
|
|
179
|
+
const [platform, ...parts] = selector.split(':');
|
|
180
|
+
if (!['darwin', 'win32', 'linux', 'freebsd', 'openbsd', 'netbsd'].includes(platform ?? ''))
|
|
181
|
+
return null;
|
|
182
|
+
const name = posix.basename(parts.join(':'));
|
|
183
|
+
return ['apple-terminal', 'iterm', 'windows-terminal', 'console', 'omarchy-launch-tui', 'ghostty', 'wezterm', 'kitty', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'x-terminal-emulator', 'alacritty', 'foot', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'xterm', 'uxterm'].includes(name) ? `${platform}:${name}` : null;
|
|
184
|
+
}
|
|
185
|
+
async function sessionBusReadiness(host, env, libraryPath, resolve, run, mayRun) {
|
|
186
|
+
if (host.id !== 'linux')
|
|
187
|
+
return { sessionBus: { status: 'unavailable', message: 'RadioCLI desktop media controls require Linux.' }, owner: unavailableOwner };
|
|
188
|
+
const socket = sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? '');
|
|
189
|
+
if (!socket)
|
|
190
|
+
return { sessionBus: { status: 'unavailable', message: 'No supported local Unix session-bus address is configured.' }, owner: unavailableOwner };
|
|
191
|
+
const command = resolve('busctl');
|
|
192
|
+
if (!command || !mayRun)
|
|
193
|
+
return { sessionBus: { ...unavailableBus, message: 'A local Unix bus is configured; busctl is unavailable for a read-only readiness check.' }, owner: unavailableOwner };
|
|
194
|
+
// Rebuild exactly one Unix transport. Never forward fallback unixexec, TCP,
|
|
195
|
+
// autolaunch, GUIDs, or unrecognized parameters to the D-Bus parser.
|
|
196
|
+
const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`;
|
|
197
|
+
const name = libraryPath ? mprisServiceName(absolutePath(libraryPath)) : 'org.freedesktop.DBus';
|
|
198
|
+
const result = await run(command, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'NameHasOwner', 's', name], probeEnvironment(env));
|
|
199
|
+
const owner = usable(result) && /^b (true|false)\s*$/.exec(result.output.trim());
|
|
200
|
+
if (!owner)
|
|
201
|
+
return { sessionBus: { status: 'unavailable', message: 'The local session bus did not return a usable response within the bounded check.' }, owner: unavailableOwner };
|
|
202
|
+
return { sessionBus: { status: 'ready', message: 'The local session bus answered a read-only query without service activation.' },
|
|
203
|
+
owner: !libraryPath ? unavailableOwner : owner[1] === 'true'
|
|
204
|
+
? { status: 'registered', message: 'A RadioCLI media-service owner is registered for this library; playback state was not inspected.' }
|
|
205
|
+
: { status: 'absent', message: 'No RadioCLI media-service owner is currently registered for this library.' } };
|
|
206
|
+
}
|
|
207
|
+
async function audioReadiness(options, env, resolve, run, mayRun) {
|
|
208
|
+
const linux = options.host.id === 'linux';
|
|
209
|
+
const wpctl = linux ? resolve('wpctl') : undefined;
|
|
210
|
+
const pactl = linux ? resolve('pactl') : undefined;
|
|
211
|
+
const socket = options.isSocket ?? (options.host.platform === process.platform ? isSocket : () => false);
|
|
212
|
+
const runtime = localPath(env.XDG_RUNTIME_DIR);
|
|
213
|
+
const pipewireRuntime = localPath(env.PIPEWIRE_RUNTIME_DIR) ?? runtime;
|
|
214
|
+
const pipewireName = env.PIPEWIRE_REMOTE ?? 'pipewire-0';
|
|
215
|
+
const pipewireSocket = pipewireName.startsWith('/') ? localPath(pipewireName)
|
|
216
|
+
: /^[\w.-]{1,100}$/.test(pipewireName) && !['.', '..', 'internal'].includes(pipewireName) && pipewireRuntime ? posix.join(pipewireRuntime, pipewireName) : null;
|
|
217
|
+
const pulseSocket = env.PULSE_SERVER ? (env.PULSE_SERVER.startsWith('unix:') ? localPath(env.PULSE_SERVER.slice(5)) : null)
|
|
218
|
+
: runtime ? posix.join(runtime, 'pulse', 'native') : null;
|
|
219
|
+
const check = async (command, path, args, extra, pattern) => {
|
|
220
|
+
if (!command || !path || !socket(path))
|
|
221
|
+
return 'unavailable';
|
|
222
|
+
if (!mayRun)
|
|
223
|
+
return 'unverified';
|
|
224
|
+
try {
|
|
225
|
+
const result = await run(command, args, { ...probeEnvironment(env), ...extra });
|
|
226
|
+
return usable(result) && pattern.test(result.output.trim()) ? 'ready' : 'unavailable';
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return 'unverified';
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const [pipewire, pulse] = linux ? await Promise.all([
|
|
233
|
+
check(wpctl, pipewireSocket, ['get-volume', '@DEFAULT_AUDIO_SINK@'], { PIPEWIRE_REMOTE: pipewireSocket ?? '', PIPEWIRE_DAEMON: 'false' }, /^Volume:\s*\d+(?:\.\d+)?(?:\s+\[MUTED\])?$/),
|
|
234
|
+
// libpulse disables autospawn when a server is explicitly supplied:
|
|
235
|
+
// https://github.com/pulseaudio/pulseaudio/blob/master/src/pulse/context.c
|
|
236
|
+
check(pactl, pulseSocket, [`--server=unix:${pulseSocket ?? ''}`, 'get-sink-volume', '@DEFAULT_SINK@'], { PULSE_SERVER: `unix:${pulseSocket ?? ''}` }, /^Volume:.*\b\d+%/)
|
|
237
|
+
]) : ['unverified', 'unverified'];
|
|
238
|
+
const backend = pipewire === 'ready' ? 'pipewire' : pulse === 'ready' ? 'pulse' : null;
|
|
239
|
+
const status = backend ? 'ready' : !linux || pipewire === 'unverified' || pulse === 'unverified' ? 'unverified' : 'unavailable';
|
|
240
|
+
return { status, backend, wpctl: { installed: Boolean(wpctl), status: pipewire }, pactl: { installed: Boolean(pactl), status: pulse },
|
|
241
|
+
message: backend ? 'A local audio server answered a read-only default-output query; audible playback and alarm-volume restoration still require acceptance checks.'
|
|
242
|
+
: !linux ? 'This bounded audio-server check applies to Linux; playback readiness is checked when tuning.'
|
|
243
|
+
: status === 'unverified' ? 'Local audio-server readiness could not be verified; installed tools alone do not establish usable audio.'
|
|
244
|
+
: 'No local audio server answered the default-output check; installed tools alone do not establish usable audio.' };
|
|
245
|
+
}
|
|
246
|
+
function unverifiedAirPlay(host) {
|
|
247
|
+
return { eligible: nativeAdapters(host).airPlay, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false,
|
|
248
|
+
message: 'The optional AirPlay readiness check could not complete; local playback is unaffected.' };
|
|
249
|
+
}
|
|
250
|
+
function probeEnvironment(env) {
|
|
251
|
+
const result = { ...env, LC_ALL: 'C' };
|
|
252
|
+
// Native clients must not inherit remote transports, autolaunch addresses,
|
|
253
|
+
// daemon switches, or debug paths from optional integration overrides.
|
|
254
|
+
for (const key of Object.keys(result))
|
|
255
|
+
if (/^(?:DBUS_|PIPEWIRE_|PULSE_)/.test(key))
|
|
256
|
+
delete result[key];
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
function localPath(value) { return value && value.startsWith('/') && value.length < 108 && !/[\s\0-\x1f\x7f;,]/.test(value) ? posix.normalize(value) : null; }
|
|
260
|
+
function isSocket(path) { return attempt(() => statSync(path).isSocket(), false); }
|
|
261
|
+
function usable(result) { return result.code === 0 && !result.timedOut && !result.truncated && Buffer.byteLength(result.output) <= maxProbeBytes; }
|
|
262
|
+
function validPreference(value) { return value === 'auto' || value === 'on' || value === 'off'; }
|
|
263
|
+
function attempt(read, fallback) { try {
|
|
264
|
+
return read();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return fallback;
|
|
268
|
+
} }
|
|
269
|
+
/** Combined stdout/stderr cap and an unconditional SIGKILL deadline. Stderr and
|
|
270
|
+
* command errors remain private; callers only publish interpreted facts. */
|
|
271
|
+
function runProbe(command, args, env, timeoutMs = probeTimeoutMs) {
|
|
272
|
+
return runAirPlayCommand(command, args, timeoutMs, maxProbeBytes, { env });
|
|
273
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
3
|
+
const largeJsonCharacters = 256 * 1024;
|
|
4
|
+
const maxWorkers = 2;
|
|
5
|
+
const maxWaitingJobs = 8;
|
|
6
|
+
let activeWorkers = 0;
|
|
7
|
+
const waiting = [];
|
|
8
|
+
// Static code supports source and packed builds; values never become code.
|
|
9
|
+
const source = `
|
|
10
|
+
const {parentPort, workerData} = require('node:worker_threads');
|
|
11
|
+
try {
|
|
12
|
+
const value = workerData.operation === 'parse' ? JSON.parse(workerData.value) : JSON.stringify(workerData.value);
|
|
13
|
+
parentPort.postMessage({value});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
parentPort.postMessage({error: error.message});
|
|
16
|
+
}
|
|
17
|
+
parentPort.close();
|
|
18
|
+
`;
|
|
19
|
+
class WorkerUnavailableError extends Error {
|
|
20
|
+
}
|
|
21
|
+
export function parseJsonAsync(value, signal) {
|
|
22
|
+
signal?.throwIfAborted();
|
|
23
|
+
return value.length < largeJsonCharacters ? Promise.resolve(JSON.parse(value)) : jsonInWorker('parse', value, signal);
|
|
24
|
+
}
|
|
25
|
+
export async function stringifyJsonAsync(value, large, signal) {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
return large ? jsonInWorker('stringify', value, signal) : JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
async function jsonInWorker(operation, value, signal) {
|
|
30
|
+
await acquireWorker(signal);
|
|
31
|
+
try {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
try {
|
|
34
|
+
return await new Promise((resolve, reject) => {
|
|
35
|
+
let worker;
|
|
36
|
+
try {
|
|
37
|
+
worker = new Worker(source, { eval: true, workerData: { operation, value }, execArgv: [] });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
reject(new WorkerUnavailableError('JSON workers are unavailable.', { cause: error }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let settled = false;
|
|
44
|
+
const finish = (error, result) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
signal?.removeEventListener('abort', aborted);
|
|
50
|
+
void worker.terminate().catch(() => undefined);
|
|
51
|
+
if (error !== undefined)
|
|
52
|
+
reject(error);
|
|
53
|
+
else
|
|
54
|
+
resolve(result);
|
|
55
|
+
};
|
|
56
|
+
const aborted = () => finish(signal.reason);
|
|
57
|
+
const timeout = setTimeout(() => finish(new Error('JSON processing timed out.')), 30_000);
|
|
58
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
59
|
+
worker.once('message', (result) => {
|
|
60
|
+
if (result.error)
|
|
61
|
+
finish(new SyntaxError(result.error));
|
|
62
|
+
else
|
|
63
|
+
finish(undefined, result.value);
|
|
64
|
+
});
|
|
65
|
+
worker.once('error', error => finish(error));
|
|
66
|
+
worker.once('exit', code => finish(new Error(`JSON worker exited before completing (${code}).`)));
|
|
67
|
+
if (signal?.aborted)
|
|
68
|
+
aborted();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (!(error instanceof WorkerUnavailableError))
|
|
74
|
+
throw error;
|
|
75
|
+
// Restricted runtimes remain functional. Yield first and honor abort;
|
|
76
|
+
// these environments necessarily perform the final parse on this thread.
|
|
77
|
+
await yieldToEventLoop();
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
return (operation === 'parse' ? JSON.parse(value) : JSON.stringify(value));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
activeWorkers -= 1;
|
|
84
|
+
waiting.shift()?.();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function acquireWorker(signal) {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
if (activeWorkers < maxWorkers) {
|
|
90
|
+
activeWorkers += 1;
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
if (waiting.length >= maxWaitingJobs)
|
|
94
|
+
return Promise.reject(new Error('JSON processing is busy. Retry after the current requests finish.'));
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const start = () => {
|
|
97
|
+
signal?.removeEventListener('abort', aborted);
|
|
98
|
+
activeWorkers += 1;
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
const aborted = () => {
|
|
102
|
+
const index = waiting.indexOf(start);
|
|
103
|
+
if (index >= 0)
|
|
104
|
+
waiting.splice(index, 1);
|
|
105
|
+
signal?.removeEventListener('abort', aborted);
|
|
106
|
+
reject(signal.reason);
|
|
107
|
+
};
|
|
108
|
+
waiting.push(start);
|
|
109
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -6,11 +6,12 @@ const applicationEnvironmentKeys = [
|
|
|
6
6
|
'RADIOCLI_FFPLAY_PATH',
|
|
7
7
|
'RADIOCLI_VLC_PATH',
|
|
8
8
|
'RADIOCLI_FFMPEG_PATH',
|
|
9
|
+
'RADIOCLI_DESKTOP_MEDIA',
|
|
9
10
|
'RADIOCLI_OFFLINE',
|
|
10
11
|
'RADIOCLI_LOW_BANDWIDTH'
|
|
11
12
|
];
|
|
12
13
|
const desktopEnvironmentKeys = ['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS'];
|
|
13
|
-
/** Persist
|
|
14
|
+
/** Persist application identity; UWSM jobs can inherit the manager's live desktop. */
|
|
14
15
|
export function launchEnvironment(env, options = {}) {
|
|
15
16
|
const result = {};
|
|
16
17
|
const platform = options.platform ?? process.platform;
|
|
@@ -37,7 +38,8 @@ export function launchEnvironment(env, options = {}) {
|
|
|
37
38
|
add(key, path.resolve(options.cwd ?? process.cwd(), value));
|
|
38
39
|
};
|
|
39
40
|
for (const key of pathEnvironmentKeys)
|
|
40
|
-
|
|
41
|
+
if (!options.inheritDesktop || key !== 'XDG_RUNTIME_DIR')
|
|
42
|
+
capturePath(key, env[key]);
|
|
41
43
|
// On Windows, removing USERPROFILE from a child environment does not
|
|
42
44
|
// reliably erase a differently-cased inherited value. Persist the effective
|
|
43
45
|
// account home so an absent selector cannot be replaced by the launcher.
|
|
@@ -46,14 +48,14 @@ export function launchEnvironment(env, options = {}) {
|
|
|
46
48
|
}
|
|
47
49
|
for (const key of applicationEnvironmentKeys)
|
|
48
50
|
add(key, env[key]);
|
|
49
|
-
if (options.includeDesktop)
|
|
51
|
+
if (options.includeDesktop && !options.inheritDesktop)
|
|
50
52
|
for (const key of desktopEnvironmentKeys)
|
|
51
53
|
add(key, env[key]);
|
|
52
54
|
add('RADIOCLI_ALARM_TERMINAL', options.terminal);
|
|
53
55
|
return result;
|
|
54
56
|
}
|
|
55
57
|
/** Keep application argv and approved environment out of native shell syntax. */
|
|
56
|
-
export function nodeLaunchCommand(nodePath, args, environment) {
|
|
58
|
+
export function nodeLaunchCommand(nodePath, args, environment, options = {}) {
|
|
57
59
|
if ([nodePath, ...args, ...Object.keys(environment), ...Object.values(environment)].some(value => value.includes('\0'))) {
|
|
58
60
|
throw new Error('Launch command values cannot contain NUL bytes.');
|
|
59
61
|
}
|
|
@@ -62,7 +64,8 @@ export function nodeLaunchCommand(nodePath, args, environment) {
|
|
|
62
64
|
// A terminal server or scheduler may have unrelated path overrides. Clear the
|
|
63
65
|
// complete identity before restoring this launch's snapshot, preserving absent
|
|
64
66
|
// selectors and the path layer's existing empty-value/default semantics.
|
|
65
|
-
|
|
67
|
+
// Only explicitly opted-in jobs inherit the current manager's runtime root.
|
|
68
|
+
const keys = [...pathEnvironmentKeys, ...applicationEnvironmentKeys, 'RADIOCLI_ALARM_TERMINAL'].filter(key => !options.inheritDesktop || key !== 'XDG_RUNTIME_DIR').map(key => `'${key}'`).join(',');
|
|
66
69
|
const program = `const p=JSON.parse(Buffer.from(process.argv[1],'base64url').toString('utf8'));const s=new Set([${keys}]);const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>!s.has(process.platform==='win32'?k.toUpperCase():k)));const r=require('node:child_process').spawnSync(process.execPath,p.args,{stdio:'inherit',env:{...e,...p.environment}});if(r.error)console.error(r.error.message);process.exit(r.status??1);`;
|
|
67
70
|
return [nodePath, '-e', program, Buffer.from(JSON.stringify({ args, environment }), 'utf8').toString('base64url')];
|
|
68
71
|
}
|
|
@@ -124,7 +127,7 @@ export function waitForLaunch(child, options = {}) {
|
|
|
124
127
|
child.once('spawn', () => {
|
|
125
128
|
if (options.waitForExit) {
|
|
126
129
|
clearTimeout(startupTimer);
|
|
127
|
-
acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), 10_000);
|
|
130
|
+
acceptedTimer = setTimeout(() => stop(new Error('Launch bootstrap did not complete.')), options.exitTimeoutMs ?? 10_000);
|
|
128
131
|
}
|
|
129
132
|
else {
|
|
130
133
|
child.unref();
|