@ciphore/radiocli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,6 +1,7 @@
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
+ 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) {
@@ -25,20 +26,41 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
25
26
  let lease;
26
27
  let systemVolumeLease;
27
28
  let listening = false;
29
+ let historyStarted = false;
28
30
  let firedAt;
31
+ const runnerWarnings = new Set();
32
+ const recordRunnerWarning = (message) => { runnerWarnings.add(message); try {
33
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: listening, message: [...runnerWarnings].join(' ') });
34
+ }
35
+ catch { } };
29
36
  let preserveNextOverride = false;
30
37
  let preserveSystemVolume = false;
38
+ let handoffOutput;
31
39
  let validTerminalOccurrence = false;
40
+ let completeLock = true;
41
+ const tuneAbort = new AbortController();
32
42
  let signalReceived = false;
33
43
  let resolveEarlySignal = () => { };
34
44
  let onPlaybackSignal = () => { };
35
45
  const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
36
- 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); });
37
47
  const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
38
48
  let action;
39
49
  let keepPlaying = false;
40
50
  let resolveAction = () => { };
41
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
+ });
42
64
  try {
43
65
  if (!alarm || !alarm.enabled)
44
66
  return { message: 'Alarm is missing or disabled.' };
@@ -49,6 +71,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
49
71
  }
50
72
  const assessment = assessScheduledOccurrence(scheduledAt, deps.now(), alarm.reliability.missedRunGraceMinutes);
51
73
  if (assessment === 'pending') {
74
+ completeLock = false;
52
75
  outcome = finish('failed', 'Scheduler launched the alarm before its occurrence.');
53
76
  return { status: outcome.status, message: outcome.message };
54
77
  }
@@ -59,13 +82,13 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
59
82
  }
60
83
  const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
61
84
  const creatingSession = deps.createSession(claimingStatus, {
62
- onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
63
- 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(); },
64
87
  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'); }
88
+ onHandoff: async () => { if (!listening)
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'); }
67
90
  });
68
- void creatingSession.then(created => { if (signalReceived)
91
+ void creatingSession.then(created => { if (signalReceived || action)
69
92
  void created.close().catch(() => undefined); }).catch(() => { });
70
93
  const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
71
94
  if ('signal' in created) {
@@ -87,7 +110,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
87
110
  const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
88
111
  for (const [candidateIndex, candidate] of candidates.entries()) {
89
112
  try {
90
- 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 }))]);
91
114
  if ('signal' in resolved) {
92
115
  outcome = finish('dismissed', 'Alarm interrupted before playback started.');
93
116
  return { status: 'dismissed', message: outcome.message };
@@ -101,6 +124,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
101
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)}` });
102
125
  }
103
126
  }
127
+ tuneAbort.signal.throwIfAborted();
104
128
  if (!outputPrepared) {
105
129
  outputPrepared = true;
106
130
  if (deps.systemVolume)
@@ -112,7 +136,8 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
112
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)}` });
113
137
  }
114
138
  }
115
- 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 });
116
141
  const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
117
142
  if ('signal' in tuned || signalReceived) {
118
143
  void playPromise.then(() => deps.player.stop()).catch(() => undefined);
@@ -139,15 +164,19 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
139
164
  }
140
165
  if (lastError)
141
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.');
142
171
  firedAt = deps.now();
143
172
  try {
144
173
  const acquiring = deps.inhibitor.acquire('RadioCLI alarm playback');
145
- void acquiring.then(acquired => { if (signalReceived)
174
+ void acquiring.then(acquired => { if (signalReceived || action)
146
175
  void acquired.release().catch(() => undefined); }).catch(() => { });
147
176
  const acquired = await Promise.race([acquiring.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
148
177
  if ('signal' in acquired) {
149
- outcome = finish('dismissed', 'Alarm interrupted while sleep protection was starting.');
150
- 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 };
151
180
  }
152
181
  lease = acquired.value;
153
182
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: true, active: true, message: 'Sleep inhibition active while playing.' });
@@ -159,16 +188,27 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
159
188
  catch (error) {
160
189
  deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues without sleep protection: ${errorMessage(error)}` });
161
190
  }
191
+ if (backendEndedAt)
192
+ throw new Error(backendMessage ?? 'Alarm stream ended while sleep protection was starting.');
162
193
  const startedAt = deps.now();
163
- deps.store.addRecent(resolvedStation);
164
- deps.store.startListeningSession(resolvedStation, startedAt);
165
194
  listening = true;
195
+ try {
196
+ await deps.store.addRecent(resolvedStation);
197
+ }
198
+ catch (error) {
199
+ recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);
200
+ }
201
+ try {
202
+ await deps.store.startListeningSession(resolvedStation, startedAt);
203
+ historyStarted = true;
204
+ }
205
+ catch (error) {
206
+ recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);
207
+ }
166
208
  const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
167
209
  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 { } });
210
+ void deps.openControls?.(activeStatus).then(result => { if (result && !result.opened && result.terminal !== 'existing-tui')
211
+ recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`); }).catch(error => { recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`); });
172
212
  onPlaybackSignal = () => { action = 'signal'; resolveAction('signal'); };
173
213
  if (signalReceived)
174
214
  onPlaybackSignal();
@@ -203,19 +243,23 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
203
243
  action = await Promise.race([actionPromise, timeout]);
204
244
  }
205
245
  }
206
- 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);
207
247
  return { status: outcome.status, message: outcome.message };
208
248
  }
209
249
  catch (error) {
210
- outcome = finish('failed', errorMessage(error));
211
- 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 };
212
252
  }
213
253
  finally {
254
+ listening = false;
255
+ unsubscribePlayer?.();
256
+ tuneAbort.abort();
214
257
  unsubscribeSignals?.();
215
258
  try {
216
259
  await deps.player.stop();
217
260
  }
218
261
  catch { }
262
+ await handoffOutput?.catch(() => undefined);
219
263
  if (!preserveSystemVolume)
220
264
  try {
221
265
  await systemVolumeLease?.release();
@@ -226,12 +270,20 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
226
270
  }
227
271
  catch { }
228
272
  }
229
- if (listening) {
273
+ if (historyStarted) {
274
+ const endedAt = backendEndedAt ?? deps.now();
230
275
  try {
231
- deps.store.checkpointActiveListeningSession(deps.now());
232
- deps.store.finishActiveListeningSession(deps.now());
276
+ await deps.store.checkpointActiveListeningSession(endedAt);
277
+ }
278
+ catch (error) {
279
+ recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);
280
+ }
281
+ try {
282
+ await deps.store.finishActiveListeningSession(endedAt);
283
+ }
284
+ catch (error) {
285
+ recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);
233
286
  }
234
- catch { }
235
287
  }
236
288
  try {
237
289
  await lease?.release();
@@ -250,12 +302,14 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
250
302
  catch { }
251
303
  if (alarm && outcome) {
252
304
  try {
253
- deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: !preserveNextOverride });
305
+ await deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: validTerminalOccurrence && !preserveNextOverride });
254
306
  const latest = deps.store.getAlarm(alarmId);
255
307
  if (alarm.schedule.type === 'once' && latest?.schedule.type === 'once' && latest.schedule.at === alarm.schedule.at && validTerminalOccurrence && !preserveNextOverride)
256
- deps.store.toggleAlarm(alarmId, false);
308
+ await deps.store.toggleAlarm(alarmId, false);
309
+ }
310
+ catch (error) {
311
+ recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);
257
312
  }
258
- catch { }
259
313
  }
260
314
  alarm = deps.store.getAlarm(alarmId);
261
315
  if (alarm)
@@ -267,7 +321,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
267
321
  }
268
322
  catch { }
269
323
  try {
270
- releaseLock();
324
+ releaseLock(completeLock);
271
325
  }
272
326
  catch (error) {
273
327
  try {
@@ -275,22 +329,31 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
275
329
  }
276
330
  catch { }
277
331
  }
278
- try {
279
- await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
280
- }
281
- catch (error) {
332
+ if (completeLock)
282
333
  try {
283
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
334
+ await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
335
+ }
336
+ catch (error) {
337
+ try {
338
+ deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
339
+ }
340
+ catch { }
284
341
  }
285
- catch { }
286
- }
287
342
  }
288
343
  }
289
344
  export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmRuntimeDirectory()) {
290
- const safe = `${alarmId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${Buffer.from(scheduledAt).toString('base64url')}`;
291
- 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}`);
292
353
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
293
354
  pruneCompletedOccurrenceLocks(root);
355
+ if (path !== legacyPath && hasLegacyOccurrenceClaim(legacyPath, alarmId))
356
+ return null;
294
357
  try {
295
358
  mkdirSync(path, { mode: 0o700 });
296
359
  }
@@ -310,8 +373,42 @@ export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmR
310
373
  return null;
311
374
  }
312
375
  }
313
- 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 }); };
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
+ }
387
+ return (completed = true) => { if (!completed) {
388
+ rmSync(path, { recursive: true, force: true });
389
+ return;
390
+ } rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
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
+ }
315
412
  }
316
413
  export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirectory(), olderThanMs = 30 * 24 * 60 * 60_000, now = Date.now()) { const directory = join(root, 'locks'); if (!existsSync(directory))
317
414
  return 0; let removed = 0; for (const name of readdirSync(directory)) {
@@ -324,17 +421,15 @@ export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirector
324
421
  }
325
422
  catch { }
326
423
  } 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'); }
424
+ export function defaultAlarmRuntimeDirectory() { return platformPaths().runtime; }
330
425
  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
426
  function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
332
427
  function processAlive(pid) { try {
333
428
  process.kill(pid, 0);
334
429
  return true;
335
430
  }
336
- catch {
337
- return false;
431
+ catch (error) {
432
+ return error.code !== 'ESRCH';
338
433
  } }
339
434
  function nextExpectedOccurrence(alarm, scheduledAt) { if (alarm.nextOverride)
340
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,10 +13,27 @@ export function isValidTimeZone(timezone) {
11
13
  }
12
14
  export function canonicalizeTimeZone(timezone) {
13
15
  const value = timezone.trim();
14
- if (!isValidTimeZone(value)) {
16
+ const cached = timezoneCache.get(value);
17
+ if (cached) {
18
+ timezoneCache.delete(value);
19
+ timezoneCache.set(value, cached);
20
+ return cached;
21
+ }
22
+ try {
23
+ if (!value)
24
+ throw new Error('Empty timezone.');
25
+ // The constructor validates the zone and resolves its canonical name.
26
+ // Constructing twice made large-library validation expensive on Node 22
27
+ // Intel hosts; keep one native validation for every supplied value.
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;
33
+ }
34
+ catch {
15
35
  throw new Error(`Invalid IANA timezone: ${timezone}`);
16
36
  }
17
- return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
18
37
  }
19
38
  export function canonicalizeAlarmTime(time) {
20
39
  const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
@@ -99,6 +118,8 @@ function zonedParts(instant, timezone) {
99
118
  hourCycle: 'h23'
100
119
  });
101
120
  formatterCache.set(timezone, formatter);
121
+ while (formatterCache.size > 128)
122
+ formatterCache.delete(formatterCache.keys().next().value);
102
123
  }
103
124
  const values = Object.fromEntries(formatter.formatToParts(instant)
104
125
  .filter(part => part.type !== 'literal')
@@ -112,6 +133,20 @@ function zonedParts(instant, timezone) {
112
133
  };
113
134
  }
114
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) {
115
150
  const estimate = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute);
116
151
  const start = estimate - 18 * 60 * 60_000;
117
152
  const end = estimate + 18 * 60 * 60_000;