@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -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 +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- 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 +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -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 +80 -56
- 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/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
constructor(directory, timeoutMs = 30_000) {
|
|
17
|
+
this.directory = directory;
|
|
18
|
+
this.timeoutMs = timeoutMs;
|
|
19
|
+
}
|
|
20
|
+
async transaction(work) {
|
|
21
|
+
const release = await this.lock();
|
|
22
|
+
try {
|
|
23
|
+
return await work();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
release();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
read() {
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = readFileSync(join(this.directory, 'state.json'), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (codeOf(error) === 'ENOENT')
|
|
36
|
+
return undefined;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(text);
|
|
41
|
+
if (value?.version !== 1 || !validId(value.generation) || typeof value.changed !== 'boolean' || !(value.baseline === null ? !value.changed : validSnapshot(value.baseline)) || !Array.isArray(value.participants) || !value.participants.every(item => item && validId(item.id) && validPid(item.pid) && (item.phase === 'pending' || item.phase === 'active')) || new Set(value.participants.map(item => item.id)).size !== value.participants.length)
|
|
42
|
+
throw new Error();
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('Shared output-volume ownership state is unreadable; the saved output state was left intact.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
save(value) {
|
|
50
|
+
const temporary = join(this.directory, `.state-${process.pid}-${randomUUID()}`);
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
|
|
53
|
+
renameSync(temporary, join(this.directory, 'state.json'));
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
try {
|
|
57
|
+
rmSync(temporary, { force: true });
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
clear() { try {
|
|
64
|
+
unlinkSync(join(this.directory, 'state.json'));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (codeOf(error) !== 'ENOENT')
|
|
68
|
+
throw error;
|
|
69
|
+
} }
|
|
70
|
+
/** A null PID is intentional: a crash before PID persistence is ambiguous. */
|
|
71
|
+
beginNativeWrite() { this.saveNativeWrite(null); }
|
|
72
|
+
recordNativeWritePid(pid) { if (!validPid(pid))
|
|
73
|
+
throw new Error('The system-output helper did not report a valid process ID.'); this.saveNativeWrite(pid); }
|
|
74
|
+
finishNativeWrite() { try {
|
|
75
|
+
unlinkSync(join(this.directory, 'native-write.json'));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (codeOf(error) !== 'ENOENT')
|
|
79
|
+
throw error;
|
|
80
|
+
} }
|
|
81
|
+
saveNativeWrite(helperPid) {
|
|
82
|
+
const temporary = join(this.directory, `.native-write-${process.pid}-${randomUUID()}`);
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, ownerPid: process.pid, helperPid }), { mode: 0o600, flag: 'wx' });
|
|
85
|
+
renameSync(temporary, join(this.directory, 'native-write.json'));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
try {
|
|
89
|
+
rmSync(temporary, { force: true });
|
|
90
|
+
}
|
|
91
|
+
catch { }
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
nativeWriteSettled() {
|
|
96
|
+
const path = join(this.directory, 'native-write.json');
|
|
97
|
+
let value;
|
|
98
|
+
try {
|
|
99
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (codeOf(error) === 'ENOENT')
|
|
103
|
+
return true;
|
|
104
|
+
throw this.unknownNativeWrite(path);
|
|
105
|
+
}
|
|
106
|
+
if (value?.version !== 1 || !validPid(value.ownerPid) || !validPid(value.helperPid))
|
|
107
|
+
throw this.unknownNativeWrite(path);
|
|
108
|
+
if (volumeProcessAlive(value.helperPid))
|
|
109
|
+
return false;
|
|
110
|
+
// Removal is only allowed after acquiring the lock, so an old observer
|
|
111
|
+
// cannot remove a newer owner's journal.
|
|
112
|
+
this.finishNativeWrite();
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
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.`); }
|
|
116
|
+
async waitForNativeWrite(deadline) { while (!this.nativeWriteSettled()) {
|
|
117
|
+
if (Date.now() >= deadline)
|
|
118
|
+
throw new Error('A system-output helper is still running; the saved volume will be recovered after it exits.');
|
|
119
|
+
await pause();
|
|
120
|
+
} }
|
|
121
|
+
async lock() {
|
|
122
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
123
|
+
const marker = `owner-${process.pid}-${randomUUID()}`;
|
|
124
|
+
const candidate = join(this.directory, `.lock-${marker}`);
|
|
125
|
+
const lock = join(this.directory, 'lock');
|
|
126
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
127
|
+
let published = false;
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(join(candidate, marker), '', { mode: 0o600, flag: 'wx' });
|
|
130
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
131
|
+
while (true) {
|
|
132
|
+
let acquired = false;
|
|
133
|
+
try {
|
|
134
|
+
renameSync(candidate, lock);
|
|
135
|
+
published = true;
|
|
136
|
+
acquired = true;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(codeOf(error) ?? ''))
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (acquired) {
|
|
143
|
+
try {
|
|
144
|
+
await this.waitForNativeWrite(deadline);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
finishLock(lock, marker);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
return () => finishLock(lock, marker);
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() >= deadline)
|
|
153
|
+
throw new Error('Shared output-volume ownership is busy; its live owner was left intact.');
|
|
154
|
+
let owners;
|
|
155
|
+
try {
|
|
156
|
+
owners = readdirSync(lock);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (codeOf(error) === 'ENOENT') {
|
|
160
|
+
await pause();
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
if (owners.length === 0) {
|
|
166
|
+
removeEmptyLock(lock);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const owner = owners[0];
|
|
170
|
+
const match = /^(owner|released)-([1-9]\d*)-([a-f\d-]{36})$/.exec(owner);
|
|
171
|
+
if (owners.length !== 1 || !match || !validPid(Number(match[2])) || !validId(match[3]))
|
|
172
|
+
throw new Error('Shared output-volume ownership lock is unreadable; it was left intact.');
|
|
173
|
+
if (match[1] === 'released' || !volumeProcessAlive(Number(match[2]))) {
|
|
174
|
+
// Claim this exact dead generation, never rename/remove a replacement
|
|
175
|
+
// lock directory. A reaper that dies is recovered by the same protocol.
|
|
176
|
+
const reaper = `owner-${process.pid}-${randomUUID()}`;
|
|
177
|
+
try {
|
|
178
|
+
renameSync(join(lock, owner), join(lock, reaper));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (codeOf(error) === 'ENOENT')
|
|
182
|
+
continue;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await this.waitForNativeWrite(deadline);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
try {
|
|
190
|
+
renameSync(join(lock, reaper), join(lock, owner));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
finishLock(lock, reaper);
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
finishLock(lock, reaper);
|
|
198
|
+
await pause();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
await pause();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
if (!published)
|
|
206
|
+
try {
|
|
207
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function volumeProcessAlive(pid) {
|
|
214
|
+
try {
|
|
215
|
+
process.kill(pid, 0);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return codeOf(error) !== 'ESRCH';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function finishLock(directory, marker) {
|
|
223
|
+
const released = `released-${process.pid}-${randomUUID()}`;
|
|
224
|
+
try {
|
|
225
|
+
renameSync(join(directory, marker), join(directory, released));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
try {
|
|
229
|
+
removeOwnedLock(directory, marker);
|
|
230
|
+
}
|
|
231
|
+
catch { }
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Do not lose a returned lease or replace a mixer failure with cleanup errors.
|
|
235
|
+
// A published completion can be reclaimed even while this PID remains alive.
|
|
236
|
+
try {
|
|
237
|
+
removeOwnedLock(directory, released);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}
|
|
241
|
+
function removeOwnedLock(directory, marker) {
|
|
242
|
+
try {
|
|
243
|
+
unlinkSync(join(directory, marker));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (codeOf(error) === 'ENOENT')
|
|
247
|
+
return;
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
removeEmptyLock(directory);
|
|
251
|
+
}
|
|
252
|
+
function removeEmptyLock(directory) {
|
|
253
|
+
// Another process may already have published a new nonempty generation.
|
|
254
|
+
try {
|
|
255
|
+
rmdirSync(directory);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? ''))
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function validPid(value) { return Number.isSafeInteger(value) && Number(value) > 0; }
|
|
263
|
+
function validId(value) { return typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value); }
|
|
264
|
+
function validSnapshot(value) { if (!value || typeof value !== 'object')
|
|
265
|
+
return false; const state = value; return Number.isInteger(state.volume) && state.volume >= 0 && state.volume <= 100 && typeof state.muted === 'boolean'; }
|
|
266
|
+
function codeOf(error) { return error?.code; }
|
|
267
|
+
function pause() { return new Promise(resolve => setTimeout(resolve, 20)); }
|
|
@@ -1,33 +1,124 @@
|
|
|
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
|
+
export function createSystemVolumeController(platform = process.platform, run = runCommand, resolve = resolveCommand, options = {}) {
|
|
7
|
+
const host = identifyPlatform({ platform });
|
|
8
|
+
const adapter = nativeAdapters(host).volume;
|
|
9
|
+
const backend = adapter === 'macos' ? macBackend(run) : adapter === 'unix-audio' ? linuxBackend(run, resolve) : adapter === 'windows' ? windowsBackend(run, resolve) : undefined;
|
|
10
|
+
return { async acquireMinimum(volume) {
|
|
11
|
+
if (!backend)
|
|
12
|
+
throw new Error(`System output volume control is unavailable on ${host.id === 'unknown' ? platform : host.id}.`);
|
|
13
|
+
const ownership = new SystemVolumeOwnership(options.directory ?? defaultSystemVolumeDirectory(), options.lockTimeoutMs);
|
|
14
|
+
const write = (value) => backend.write(value, run === runCommand ? (command, args) => runCommand(command, args, ownership) : run);
|
|
15
|
+
return ownership.transaction(async () => {
|
|
16
|
+
let state = ownership.read();
|
|
17
|
+
if (state) {
|
|
18
|
+
state.participants = state.participants.filter(activeParticipant);
|
|
19
|
+
if (state.participants.length === 0) {
|
|
20
|
+
// A crashed owner or failed final restore must not turn the raised
|
|
21
|
+
// output into the next alarm's baseline.
|
|
22
|
+
if (state.changed && state.baseline)
|
|
23
|
+
await write(state.baseline);
|
|
24
|
+
ownership.clear();
|
|
25
|
+
state = undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const target = clamp(volume);
|
|
29
|
+
const before = await backend.read();
|
|
30
|
+
const changed = before.muted || before.volume < target;
|
|
31
|
+
const prior = state;
|
|
32
|
+
const id = randomUUID();
|
|
33
|
+
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' }] };
|
|
34
|
+
// Persist the original state before any mixer command can partly succeed.
|
|
35
|
+
ownership.save(joined);
|
|
36
|
+
try {
|
|
37
|
+
if (changed)
|
|
38
|
+
await write({ volume: Math.max(before.volume, target), muted: false });
|
|
39
|
+
// Until this commit succeeds, a later transaction can discard the
|
|
40
|
+
// pending participant even if failed cleanup left its PID alive.
|
|
41
|
+
ownership.save({ ...joined, participants: joined.participants.map(item => item.id === id ? { ...item, phase: 'active' } : item) });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
let restoreError;
|
|
45
|
+
if (changed)
|
|
46
|
+
try {
|
|
47
|
+
await write(before);
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
restoreError = cause;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
if (restoreError)
|
|
54
|
+
ownership.save({ ...joined, participants: joined.participants.filter(item => item.id !== id) });
|
|
55
|
+
else if (prior)
|
|
56
|
+
ownership.save(prior);
|
|
57
|
+
else
|
|
58
|
+
ownership.clear();
|
|
59
|
+
}
|
|
60
|
+
catch (saveError) {
|
|
61
|
+
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)}`);
|
|
62
|
+
}
|
|
63
|
+
if (restoreError)
|
|
64
|
+
throw new Error(`${messageOf(error)}; restoring the previous output state also failed: ${messageOf(restoreError)}`);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
let released = false;
|
|
68
|
+
return {
|
|
69
|
+
message: changed ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`,
|
|
70
|
+
async release({ preserve = false } = {}) {
|
|
71
|
+
if (released)
|
|
72
|
+
return;
|
|
73
|
+
await ownership.transaction(async () => {
|
|
74
|
+
if (released)
|
|
75
|
+
return;
|
|
76
|
+
const current = ownership.read();
|
|
77
|
+
if (!current || current.generation !== joined.generation) {
|
|
78
|
+
released = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
current.participants = current.participants.filter(item => item.id !== id && activeParticipant(item));
|
|
82
|
+
if (preserve) {
|
|
83
|
+
// Handoff commits the current output as the user's new baseline.
|
|
84
|
+
// A later acquisition samples it before making another adjustment.
|
|
85
|
+
current.baseline = null;
|
|
86
|
+
current.changed = false;
|
|
87
|
+
}
|
|
88
|
+
ownership.save(current);
|
|
89
|
+
if (current.participants.length === 0) {
|
|
90
|
+
if (current.changed && current.baseline)
|
|
91
|
+
await write(current.baseline);
|
|
92
|
+
ownership.clear();
|
|
93
|
+
}
|
|
94
|
+
released = true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
} };
|
|
10
100
|
}
|
|
101
|
+
function activeParticipant(item) { return item.phase === 'active' && volumeProcessAlive(item.pid); }
|
|
11
102
|
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(
|
|
103
|
+
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'); } }; }
|
|
13
104
|
function linuxBackend(run, resolve) {
|
|
14
105
|
const wpctl = resolve('wpctl');
|
|
15
106
|
if (wpctl)
|
|
16
107
|
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(
|
|
108
|
+
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, execute = run) { await checked(execute(wpctl, ['set-volume', '@DEFAULT_AUDIO_SINK@', `${clamp(value.volume)}%`]), 'set PipeWire output volume'); await checked(execute(wpctl, ['set-mute', '@DEFAULT_AUDIO_SINK@', value.muted ? '1' : '0']), 'set PipeWire mute state'); } };
|
|
18
109
|
const pactl = resolve('pactl');
|
|
19
110
|
if (pactl)
|
|
20
111
|
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(
|
|
112
|
+
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, execute = run) { await checked(execute(pactl, ['set-sink-volume', '@DEFAULT_SINK@', `${clamp(value.volume)}%`]), 'set PulseAudio output volume'); await checked(execute(pactl, ['set-sink-mute', '@DEFAULT_SINK@', value.muted ? '1' : '0']), 'set PulseAudio mute state'); } };
|
|
22
113
|
const amixer = resolve('amixer');
|
|
23
114
|
if (amixer)
|
|
24
115
|
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(
|
|
116
|
+
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, execute = run) { await checked(execute(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); } };
|
|
26
117
|
return undefined;
|
|
27
118
|
}
|
|
28
119
|
function windowsBackend(run, resolve) { const powershell = resolve('powershell.exe') ?? resolve('pwsh.exe'); if (!powershell)
|
|
29
120
|
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(
|
|
121
|
+
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
122
|
const windowsCoreAudio = String.raw `Add-Type -TypeDefinition @'
|
|
32
123
|
using System; using System.Runtime.InteropServices;
|
|
33
124
|
public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole, eMultimedia, eCommunications }
|
|
@@ -38,6 +129,56 @@ public enum EDataFlow { eRender, eCapture, eAll } public enum ERole { eConsole,
|
|
|
38
129
|
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
130
|
'@`;
|
|
40
131
|
function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); }
|
|
132
|
+
function messageOf(error) { return error instanceof Error ? error.message : String(error); }
|
|
41
133
|
async function checked(promise, label) { const result = await promise; if (result.code !== 0)
|
|
42
134
|
throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); return result; }
|
|
43
|
-
function runCommand(command, args
|
|
135
|
+
function runCommand(command, args, ownership) {
|
|
136
|
+
return new Promise(resolve => {
|
|
137
|
+
ownership?.beginNativeWrite();
|
|
138
|
+
let child;
|
|
139
|
+
try {
|
|
140
|
+
child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000, killSignal: 'SIGKILL' });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
let message = messageOf(error);
|
|
144
|
+
try {
|
|
145
|
+
ownership?.finishNativeWrite();
|
|
146
|
+
}
|
|
147
|
+
catch (cleanupError) {
|
|
148
|
+
message += `; system-output command journal cleanup failed: ${messageOf(cleanupError)}`;
|
|
149
|
+
}
|
|
150
|
+
resolve({ code: 127, stdout: '', stderr: message });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let stdout = '';
|
|
154
|
+
let stderr = '';
|
|
155
|
+
let failure;
|
|
156
|
+
child.stdout.on('data', value => stdout += String(value));
|
|
157
|
+
child.stderr.on('data', value => stderr += String(value));
|
|
158
|
+
// Spawn/kill errors do not prove the helper has closed. Keep ownership until
|
|
159
|
+
// close, including when recording its PID fails after the process starts.
|
|
160
|
+
child.on('error', error => { failure = error.message; });
|
|
161
|
+
child.on('close', (code, signal) => {
|
|
162
|
+
try {
|
|
163
|
+
ownership?.finishNativeWrite();
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
failure = `${failure ? `${failure}; ` : ''}system-output command journal cleanup failed: ${messageOf(error)}`;
|
|
167
|
+
}
|
|
168
|
+
resolve({ code: failure ? 1 : code ?? 1, stdout, stderr: failure || stderr || (signal ? 'The output-volume command was interrupted or timed out.' : '') });
|
|
169
|
+
});
|
|
170
|
+
if (ownership && child.pid)
|
|
171
|
+
try {
|
|
172
|
+
ownership.recordNativeWritePid(child.pid);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
failure = `The system-output helper PID could not be saved: ${messageOf(error)}`;
|
|
176
|
+
try {
|
|
177
|
+
child.kill('SIGKILL');
|
|
178
|
+
}
|
|
179
|
+
catch (killError) {
|
|
180
|
+
failure += `; stopping the helper failed: ${messageOf(killError)}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|