@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. 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 { resolveCommand } from '../player/command.js';
3
- export function createSystemVolumeController(platform = process.platform, run = runCommand, resolve = resolveCommand) {
4
- const backend = platform === 'darwin' ? macBackend(run) : platform === 'linux' ? linuxBackend(run, resolve) : platform === 'win32' ? windowsBackend(run, resolve) : undefined;
5
- return { async acquireMinimum(volume) { if (!backend)
6
- throw new Error(`System output volume control is unavailable on ${platform}.`); const target = clamp(volume); const before = await backend.read(); const applied = Math.max(before.volume, target); if (before.muted || before.volume < target)
7
- await backend.write({ volume: applied, muted: false }); let released = false; return { message: before.muted || before.volume < target ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`, async release() { if (released)
8
- return; released = true; if (before.muted || before.volume < target)
9
- await backend.write(before); } }; } };
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(run('/usr/bin/osascript', ['-e', `set volume output volume ${clamp(value.volume)} ${value.muted ? 'with' : 'without'} output muted`]), 'set macOS output volume'); } }; }
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(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'); } };
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(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'); } };
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(run(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); } };
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(run(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted ? 'true' : 'false'})`]), 'set Windows output volume'); } }; }
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) { return new Promise(resolve => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', value => stdout += String(value)); child.stderr.on('data', value => stderr += String(value)); child.on('error', error => resolve({ code: 127, stdout, stderr: error.message })); child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); }); }
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
+ }