@ciphore/radiocli 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { PlayerController } from '../player/player-controller.js';
6
+ import { launchHeadlessHost } from './launcher.js';
7
+ import { platformPaths } from '../platform/paths.js';
8
+ import { storageReadiness } from '../platform/storage.js';
9
+ import { listenLoopback } from '../platform/loopback.js';
10
+ import { ensureRadioSession, radioSessionMayBeActive } from './session.js';
11
+ export async function openTuiSession() {
12
+ if (!radioSessionMayBeActive()) {
13
+ if (process.env.RADIOCLI_TUI_PLAYBACK === 'inline')
14
+ return { warning: 'Playback runs in this terminal. Crash recovery is disabled by RADIOCLI_TUI_PLAYBACK=inline.' };
15
+ if (storageReadiness(join(platformPaths().runtime, 'agent-session.json')).status === 'unavailable') {
16
+ return { warning: 'Playback recovery is unavailable: the runtime directory is not writable. Set RADIOCLI_HOME to a private writable directory.' };
17
+ }
18
+ const probe = createServer();
19
+ try {
20
+ await listenLoopback(probe);
21
+ }
22
+ catch {
23
+ return { warning: 'Playback recovery is unavailable: this environment blocks local loopback control. Playback runs in this terminal.' };
24
+ }
25
+ finally {
26
+ await new Promise(resolve => probe.close(() => resolve()));
27
+ }
28
+ }
29
+ return { session: await TuiSession.connect() };
30
+ }
31
+ /** A terminal is a view of the existing playback owner. Its death must never
32
+ * destroy the owner's IPC connection, queue, timers, or listening history. */
33
+ export class TuiSession {
34
+ client;
35
+ clientId;
36
+ status;
37
+ listeners = new Set();
38
+ timer;
39
+ revision = 0;
40
+ closing;
41
+ closed = false;
42
+ constructor(client, clientId, status) {
43
+ this.client = client;
44
+ this.clientId = clientId;
45
+ this.status = status;
46
+ }
47
+ static async connect() {
48
+ const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../cli.tsx' : '../cli.js', import.meta.url));
49
+ const client = await ensureRadioSession(() => launchHeadlessHost(process.execPath, cli), 15_000, { source: 'cli' });
50
+ const clientId = randomUUID();
51
+ let result;
52
+ try {
53
+ result = await client.call({ type: 'tui-attach', pid: process.pid, clientId });
54
+ }
55
+ catch (error) {
56
+ if (error instanceof Error && /Invalid radio command|Unsupported radio command/i.test(error.message)) {
57
+ throw new Error('The running player predates TUI recovery. Run radiocli stop, then reopen radiocli to use the updated player.');
58
+ }
59
+ throw error;
60
+ }
61
+ if (!result.ok)
62
+ throw new Error(result.message);
63
+ if (!result.status.tui)
64
+ throw new Error('The playback host does not support terminal recovery. Stop the older session and reopen RadioCLI.');
65
+ const session = new TuiSession(client, clientId, result.status);
66
+ session.poll();
67
+ return session;
68
+ }
69
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
70
+ async call(command) {
71
+ if (this.closed)
72
+ throw new Error('RadioCLI terminal session is closed.');
73
+ const result = await this.client.call(command);
74
+ this.revision++;
75
+ this.status = result.status;
76
+ this.emit();
77
+ return result;
78
+ }
79
+ report(error) {
80
+ this.status = { ...this.status, persistenceWarning: error instanceof Error ? error.message : String(error) };
81
+ this.emit();
82
+ }
83
+ disconnect() {
84
+ clearTimeout(this.timer);
85
+ this.closed = true;
86
+ this.listeners.clear();
87
+ }
88
+ close() {
89
+ return this.closing ??= (async () => {
90
+ clearTimeout(this.timer);
91
+ try {
92
+ const result = await this.call({ type: 'tui-detach', clientId: this.clientId });
93
+ if (!result.ok)
94
+ throw new Error(result.message);
95
+ }
96
+ finally {
97
+ this.disconnect();
98
+ }
99
+ })();
100
+ }
101
+ poll() {
102
+ this.timer = setTimeout(() => {
103
+ const revision = this.revision;
104
+ void this.client.status().then(status => {
105
+ if (!this.closed && revision === this.revision && JSON.stringify(status) !== JSON.stringify(this.status)) {
106
+ this.status = status;
107
+ this.emit();
108
+ }
109
+ }).catch(() => {
110
+ if (!this.closed && revision === this.revision) {
111
+ this.status = { ...this.status, playback: { ...this.status.playback, state: 'error', ready: false,
112
+ message: 'Playback host disconnected. Reopen RadioCLI to reconnect.' } };
113
+ this.emit();
114
+ }
115
+ }).finally(() => { if (!this.closed && !this.closing)
116
+ this.poll(); });
117
+ }, 250);
118
+ this.timer.unref();
119
+ }
120
+ emit() { for (const listener of this.listeners)
121
+ listener(); }
122
+ }
123
+ /** Keep synchronous presentation queries local, while every audio mutation
124
+ * reaches the same owner used by CLI, agent, alarm, and desktop controls. */
125
+ export class TuiPlayer extends PlayerController {
126
+ session;
127
+ constructor(session, settings) {
128
+ super(settings);
129
+ this.session = session;
130
+ }
131
+ getState() { return { ...this.session.status.playback }; }
132
+ onChange(listener) {
133
+ listener(this.getState());
134
+ return this.session.subscribe(() => listener(this.getState()));
135
+ }
136
+ onMetadata(listener) {
137
+ let previous;
138
+ return this.session.subscribe(() => {
139
+ const metadata = this.session.status.nowPlaying;
140
+ const key = JSON.stringify(metadata);
141
+ if (metadata && key !== previous) {
142
+ previous = key;
143
+ listener(metadata);
144
+ }
145
+ });
146
+ }
147
+ diagnostics() { return { ...this.session.status.tui.diagnostics }; }
148
+ desktopMediaCompatible() { return this.session.status.tui.desktopMediaCompatible; }
149
+ detectedBackends() { return [...this.session.status.tui.diagnostics.availableBackends]; }
150
+ refreshDetectedBackends() {
151
+ void this.session.call({ type: 'tui-refresh' }).catch(error => this.session.report(error));
152
+ return this.detectedBackends();
153
+ }
154
+ detectedAirPlayDevices() { return [...this.session.status.tui.devices]; }
155
+ airPlaySelectionRequired() { return this.session.status.tui.selectionRequired; }
156
+ async refreshAirPlayDevices() {
157
+ const result = await this.session.call({ type: 'airplay-list' });
158
+ if (!result.ok)
159
+ throw new Error(result.message);
160
+ return result.data ?? [];
161
+ }
162
+ async selectAirPlayDevice(deviceId) { return (await this.session.call({ type: 'tui-output', deviceId })).ok; }
163
+ async submitAirPlayPasscode(code) { return this.control({ type: 'airplay-passcode', code }); }
164
+ async pause() { return this.control({ type: 'pause' }); }
165
+ async resume() { return this.control({ type: 'resume' }); }
166
+ async togglePause() { return this.control({ type: 'tui-toggle-pause' }); }
167
+ async setVolume(volume, options = {}) {
168
+ return this.control({ type: 'set-volume', volume: Math.max(0, Math.min(100, Math.round(volume))), allowStopped: options.allowStopped });
169
+ }
170
+ async adjustVolume(delta) { return this.control({ type: 'tui-adjust-volume', delta }); }
171
+ async setMuted(muted, options = {}) { return this.control({ type: 'set-muted', muted, allowStopped: options.allowStopped }); }
172
+ async toggleMute() { return this.control({ type: 'tui-toggle-muted' }); }
173
+ async stop() { await this.session.call({ type: 'stop', retainSelection: true }); }
174
+ async play() { throw new Error('Terminal playback must use the shared radio engine.'); }
175
+ async control(command) {
176
+ const result = await this.session.call(command);
177
+ return { ok: result.ok, message: result.message };
178
+ }
179
+ }
180
+ export class TuiRadioEngine {
181
+ session;
182
+ constructor(session) {
183
+ this.session = session;
184
+ }
185
+ snapshot() {
186
+ const status = this.session.status;
187
+ return { station: status.station, tuningStation: status.tui?.tuningStation ?? null, queue: [...status.queue],
188
+ playback: { ...status.playback }, nowPlaying: status.nowPlaying ?? null, warning: status.persistenceWarning, message: status.tui?.message };
189
+ }
190
+ onChange(listener) { listener(this.snapshot()); return this.session.subscribe(() => listener(this.snapshot())); }
191
+ clearWarning() { this.session.status.persistenceWarning = undefined; }
192
+ cancel() { void this.session.call({ type: 'tui-cancel' }).catch(error => this.session.report(error)); }
193
+ async tune(station, options = {}) {
194
+ options.signal?.throwIfAborted();
195
+ const abort = () => this.cancel();
196
+ options.signal?.addEventListener('abort', abort, { once: true });
197
+ try {
198
+ const result = await this.session.call({ type: 'play', station, queue: options.queue, startPaused: options.startPaused });
199
+ options.signal?.throwIfAborted();
200
+ if (!result.ok || !result.status.station)
201
+ throw new Error(result.message);
202
+ return result.status.station;
203
+ }
204
+ finally {
205
+ options.signal?.removeEventListener('abort', abort);
206
+ }
207
+ }
208
+ async stop(options = {}) { await this.session.call({ type: 'stop', ...options }); }
209
+ async pause() { const result = await this.session.call({ type: 'pause' }); return { ok: result.ok, message: result.message }; }
210
+ async resume() { const result = await this.session.call({ type: 'resume' }); return { ok: result.ok, message: result.message }; }
211
+ select(station, queue) { void this.session.call({ type: 'tui-select', station, queue }).catch(error => this.session.report(error)); }
212
+ close() { return this.session.close(); }
213
+ }
@@ -1,5 +1,7 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { LibraryRepository } from '../storage/repository.js';
4
+ import { matchesStationId } from '../storage/station-identity.js';
3
5
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
6
  import { ProviderManager } from '../providers/provider-manager.js';
5
7
  import { PlayerController } from '../player/player-controller.js';
@@ -74,6 +76,7 @@ export async function runAlarmCommand(args, dependencies = {}) {
74
76
  if (sub === 'remove') {
75
77
  const id = requiredPos(rest, 0);
76
78
  requireAlarm(store, id);
79
+ store.toggleAlarm(id, false);
77
80
  await scheduler.remove(id);
78
81
  if (!store.removeAlarm(id))
79
82
  throw new Error(`Alarm not found: ${id}`);
@@ -157,7 +160,13 @@ export async function runAlarmCommand(args, dependencies = {}) {
157
160
  if (sub === 'internal-run') {
158
161
  const id = requiredPos(rest, 0);
159
162
  const at = requiredPos(rest, 1);
160
- await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
163
+ const repository = new LibraryRepository(store, { worker: !dependencies.store });
164
+ try {
165
+ await runAlarm(id, at, createRuntimeDeps(repository, scheduler, id));
166
+ }
167
+ finally {
168
+ await repository.close();
169
+ }
161
170
  return;
162
171
  }
163
172
  if (sub === 'internal-launchd') {
@@ -165,7 +174,13 @@ export async function runAlarmCommand(args, dependencies = {}) {
165
174
  const at = requiredPos(rest, 1);
166
175
  if (!shouldRunLaunchdOccurrence(at, new Date()))
167
176
  return;
168
- await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
177
+ const repository = new LibraryRepository(store, { worker: !dependencies.store });
178
+ try {
179
+ await runAlarm(id, at, createRuntimeDeps(repository, scheduler, id));
180
+ }
181
+ finally {
182
+ await repository.close();
183
+ }
169
184
  return;
170
185
  }
171
186
  if (sub === 'internal-guard-run') {
@@ -238,7 +253,8 @@ function parseDays(value) { if (value === 'daily')
238
253
  return [1, 2, 3, 4, 5, 6, 7]; if (value === 'weekdays')
239
254
  return [1, 2, 3, 4, 5]; if (value === 'weekends')
240
255
  return [6, 7]; return canonicalizeIsoWeekdays(value.split(',').map(Number)); }
241
- function resolveStation(store, key) { const state = store.snapshot(); const stations = [...state.favorites, ...state.imported, ...state.recent.map(item => item.station), ...state.alarms.flatMap(alarm => [alarm.station, ...(alarm.playback.fallbackStation ? [alarm.playback.fallbackStation] : [])])]; const station = stations.find(item => stationKey(item) === key); if (!station)
256
+ function resolveStation(store, key) { const state = store.snapshot(); const stations = [...state.favorites, ...state.imported, ...state.recent.map(item => item.station), ...state.alarms.flatMap(alarm => [alarm.station, ...(alarm.playback.fallbackStation ? [alarm.playback.fallbackStation] : [])])]; const matches = new Map(stations.filter(item => matchesStationId(item, key)).map(item => [stationKey(item), item])); if (matches.size > 1)
257
+ throw new Error('This legacy station ID is ambiguous. List stations and use the current ID.'); const station = [...matches.values()][0]; if (!station)
242
258
  throw new Error(`Station not found: ${key}. Add it to favorites/imports/recents first.`); return station; }
243
259
  function parseOptions(args) { const allowed = new Set(['station', 'once', 'time', 'days', 'timezone', 'label', 'volume', 'fade', 'stop-after', 'fallback', 'grace', 'disabled', 'wake', 'no-wake', 'guard', 'no-guard', 'clear-fallback']); const values = new Map(); const positionals = []; for (let index = 0; index < args.length; index += 1) {
244
260
  const arg = args[index];
@@ -0,0 +1,25 @@
1
+ import { nextOccurrenceForAlarm } from './schedule.js';
2
+ /** Reuse civil-time work until its result expires; clock reversals invalidate it. */
3
+ export class AlarmOccurrenceCache {
4
+ limit;
5
+ entries = new Map();
6
+ constructor(limit = 512) {
7
+ this.limit = limit;
8
+ }
9
+ next(alarm, now) {
10
+ const time = now.getTime();
11
+ const fingerprint = JSON.stringify([alarm.enabled, alarm.schedule, alarm.nextOverride?.at]);
12
+ const cached = this.entries.get(alarm.id);
13
+ if (cached && cached.fingerprint === fingerprint && time >= cached.computedAt && (cached.at === null || cached.at > time)) {
14
+ this.entries.delete(alarm.id);
15
+ this.entries.set(alarm.id, cached);
16
+ return cached.at === null ? null : new Date(cached.at);
17
+ }
18
+ const occurrence = nextOccurrenceForAlarm(alarm, now);
19
+ this.entries.delete(alarm.id);
20
+ this.entries.set(alarm.id, { fingerprint, computedAt: time, at: occurrence?.getTime() ?? null });
21
+ while (this.entries.size > this.limit)
22
+ this.entries.delete(this.entries.keys().next().value);
23
+ return occurrence;
24
+ }
25
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { platformPaths } from '../platform/paths.js';
3
3
  import { dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
4
5
  import { assessScheduledOccurrence, NATIVE_DISPATCH_TOLERANCE_MS, nextOccurrenceForAlarm } from './schedule.js';
5
6
  import { startActiveAlarmSession } from './active-session.js';
6
7
  export async function runAlarm(alarmId, scheduledAtText, deps) {
@@ -37,16 +38,29 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
37
38
  let handoffOutput;
38
39
  let validTerminalOccurrence = false;
39
40
  let completeLock = true;
41
+ const tuneAbort = new AbortController();
40
42
  let signalReceived = false;
41
43
  let resolveEarlySignal = () => { };
42
44
  let onPlaybackSignal = () => { };
43
45
  const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
44
- const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
46
+ const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; tuneAbort.abort(); resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
45
47
  const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
46
48
  let action;
47
49
  let keepPlaying = false;
48
50
  let resolveAction = () => { };
49
51
  const actionPromise = new Promise(resolve => { resolveAction = resolve; });
52
+ let backendMessage;
53
+ let playbackStarted = false;
54
+ let backendEndedAt;
55
+ const unsubscribePlayer = deps.player.onChange?.(state => {
56
+ if ((!listening && !playbackStarted) || action || !['error', 'stopped', 'idle'].includes(state.state))
57
+ return;
58
+ backendMessage = state.message ?? 'Alarm stream ended before its scheduled stop.';
59
+ backendEndedAt = deps.now();
60
+ action = 'backend-ended';
61
+ resolveAction('backend-ended');
62
+ resolveEarlySignal();
63
+ });
50
64
  try {
51
65
  if (!alarm || !alarm.enabled)
52
66
  return { message: 'Alarm is missing or disabled.' };
@@ -68,13 +82,13 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
68
82
  }
69
83
  const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
70
84
  const creatingSession = deps.createSession(claimingStatus, {
71
- onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
72
- onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
85
+ onDismiss: () => { tuneAbort.abort(); action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
86
+ onSnooze: async (minutes) => { await deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; tuneAbort.abort(); action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
73
87
  onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
74
88
  onHandoff: async () => { if (!listening)
75
89
  throw new Error('Alarm playback is still starting.'); handoffOutput = (systemVolumeLease?.release({ preserve: true }) ?? Promise.resolve()).then(() => { preserveSystemVolume = true; }); await handoffOutput; action = 'handoff'; resolveAction('handoff'); }
76
90
  });
77
- void creatingSession.then(created => { if (signalReceived)
91
+ void creatingSession.then(created => { if (signalReceived || action)
78
92
  void created.close().catch(() => undefined); }).catch(() => { });
79
93
  const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
80
94
  if ('signal' in created) {
@@ -96,7 +110,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
96
110
  const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
97
111
  for (const [candidateIndex, candidate] of candidates.entries()) {
98
112
  try {
99
- const resolved = await Promise.race([deps.providers.resolve(candidate).then(stream => ({ stream })), earlySignal.then(() => ({ signal: true }))]);
113
+ const resolved = await Promise.race([deps.providers.resolve(candidate, { signal: tuneAbort.signal }).then(stream => ({ stream })), earlySignal.then(() => ({ signal: true }))]);
100
114
  if ('signal' in resolved) {
101
115
  outcome = finish('dismissed', 'Alarm interrupted before playback started.');
102
116
  return { status: 'dismissed', message: outcome.message };
@@ -110,6 +124,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
110
124
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}` });
111
125
  }
112
126
  }
127
+ tuneAbort.signal.throwIfAborted();
113
128
  if (!outputPrepared) {
114
129
  outputPrepared = true;
115
130
  if (deps.systemVolume)
@@ -121,7 +136,8 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
121
136
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
122
137
  }
123
138
  }
124
- const playPromise = deps.player.play(candidate, resolved.stream.url);
139
+ tuneAbort.signal.throwIfAborted();
140
+ const playPromise = deps.player.play(candidate, resolved.stream.url, { signal: tuneAbort.signal });
125
141
  const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
126
142
  if ('signal' in tuned || signalReceived) {
127
143
  void playPromise.then(() => deps.player.stop()).catch(() => undefined);
@@ -148,15 +164,19 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
148
164
  }
149
165
  if (lastError)
150
166
  throw lastError;
167
+ playbackStarted = true;
168
+ const initialPlayback = deps.player.getState();
169
+ if (initialPlayback.state && ['error', 'stopped', 'idle'].includes(initialPlayback.state))
170
+ throw new Error(initialPlayback.message ?? 'Alarm stream ended before playback could be published.');
151
171
  firedAt = deps.now();
152
172
  try {
153
173
  const acquiring = deps.inhibitor.acquire('RadioCLI alarm playback');
154
- void acquiring.then(acquired => { if (signalReceived)
174
+ void acquiring.then(acquired => { if (signalReceived || action)
155
175
  void acquired.release().catch(() => undefined); }).catch(() => { });
156
176
  const acquired = await Promise.race([acquiring.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
157
177
  if ('signal' in acquired) {
158
- outcome = finish('dismissed', 'Alarm interrupted while sleep protection was starting.');
159
- return { status: 'dismissed', message: outcome.message };
178
+ outcome = finish(backendEndedAt ? 'failed' : 'dismissed', backendEndedAt ? backendMessage : 'Alarm interrupted while sleep protection was starting.');
179
+ return { status: outcome.status, message: outcome.message };
160
180
  }
161
181
  lease = acquired.value;
162
182
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: true, active: true, message: 'Sleep inhibition active while playing.' });
@@ -168,16 +188,18 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
168
188
  catch (error) {
169
189
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues without sleep protection: ${errorMessage(error)}` });
170
190
  }
191
+ if (backendEndedAt)
192
+ throw new Error(backendMessage ?? 'Alarm stream ended while sleep protection was starting.');
171
193
  const startedAt = deps.now();
172
194
  listening = true;
173
195
  try {
174
- deps.store.addRecent(resolvedStation);
196
+ await deps.store.addRecent(resolvedStation);
175
197
  }
176
198
  catch (error) {
177
199
  recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);
178
200
  }
179
201
  try {
180
- deps.store.startListeningSession(resolvedStation, startedAt);
202
+ await deps.store.startListeningSession(resolvedStation, startedAt);
181
203
  historyStarted = true;
182
204
  }
183
205
  catch (error) {
@@ -221,15 +243,17 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
221
243
  action = await Promise.race([actionPromise, timeout]);
222
244
  }
223
245
  }
224
- outcome = finish(action === 'dismissed' || action === 'snoozed' || action === 'signal' ? 'dismissed' : 'played', action === 'snoozed' ? 'Snoozed.' : action === 'handoff' ? 'Handed off to interactive playback.' : action === 'signal' ? 'Interrupted.' : undefined);
246
+ outcome = finish(action === 'backend-ended' ? 'failed' : action === 'dismissed' || action === 'snoozed' || action === 'signal' ? 'dismissed' : 'played', action === 'backend-ended' ? backendMessage : action === 'snoozed' ? 'Snoozed.' : action === 'handoff' ? 'Handed off to interactive playback.' : action === 'signal' ? 'Interrupted.' : undefined);
225
247
  return { status: outcome.status, message: outcome.message };
226
248
  }
227
249
  catch (error) {
228
- outcome = finish('failed', errorMessage(error));
229
- return { status: 'failed', message: outcome.message };
250
+ outcome = finish(tuneAbort.signal.aborted && action !== 'backend-ended' ? 'dismissed' : 'failed', errorMessage(error));
251
+ return { status: outcome.status, message: outcome.message };
230
252
  }
231
253
  finally {
232
254
  listening = false;
255
+ unsubscribePlayer?.();
256
+ tuneAbort.abort();
233
257
  unsubscribeSignals?.();
234
258
  try {
235
259
  await deps.player.stop();
@@ -247,14 +271,15 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
247
271
  catch { }
248
272
  }
249
273
  if (historyStarted) {
274
+ const endedAt = backendEndedAt ?? deps.now();
250
275
  try {
251
- deps.store.checkpointActiveListeningSession(deps.now());
276
+ await deps.store.checkpointActiveListeningSession(endedAt);
252
277
  }
253
278
  catch (error) {
254
279
  recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);
255
280
  }
256
281
  try {
257
- deps.store.finishActiveListeningSession(deps.now());
282
+ await deps.store.finishActiveListeningSession(endedAt);
258
283
  }
259
284
  catch (error) {
260
285
  recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);
@@ -277,10 +302,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
277
302
  catch { }
278
303
  if (alarm && outcome) {
279
304
  try {
280
- deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: validTerminalOccurrence && !preserveNextOverride });
305
+ await deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: validTerminalOccurrence && !preserveNextOverride });
281
306
  const latest = deps.store.getAlarm(alarmId);
282
307
  if (alarm.schedule.type === 'once' && latest?.schedule.type === 'once' && latest.schedule.at === alarm.schedule.at && validTerminalOccurrence && !preserveNextOverride)
283
- deps.store.toggleAlarm(alarmId, false);
308
+ await deps.store.toggleAlarm(alarmId, false);
284
309
  }
285
310
  catch (error) {
286
311
  recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);
@@ -317,10 +342,18 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
317
342
  }
318
343
  }
319
344
  export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmRuntimeDirectory()) {
320
- const safe = `${alarmId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${Buffer.from(scheduledAt).toString('base64url')}`;
321
- const path = join(root, 'locks', safe);
345
+ const occurrence = Buffer.from(scheduledAt).toString('base64url');
346
+ // Keep existing claims for application-generated UUIDs. Other accepted IDs
347
+ // need a bounded, case/normalization-safe identity on every filesystem. The
348
+ // dot prefix cannot be produced by the old lossy sanitizer.
349
+ const id = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(alarmId)
350
+ ? alarmId : `.id-${createHash('sha256').update(JSON.stringify(alarmId)).digest('hex')}`;
351
+ const path = join(root, 'locks', `${id}-${occurrence}`);
352
+ const legacyPath = join(root, 'locks', `${alarmId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${occurrence}`);
322
353
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
323
354
  pruneCompletedOccurrenceLocks(root);
355
+ if (path !== legacyPath && hasLegacyOccurrenceClaim(legacyPath, alarmId))
356
+ return null;
324
357
  try {
325
358
  mkdirSync(path, { mode: 0o700 });
326
359
  }
@@ -340,12 +373,43 @@ export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmR
340
373
  return null;
341
374
  }
342
375
  }
343
- writeFileSync(join(path, 'running'), String(process.pid), { mode: 0o600 });
376
+ try {
377
+ writeFileSync(join(path, 'running'), String(process.pid), { mode: 0o600 });
378
+ writeFileSync(join(path, 'alarm-id'), JSON.stringify(alarmId), { mode: 0o600 });
379
+ }
380
+ catch (error) {
381
+ try {
382
+ rmSync(path, { recursive: true, force: true });
383
+ }
384
+ catch { }
385
+ throw error;
386
+ }
344
387
  return (completed = true) => { if (!completed) {
345
388
  rmSync(path, { recursive: true, force: true });
346
389
  return;
347
390
  } rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
348
391
  }
392
+ function hasLegacyOccurrenceClaim(path, alarmId) {
393
+ if (!existsSync(path))
394
+ return false;
395
+ try {
396
+ try {
397
+ if (JSON.parse(readFileSync(join(path, 'alarm-id'), 'utf8')) !== alarmId)
398
+ return false;
399
+ }
400
+ catch (error) {
401
+ if (error.code !== 'ENOENT')
402
+ throw error;
403
+ }
404
+ if (existsSync(join(path, 'completed')))
405
+ return true;
406
+ const pid = Number(readFileSync(join(path, 'running'), 'utf8'));
407
+ return !Number.isInteger(pid) || pid < 1 || processAlive(pid);
408
+ }
409
+ catch {
410
+ return existsSync(path); /* Unknown legacy ownership fails closed. */
411
+ }
412
+ }
349
413
  export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirectory(), olderThanMs = 30 * 24 * 60 * 60_000, now = Date.now()) { const directory = join(root, 'locks'); if (!existsSync(directory))
350
414
  return 0; let removed = 0; for (const name of readdirSync(directory)) {
351
415
  const completed = join(directory, name, 'completed');
@@ -364,8 +428,8 @@ function processAlive(pid) { try {
364
428
  process.kill(pid, 0);
365
429
  return true;
366
430
  }
367
- catch {
368
- return false;
431
+ catch (error) {
432
+ return error.code !== 'ESRCH';
369
433
  } }
370
434
  function nextExpectedOccurrence(alarm, scheduledAt) { if (alarm.nextOverride)
371
435
  return new Date(alarm.nextOverride.at); if (alarm.schedule.type === 'once')
@@ -1,5 +1,7 @@
1
1
  export const NATIVE_DISPATCH_TOLERANCE_MS = 30_000;
2
2
  const formatterCache = new Map();
3
+ const timezoneCache = new Map();
4
+ const civilOccurrenceCache = new Map();
3
5
  export function isValidTimeZone(timezone) {
4
6
  try {
5
7
  new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
@@ -11,13 +13,23 @@ export function isValidTimeZone(timezone) {
11
13
  }
12
14
  export function canonicalizeTimeZone(timezone) {
13
15
  const value = timezone.trim();
16
+ const cached = timezoneCache.get(value);
17
+ if (cached) {
18
+ timezoneCache.delete(value);
19
+ timezoneCache.set(value, cached);
20
+ return cached;
21
+ }
14
22
  try {
15
23
  if (!value)
16
24
  throw new Error('Empty timezone.');
17
25
  // The constructor validates the zone and resolves its canonical name.
18
26
  // Constructing twice made large-library validation expensive on Node 22
19
27
  // Intel hosts; keep one native validation for every supplied value.
20
- return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
28
+ const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
29
+ timezoneCache.set(value, canonical);
30
+ while (timezoneCache.size > 256)
31
+ timezoneCache.delete(timezoneCache.keys().next().value);
32
+ return canonical;
21
33
  }
22
34
  catch {
23
35
  throw new Error(`Invalid IANA timezone: ${timezone}`);
@@ -106,6 +118,8 @@ function zonedParts(instant, timezone) {
106
118
  hourCycle: 'h23'
107
119
  });
108
120
  formatterCache.set(timezone, formatter);
121
+ while (formatterCache.size > 128)
122
+ formatterCache.delete(formatterCache.keys().next().value);
109
123
  }
110
124
  const values = Object.fromEntries(formatter.formatToParts(instant)
111
125
  .filter(part => part.type !== 'literal')
@@ -119,6 +133,20 @@ function zonedParts(instant, timezone) {
119
133
  };
120
134
  }
121
135
  function resolveCivilOccurrence(target, timezone) {
136
+ const key = `${timezone}:${target.year}:${target.month}:${target.day}:${target.hour}:${target.minute}`;
137
+ if (civilOccurrenceCache.has(key)) {
138
+ const cached = civilOccurrenceCache.get(key);
139
+ civilOccurrenceCache.delete(key);
140
+ civilOccurrenceCache.set(key, cached);
141
+ return cached === null ? null : new Date(cached);
142
+ }
143
+ const result = scanCivilOccurrence(target, timezone);
144
+ civilOccurrenceCache.set(key, result?.getTime() ?? null);
145
+ while (civilOccurrenceCache.size > 2048)
146
+ civilOccurrenceCache.delete(civilOccurrenceCache.keys().next().value);
147
+ return result;
148
+ }
149
+ function scanCivilOccurrence(target, timezone) {
122
150
  const estimate = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute);
123
151
  const start = estimate - 18 * 60 * 60_000;
124
152
  const end = estimate + 18 * 60 * 60_000;
@@ -13,6 +13,7 @@ import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
13
13
  import { resolveCommandDetails } from '../platform/executables.js';
14
14
  import { launchEnvironment, nodeLaunchCommand } from '../platform/launch-command.js';
15
15
  import { powershellCommand } from '../platform/shell.js';
16
+ import { identifyOmarchy } from '../platform/omarchy.js';
16
17
  export class SchedulerService {
17
18
  adapter;
18
19
  now;
@@ -143,7 +144,14 @@ export function createSchedulerAdapter(deps = {}) {
143
144
  const resolve = (command) => resolveCommandDetails(command, { platform, env, home }).path;
144
145
  const run = deps.run ?? ((command, args) => runCommand(resolve(command) ?? command, args));
145
146
  const commandExists = deps.commandExists ?? (command => resolve(command) !== null);
146
- const common = { platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run };
147
+ const profile = policy.scheduler === 'systemd' ? identifyOmarchy({ ...deps.omarchy, platform, env, home }) : undefined;
148
+ const inheritDesktop = profile?.kind === 'quattro' && profile.source === 'package' && profile.packageName === 'omarchy';
149
+ const detectedTerminal = detectGraphicalTerminal(platform, env, undefined, deps.omarchy);
150
+ // A job registered over SSH may next run inside UWSM's live desktop. Do not
151
+ // turn the registration-time absence of a display into a permanent selector.
152
+ const configuredTerminal = env.RADIOCLI_ALARM_TERMINAL?.trim();
153
+ const terminal = inheritDesktop && detectedTerminal.endsWith(':unsupported') ? configuredTerminal || undefined : detectedTerminal;
154
+ const common = { platform, home, nodePath, cliPath, env, inheritDesktop, terminal, write, removeFile, run };
147
155
  if (policy.scheduler === 'launchd')
148
156
  return launchdAdapter(common);
149
157
  if (policy.scheduler === 'task-scheduler')
@@ -156,7 +164,7 @@ export function createSchedulerAdapter(deps = {}) {
156
164
  return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id === 'unknown' ? host.platform : host.id}.`);
157
165
  }
158
166
  function runtimeEnvironment(common) {
159
- return launchEnvironment(common.env, { platform: common.platform, includeDesktop: true, terminal: common.terminal });
167
+ return launchEnvironment(common.env, { platform: common.platform, includeDesktop: true, inheritDesktop: common.inheritDesktop, terminal: common.terminal });
160
168
  }
161
169
  const jobName = (id) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`;
162
170
  const invocationArgs = (common, id, occurrence, internalCommand = 'internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()];
@@ -229,7 +237,7 @@ function systemdAdapter(common) {
229
237
  capabilities,
230
238
  probeCapabilities,
231
239
  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'); },
240
+ throw new Error(readiness.message); const n = names(alarm.id); const env = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd = systemdExecCommand(nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common), { inheritDesktop: common.inheritDesktop })); common.write(join(base, n.service), `[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base, n.timer), `[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl', ['--user', 'enable', '--now', n.timer]), 'systemctl enable'); },
233
241
  async remove(id) {
234
242
  const n = names(id);
235
243
  await removeWithVerification(() => common.run('systemctl', ['--user', 'disable', '--now', n.timer]), () => common.run('systemctl', ['--user', 'show', n.timer, '--property=LoadState', '--property=ActiveState']), result => /^LoadState=not-found\r?$/m.test(result.stdout) && /^ActiveState=inactive\r?$/m.test(result.stdout), 'systemd timer');