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