@ciphore/radiocli 0.2.3 → 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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
2
+ import { platformPaths } from '../platform/paths.js';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { assessScheduledOccurrence, NATIVE_DISPATCH_TOLERANCE_MS, nextOccurrenceForAlarm } from './schedule.js';
5
5
  import { startActiveAlarmSession } from './active-session.js';
@@ -25,10 +25,18 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
25
25
  let lease;
26
26
  let systemVolumeLease;
27
27
  let listening = false;
28
+ let historyStarted = false;
28
29
  let firedAt;
30
+ const runnerWarnings = new Set();
31
+ const recordRunnerWarning = (message) => { runnerWarnings.add(message); try {
32
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: listening, message: [...runnerWarnings].join(' ') });
33
+ }
34
+ catch { } };
29
35
  let preserveNextOverride = false;
30
36
  let preserveSystemVolume = false;
37
+ let handoffOutput;
31
38
  let validTerminalOccurrence = false;
39
+ let completeLock = true;
32
40
  let signalReceived = false;
33
41
  let resolveEarlySignal = () => { };
34
42
  let onPlaybackSignal = () => { };
@@ -49,6 +57,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
49
57
  }
50
58
  const assessment = assessScheduledOccurrence(scheduledAt, deps.now(), alarm.reliability.missedRunGraceMinutes);
51
59
  if (assessment === 'pending') {
60
+ completeLock = false;
52
61
  outcome = finish('failed', 'Scheduler launched the alarm before its occurrence.');
53
62
  return { status: outcome.status, message: outcome.message };
54
63
  }
@@ -62,8 +71,8 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
62
71
  onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
63
72
  onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
64
73
  onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
65
- onHandoff: () => { if (!listening)
66
- throw new Error('Alarm playback is still starting.'); preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
74
+ onHandoff: async () => { if (!listening)
75
+ throw new Error('Alarm playback is still starting.'); handoffOutput = (systemVolumeLease?.release({ preserve: true }) ?? Promise.resolve()).then(() => { preserveSystemVolume = true; }); await handoffOutput; action = 'handoff'; resolveAction('handoff'); }
67
76
  });
68
77
  void creatingSession.then(created => { if (signalReceived)
69
78
  void created.close().catch(() => undefined); }).catch(() => { });
@@ -160,15 +169,24 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
160
169
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues without sleep protection: ${errorMessage(error)}` });
161
170
  }
162
171
  const startedAt = deps.now();
163
- deps.store.addRecent(resolvedStation);
164
- deps.store.startListeningSession(resolvedStation, startedAt);
165
172
  listening = true;
173
+ try {
174
+ deps.store.addRecent(resolvedStation);
175
+ }
176
+ catch (error) {
177
+ recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);
178
+ }
179
+ try {
180
+ deps.store.startListeningSession(resolvedStation, startedAt);
181
+ historyStarted = true;
182
+ }
183
+ catch (error) {
184
+ recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);
185
+ }
166
186
  const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
167
187
  session.update(activeStatus);
168
- void deps.openControls?.(activeStatus).catch(error => { try {
169
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}` });
170
- }
171
- catch { } });
188
+ void deps.openControls?.(activeStatus).then(result => { if (result && !result.opened && result.terminal !== 'existing-tui')
189
+ recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`); }).catch(error => { recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`); });
172
190
  onPlaybackSignal = () => { action = 'signal'; resolveAction('signal'); };
173
191
  if (signalReceived)
174
192
  onPlaybackSignal();
@@ -211,11 +229,13 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
211
229
  return { status: 'failed', message: outcome.message };
212
230
  }
213
231
  finally {
232
+ listening = false;
214
233
  unsubscribeSignals?.();
215
234
  try {
216
235
  await deps.player.stop();
217
236
  }
218
237
  catch { }
238
+ await handoffOutput?.catch(() => undefined);
219
239
  if (!preserveSystemVolume)
220
240
  try {
221
241
  await systemVolumeLease?.release();
@@ -226,12 +246,19 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
226
246
  }
227
247
  catch { }
228
248
  }
229
- if (listening) {
249
+ if (historyStarted) {
230
250
  try {
231
251
  deps.store.checkpointActiveListeningSession(deps.now());
252
+ }
253
+ catch (error) {
254
+ recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);
255
+ }
256
+ try {
232
257
  deps.store.finishActiveListeningSession(deps.now());
233
258
  }
234
- catch { }
259
+ catch (error) {
260
+ recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);
261
+ }
235
262
  }
236
263
  try {
237
264
  await lease?.release();
@@ -250,12 +277,14 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
250
277
  catch { }
251
278
  if (alarm && outcome) {
252
279
  try {
253
- deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: !preserveNextOverride });
280
+ deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: validTerminalOccurrence && !preserveNextOverride });
254
281
  const latest = deps.store.getAlarm(alarmId);
255
282
  if (alarm.schedule.type === 'once' && latest?.schedule.type === 'once' && latest.schedule.at === alarm.schedule.at && validTerminalOccurrence && !preserveNextOverride)
256
283
  deps.store.toggleAlarm(alarmId, false);
257
284
  }
258
- catch { }
285
+ catch (error) {
286
+ recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);
287
+ }
259
288
  }
260
289
  alarm = deps.store.getAlarm(alarmId);
261
290
  if (alarm)
@@ -267,7 +296,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
267
296
  }
268
297
  catch { }
269
298
  try {
270
- releaseLock();
299
+ releaseLock(completeLock);
271
300
  }
272
301
  catch (error) {
273
302
  try {
@@ -275,15 +304,16 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
275
304
  }
276
305
  catch { }
277
306
  }
278
- try {
279
- await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
280
- }
281
- catch (error) {
307
+ if (completeLock)
282
308
  try {
283
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
309
+ await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
310
+ }
311
+ catch (error) {
312
+ try {
313
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
314
+ }
315
+ catch { }
284
316
  }
285
- catch { }
286
- }
287
317
  }
288
318
  }
289
319
  export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmRuntimeDirectory()) {
@@ -311,7 +341,10 @@ export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmR
311
341
  }
312
342
  }
313
343
  writeFileSync(join(path, 'running'), String(process.pid), { mode: 0o600 });
314
- return () => { rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
344
+ return (completed = true) => { if (!completed) {
345
+ rmSync(path, { recursive: true, force: true });
346
+ return;
347
+ } rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
315
348
  }
316
349
  export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirectory(), olderThanMs = 30 * 24 * 60 * 60_000, now = Date.now()) { const directory = join(root, 'locks'); if (!existsSync(directory))
317
350
  return 0; let removed = 0; for (const name of readdirSync(directory)) {
@@ -324,9 +357,7 @@ export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirector
324
357
  }
325
358
  catch { }
326
359
  } return removed; }
327
- export function defaultAlarmRuntimeDirectory() { if (process.env.RADIOCLI_HOME)
328
- return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
329
- return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
360
+ export function defaultAlarmRuntimeDirectory() { return platformPaths().runtime; }
330
361
  export const defaultRunnerUtilities = { acquireLock: acquireOccurrenceLock, createSession: startActiveAlarmSession, wait: (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)), subscribeSignals: (handler) => { process.once('SIGTERM', handler); process.once('SIGHUP', handler); process.once('SIGINT', handler); return () => { process.off('SIGTERM', handler); process.off('SIGHUP', handler); process.off('SIGINT', handler); }; } };
331
362
  function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
332
363
  function processAlive(pid) { try {
@@ -11,10 +11,17 @@ export function isValidTimeZone(timezone) {
11
11
  }
12
12
  export function canonicalizeTimeZone(timezone) {
13
13
  const value = timezone.trim();
14
- if (!isValidTimeZone(value)) {
14
+ try {
15
+ if (!value)
16
+ throw new Error('Empty timezone.');
17
+ // The constructor validates the zone and resolves its canonical name.
18
+ // Constructing twice made large-library validation expensive on Node 22
19
+ // Intel hosts; keep one native validation for every supplied value.
20
+ return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
21
+ }
22
+ catch {
15
23
  throw new Error(`Invalid IANA timezone: ${timezone}`);
16
24
  }
17
- return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
18
25
  }
19
26
  export function canonicalizeAlarmTime(time) {
20
27
  const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
@@ -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)); }