@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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
/** The output device belongs to the OS account, not a RADIOCLI_HOME/XDG profile. */
|
|
6
|
+
export function defaultSystemVolumeDirectory() {
|
|
7
|
+
const home = userInfo().homedir;
|
|
8
|
+
if (!home || !isAbsolute(home))
|
|
9
|
+
throw new Error('The OS account home is unavailable for shared output-volume ownership.');
|
|
10
|
+
return join(home, '.radiocli', 'system-volume');
|
|
11
|
+
}
|
|
12
|
+
/** One short transaction covers both the saved baseline and native mixer writes. */
|
|
13
|
+
export class SystemVolumeOwnership {
|
|
14
|
+
directory;
|
|
15
|
+
timeoutMs;
|
|
16
|
+
output;
|
|
17
|
+
constructor(directory, timeoutMs = 30_000, output) {
|
|
18
|
+
this.directory = directory;
|
|
19
|
+
this.timeoutMs = timeoutMs;
|
|
20
|
+
this.output = output;
|
|
21
|
+
}
|
|
22
|
+
async transaction(work) {
|
|
23
|
+
const release = await this.lock();
|
|
24
|
+
try {
|
|
25
|
+
return await work();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
release();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
read() {
|
|
32
|
+
const value = this.readDocument();
|
|
33
|
+
if (!value)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (value.version === 1) {
|
|
36
|
+
if (!this.output)
|
|
37
|
+
return value;
|
|
38
|
+
this.checkLegacy(value);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (!this.output)
|
|
42
|
+
throw new Error('Saved output-volume ownership belongs to an identified Linux audio device; its recovery state was left intact.');
|
|
43
|
+
return value.outputs.find(item => sameOutput(item.identity, this.output))?.lease;
|
|
44
|
+
}
|
|
45
|
+
/** Enumerate other recovery records while holding the shared root lock. */
|
|
46
|
+
otherOutputs() {
|
|
47
|
+
const value = this.readDocument();
|
|
48
|
+
return this.output && value?.version === 2 ? value.outputs.map(item => item.identity).filter(identity => !sameOutput(identity, this.output)) : [];
|
|
49
|
+
}
|
|
50
|
+
readDocument() {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(join(this.directory, 'state.json'), 'utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (codeOf(error) === 'ENOENT')
|
|
57
|
+
return undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const value = JSON.parse(text);
|
|
62
|
+
if (value?.version === 2) {
|
|
63
|
+
if (!Array.isArray(value.outputs) || value.outputs.length > 128 || !value.outputs.every(item => item && validOutput(item.identity) && validLease(item.lease)) || new Set(value.outputs.map(item => outputKey(item.identity))).size !== value.outputs.length)
|
|
64
|
+
throw new Error();
|
|
65
|
+
}
|
|
66
|
+
else if (!validLease(value))
|
|
67
|
+
throw new Error();
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error('Shared output-volume ownership state is unreadable; the saved output state was left intact.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
save(value) {
|
|
75
|
+
if (!this.output) {
|
|
76
|
+
this.writeDocument(value);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const existing = this.readDocument();
|
|
80
|
+
if (existing?.version === 1)
|
|
81
|
+
this.checkLegacy(existing);
|
|
82
|
+
const outputs = existing?.version === 2 ? existing.outputs.filter(item => !sameOutput(item.identity, this.output)) : [];
|
|
83
|
+
if (outputs.length >= 128)
|
|
84
|
+
throw new Error('Too many unresolved output-volume recovery records; saved output states were retained.');
|
|
85
|
+
this.writeDocument({ version: 2, outputs: [...outputs, { identity: this.output, lease: value }] });
|
|
86
|
+
}
|
|
87
|
+
writeDocument(value) {
|
|
88
|
+
const temporary = join(this.directory, `.state-${process.pid}-${randomUUID()}`);
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
|
|
91
|
+
renameSync(temporary, join(this.directory, 'state.json'));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
try {
|
|
95
|
+
rmSync(temporary, { force: true });
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
clear() {
|
|
102
|
+
if (this.output) {
|
|
103
|
+
const value = this.readDocument();
|
|
104
|
+
if (value?.version === 1)
|
|
105
|
+
this.checkLegacy(value);
|
|
106
|
+
if (value?.version === 2) {
|
|
107
|
+
const outputs = value.outputs.filter(item => !sameOutput(item.identity, this.output));
|
|
108
|
+
if (outputs.length) {
|
|
109
|
+
this.writeDocument({ version: 2, outputs });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(join(this.directory, 'state.json'));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (codeOf(error) !== 'ENOENT')
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
checkLegacy(value) {
|
|
123
|
+
// Older Linux jobs recorded only the default sink's volume. That baseline
|
|
124
|
+
// cannot safely be assigned to whichever device happens to be default now.
|
|
125
|
+
if (value.changed || value.participants.some(item => item.phase === 'active' && volumeProcessAlive(item.pid)))
|
|
126
|
+
throw new Error('An earlier alarm saved output-volume ownership without a device identity. Finish its active alarms or repair the saved state.json after checking the original output; the saved state was retained and no output was changed.');
|
|
127
|
+
}
|
|
128
|
+
/** A null PID is intentional: a crash before PID persistence is ambiguous. */
|
|
129
|
+
beginNativeWrite() { this.saveNativeWrite(null); }
|
|
130
|
+
recordNativeWritePid(pid) { if (!validPid(pid))
|
|
131
|
+
throw new Error('The system-output helper did not report a valid process ID.'); this.saveNativeWrite(pid); }
|
|
132
|
+
finishNativeWrite() { try {
|
|
133
|
+
unlinkSync(join(this.directory, 'native-write.json'));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (codeOf(error) !== 'ENOENT')
|
|
137
|
+
throw error;
|
|
138
|
+
} }
|
|
139
|
+
saveNativeWrite(helperPid) {
|
|
140
|
+
const temporary = join(this.directory, `.native-write-${process.pid}-${randomUUID()}`);
|
|
141
|
+
try {
|
|
142
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, ownerPid: process.pid, helperPid }), { mode: 0o600, flag: 'wx' });
|
|
143
|
+
renameSync(temporary, join(this.directory, 'native-write.json'));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
try {
|
|
147
|
+
rmSync(temporary, { force: true });
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
nativeWriteSettled() {
|
|
154
|
+
const path = join(this.directory, 'native-write.json');
|
|
155
|
+
let value;
|
|
156
|
+
try {
|
|
157
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (codeOf(error) === 'ENOENT')
|
|
161
|
+
return true;
|
|
162
|
+
throw this.unknownNativeWrite(path);
|
|
163
|
+
}
|
|
164
|
+
if (value?.version !== 1 || !validPid(value.ownerPid) || !validPid(value.helperPid))
|
|
165
|
+
throw this.unknownNativeWrite(path);
|
|
166
|
+
if (volumeProcessAlive(value.helperPid))
|
|
167
|
+
return false;
|
|
168
|
+
// Removal is only allowed after acquiring the lock, so an old observer
|
|
169
|
+
// cannot remove a newer owner's journal.
|
|
170
|
+
this.finishNativeWrite();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
unknownNativeWrite(path) { return new Error(`An interrupted system-output helper could not be identified; the saved volume was retained. Restart the computer, then remove ${path} before retrying system output control.`); }
|
|
174
|
+
async waitForNativeWrite(deadline) { while (!this.nativeWriteSettled()) {
|
|
175
|
+
if (Date.now() >= deadline)
|
|
176
|
+
throw new Error('A system-output helper is still running; the saved volume will be recovered after it exits.');
|
|
177
|
+
await pause();
|
|
178
|
+
} }
|
|
179
|
+
async lock() {
|
|
180
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
181
|
+
const marker = `owner-${process.pid}-${randomUUID()}`;
|
|
182
|
+
const candidate = join(this.directory, `.lock-${marker}`);
|
|
183
|
+
const lock = join(this.directory, 'lock');
|
|
184
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
185
|
+
let published = false;
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(join(candidate, marker), '', { mode: 0o600, flag: 'wx' });
|
|
188
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
189
|
+
while (true) {
|
|
190
|
+
let acquired = false;
|
|
191
|
+
try {
|
|
192
|
+
renameSync(candidate, lock);
|
|
193
|
+
published = true;
|
|
194
|
+
acquired = true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(codeOf(error) ?? ''))
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
if (acquired) {
|
|
201
|
+
try {
|
|
202
|
+
await this.waitForNativeWrite(deadline);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
finishLock(lock, marker);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return () => finishLock(lock, marker);
|
|
209
|
+
}
|
|
210
|
+
if (Date.now() >= deadline)
|
|
211
|
+
throw new Error('Shared output-volume ownership is busy; its live owner was left intact.');
|
|
212
|
+
let owners;
|
|
213
|
+
try {
|
|
214
|
+
owners = readdirSync(lock);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (codeOf(error) === 'ENOENT') {
|
|
218
|
+
await pause();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
if (owners.length === 0) {
|
|
224
|
+
removeEmptyLock(lock);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const owner = owners[0];
|
|
228
|
+
const match = /^(owner|released)-([1-9]\d*)-([a-f\d-]{36})$/.exec(owner);
|
|
229
|
+
if (owners.length !== 1 || !match || !validPid(Number(match[2])) || !validId(match[3]))
|
|
230
|
+
throw new Error('Shared output-volume ownership lock is unreadable; it was left intact.');
|
|
231
|
+
if (match[1] === 'released' || !volumeProcessAlive(Number(match[2]))) {
|
|
232
|
+
// Claim this exact dead generation, never rename/remove a replacement
|
|
233
|
+
// lock directory. A reaper that dies is recovered by the same protocol.
|
|
234
|
+
const reaper = `owner-${process.pid}-${randomUUID()}`;
|
|
235
|
+
try {
|
|
236
|
+
renameSync(join(lock, owner), join(lock, reaper));
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (codeOf(error) === 'ENOENT')
|
|
240
|
+
continue;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await this.waitForNativeWrite(deadline);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
try {
|
|
248
|
+
renameSync(join(lock, reaper), join(lock, owner));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
finishLock(lock, reaper);
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
finishLock(lock, reaper);
|
|
256
|
+
await pause();
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
await pause();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (!published)
|
|
264
|
+
try {
|
|
265
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function volumeProcessAlive(pid) {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(pid, 0);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
return codeOf(error) !== 'ESRCH';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function finishLock(directory, marker) {
|
|
281
|
+
const released = `released-${process.pid}-${randomUUID()}`;
|
|
282
|
+
try {
|
|
283
|
+
renameSync(join(directory, marker), join(directory, released));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
try {
|
|
287
|
+
removeOwnedLock(directory, marker);
|
|
288
|
+
}
|
|
289
|
+
catch { }
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Do not lose a returned lease or replace a mixer failure with cleanup errors.
|
|
293
|
+
// A published completion can be reclaimed even while this PID remains alive.
|
|
294
|
+
try {
|
|
295
|
+
removeOwnedLock(directory, released);
|
|
296
|
+
}
|
|
297
|
+
catch { }
|
|
298
|
+
}
|
|
299
|
+
function removeOwnedLock(directory, marker) {
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(join(directory, marker));
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (codeOf(error) === 'ENOENT')
|
|
305
|
+
return;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
removeEmptyLock(directory);
|
|
309
|
+
}
|
|
310
|
+
function removeEmptyLock(directory) {
|
|
311
|
+
// Another process may already have published a new nonempty generation.
|
|
312
|
+
try {
|
|
313
|
+
rmdirSync(directory);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? ''))
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function validPid(value) { return Number.isSafeInteger(value) && Number(value) > 0; }
|
|
321
|
+
function validId(value) { return typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value); }
|
|
322
|
+
function validSnapshot(value) { if (!value || typeof value !== 'object')
|
|
323
|
+
return false; const state = value; return Number.isInteger(state.volume) && state.volume >= 0 && state.volume <= 100 && typeof state.muted === 'boolean'; }
|
|
324
|
+
function validLease(value) { if (!value || typeof value !== 'object')
|
|
325
|
+
return false; const state = value; return state.version === 1 && validId(state.generation) && typeof state.changed === 'boolean' && (state.baseline === null ? !state.changed : validSnapshot(state.baseline)) && Array.isArray(state.participants) && state.participants.every(item => item && validId(item.id) && validPid(item.pid) && (item.phase === 'pending' || item.phase === 'active')) && new Set(state.participants.map(item => item.id)).size === state.participants.length; }
|
|
326
|
+
function validOutput(value) { if (!value || typeof value !== 'object')
|
|
327
|
+
return false; const output = value; return ['pipewire', 'pulse', 'pulse-pipewire'].includes(output.kind) && validLabel(output.server) && validLabel(output.name) && decimalId(output.id) && (output.kind === 'pulse' || decimalId(output.serial)) && (output.kind !== 'pulse-pipewire' || decimalId(output.nodeId) && validLabel(output.sinkName)); }
|
|
328
|
+
function validLabel(value) { return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value); }
|
|
329
|
+
function decimalId(value) { return typeof value === 'string' && /^\d{1,20}$/.test(value); }
|
|
330
|
+
// The PipeWire core, object serial and name identify a sink across both CLIs.
|
|
331
|
+
// Backend-specific IDs remain in the record for independent mutation checks.
|
|
332
|
+
function outputKey(value) { return JSON.stringify(value.kind === 'pulse' ? ['pulse', value.server, value.id, value.name] : ['pipewire', value.server, value.serial, value.name]); }
|
|
333
|
+
function sameOutput(first, second) { return outputKey(first) === outputKey(second); }
|
|
334
|
+
function codeOf(error) { return error?.code; }
|
|
335
|
+
function pause() { return new Promise(resolve => setTimeout(resolve, 20)); }
|
|
@@ -1,33 +1,133 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolveCommand } from '../platform/executables.js';
|
|
4
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
5
|
+
import { defaultSystemVolumeDirectory, SystemVolumeOwnership, volumeProcessAlive } from './system-volume-ownership.js';
|
|
6
|
+
import { linuxVolumeBackend } from './system-volume-linux.js';
|
|
7
|
+
export function createSystemVolumeController(platform = process.platform, run = runCommand, resolve = resolveCommand, options = {}) {
|
|
8
|
+
const host = identifyPlatform({ platform });
|
|
9
|
+
const adapter = nativeAdapters(host).volume;
|
|
10
|
+
const backend = adapter === 'macos' ? macBackend(run) : adapter === 'unix-audio' ? linuxVolumeBackend(run, resolve) : adapter === 'windows' ? windowsBackend(run, resolve) : undefined;
|
|
11
|
+
return { async acquireMinimum(volume) {
|
|
12
|
+
if (!backend)
|
|
13
|
+
throw new Error(`System output volume control is unavailable on ${host.id === 'unknown' ? platform : host.id}.`);
|
|
14
|
+
const output = await backend.select?.();
|
|
15
|
+
const directory = options.directory ?? defaultSystemVolumeDirectory();
|
|
16
|
+
const ownership = new SystemVolumeOwnership(directory, options.lockTimeoutMs, output);
|
|
17
|
+
const execute = run === runCommand ? (command, args) => runCommand(command, args, ownership) : run;
|
|
18
|
+
const write = (value) => backend.write(value, execute, output);
|
|
19
|
+
return ownership.transaction(async () => {
|
|
20
|
+
let state = ownership.read();
|
|
21
|
+
let pendingRecovery = 0;
|
|
22
|
+
// Recover dead owners on their still-connected original outputs even if
|
|
23
|
+
// the default has moved. An unplugged output remains repairable without
|
|
24
|
+
// preventing a different output from ringing.
|
|
25
|
+
for (const identity of ownership.otherOutputs()) {
|
|
26
|
+
const priorOutput = new SystemVolumeOwnership(directory, options.lockTimeoutMs, identity);
|
|
27
|
+
const prior = priorOutput.read();
|
|
28
|
+
if (!prior || prior.participants.some(activeParticipant))
|
|
29
|
+
continue;
|
|
30
|
+
if (prior.changed && prior.baseline) {
|
|
31
|
+
try {
|
|
32
|
+
await backend.write(prior.baseline, execute, identity);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
pendingRecovery += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
priorOutput.clear();
|
|
40
|
+
}
|
|
41
|
+
if (state) {
|
|
42
|
+
state.participants = state.participants.filter(activeParticipant);
|
|
43
|
+
if (state.participants.length === 0) {
|
|
44
|
+
// A crashed owner or failed final restore must not turn the raised
|
|
45
|
+
// output into the next alarm's baseline.
|
|
46
|
+
if (state.changed && state.baseline)
|
|
47
|
+
await write(state.baseline);
|
|
48
|
+
ownership.clear();
|
|
49
|
+
state = undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const target = clamp(volume);
|
|
53
|
+
const before = await backend.read(output);
|
|
54
|
+
const changed = before.muted || before.volume < target;
|
|
55
|
+
const prior = state;
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
const joined = { version: 1, generation: state?.generation ?? randomUUID(), baseline: state?.baseline ?? before, changed: Boolean(state?.changed) || changed, participants: [...state?.participants ?? [], { id, pid: process.pid, phase: 'pending' }] };
|
|
58
|
+
// Persist the original state before any mixer command can partly succeed.
|
|
59
|
+
ownership.save(joined);
|
|
60
|
+
try {
|
|
61
|
+
if (changed)
|
|
62
|
+
await write({ volume: Math.max(before.volume, target), muted: false });
|
|
63
|
+
// Until this commit succeeds, a later transaction can discard the
|
|
64
|
+
// pending participant even if failed cleanup left its PID alive.
|
|
65
|
+
ownership.save({ ...joined, participants: joined.participants.map(item => item.id === id ? { ...item, phase: 'active' } : item) });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
let restoreError;
|
|
69
|
+
if (changed)
|
|
70
|
+
try {
|
|
71
|
+
await write(before);
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
restoreError = cause;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (restoreError)
|
|
78
|
+
ownership.save({ ...joined, participants: joined.participants.filter(item => item.id !== id) });
|
|
79
|
+
else if (prior)
|
|
80
|
+
ownership.save(prior);
|
|
81
|
+
else
|
|
82
|
+
ownership.clear();
|
|
83
|
+
}
|
|
84
|
+
catch (saveError) {
|
|
85
|
+
throw new Error(`${messageOf(error)}${restoreError ? `; restoring the previous output state also failed: ${messageOf(restoreError)}` : ''}; output-volume recovery state could not be updated: ${messageOf(saveError)}`);
|
|
86
|
+
}
|
|
87
|
+
if (restoreError)
|
|
88
|
+
throw new Error(`${messageOf(error)}; restoring the previous output state also failed: ${messageOf(restoreError)}`);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
let released = false;
|
|
92
|
+
return {
|
|
93
|
+
message: (changed ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`) + (pendingRecovery ? ` ${pendingRecovery} earlier output recovery record${pendingRecovery === 1 ? ' remains' : 's remain'} unresolved and retained; its original device must be checked.` : ''),
|
|
94
|
+
async release({ preserve = false } = {}) {
|
|
95
|
+
if (released)
|
|
96
|
+
return;
|
|
97
|
+
await ownership.transaction(async () => {
|
|
98
|
+
if (released)
|
|
99
|
+
return;
|
|
100
|
+
const current = ownership.read();
|
|
101
|
+
if (!current || current.generation !== joined.generation) {
|
|
102
|
+
released = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
current.participants = current.participants.filter(item => item.id !== id && activeParticipant(item));
|
|
106
|
+
if (preserve) {
|
|
107
|
+
// Handoff commits the current output as the user's new baseline.
|
|
108
|
+
// A later acquisition samples it before making another adjustment.
|
|
109
|
+
current.baseline = null;
|
|
110
|
+
current.changed = false;
|
|
111
|
+
}
|
|
112
|
+
ownership.save(current);
|
|
113
|
+
if (current.participants.length === 0) {
|
|
114
|
+
if (current.changed && current.baseline)
|
|
115
|
+
await write(current.baseline);
|
|
116
|
+
ownership.clear();
|
|
117
|
+
}
|
|
118
|
+
released = true;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
} };
|
|
10
124
|
}
|
|
125
|
+
function activeParticipant(item) { return item.phase === 'active' && volumeProcessAlive(item.pid); }
|
|
11
126
|
function macBackend(run) { return { async read() { const result = await checked(run('/usr/bin/osascript', ['-e', 'set s to get volume settings', '-e', 'return (output volume of s as text) & "," & (output muted of s as text)']), 'read macOS output volume'); const match = /^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim()); if (!match)
|
|
12
|
-
throw new Error('macOS returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value) { await checked(
|
|
13
|
-
function linuxBackend(run, resolve) {
|
|
14
|
-
const wpctl = resolve('wpctl');
|
|
15
|
-
if (wpctl)
|
|
16
|
-
return { async read() { const result = await checked(run(wpctl, ['get-volume', '@DEFAULT_AUDIO_SINK@']), 'read PipeWire output volume'); const match = /Volume:\s*([\d.]+)/i.exec(result.stdout); if (!match)
|
|
17
|
-
throw new Error('wpctl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1]) * 100), muted: /\[MUTED\]/i.test(result.stdout) }; }, async write(value) { await checked(run(wpctl, ['set-volume', '@DEFAULT_AUDIO_SINK@', `${clamp(value.volume)}%`]), 'set PipeWire output volume'); await checked(run(wpctl, ['set-mute', '@DEFAULT_AUDIO_SINK@', value.muted ? '1' : '0']), 'set PipeWire mute state'); } };
|
|
18
|
-
const pactl = resolve('pactl');
|
|
19
|
-
if (pactl)
|
|
20
|
-
return { async read() { const [volume, mute] = await Promise.all([checked(run(pactl, ['get-sink-volume', '@DEFAULT_SINK@']), 'read PulseAudio output volume'), checked(run(pactl, ['get-sink-mute', '@DEFAULT_SINK@']), 'read PulseAudio mute state')]); const match = /(\d+)%/.exec(volume.stdout); if (!match)
|
|
21
|
-
throw new Error('pactl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: /yes/i.test(mute.stdout) }; }, async write(value) { await checked(run(pactl, ['set-sink-volume', '@DEFAULT_SINK@', `${clamp(value.volume)}%`]), 'set PulseAudio output volume'); await checked(run(pactl, ['set-sink-mute', '@DEFAULT_SINK@', value.muted ? '1' : '0']), 'set PulseAudio mute state'); } };
|
|
22
|
-
const amixer = resolve('amixer');
|
|
23
|
-
if (amixer)
|
|
24
|
-
return { async read() { const result = await checked(run(amixer, ['get', 'Master']), 'read ALSA output volume'); const matches = [...result.stdout.matchAll(/\[(\d+)%\]/g)]; const last = matches.at(-1); if (!last)
|
|
25
|
-
throw new Error('amixer returned an unreadable output-volume state.'); return { volume: clamp(Number(last[1])), muted: /\[off\]/i.test(result.stdout) }; }, async write(value) { await checked(run(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); } };
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
127
|
+
throw new Error('macOS returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value, execute = run) { await checked(execute('/usr/bin/osascript', ['-e', `set volume output volume ${clamp(value.volume)} ${value.muted ? 'with' : 'without'} output muted`]), 'set macOS output volume'); } }; }
|
|
28
128
|
function windowsBackend(run, resolve) { const powershell = resolve('powershell.exe') ?? resolve('pwsh.exe'); if (!powershell)
|
|
29
129
|
return undefined; return { async read() { const result = await checked(run(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Get()`]), 'read Windows output volume'); const match = /^(\d+)\s*,\s*(true|false)/im.exec(result.stdout); if (!match)
|
|
30
|
-
throw new Error('Windows returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value) { await checked(
|
|
130
|
+
throw new Error('Windows returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value, execute = run) { await checked(execute(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted ? 'true' : 'false'})`]), 'set Windows output volume'); } }; }
|
|
31
131
|
const windowsCoreAudio = String.raw `Add-Type -TypeDefinition @'
|
|
32
132
|
using System; using System.Runtime.InteropServices;
|
|
33
133
|
public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, eMultimedia, eCommunications }
|
|
@@ -38,6 +138,68 @@ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole,
|
|
|
38
138
|
public static class RadioCliVolume { static IAudioEndpointVolume Endpoint(){IMMDevice d;((IMMDeviceEnumerator)new MMDeviceEnumerator()).GetDefaultAudioEndpoint(EDataFlow.eRender,ERole.eMultimedia,out d);object o;Guid id=typeof(IAudioEndpointVolume).GUID;d.Activate(ref id,23,IntPtr.Zero,out o);return(IAudioEndpointVolume)o;} public static string Get(){float v;bool m;var e=Endpoint();e.GetMasterVolumeLevelScalar(out v);e.GetMute(out m);return Math.Round(v*100)+","+m.ToString().ToLower();} public static void Set(int v,bool m){var e=Endpoint();e.SetMasterVolumeLevelScalar(Math.Max(0,Math.Min(100,v))/100f,Guid.Empty);e.SetMute(m,Guid.Empty);} }
|
|
39
139
|
'@`;
|
|
40
140
|
function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); }
|
|
141
|
+
function messageOf(error) { return error instanceof Error ? error.message : String(error); }
|
|
41
142
|
async function checked(promise, label) { const result = await promise; if (result.code !== 0)
|
|
42
143
|
throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); return result; }
|
|
43
|
-
function runCommand(command, args
|
|
144
|
+
function runCommand(command, args, ownership) {
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
ownership?.beginNativeWrite();
|
|
147
|
+
let child;
|
|
148
|
+
try {
|
|
149
|
+
child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000, killSignal: 'SIGKILL', env: { ...process.env, LC_ALL: 'C' } });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
let message = messageOf(error);
|
|
153
|
+
try {
|
|
154
|
+
ownership?.finishNativeWrite();
|
|
155
|
+
}
|
|
156
|
+
catch (cleanupError) {
|
|
157
|
+
message += `; system-output command journal cleanup failed: ${messageOf(cleanupError)}`;
|
|
158
|
+
}
|
|
159
|
+
resolve({ code: 127, stdout: '', stderr: message });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let stdout = '';
|
|
163
|
+
let stderr = '';
|
|
164
|
+
let failure;
|
|
165
|
+
const append = (value, output) => { const text = String(value); if (output === 'stdout')
|
|
166
|
+
stdout += text;
|
|
167
|
+
else
|
|
168
|
+
stderr += text; if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > 64 * 1024) {
|
|
169
|
+
stdout = stdout.slice(0, 32768);
|
|
170
|
+
stderr = stderr.slice(0, 32768);
|
|
171
|
+
failure = 'The output-volume command returned too much data.';
|
|
172
|
+
try {
|
|
173
|
+
child.kill('SIGKILL');
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
} };
|
|
177
|
+
child.stdout.on('data', value => append(value, 'stdout'));
|
|
178
|
+
child.stderr.on('data', value => append(value, 'stderr'));
|
|
179
|
+
// Spawn/kill errors do not prove the helper has closed. Keep ownership until
|
|
180
|
+
// close, including when recording its PID fails after the process starts.
|
|
181
|
+
child.on('error', error => { failure = error.message; });
|
|
182
|
+
child.on('close', (code, signal) => {
|
|
183
|
+
try {
|
|
184
|
+
ownership?.finishNativeWrite();
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
failure = `${failure ? `${failure}; ` : ''}system-output command journal cleanup failed: ${messageOf(error)}`;
|
|
188
|
+
}
|
|
189
|
+
resolve({ code: failure ? 1 : code ?? 1, stdout, stderr: failure || stderr || (signal ? 'The output-volume command was interrupted or timed out.' : '') });
|
|
190
|
+
});
|
|
191
|
+
if (ownership && child.pid)
|
|
192
|
+
try {
|
|
193
|
+
ownership.recordNativeWritePid(child.pid);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
failure = `The system-output helper PID could not be saved: ${messageOf(error)}`;
|
|
197
|
+
try {
|
|
198
|
+
child.kill('SIGKILL');
|
|
199
|
+
}
|
|
200
|
+
catch (killError) {
|
|
201
|
+
failure += `; stopping the helper failed: ${messageOf(killError)}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|