@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.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/alarms/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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).
|
|
169
|
-
|
|
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:
|
|
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 (
|
|
273
|
+
if (historyStarted) {
|
|
274
|
+
const endedAt = backendEndedAt ?? deps.now();
|
|
230
275
|
try {
|
|
231
|
-
deps.store.checkpointActiveListeningSession(
|
|
232
|
-
|
|
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
|
-
|
|
279
|
-
await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
|
|
280
|
-
}
|
|
281
|
-
catch (error) {
|
|
332
|
+
if (completeLock)
|
|
282
333
|
try {
|
|
283
|
-
deps.
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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() {
|
|
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
|
|
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')
|
package/dist/alarms/schedule.js
CHANGED
|
@@ -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
|
-
|
|
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;
|