@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
@@ -8,7 +8,11 @@ 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';
12
16
  export class SchedulerService {
13
17
  adapter;
14
18
  now;
@@ -94,7 +98,7 @@ export class SchedulerService {
94
98
  throw error;
95
99
  } this.health.remove(alarmId); }
96
100
  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) }; }
101
+ 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
102
  async reconcileGuard(alarm) { if (!this.guard)
99
103
  return; try {
100
104
  const requested = alarm.enabled && alarm.reliability.keepAwakeUntilAlarm;
@@ -111,7 +115,7 @@ export class SchedulerService {
111
115
  } }
112
116
  async ensureGuardAbsent(alarmId) { if (!this.guard)
113
117
  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))
118
+ 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
119
  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
120
  }
117
121
  class GuardReconcileError extends Error {
@@ -132,29 +136,27 @@ export function createSchedulerAdapter(deps = {}) {
132
136
  const nodePath = deps.nodePath ?? process.execPath;
133
137
  const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
134
138
  const env = deps.env ?? process.env;
139
+ const host = identifyPlatform({ platform, env });
140
+ const policy = nativeAdapters(host);
135
141
  const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }); });
136
142
  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')
143
+ const resolve = (command) => resolveCommandDetails(command, { platform, env, home }).path;
144
+ const run = deps.run ?? ((command, args) => runCommand(resolve(command) ?? command, args));
145
+ const commandExists = deps.commandExists ?? (command => resolve(command) !== null);
146
+ const common = { platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run };
147
+ if (policy.scheduler === 'launchd')
141
148
  return launchdAdapter(common);
142
- if (platform === 'win32')
149
+ if (policy.scheduler === 'task-scheduler')
143
150
  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}.`);
151
+ if (policy.scheduler === 'systemd') {
152
+ if (commandExists('systemctl'))
153
+ return systemdAdapter(common);
154
+ return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.');
155
+ }
156
+ return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id === 'unknown' ? host.platform : host.id}.`);
149
157
  }
150
158
  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;
159
+ return launchEnvironment(common.env, { platform: common.platform, includeDesktop: true, terminal: common.terminal });
158
160
  }
159
161
  const jobName = (id) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`;
160
162
  const invocationArgs = (common, id, occurrence, internalCommand = 'internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()];
@@ -170,38 +172,41 @@ function launchdAdapter(common) {
170
172
  extras = readdirSync(directory).filter(name => name.startsWith(prefix) && name.endsWith('.plist')).map(name => join(directory, name));
171
173
  }
172
174
  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)
175
+ 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
176
  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()}`);
177
+ const removeJob = async (path) => {
178
+ const label = path.split('/').at(-1).slice(0, -6);
179
+ let failure;
180
+ try {
181
+ ensureSuccess(await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootout');
185
182
  }
186
183
  catch (error) {
187
184
  failure = error;
188
- } if (failure) {
185
+ }
186
+ if (failure) {
189
187
  let status;
190
188
  try {
191
189
  status = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]);
192
190
  }
193
191
  catch (error) {
194
- throw new Error(`Unable to verify completed launchd job removal: ${errorMessage(error)}`);
192
+ throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);
195
193
  }
196
194
  if (status.code === 0)
197
- throw new Error(`Completed launchd job is still loaded: ${errorMessage(failure)}`);
195
+ throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`);
198
196
  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
- } },
197
+ throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`);
198
+ }
199
+ common.removeFile(path);
200
+ };
201
+ return {
202
+ 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.' }),
203
+ async install(alarm, occurrence) { await installJob(alarm, occurrence, jobName(alarm.id), pathFor(alarm.id), true); },
204
+ 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)
205
+ return; await installJob(alarm, occurrence, label, occurrencePath(alarm.id, occurrence), false); },
206
+ async removeFromRunner() { },
207
+ async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); await removeJob(existsSync(specific) ? specific : pathFor(id)); },
208
+ async remove(id) { for (const path of pathsFor(id))
209
+ await removeJob(path); },
205
210
  async status(id) { const paths = pathsFor(id).filter(existsSync); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); if (!paths.length)
206
211
  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
212
  };
@@ -209,38 +214,175 @@ function launchdAdapter(common) {
209
214
  function systemdAdapter(common) {
210
215
  const base = join(common.home, '.config', 'systemd', 'user');
211
216
  const names = (id) => ({ service: `${jobName(id)}.service`, timer: `${jobName(id)}.timer` });
217
+ 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.' });
218
+ const probeCapabilities = async () => {
219
+ try {
220
+ const result = await boundedProbe(common.run('systemctl', ['--user', 'show', '--property=Version', '--value']));
221
+ ensureSuccess(result, 'systemd user manager probe');
222
+ return { ...capabilities(), message: 'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.' };
223
+ }
224
+ catch (error) {
225
+ return { ...capabilities(), supported: false, catchUpAfterWake: false, message: `The systemd user manager is unavailable: ${errorMessage(error)}` };
226
+ }
227
+ };
212
228
  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' }; }
229
+ capabilities,
230
+ probeCapabilities,
231
+ async install(alarm, occurrence) { const readiness = await probeCapabilities(); if (!readiness.supported)
232
+ 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))); 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'); },
233
+ async remove(id) {
234
+ const n = names(id);
235
+ 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');
236
+ common.removeFile(join(base, n.timer));
237
+ common.removeFile(join(base, n.service));
238
+ ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload after removal');
239
+ },
240
+ async status(id) { const readiness = await probeCapabilities(); if (!readiness.supported)
241
+ 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
242
  };
218
243
  }
219
244
  function windowsAdapter(common) {
220
245
  const xmlPath = (id) => join(common.env.LOCALAPPDATA ?? join(common.home, 'AppData', 'Local'), 'RadioCLI', 'scheduler', `${jobName(id)}.xml`);
221
246
  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)); },
247
+ 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.' }),
248
+ async install(alarm, occurrence) {
249
+ const name = `\\RadioCLI\\${jobName(alarm.id)}`;
250
+ const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common));
251
+ // Task Scheduler expands %NAME% in Path and Arguments itself. Only its
252
+ // standard PowerShell path and fixed flags/encoded data cross that boundary.
253
+ const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
254
+ const args = powershellCommand(direct).join(' ');
255
+ 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>`;
256
+ const path = xmlPath(alarm.id);
257
+ common.write(path, body);
258
+ ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create');
259
+ },
260
+ async remove(id) {
261
+ const name = `\\RadioCLI\\${jobName(id)}`;
262
+ const removal = await settledOperation(async () => {
263
+ const result = await boundedWindowsCommand(() => common.run('schtasks.exe', ['/Delete', '/TN', name, '/F']), 'deletion');
264
+ if (result.code !== 0)
265
+ throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`);
266
+ });
267
+ let state;
268
+ try {
269
+ const result = await boundedWindowsCommand(() => common.run('powershell.exe', windowsTaskLookupCommand(name)), 'absence lookup');
270
+ state = windowsTaskState(result, name);
271
+ }
272
+ catch (error) {
273
+ throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error ?? ''} ${errorMessage(error)}`);
274
+ }
275
+ if (state.state !== 'absent')
276
+ throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error ?? ''} ${state.message}`);
277
+ common.removeFile(xmlPath(id));
278
+ },
225
279
  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
280
  };
227
281
  }
282
+ function windowsTaskLookupCommand(taskPath) {
283
+ // Only GetTask failures can establish absence. A missing COM service or root
284
+ // folder must remain a repairable error, regardless of the UI language.
285
+ const encodedPath = Buffer.from(taskPath, 'utf8').toString('base64');
286
+ const script = [
287
+ "$ErrorActionPreference='Stop'",
288
+ "$ProgressPreference='SilentlyContinue'",
289
+ '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)',
290
+ `$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
291
+ "$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}",
292
+ "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}",
293
+ '[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))',
294
+ 'exit 0'
295
+ ].join(';');
296
+ return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
297
+ }
298
+ function windowsTaskState(result, taskPath) {
299
+ const unknown = (message) => ({ state: 'unknown', message: `${message} ${windowsCommandMessage(result)}` });
300
+ if (result.code !== 0)
301
+ return unknown('Task Scheduler lookup process failed.');
302
+ let value;
303
+ try {
304
+ value = JSON.parse(result.stdout);
305
+ }
306
+ catch {
307
+ return unknown('Malformed Task Scheduler lookup response.');
308
+ }
309
+ if (!value || typeof value !== 'object' || Array.isArray(value))
310
+ return unknown('Malformed Task Scheduler lookup response.');
311
+ const response = value;
312
+ if (response.taskPath !== taskPath || !['connect', 'root', 'lookup'].includes(String(response.stage)) ||
313
+ !['present', 'error'].includes(String(response.status)) || typeof response.hresult !== 'number' ||
314
+ !Number.isInteger(response.hresult) || response.hresult < -2147483648 || response.hresult > 2147483647 || typeof response.message !== 'string') {
315
+ return unknown('Invalid or unrelated Task Scheduler lookup response.');
316
+ }
317
+ if (response.status === 'present' && response.stage === 'lookup' && response.hresult === 0)
318
+ return { state: 'present', message: 'Task Scheduler job is still registered.' };
319
+ // HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the
320
+ // exact GetTask lookup. Never interpret schtasks exit 1 or localized text.
321
+ if (response.status === 'error' && response.stage === 'lookup' && [-2147024894, -2147024893].includes(response.hresult))
322
+ return { state: 'absent', message: response.message };
323
+ return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult >>> 0).toString(16).padStart(8, '0')}: ${response.message}`);
324
+ }
325
+ function windowsCommandMessage(result) { return [`exit ${result.code}`, result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(' '); }
326
+ async function boundedWindowsCommand(work, label) {
327
+ let timer;
328
+ try {
329
+ return await Promise.race([Promise.resolve().then(work), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Task Scheduler ${label} timed out.`)), 10_000); })]);
330
+ }
331
+ finally {
332
+ if (timer)
333
+ clearTimeout(timer);
334
+ }
335
+ }
228
336
  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 })); }); }
337
+ function unavailableAdapter(message) {
338
+ return { ...unsupportedAdapter(message), remove: async () => { throw new Error(message); } };
339
+ }
340
+ 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 })); }); }
341
+ async function boundedProbe(probe) { let timer; try {
342
+ return await Promise.race([probe, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('User-manager probe timed out.')), 3_000); })]);
343
+ }
344
+ finally {
345
+ if (timer)
346
+ clearTimeout(timer);
347
+ } }
348
+ async function removeWithVerification(remove, query, isAbsent, label) {
349
+ let failure;
350
+ try {
351
+ ensureSuccess(await remove(), `${label} removal`);
352
+ }
353
+ catch (error) {
354
+ failure = error;
355
+ }
356
+ if (!failure)
357
+ return;
358
+ let result;
359
+ try {
360
+ result = await query();
361
+ }
362
+ catch (error) {
363
+ throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);
364
+ }
365
+ if (isAbsent(result))
366
+ return;
367
+ throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`);
368
+ }
369
+ function commandMessage(result) { return (result.stderr || result.stdout || `exit ${result.code}`).trim(); }
230
370
  function ensureSuccess(result, label) { if (result.code !== 0)
231
371
  throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); }
232
372
  function xml(value) { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;'); }
233
373
  function systemdQuote(value) { if (/[\r\n\0]/.test(value))
234
374
  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')}"`; }
375
+ function systemdExecCommand(values) {
376
+ // ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has
377
+ // different syntax: its dollar signs are already literal and must stay so.
378
+ return `:${values.map(systemdQuote).join(' ')}`;
379
+ }
238
380
  function localParts(date) { return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds() }; }
239
381
  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
382
  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
383
  function unitText(value) { return value.replace(/[\r\n\0]/g, ' ').replaceAll('%', '%%').slice(0, 200); }
242
384
  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); }
385
+ 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
386
  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
387
  const index = next;
246
388
  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)); }