@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.
- package/CHANGELOG.md +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- 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 +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- 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 +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- 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 +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- 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 +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- 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
|
@@ -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
|
+
}
|
package/dist/alarms/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/alarms/runner.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
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,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
|
-
|
|
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;
|
package/dist/alarms/scheduler.js
CHANGED
|
@@ -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
|
|
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');
|