@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.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- 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
package/dist/alarms/scheduler.js
CHANGED
|
@@ -8,7 +8,12 @@ import { nextOccurrenceForAlarm } from './schedule.js';
|
|
|
8
8
|
import { AlarmRuntimeHealthStore } from './runtime-health.js';
|
|
9
9
|
import { AlarmGuardService } from './guard.js';
|
|
10
10
|
import { AlarmPowerGuardStore } from './power-guard-store.js';
|
|
11
|
-
import {
|
|
11
|
+
import { detectGraphicalTerminal } from '../platform/terminals.js';
|
|
12
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
13
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
14
|
+
import { launchEnvironment, nodeLaunchCommand } from '../platform/launch-command.js';
|
|
15
|
+
import { powershellCommand } from '../platform/shell.js';
|
|
16
|
+
import { identifyOmarchy } from '../platform/omarchy.js';
|
|
12
17
|
export class SchedulerService {
|
|
13
18
|
adapter;
|
|
14
19
|
now;
|
|
@@ -94,7 +99,7 @@ export class SchedulerService {
|
|
|
94
99
|
throw error;
|
|
95
100
|
} this.health.remove(alarmId); }
|
|
96
101
|
async statusAll(alarms) { return mapConcurrent(alarms, 4, async (alarm) => { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); return { alarmId: alarm.id, nextOccurrence: occurrence?.toISOString() ?? null, native: occurrence ? await this.adapter.status(alarm.id) : { installed: false, healthy: true, message: 'No enabled future occurrence; no native job is expected.' }, health: this.health.get(alarm.id) }; }); }
|
|
97
|
-
async runtimeStatus(alarms = []) {
|
|
102
|
+
async runtimeStatus(alarms = []) { const [capabilities, status] = await Promise.all([this.adapter.probeCapabilities?.() ?? this.doctor(), this.statusAll(alarms)]); return { capabilities, entries: this.health.list(), alarms: status }; }
|
|
98
103
|
async reconcileGuard(alarm) { if (!this.guard)
|
|
99
104
|
return; try {
|
|
100
105
|
const requested = alarm.enabled && alarm.reliability.keepAwakeUntilAlarm;
|
|
@@ -111,7 +116,7 @@ export class SchedulerService {
|
|
|
111
116
|
} }
|
|
112
117
|
async ensureGuardAbsent(alarmId) { if (!this.guard)
|
|
113
118
|
return; const stopped = await this.guard.stop(alarmId); if (stopped)
|
|
114
|
-
return; const status = await this.guard.status?.(); if (status && !status.guards.some(item => item.alarmId === alarmId))
|
|
119
|
+
return; const status = await this.guard.status?.(); if (status && !status.guards.some(item => item.alarmId === alarmId) && !status.unresolvedGuards?.some(item => !item.alarmId || item.alarmId === alarmId))
|
|
115
120
|
return; const message = 'Alarm Guard teardown could not be verified; the alarm definition was retained for repair.'; this.health.record({ alarmId, component: 'power', healthy: false, active: true, message }); throw new GuardReconcileError(message); }
|
|
116
121
|
}
|
|
117
122
|
class GuardReconcileError extends Error {
|
|
@@ -132,29 +137,34 @@ export function createSchedulerAdapter(deps = {}) {
|
|
|
132
137
|
const nodePath = deps.nodePath ?? process.execPath;
|
|
133
138
|
const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
134
139
|
const env = deps.env ?? process.env;
|
|
140
|
+
const host = identifyPlatform({ platform, env });
|
|
141
|
+
const policy = nativeAdapters(host);
|
|
135
142
|
const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }); });
|
|
136
143
|
const removeFile = deps.removeFile ?? (path => rmSync(path, { force: true }));
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
144
|
+
const resolve = (command) => resolveCommandDetails(command, { platform, env, home }).path;
|
|
145
|
+
const run = deps.run ?? ((command, args) => runCommand(resolve(command) ?? command, args));
|
|
146
|
+
const commandExists = deps.commandExists ?? (command => resolve(command) !== null);
|
|
147
|
+
const profile = policy.scheduler === 'systemd' ? identifyOmarchy({ ...deps.omarchy, platform, env, home }) : undefined;
|
|
148
|
+
const inheritDesktop = profile?.kind === 'quattro' && profile.source === 'package' && profile.packageName === 'omarchy';
|
|
149
|
+
const detectedTerminal = detectGraphicalTerminal(platform, env, undefined, deps.omarchy);
|
|
150
|
+
// A job registered over SSH may next run inside UWSM's live desktop. Do not
|
|
151
|
+
// turn the registration-time absence of a display into a permanent selector.
|
|
152
|
+
const configuredTerminal = env.RADIOCLI_ALARM_TERMINAL?.trim();
|
|
153
|
+
const terminal = inheritDesktop && detectedTerminal.endsWith(':unsupported') ? configuredTerminal || undefined : detectedTerminal;
|
|
154
|
+
const common = { platform, home, nodePath, cliPath, env, inheritDesktop, terminal, write, removeFile, run };
|
|
155
|
+
if (policy.scheduler === 'launchd')
|
|
141
156
|
return launchdAdapter(common);
|
|
142
|
-
if (
|
|
157
|
+
if (policy.scheduler === 'task-scheduler')
|
|
143
158
|
return windowsAdapter(common);
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
if (policy.scheduler === 'systemd') {
|
|
160
|
+
if (commandExists('systemctl'))
|
|
161
|
+
return systemdAdapter(common);
|
|
162
|
+
return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.');
|
|
163
|
+
}
|
|
164
|
+
return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id === 'unknown' ? host.platform : host.id}.`);
|
|
149
165
|
}
|
|
150
166
|
function runtimeEnvironment(common) {
|
|
151
|
-
|
|
152
|
-
for (const key of ['RADIOCLI_HOME', 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR']) {
|
|
153
|
-
const value = common.env[key];
|
|
154
|
-
if (value)
|
|
155
|
-
result[key] = value;
|
|
156
|
-
}
|
|
157
|
-
return result;
|
|
167
|
+
return launchEnvironment(common.env, { platform: common.platform, includeDesktop: true, inheritDesktop: common.inheritDesktop, terminal: common.terminal });
|
|
158
168
|
}
|
|
159
169
|
const jobName = (id) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`;
|
|
160
170
|
const invocationArgs = (common, id, occurrence, internalCommand = 'internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()];
|
|
@@ -170,38 +180,41 @@ function launchdAdapter(common) {
|
|
|
170
180
|
extras = readdirSync(directory).filter(name => name.startsWith(prefix) && name.endsWith('.plist')).map(name => join(directory, name));
|
|
171
181
|
}
|
|
172
182
|
catch { } return [pathFor(id), ...extras]; };
|
|
173
|
-
const installJob = async (alarm, occurrence, label, path, replace) => { const local = localParts(occurrence); const entries = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join(''); const envArgs = `<key>EnvironmentVariables</key><dict>${entries}</dict>`; const args =
|
|
183
|
+
const installJob = async (alarm, occurrence, label, path, replace) => { const local = localParts(occurrence); const entries = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join(''); const envArgs = `<key>EnvironmentVariables</key><dict>${entries}</dict>`; const args = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence, 'internal-launchd'), runtimeEnvironment(common)).map(value => `<string>${xml(value)}</string>`).join(''); common.write(path, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args}</array>${envArgs}<key>StartCalendarInterval</key><dict><key>Month</key><integer>${local.month}</integer><key>Day</key><integer>${local.day}</integer><key>Hour</key><integer>${local.hour}</integer><key>Minute</key><integer>${local.minute}</integer></dict><key>ProcessType</key><string>Interactive</string><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string><!-- launchd repeats this calendar pattern annually; the idempotent runner gates, removes, or reschedules it. --></dict></plist>\n`); if (replace)
|
|
174
184
|
await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]).catch(() => undefined); ensureSuccess(await common.run('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootstrap'); };
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
async removeFromRunner() { },
|
|
181
|
-
async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); const path = existsSync(specific) ? specific : pathFor(id); const label = path.split('/').at(-1).slice(0, -6); let failure; try {
|
|
182
|
-
const result = await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]);
|
|
183
|
-
if (result.code !== 0)
|
|
184
|
-
failure = new Error(`launchctl bootout failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
|
|
185
|
+
const removeJob = async (path) => {
|
|
186
|
+
const label = path.split('/').at(-1).slice(0, -6);
|
|
187
|
+
let failure;
|
|
188
|
+
try {
|
|
189
|
+
ensureSuccess(await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootout');
|
|
185
190
|
}
|
|
186
191
|
catch (error) {
|
|
187
192
|
failure = error;
|
|
188
|
-
}
|
|
193
|
+
}
|
|
194
|
+
if (failure) {
|
|
189
195
|
let status;
|
|
190
196
|
try {
|
|
191
197
|
status = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]);
|
|
192
198
|
}
|
|
193
199
|
catch (error) {
|
|
194
|
-
throw new Error(`Unable to verify
|
|
200
|
+
throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);
|
|
195
201
|
}
|
|
196
202
|
if (status.code === 0)
|
|
197
|
-
throw new Error(`
|
|
203
|
+
throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`);
|
|
198
204
|
if (!isLaunchdServiceNotFound(status, label))
|
|
199
|
-
throw new Error(`Unable to verify
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`);
|
|
206
|
+
}
|
|
207
|
+
common.removeFile(path);
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
capabilities: () => ({ name: 'launchd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.' }),
|
|
211
|
+
async install(alarm, occurrence) { await installJob(alarm, occurrence, jobName(alarm.id), pathFor(alarm.id), true); },
|
|
212
|
+
async installFromRunner(alarm, occurrence) { const label = occurrenceLabel(alarm.id, occurrence); const existing = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]); if (existing.code === 0)
|
|
213
|
+
return; await installJob(alarm, occurrence, label, occurrencePath(alarm.id, occurrence), false); },
|
|
214
|
+
async removeFromRunner() { },
|
|
215
|
+
async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); await removeJob(existsSync(specific) ? specific : pathFor(id)); },
|
|
216
|
+
async remove(id) { for (const path of pathsFor(id))
|
|
217
|
+
await removeJob(path); },
|
|
205
218
|
async status(id) { const paths = pathsFor(id).filter(existsSync); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); if (!paths.length)
|
|
206
219
|
return { installed: false, healthy: false, message: 'LaunchAgent artifact missing' }; const results = await Promise.all(paths.map(path => common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${path.split('/').at(-1).slice(0, -6)}`]))); const registered = results.some(result => result.code === 0); return { installed: registered, healthy: registered && executable, message: !registered ? 'launchd registration missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
|
|
207
220
|
};
|
|
@@ -209,38 +222,175 @@ function launchdAdapter(common) {
|
|
|
209
222
|
function systemdAdapter(common) {
|
|
210
223
|
const base = join(common.home, '.config', 'systemd', 'user');
|
|
211
224
|
const names = (id) => ({ service: `${jobName(id)}.service`, timer: `${jobName(id)}.timer` });
|
|
225
|
+
const capabilities = () => ({ name: 'systemd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'systemd tooling detected; user-manager readiness is checked during registration and diagnostics. Persistent user timers can catch up after login/wake; WakeSystem is not claimed without system privileges.' });
|
|
226
|
+
const probeCapabilities = async () => {
|
|
227
|
+
try {
|
|
228
|
+
const result = await boundedProbe(common.run('systemctl', ['--user', 'show', '--property=Version', '--value']));
|
|
229
|
+
ensureSuccess(result, 'systemd user manager probe');
|
|
230
|
+
return { ...capabilities(), message: 'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.' };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
return { ...capabilities(), supported: false, catchUpAfterWake: false, message: `The systemd user manager is unavailable: ${errorMessage(error)}` };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
212
236
|
return {
|
|
213
|
-
capabilities
|
|
214
|
-
|
|
215
|
-
async
|
|
216
|
-
|
|
237
|
+
capabilities,
|
|
238
|
+
probeCapabilities,
|
|
239
|
+
async install(alarm, occurrence) { const readiness = await probeCapabilities(); if (!readiness.supported)
|
|
240
|
+
throw new Error(readiness.message); const n = names(alarm.id); const env = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd = systemdExecCommand(nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common), { inheritDesktop: common.inheritDesktop })); common.write(join(base, n.service), `[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base, n.timer), `[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl', ['--user', 'enable', '--now', n.timer]), 'systemctl enable'); },
|
|
241
|
+
async remove(id) {
|
|
242
|
+
const n = names(id);
|
|
243
|
+
await removeWithVerification(() => common.run('systemctl', ['--user', 'disable', '--now', n.timer]), () => common.run('systemctl', ['--user', 'show', n.timer, '--property=LoadState', '--property=ActiveState']), result => /^LoadState=not-found\r?$/m.test(result.stdout) && /^ActiveState=inactive\r?$/m.test(result.stdout), 'systemd timer');
|
|
244
|
+
common.removeFile(join(base, n.timer));
|
|
245
|
+
common.removeFile(join(base, n.service));
|
|
246
|
+
ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload after removal');
|
|
247
|
+
},
|
|
248
|
+
async status(id) { const readiness = await probeCapabilities(); if (!readiness.supported)
|
|
249
|
+
return { installed: false, healthy: false, message: readiness.message }; const n = names(id); const [enabled, active] = await Promise.all([common.run('systemctl', ['--user', 'is-enabled', n.timer]), common.run('systemctl', ['--user', 'is-active', n.timer])]); const artifact = existsSync(join(base, n.timer)) && existsSync(join(base, n.service)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: enabled.code === 0 && artifact, healthy: enabled.code === 0 && active.code === 0 && artifact && executable, message: enabled.code !== 0 ? 'user timer not enabled' : active.code !== 0 ? 'user timer is not active' : !artifact ? 'systemd unit artifact missing' : !executable ? 'RadioCLI executable missing' : 'enabled, active, and executable' }; }
|
|
217
250
|
};
|
|
218
251
|
}
|
|
219
252
|
function windowsAdapter(common) {
|
|
220
253
|
const xmlPath = (id) => join(common.env.LOCALAPPDATA ?? join(common.home, 'AppData', 'Local'), 'RadioCLI', 'scheduler', `${jobName(id)}.xml`);
|
|
221
254
|
return {
|
|
222
|
-
capabilities: () => ({ supported: true, exactWake: false, catchUpAfterWake: true, message: 'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.' }),
|
|
223
|
-
async install(alarm, occurrence) {
|
|
224
|
-
|
|
255
|
+
capabilities: () => ({ name: 'Task Scheduler', supported: true, exactWake: false, catchUpAfterWake: true, message: 'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.' }),
|
|
256
|
+
async install(alarm, occurrence) {
|
|
257
|
+
const name = `\\RadioCLI\\${jobName(alarm.id)}`;
|
|
258
|
+
const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common));
|
|
259
|
+
// Task Scheduler expands %NAME% in Path and Arguments itself. Only its
|
|
260
|
+
// standard PowerShell path and fixed flags/encoded data cross that boundary.
|
|
261
|
+
const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
262
|
+
const args = powershellCommand(direct).join(' ');
|
|
263
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?><Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><RegistrationInfo><Description>${xml(alarm.label)}</Description></RegistrationInfo><Triggers><TimeTrigger><StartBoundary>${windowsLocalBoundary(occurrence)}</StartBoundary><Enabled>true</Enabled></TimeTrigger></Triggers><Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><StartWhenAvailable>true</StartWhenAvailable><WakeToRun>${alarm.reliability.wakeIfSupported}</WakeToRun><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy></Settings><Actions Context="Author"><Exec><Command>${xml(command)}</Command><Arguments>${xml(args)}</Arguments></Exec></Actions></Task>`;
|
|
264
|
+
const path = xmlPath(alarm.id);
|
|
265
|
+
common.write(path, body);
|
|
266
|
+
ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create');
|
|
267
|
+
},
|
|
268
|
+
async remove(id) {
|
|
269
|
+
const name = `\\RadioCLI\\${jobName(id)}`;
|
|
270
|
+
const removal = await settledOperation(async () => {
|
|
271
|
+
const result = await boundedWindowsCommand(() => common.run('schtasks.exe', ['/Delete', '/TN', name, '/F']), 'deletion');
|
|
272
|
+
if (result.code !== 0)
|
|
273
|
+
throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`);
|
|
274
|
+
});
|
|
275
|
+
let state;
|
|
276
|
+
try {
|
|
277
|
+
const result = await boundedWindowsCommand(() => common.run('powershell.exe', windowsTaskLookupCommand(name)), 'absence lookup');
|
|
278
|
+
state = windowsTaskState(result, name);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error ?? ''} ${errorMessage(error)}`);
|
|
282
|
+
}
|
|
283
|
+
if (state.state !== 'absent')
|
|
284
|
+
throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error ?? ''} ${state.message}`);
|
|
285
|
+
common.removeFile(xmlPath(id));
|
|
286
|
+
},
|
|
225
287
|
async status(id) { const result = await common.run('schtasks.exe', ['/Query', '/TN', `\\RadioCLI\\${jobName(id)}`]); const artifact = existsSync(xmlPath(id)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: result.code === 0 && artifact, healthy: result.code === 0 && artifact && executable, message: result.code !== 0 ? 'task not registered' : !artifact ? 'task XML artifact missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
|
|
226
288
|
};
|
|
227
289
|
}
|
|
290
|
+
function windowsTaskLookupCommand(taskPath) {
|
|
291
|
+
// Only GetTask failures can establish absence. A missing COM service or root
|
|
292
|
+
// folder must remain a repairable error, regardless of the UI language.
|
|
293
|
+
const encodedPath = Buffer.from(taskPath, 'utf8').toString('base64');
|
|
294
|
+
const script = [
|
|
295
|
+
"$ErrorActionPreference='Stop'",
|
|
296
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
297
|
+
'[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)',
|
|
298
|
+
`$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
|
|
299
|
+
"$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}",
|
|
300
|
+
"try{$radiocliService=New-Object -ComObject 'Schedule.Service';$null=$radiocliService.Connect();$radiocliResult.stage='root';$radiocliFolder=$radiocliService.GetFolder('\\');$radiocliResult.stage='lookup';$null=$radiocliFolder.GetTask($radiocliTaskPath);$radiocliResult.status='present'}catch{$radiocliException=$_.Exception;$radiocliResult.message=$radiocliException.ToString();for($radiocliDepth=0;$null -ne $radiocliException.InnerException -and $radiocliDepth -lt 16;$radiocliDepth++){$radiocliException=$radiocliException.InnerException};$radiocliResult.hresult=$radiocliException.HResult}",
|
|
301
|
+
'[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))',
|
|
302
|
+
'exit 0'
|
|
303
|
+
].join(';');
|
|
304
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
305
|
+
}
|
|
306
|
+
function windowsTaskState(result, taskPath) {
|
|
307
|
+
const unknown = (message) => ({ state: 'unknown', message: `${message} ${windowsCommandMessage(result)}` });
|
|
308
|
+
if (result.code !== 0)
|
|
309
|
+
return unknown('Task Scheduler lookup process failed.');
|
|
310
|
+
let value;
|
|
311
|
+
try {
|
|
312
|
+
value = JSON.parse(result.stdout);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return unknown('Malformed Task Scheduler lookup response.');
|
|
316
|
+
}
|
|
317
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
318
|
+
return unknown('Malformed Task Scheduler lookup response.');
|
|
319
|
+
const response = value;
|
|
320
|
+
if (response.taskPath !== taskPath || !['connect', 'root', 'lookup'].includes(String(response.stage)) ||
|
|
321
|
+
!['present', 'error'].includes(String(response.status)) || typeof response.hresult !== 'number' ||
|
|
322
|
+
!Number.isInteger(response.hresult) || response.hresult < -2147483648 || response.hresult > 2147483647 || typeof response.message !== 'string') {
|
|
323
|
+
return unknown('Invalid or unrelated Task Scheduler lookup response.');
|
|
324
|
+
}
|
|
325
|
+
if (response.status === 'present' && response.stage === 'lookup' && response.hresult === 0)
|
|
326
|
+
return { state: 'present', message: 'Task Scheduler job is still registered.' };
|
|
327
|
+
// HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the
|
|
328
|
+
// exact GetTask lookup. Never interpret schtasks exit 1 or localized text.
|
|
329
|
+
if (response.status === 'error' && response.stage === 'lookup' && [-2147024894, -2147024893].includes(response.hresult))
|
|
330
|
+
return { state: 'absent', message: response.message };
|
|
331
|
+
return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult >>> 0).toString(16).padStart(8, '0')}: ${response.message}`);
|
|
332
|
+
}
|
|
333
|
+
function windowsCommandMessage(result) { return [`exit ${result.code}`, result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(' '); }
|
|
334
|
+
async function boundedWindowsCommand(work, label) {
|
|
335
|
+
let timer;
|
|
336
|
+
try {
|
|
337
|
+
return await Promise.race([Promise.resolve().then(work), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Task Scheduler ${label} timed out.`)), 10_000); })]);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
if (timer)
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
228
344
|
function unsupportedAdapter(message) { return { capabilities: () => ({ supported: false, exactWake: false, catchUpAfterWake: false, message }), install: async () => { throw new Error(message); }, remove: async () => { }, status: async () => ({ installed: false, healthy: false, message }) }; }
|
|
229
|
-
function
|
|
345
|
+
function unavailableAdapter(message) {
|
|
346
|
+
return { ...unsupportedAdapter(message), remove: async () => { throw new Error(message); } };
|
|
347
|
+
}
|
|
348
|
+
function runCommand(command, args) { return new Promise(resolve => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000 }); let stdout = ''; let stderr = ''; child.stdout.on('data', v => stdout += String(v)); child.stderr.on('data', v => stderr += String(v)); child.on('error', e => resolve({ code: 127, stdout, stderr: e.message })); child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); }); }
|
|
349
|
+
async function boundedProbe(probe) { let timer; try {
|
|
350
|
+
return await Promise.race([probe, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('User-manager probe timed out.')), 3_000); })]);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
if (timer)
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
} }
|
|
356
|
+
async function removeWithVerification(remove, query, isAbsent, label) {
|
|
357
|
+
let failure;
|
|
358
|
+
try {
|
|
359
|
+
ensureSuccess(await remove(), `${label} removal`);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
failure = error;
|
|
363
|
+
}
|
|
364
|
+
if (!failure)
|
|
365
|
+
return;
|
|
366
|
+
let result;
|
|
367
|
+
try {
|
|
368
|
+
result = await query();
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);
|
|
372
|
+
}
|
|
373
|
+
if (isAbsent(result))
|
|
374
|
+
return;
|
|
375
|
+
throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`);
|
|
376
|
+
}
|
|
377
|
+
function commandMessage(result) { return (result.stderr || result.stdout || `exit ${result.code}`).trim(); }
|
|
230
378
|
function ensureSuccess(result, label) { if (result.code !== 0)
|
|
231
379
|
throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); }
|
|
232
380
|
function xml(value) { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
233
381
|
function systemdQuote(value) { if (/[\r\n\0]/.test(value))
|
|
234
382
|
throw new Error('Scheduler arguments cannot contain control characters.'); return `"${value.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; }
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
383
|
+
function systemdExecCommand(values) {
|
|
384
|
+
// ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has
|
|
385
|
+
// different syntax: its dollar signs are already literal and must stay so.
|
|
386
|
+
return `:${values.map(systemdQuote).join(' ')}`;
|
|
387
|
+
}
|
|
238
388
|
function localParts(date) { return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds() }; }
|
|
239
389
|
function systemdCalendar(date) { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; }
|
|
240
390
|
function windowsLocalBoundary(date) { const p = localParts(date); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}T${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}`; }
|
|
241
391
|
function unitText(value) { return value.replace(/[\r\n\0]/g, ' ').replaceAll('%', '%%').slice(0, 200); }
|
|
242
392
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
243
|
-
function isLaunchdServiceNotFound(result, label) { const output = `${result.stderr}\n${result.stdout}`;
|
|
393
|
+
function isLaunchdServiceNotFound(result, label) { const output = `${result.stderr}\n${result.stdout}`; const match = /could not find service "([^"]+)" in domain for\b/i.exec(output); return result.code !== 0 && match?.[1] === label; }
|
|
244
394
|
async function mapConcurrent(items, limit, work) { const results = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (true) {
|
|
245
395
|
const index = next;
|
|
246
396
|
next += 1;
|
|
@@ -48,7 +48,7 @@ export async function verifyAlarmSetup(scheduler, alarm, settings, onUpdate = ()
|
|
|
48
48
|
const [status] = await scheduler.statusAll([temporary]);
|
|
49
49
|
if (!status?.native.installed || !status.native.healthy)
|
|
50
50
|
throw new Error(status?.native.message ?? 'The native job could not be verified.');
|
|
51
|
-
set('scheduler', 'passed', `Registered and queried a disposable ${
|
|
51
|
+
set('scheduler', 'passed', `Registered and queried a disposable ${capability.name ?? 'native scheduler'} job.`);
|
|
52
52
|
if (alarm?.reliability.wakeIfSupported)
|
|
53
53
|
set('wake', capability.exactWake ? 'passed' : 'warning', capability.exactWake ? 'This scheduler reports exact wake support.' : `The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`);
|
|
54
54
|
else
|
|
@@ -174,7 +174,6 @@ finally {
|
|
|
174
174
|
await server.close();
|
|
175
175
|
rmSync(root, { recursive: true, force: true });
|
|
176
176
|
} }
|
|
177
|
-
function platformSchedulerName() { return process.platform === 'darwin' ? 'launchd' : process.platform === 'win32' ? 'Task Scheduler' : 'systemd'; }
|
|
178
177
|
function friendlyTerminal(value) { return value.replace(/^darwin:/, '').replace(/^win32:/, '').replace(/^linux:/, ''); }
|
|
179
178
|
function messageOf(error) { return error instanceof Error ? error.message : String(error); }
|
|
180
179
|
function wait(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); }
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Never persist or mutate a default-sink alias: it can change during an alarm. */
|
|
2
|
+
export function linuxVolumeBackend(run, resolve) {
|
|
3
|
+
const wpctl = resolve('wpctl');
|
|
4
|
+
if (wpctl) {
|
|
5
|
+
const identity = async (id) => {
|
|
6
|
+
const [status, detail] = await Promise.all([
|
|
7
|
+
checked(run(wpctl, ['status']), 'identify the PipeWire server'),
|
|
8
|
+
checked(run(wpctl, ['inspect', id]), 'identify the PipeWire output')
|
|
9
|
+
]);
|
|
10
|
+
const server = /^PipeWire .+cookie:\s*(\d{1,20})\]/m.exec(status)?.[1];
|
|
11
|
+
const node = /^id (\d{1,20}), type PipeWire:Interface:Node\s*$/m.exec(detail)?.[1];
|
|
12
|
+
const property = (name) => new RegExp(`^\\s*\\*?\\s*${name.replaceAll('.', '\\.')} = "([^"\\r\\n]+)"\\s*$`, 'm').exec(detail)?.[1];
|
|
13
|
+
const serial = property('object.serial');
|
|
14
|
+
const name = property('node.name');
|
|
15
|
+
if (!server || !node || !serial || !/^\d{1,20}$/.test(serial) || !validName(name) || property('media.class') !== 'Audio/Sink')
|
|
16
|
+
throw new Error('PipeWire returned an unreadable output identity; no default-output mutation is safe.');
|
|
17
|
+
return { kind: 'pipewire', server, id: node, serial, name };
|
|
18
|
+
};
|
|
19
|
+
const verify = async (output) => {
|
|
20
|
+
if (!output || output.kind === 'pulse')
|
|
21
|
+
throw changedOutput();
|
|
22
|
+
const id = output.kind === 'pipewire' ? output.id : output.nodeId;
|
|
23
|
+
const current = await identity(id);
|
|
24
|
+
if (current.server !== output.server || current.id !== id || current.serial !== output.serial || current.name !== output.name)
|
|
25
|
+
throw changedOutput();
|
|
26
|
+
return id;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
select: () => identity('@DEFAULT_AUDIO_SINK@'),
|
|
30
|
+
async read(output) {
|
|
31
|
+
const id = await verify(output);
|
|
32
|
+
const text = await checked(run(wpctl, ['get-volume', id]), 'read PipeWire output volume');
|
|
33
|
+
const match = /Volume:\s*(\d+(?:\.\d+)?)(?:\s|$)/i.exec(text);
|
|
34
|
+
if (!match)
|
|
35
|
+
throw new Error('wpctl returned an unreadable output-volume state.');
|
|
36
|
+
await verify(output);
|
|
37
|
+
return { volume: clamp(Number(match[1]) * 100), muted: /\[MUTED\]/i.test(text) };
|
|
38
|
+
},
|
|
39
|
+
async write(value, execute = run, output) {
|
|
40
|
+
const id = await verify(output);
|
|
41
|
+
await checked(execute(wpctl, ['set-volume', id, `${clamp(value.volume)}%`]), 'set PipeWire output volume');
|
|
42
|
+
// A hot-unplug between the two commands must not unmute a reused ID.
|
|
43
|
+
await verify(output);
|
|
44
|
+
await checked(execute(wpctl, ['set-mute', id, value.muted ? '1' : '0']), 'set PipeWire mute state');
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const pactl = resolve('pactl');
|
|
49
|
+
if (pactl) {
|
|
50
|
+
// The C-locale textual interface also supports PulseAudio versions predating
|
|
51
|
+
// pactl's JSON output. Its public server cookie changes across restarts.
|
|
52
|
+
const info = async () => {
|
|
53
|
+
const text = await checked(run(pactl, ['info']), 'identify the PulseAudio server');
|
|
54
|
+
const server = /^Cookie:\s*([\da-f]+(?::[\da-f]+)*)\s*$/im.exec(text)?.[1]?.toLowerCase();
|
|
55
|
+
const name = /^Default Sink:\s*(.+)\s*$/m.exec(text)?.[1]?.trim();
|
|
56
|
+
if (!validName(server))
|
|
57
|
+
throw new Error('PulseAudio returned an unreadable server identity; no default-output mutation is safe.');
|
|
58
|
+
const pipewire = /^Server Name: PulseAudio \(on PipeWire [\w.+-]+\)\s*$/m.test(text);
|
|
59
|
+
const cookie = /^([\da-f]{1,4}):([\da-f]{1,4})$/.exec(server);
|
|
60
|
+
if (pipewire && !cookie)
|
|
61
|
+
throw new Error('PulseAudio returned an unreadable PipeWire server cookie.');
|
|
62
|
+
// PipeWire's Pulse server exposes the same core cookie as wpctl, rendered
|
|
63
|
+
// as two hexadecimal words. Classic PulseAudio stays in a separate domain.
|
|
64
|
+
return { pipewire, server: pipewire ? Number.parseInt(`${cookie[1].padStart(4, '0')}${cookie[2].padStart(4, '0')}`, 16).toString() : server, name: validName(name) ? name : undefined };
|
|
65
|
+
};
|
|
66
|
+
const sinks = async (server) => {
|
|
67
|
+
const text = await checked(run(pactl, server.pipewire ? ['list', 'sinks'] : ['list', 'short', 'sinks']), 'identify PulseAudio outputs');
|
|
68
|
+
const outputs = server.pipewire ? parsePipeWireSinks(text, server.server) : text.split(/\r?\n/).filter(Boolean).map(line => {
|
|
69
|
+
const [id, name] = line.split('\t');
|
|
70
|
+
if (!id || !/^\d{1,20}$/.test(id) || !validName(name))
|
|
71
|
+
throw new Error('PulseAudio returned an unreadable output identity.');
|
|
72
|
+
return { kind: 'pulse', server: server.server, id, name };
|
|
73
|
+
});
|
|
74
|
+
if (new Set(outputs.map(item => item.id)).size !== outputs.length || new Set(outputs.map(item => item.name)).size !== outputs.length)
|
|
75
|
+
throw new Error('PulseAudio returned ambiguous output identities.');
|
|
76
|
+
return outputs;
|
|
77
|
+
};
|
|
78
|
+
const verify = async (output) => {
|
|
79
|
+
if (!output)
|
|
80
|
+
throw changedOutput();
|
|
81
|
+
const server = await info();
|
|
82
|
+
const outputs = await sinks(server);
|
|
83
|
+
if (server.server !== output.server)
|
|
84
|
+
throw changedOutput();
|
|
85
|
+
const current = outputs.find(item => {
|
|
86
|
+
if (output.kind === 'pulse')
|
|
87
|
+
return item.kind === 'pulse' && item.id === output.id && item.name === output.name;
|
|
88
|
+
if (item.kind !== 'pulse-pipewire' || item.serial !== output.serial || item.name !== output.name)
|
|
89
|
+
return false;
|
|
90
|
+
return output.kind === 'pipewire' ? item.nodeId === output.id : item.nodeId === output.nodeId && item.id === output.id && item.sinkName === output.sinkName;
|
|
91
|
+
});
|
|
92
|
+
if (!current)
|
|
93
|
+
throw changedOutput();
|
|
94
|
+
return current.id;
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
async select() {
|
|
98
|
+
const server = await info();
|
|
99
|
+
const outputs = await sinks(server);
|
|
100
|
+
const output = outputs.find(item => (item.kind === 'pulse-pipewire' ? item.sinkName : item.name) === server.name);
|
|
101
|
+
if (!output)
|
|
102
|
+
throw new Error('The default PulseAudio output is unavailable.');
|
|
103
|
+
return output;
|
|
104
|
+
},
|
|
105
|
+
async read(output) {
|
|
106
|
+
const id = await verify(output);
|
|
107
|
+
const [volume, mute] = await Promise.all([
|
|
108
|
+
checked(run(pactl, ['get-sink-volume', id]), 'read PulseAudio output volume'),
|
|
109
|
+
checked(run(pactl, ['get-sink-mute', id]), 'read PulseAudio mute state')
|
|
110
|
+
]);
|
|
111
|
+
const match = /(\d+)%/.exec(volume);
|
|
112
|
+
const muted = /^Mute:\s*(yes|no)\s*$/im.exec(mute);
|
|
113
|
+
if (!match || !muted)
|
|
114
|
+
throw new Error('pactl returned an unreadable output-volume state.');
|
|
115
|
+
await verify(output);
|
|
116
|
+
return { volume: clamp(Number(match[1])), muted: muted[1].toLowerCase() === 'yes' };
|
|
117
|
+
},
|
|
118
|
+
async write(value, execute = run, output) {
|
|
119
|
+
const id = await verify(output);
|
|
120
|
+
await checked(execute(pactl, ['set-sink-volume', id, `${clamp(value.volume)}%`]), 'set PulseAudio output volume');
|
|
121
|
+
await verify(output);
|
|
122
|
+
await checked(execute(pactl, ['set-sink-mute', id, value.muted ? '1' : '0']), 'set PulseAudio mute state');
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const amixer = resolve('amixer');
|
|
127
|
+
if (amixer)
|
|
128
|
+
return {
|
|
129
|
+
async read() {
|
|
130
|
+
const text = await checked(run(amixer, ['get', 'Master']), 'read ALSA output volume');
|
|
131
|
+
const last = [...text.matchAll(/\[(\d+)%\]/g)].at(-1);
|
|
132
|
+
if (!last)
|
|
133
|
+
throw new Error('amixer returned an unreadable output-volume state.');
|
|
134
|
+
return { volume: clamp(Number(last[1])), muted: /\[off\]/i.test(text) };
|
|
135
|
+
},
|
|
136
|
+
async write(value, execute = run) { await checked(execute(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); }
|
|
137
|
+
};
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
function parsePipeWireSinks(text, server) {
|
|
141
|
+
if (!text.trim())
|
|
142
|
+
return [];
|
|
143
|
+
return text.split(/(?=^Sink #)/m).filter(block => block.trim()).map(block => {
|
|
144
|
+
const id = /^Sink #(\d{1,20})\s*$/m.exec(block)?.[1];
|
|
145
|
+
const sinkName = /^\s+Name:\s*(.+)\s*$/m.exec(block)?.[1]?.trim();
|
|
146
|
+
const property = (name) => new RegExp(`^\\s+${name.replaceAll('.', '\\.')} = "([^"\\r\\n]+)"\\s*$`, 'm').exec(block)?.[1];
|
|
147
|
+
const nodeId = property('object.id');
|
|
148
|
+
const serial = property('object.serial');
|
|
149
|
+
const name = property('node.name');
|
|
150
|
+
if (!id || !nodeId || !/^\d{1,20}$/.test(nodeId) || !serial || !/^\d{1,20}$/.test(serial) || !validName(name) || !validName(sinkName))
|
|
151
|
+
throw new Error('PulseAudio returned an unreadable PipeWire output identity.');
|
|
152
|
+
return { kind: 'pulse-pipewire', server, id, nodeId, serial, name, sinkName };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function changedOutput() { return new Error('The original alarm output disconnected or its audio-server identity changed. Its saved volume was retained; no replacement output was changed.'); }
|
|
156
|
+
function validName(value) { return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value); }
|
|
157
|
+
function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); }
|
|
158
|
+
async function checked(promise, label) { const result = await promise; if (result.code !== 0)
|
|
159
|
+
throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); if (Buffer.byteLength(result.stdout) > 64 * 1024)
|
|
160
|
+
throw new Error(`${label} returned too much data.`); return result.stdout; }
|