@ciphore/radiocli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -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 +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- 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 +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -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 +80 -56
- 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/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
package/dist/alarms/guard.js
CHANGED
|
@@ -2,11 +2,13 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createServer, request } from 'node:http';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { AlarmPowerGuardStore } from './power-guard-store.js';
|
|
8
8
|
import { nextOccurrenceForAlarm } from './schedule.js';
|
|
9
9
|
import { defaultAlarmRuntimeDirectory } from './runner.js';
|
|
10
|
+
import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
|
|
11
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
10
12
|
export class AlarmGuardService {
|
|
11
13
|
store;
|
|
12
14
|
nodePath;
|
|
@@ -41,6 +43,8 @@ export class AlarmGuardService {
|
|
|
41
43
|
// A recurring alarm may have been edited/rescheduled while its old guard is
|
|
42
44
|
// alive. Stop that exact process tree before replacing its occurrence state.
|
|
43
45
|
await this.stopOwned(alarm.id);
|
|
46
|
+
if (this.hasOwnershipRecords(alarm.id))
|
|
47
|
+
throw new Error('Previous Alarm Guard ownership is unresolved; retain the alarm for repair before starting another guard.');
|
|
44
48
|
this.store.request(alarm.id, occurrenceAt);
|
|
45
49
|
const token = randomBytes(32).toString('hex');
|
|
46
50
|
const child = this.spawn(this.nodePath, [this.cliPath, 'alarm', 'internal-guard-run', alarm.id, occurrenceAt, path, token], process.env);
|
|
@@ -73,19 +77,19 @@ export class AlarmGuardService {
|
|
|
73
77
|
return this.withOwnership(alarmId, () => this.stopOwned(alarmId));
|
|
74
78
|
const ids = [...new Set(this.pidFiles().map(readPid).filter((item) => Boolean(item)).map(item => item.alarmId))];
|
|
75
79
|
const results = await Promise.all(ids.map(id => this.withOwnership(id, () => this.stopOwned(id))));
|
|
76
|
-
return results.every(Boolean);
|
|
80
|
+
return results.every(Boolean) && !this.hasOwnershipRecords();
|
|
77
81
|
}
|
|
78
82
|
async stopOwned(alarmId) {
|
|
79
83
|
const targets = this.pidFiles()
|
|
80
|
-
.map(readPid)
|
|
81
|
-
.filter((item) => Boolean(item))
|
|
82
|
-
.filter(item => !alarmId || item.alarmId === alarmId);
|
|
84
|
+
.map(path => ({ path, owner: readPid(path) }))
|
|
85
|
+
.filter((item) => Boolean(item.owner))
|
|
86
|
+
.filter(item => !alarmId || item.owner.alarmId === alarmId);
|
|
83
87
|
if (!targets.length)
|
|
84
88
|
return false;
|
|
85
|
-
for (const current of targets) {
|
|
86
|
-
const path = this.path(current.alarmId, current.occurrenceAt);
|
|
89
|
+
for (const { path, owner: current } of targets) {
|
|
87
90
|
if (!(await this.verify(current))) {
|
|
88
|
-
|
|
91
|
+
if (!isAlive(current.pid))
|
|
92
|
+
rmSync(path, { force: true });
|
|
89
93
|
markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.');
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
@@ -108,17 +112,28 @@ export class AlarmGuardService {
|
|
|
108
112
|
this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt);
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
|
-
return
|
|
115
|
+
return !this.hasOwnershipRecords(alarmId);
|
|
112
116
|
}
|
|
113
117
|
async status() {
|
|
114
118
|
const guards = [];
|
|
119
|
+
const unresolvedGuards = [];
|
|
115
120
|
for (const path of this.pidFiles()) {
|
|
116
121
|
const pid = readPid(path);
|
|
117
|
-
if (!pid)
|
|
122
|
+
if (!pid) {
|
|
123
|
+
const alarmId = alarmIdFromPath(path);
|
|
124
|
+
unresolvedGuards.push({ ...(alarmId ? { alarmId } : {}), message: 'Guard ownership metadata cannot be read; its record was retained for repair.' });
|
|
118
125
|
continue;
|
|
126
|
+
}
|
|
119
127
|
if (!(await this.verify(pid))) {
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
if (isAlive(pid.pid)) {
|
|
129
|
+
const message = 'Guard ownership cannot be verified; its record was retained and the PID was not signaled.';
|
|
130
|
+
unresolvedGuards.push({ alarmId: pid.alarmId, occurrenceAt: pid.occurrenceAt, pid: pid.pid, message });
|
|
131
|
+
markIfCurrent(this.store, pid, message);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
rmSync(path, { force: true });
|
|
135
|
+
markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.');
|
|
136
|
+
}
|
|
122
137
|
continue;
|
|
123
138
|
}
|
|
124
139
|
guards.push({ active: true, alarmId: pid.alarmId, occurrenceAt: pid.occurrenceAt, pid: pid.pid });
|
|
@@ -126,7 +141,10 @@ export class AlarmGuardService {
|
|
|
126
141
|
return {
|
|
127
142
|
active: guards.length > 0,
|
|
128
143
|
guards,
|
|
129
|
-
|
|
144
|
+
unresolvedGuards,
|
|
145
|
+
message: unresolvedGuards.length
|
|
146
|
+
? `${guards.length} alarm guards verified active; ${unresolvedGuards.length} ownership records require repair.`
|
|
147
|
+
: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.'
|
|
130
148
|
};
|
|
131
149
|
}
|
|
132
150
|
path(alarmId, occurrenceAt) {
|
|
@@ -137,6 +155,13 @@ export class AlarmGuardService {
|
|
|
137
155
|
return [];
|
|
138
156
|
return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name));
|
|
139
157
|
}
|
|
158
|
+
hasOwnershipRecords(alarmId) {
|
|
159
|
+
return this.pidFiles().some(path => {
|
|
160
|
+
const ownerId = readPid(path)?.alarmId ?? alarmIdFromPath(path);
|
|
161
|
+
// Unknown records require global repair, but cannot identify this alarm.
|
|
162
|
+
return !alarmId || ownerId === alarmId;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
140
165
|
async withOwnership(alarmId, work) { const lock = join(this.directory, `.lock-${Buffer.from(alarmId).toString('base64url')}`); mkdirSync(this.directory, { recursive: true, mode: 0o700 }); const deadline = Date.now() + 3000; while (true) {
|
|
141
166
|
try {
|
|
142
167
|
mkdirSync(lock, { mode: 0o700 });
|
|
@@ -240,7 +265,7 @@ function readPid(path) {
|
|
|
240
265
|
return undefined;
|
|
241
266
|
try {
|
|
242
267
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
243
|
-
if (!Number.isInteger(value.pid) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !value.alarmId || !/^[a-f0-9]{64}$/.test(value.token) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt)))
|
|
268
|
+
if (!Number.isInteger(value.pid) || value.pid < 1 || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || typeof value.alarmId !== 'string' || !value.alarmId || (value.host !== undefined && !isLoopbackHost(value.host)) || !/^[a-f0-9]{64}$/.test(value.token) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt)))
|
|
244
269
|
throw new Error();
|
|
245
270
|
return value;
|
|
246
271
|
}
|
|
@@ -248,12 +273,20 @@ function readPid(path) {
|
|
|
248
273
|
return undefined;
|
|
249
274
|
}
|
|
250
275
|
}
|
|
276
|
+
function alarmIdFromPath(path) {
|
|
277
|
+
const name = basename(path, '.json');
|
|
278
|
+
const decoded = Buffer.from(name, 'base64url').toString();
|
|
279
|
+
if (Buffer.from(decoded).toString('base64url') !== name)
|
|
280
|
+
return undefined;
|
|
281
|
+
const separator = decoded.indexOf('\0');
|
|
282
|
+
return separator > 0 ? decoded.slice(0, separator) : undefined;
|
|
283
|
+
}
|
|
251
284
|
function writePrivate(path, value) {
|
|
252
285
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
253
286
|
const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
254
287
|
writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
|
|
255
288
|
renameSync(temp, path);
|
|
256
|
-
if (
|
|
289
|
+
if (nativeAdapters().posixPermissions)
|
|
257
290
|
chmodSync(path, 0o600);
|
|
258
291
|
}
|
|
259
292
|
function isAlive(pid) {
|
|
@@ -261,32 +294,41 @@ function isAlive(pid) {
|
|
|
261
294
|
process.kill(pid, 0);
|
|
262
295
|
return true;
|
|
263
296
|
}
|
|
264
|
-
catch {
|
|
265
|
-
return
|
|
297
|
+
catch (error) {
|
|
298
|
+
return error.code !== 'ESRCH';
|
|
266
299
|
}
|
|
267
300
|
}
|
|
268
301
|
function ownsArtifact(path, token) {
|
|
269
302
|
return readPid(path)?.token === token;
|
|
270
303
|
}
|
|
271
|
-
async function startGuardControl(path, token, alarmId, occurrenceAt, onStop) {
|
|
272
|
-
res.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
async function startGuardControl(path, token, alarmId, occurrenceAt, onStop) {
|
|
305
|
+
const server = createServer((req, res) => { if (req.headers.authorization !== `Bearer ${token}`) {
|
|
306
|
+
res.statusCode = 401;
|
|
307
|
+
res.end('{}');
|
|
308
|
+
return;
|
|
309
|
+
} if (req.method === 'POST' && req.url === '/stop') {
|
|
310
|
+
onStop();
|
|
311
|
+
res.end('{}');
|
|
312
|
+
return;
|
|
313
|
+
} if (req.method !== 'GET' || req.url !== '/challenge') {
|
|
314
|
+
res.statusCode = 404;
|
|
315
|
+
res.end('{}');
|
|
316
|
+
return;
|
|
317
|
+
} res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ alarmId, occurrenceAt, pid: process.pid })); });
|
|
318
|
+
const close = () => new Promise(resolve => server.close(() => resolve()));
|
|
319
|
+
try {
|
|
320
|
+
const { host, port } = await listenLoopback(server);
|
|
321
|
+
writePrivate(path, { alarmId, occurrenceAt, pid: process.pid, host, port, token });
|
|
322
|
+
return { close };
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
await close();
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
287
329
|
function challengeGuard(guard) { return callGuard(guard, 'GET', '/challenge', true); }
|
|
288
330
|
function requestGuardStop(guard) { return callGuard(guard, 'POST', '/stop', false); }
|
|
289
|
-
function callGuard(guard, method, path, validateIdentity) { return new Promise(resolve => { const req = request({ host: '127.0.0.1', port: guard.port, path, method, headers: { authorization: `Bearer ${guard.token}` } }, res => { let body = ''; res.on('data', value => body += String(value)); res.on('end', () => { if (!validateIdentity) {
|
|
331
|
+
function callGuard(guard, method, path, validateIdentity) { return new Promise(resolve => { const req = request({ host: guard.host ?? '127.0.0.1', port: guard.port, path, method, agent: false, headers: { authorization: `Bearer ${guard.token}` } }, res => { let body = ''; res.on('data', value => body += String(value)); res.on('end', () => { if (!validateIdentity) {
|
|
290
332
|
resolve(res.statusCode === 200);
|
|
291
333
|
return;
|
|
292
334
|
} try {
|
|
@@ -297,9 +339,10 @@ catch {
|
|
|
297
339
|
resolve(false);
|
|
298
340
|
} }); }); req.once('error', () => resolve(false)); req.setTimeout(500, () => req.destroy()); req.end(); }); }
|
|
299
341
|
export async function terminateGuardTree(pid, ops = { platform: process.platform, isAlive, kill: (target, signal) => process.kill(target, signal), taskkill: taskkillTree, wait: delay }) {
|
|
342
|
+
const windows = nativeAdapters(identifyPlatform({ platform: ops.platform })).ipc === 'named-pipe';
|
|
300
343
|
// Kill the entire detached tree first on Windows so a dying parent cannot
|
|
301
344
|
// orphan its PowerShell execution-state helper.
|
|
302
|
-
if (
|
|
345
|
+
if (windows) {
|
|
303
346
|
await ops.taskkill(pid, false);
|
|
304
347
|
}
|
|
305
348
|
else {
|
|
@@ -314,7 +357,7 @@ export async function terminateGuardTree(pid, ops = { platform: process.platform
|
|
|
314
357
|
await ops.wait(50);
|
|
315
358
|
if (!ops.isAlive(pid))
|
|
316
359
|
return;
|
|
317
|
-
if (
|
|
360
|
+
if (windows)
|
|
318
361
|
await ops.taskkill(pid, true);
|
|
319
362
|
else {
|
|
320
363
|
// The guard is a detached process-group leader; force-killing the negative
|
package/dist/alarms/inhibitor.js
CHANGED
|
@@ -1,34 +1,38 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
3
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
4
4
|
// The script contains no interpolated data. It keeps one process-level Windows
|
|
5
5
|
// execution-state request alive until the parent terminates it.
|
|
6
6
|
const windowsInhibitorScript = (parentPid) => `$s='[DllImport("kernel32.dll")]public static extern uint SetThreadExecutionState(uint e);';Add-Type -MemberDefinition $s -Name P -Namespace R;[R.P]::SetThreadExecutionState(0x80000001)|Out-Null;try{while(Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue){Start-Sleep -Seconds 2}}finally{[R.P]::SetThreadExecutionState(0x80000000)|Out-Null}`;
|
|
7
7
|
export function createPowerInhibitor(deps = {}) {
|
|
8
8
|
const platform = deps.platform ?? process.platform;
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
9
|
+
const env = deps.env ?? process.env;
|
|
10
|
+
const host = identifyPlatform({ platform, env });
|
|
11
|
+
const adapter = nativeAdapters(host).inhibitor;
|
|
12
|
+
const spawn = deps.spawn ?? ((command, args) => spawnDetached(command, args, env));
|
|
13
|
+
const spec = adapter === 'caffeinate'
|
|
12
14
|
? { command: 'caffeinate', args: ['-i', '-w', String(process.pid)], message: 'Prevents idle system sleep; the display may sleep.' }
|
|
13
|
-
:
|
|
15
|
+
: adapter === 'logind'
|
|
14
16
|
? { command: 'systemd-inhibit', args: ['--what=sleep', '--who=RadioCLI', '--why=Scheduled radio', '--mode=block', 'sh', '-c', 'while kill -0 "$1" 2>/dev/null; do sleep 2; done', 'radiocli-inhibitor', String(process.pid)], message: 'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.' }
|
|
15
|
-
:
|
|
17
|
+
: adapter === 'windows'
|
|
16
18
|
? { command: 'powershell.exe', args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsInhibitorScript(process.pid)], message: 'Prevents idle system sleep; explicit sleep and lid policy may override it.' }
|
|
17
19
|
: null;
|
|
18
20
|
let active = false;
|
|
19
|
-
const
|
|
21
|
+
const command = spec ? (deps.commandExists ? (deps.commandExists(spec.command) ? spec.command : null) : resolveCommandDetails(spec.command, { platform, env }).path) : null;
|
|
22
|
+
const supported = Boolean(command);
|
|
23
|
+
const message = !spec ? `Power inhibition is unsupported on ${host.id === 'unknown' ? platform : host.id}.` : !command ? `${spec.command} is unavailable; sleep protection cannot be acquired.` : spec.message;
|
|
20
24
|
return {
|
|
21
|
-
status: () => ({ supported, active, message
|
|
25
|
+
status: () => ({ supported, active, message }),
|
|
22
26
|
async acquire(_reason) {
|
|
23
|
-
if (!spec || !
|
|
24
|
-
throw new Error(
|
|
25
|
-
const child = spawn(
|
|
26
|
-
const exitedEarly = await
|
|
27
|
+
if (!spec || !command)
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
const child = spawn(command, spec.args);
|
|
30
|
+
const exitedEarly = await exitedWithin(child.exited, 50);
|
|
27
31
|
if (exitedEarly)
|
|
28
32
|
throw new Error(`${spec.command} exited before sleep inhibition became active.`);
|
|
29
33
|
active = true;
|
|
30
34
|
let released = false;
|
|
31
|
-
const unexpectedExit = child.exited.then(() => released ? new Promise(() => { }) : new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`));
|
|
35
|
+
const unexpectedExit = child.exited.then(() => { active = false; return released ? new Promise(() => { }) : new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`); });
|
|
32
36
|
return { unexpectedExit, async release() { if (released)
|
|
33
37
|
return; released = true; active = false; child.kill('SIGTERM'); if (await exitedWithin(child.exited, 1000))
|
|
34
38
|
return; child.kill('SIGKILL'); if (!(await exitedWithin(child.exited, 500)))
|
|
@@ -36,13 +40,14 @@ export function createPowerInhibitor(deps = {}) {
|
|
|
36
40
|
}
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
return true;
|
|
42
|
-
return Boolean(process.env.PATH?.split(delimiter).some(path => existsSync(join(path, command)) || (process.platform === 'win32' && existsSync(join(path, `${command}.exe`)))));
|
|
43
|
-
}
|
|
44
|
-
function spawnDetached(command, args) {
|
|
45
|
-
const child = nodeSpawn(command, args, { stdio: 'ignore', windowsHide: true });
|
|
43
|
+
function spawnDetached(command, args, env) {
|
|
44
|
+
const child = nodeSpawn(command, args, { env, stdio: 'ignore', windowsHide: true });
|
|
46
45
|
return { pid: child.pid, kill: signal => child.kill(signal), exited: new Promise(resolve => { child.once('exit', resolve); child.once('error', resolve); }) };
|
|
47
46
|
}
|
|
48
|
-
async function exitedWithin(exited, milliseconds) {
|
|
47
|
+
async function exitedWithin(exited, milliseconds) { let timer; try {
|
|
48
|
+
return await Promise.race([exited.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), milliseconds); })]);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
if (timer)
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
} }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { platformPaths } from '../platform/paths.js';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
const instantSchema = z.string()
|
|
@@ -123,15 +123,7 @@ export class AlarmPowerGuardStore {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
function defaultAlarmPowerGuardPath() {
|
|
126
|
-
|
|
127
|
-
return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json');
|
|
128
|
-
if (process.platform === 'darwin') {
|
|
129
|
-
return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json');
|
|
130
|
-
}
|
|
131
|
-
if (process.platform === 'win32') {
|
|
132
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json');
|
|
133
|
-
}
|
|
134
|
-
return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json');
|
|
126
|
+
return join(platformPaths().alarmRuntime, 'alarm-power-guards.json');
|
|
135
127
|
}
|
|
136
128
|
function validDate(date, label) {
|
|
137
129
|
if (!Number.isFinite(date.getTime()))
|
package/dist/alarms/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
4
|
import { assessScheduledOccurrence, NATIVE_DISPATCH_TOLERANCE_MS, nextOccurrenceForAlarm } from './schedule.js';
|
|
5
5
|
import { startActiveAlarmSession } from './active-session.js';
|
|
@@ -25,16 +25,28 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
25
25
|
let lease;
|
|
26
26
|
let systemVolumeLease;
|
|
27
27
|
let listening = false;
|
|
28
|
+
let historyStarted = false;
|
|
28
29
|
let firedAt;
|
|
30
|
+
const runnerWarnings = new Set();
|
|
31
|
+
const recordRunnerWarning = (message) => { runnerWarnings.add(message); try {
|
|
32
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: listening, message: [...runnerWarnings].join(' ') });
|
|
33
|
+
}
|
|
34
|
+
catch { } };
|
|
29
35
|
let preserveNextOverride = false;
|
|
30
36
|
let preserveSystemVolume = false;
|
|
37
|
+
let handoffOutput;
|
|
31
38
|
let validTerminalOccurrence = false;
|
|
39
|
+
let completeLock = true;
|
|
32
40
|
let signalReceived = false;
|
|
33
41
|
let resolveEarlySignal = () => { };
|
|
34
42
|
let onPlaybackSignal = () => { };
|
|
35
43
|
const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
|
|
36
44
|
const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
|
|
37
45
|
const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
|
|
46
|
+
let action;
|
|
47
|
+
let keepPlaying = false;
|
|
48
|
+
let resolveAction = () => { };
|
|
49
|
+
const actionPromise = new Promise(resolve => { resolveAction = resolve; });
|
|
38
50
|
try {
|
|
39
51
|
if (!alarm || !alarm.enabled)
|
|
40
52
|
return { message: 'Alarm is missing or disabled.' };
|
|
@@ -45,6 +57,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
45
57
|
}
|
|
46
58
|
const assessment = assessScheduledOccurrence(scheduledAt, deps.now(), alarm.reliability.missedRunGraceMinutes);
|
|
47
59
|
if (assessment === 'pending') {
|
|
60
|
+
completeLock = false;
|
|
48
61
|
outcome = finish('failed', 'Scheduler launched the alarm before its occurrence.');
|
|
49
62
|
return { status: outcome.status, message: outcome.message };
|
|
50
63
|
}
|
|
@@ -53,6 +66,22 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
53
66
|
outcome = finish('missed', 'Alarm was outside its missed-run grace window.');
|
|
54
67
|
return { status: outcome.status, message: outcome.message };
|
|
55
68
|
}
|
|
69
|
+
const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
|
|
70
|
+
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(); },
|
|
73
|
+
onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
|
|
74
|
+
onHandoff: async () => { if (!listening)
|
|
75
|
+
throw new Error('Alarm playback is still starting.'); handoffOutput = (systemVolumeLease?.release({ preserve: true }) ?? Promise.resolve()).then(() => { preserveSystemVolume = true; }); await handoffOutput; action = 'handoff'; resolveAction('handoff'); }
|
|
76
|
+
});
|
|
77
|
+
void creatingSession.then(created => { if (signalReceived)
|
|
78
|
+
void created.close().catch(() => undefined); }).catch(() => { });
|
|
79
|
+
const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
80
|
+
if ('signal' in created) {
|
|
81
|
+
outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
|
|
82
|
+
return { status: 'dismissed', message: outcome.message };
|
|
83
|
+
}
|
|
84
|
+
session = created.value;
|
|
56
85
|
const claimed = deps.store.getAlarm(alarmId);
|
|
57
86
|
if (claimed?.enabled && claimed.schedule.type === 'recurring') {
|
|
58
87
|
if (deps.scheduler.syncClaimed)
|
|
@@ -60,16 +89,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
60
89
|
else
|
|
61
90
|
await deps.scheduler.sync(claimed);
|
|
62
91
|
}
|
|
63
|
-
if (deps.systemVolume)
|
|
64
|
-
try {
|
|
65
|
-
systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
|
|
66
|
-
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
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)}` });
|
|
70
|
-
}
|
|
71
92
|
let resolvedStation = alarm.station;
|
|
72
93
|
let lastError;
|
|
94
|
+
let interactivePreempted = false;
|
|
95
|
+
let outputPrepared = false;
|
|
73
96
|
const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
|
|
74
97
|
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
75
98
|
try {
|
|
@@ -78,6 +101,26 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
78
101
|
outcome = finish('dismissed', 'Alarm interrupted before playback started.');
|
|
79
102
|
return { status: 'dismissed', message: outcome.message };
|
|
80
103
|
}
|
|
104
|
+
if (!interactivePreempted) {
|
|
105
|
+
interactivePreempted = true;
|
|
106
|
+
try {
|
|
107
|
+
await deps.preemptInteractivePlayback?.();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!outputPrepared) {
|
|
114
|
+
outputPrepared = true;
|
|
115
|
+
if (deps.systemVolume)
|
|
116
|
+
try {
|
|
117
|
+
systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
|
|
118
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
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
|
+
}
|
|
123
|
+
}
|
|
81
124
|
const playPromise = deps.player.play(candidate, resolved.stream.url);
|
|
82
125
|
const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
|
|
83
126
|
if ('signal' in tuned || signalReceived) {
|
|
@@ -126,32 +169,24 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
126
169
|
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues without sleep protection: ${errorMessage(error)}` });
|
|
127
170
|
}
|
|
128
171
|
const startedAt = deps.now();
|
|
129
|
-
deps.store.addRecent(resolvedStation);
|
|
130
|
-
deps.store.startListeningSession(resolvedStation, startedAt);
|
|
131
172
|
listening = true;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
let resolveAction = () => { };
|
|
135
|
-
const actionPromise = new Promise(resolve => { resolveAction = resolve; });
|
|
136
|
-
const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
|
|
137
|
-
const creatingSession = deps.createSession(activeStatus, {
|
|
138
|
-
onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); },
|
|
139
|
-
onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); },
|
|
140
|
-
onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
|
|
141
|
-
onHandoff: () => { preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
|
|
142
|
-
});
|
|
143
|
-
void creatingSession.then(created => { if (signalReceived)
|
|
144
|
-
void created.close().catch(() => undefined); }).catch(() => { });
|
|
145
|
-
const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
146
|
-
if ('signal' in created) {
|
|
147
|
-
outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
|
|
148
|
-
return { status: 'dismissed', message: outcome.message };
|
|
173
|
+
try {
|
|
174
|
+
deps.store.addRecent(resolvedStation);
|
|
149
175
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
176
|
+
catch (error) {
|
|
177
|
+
recordRunnerWarning(`Recent listening history could not be saved: ${errorMessage(error)}`);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
deps.store.startListeningSession(resolvedStation, startedAt);
|
|
181
|
+
historyStarted = true;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
recordRunnerWarning(`Listening history could not be started: ${errorMessage(error)}`);
|
|
153
185
|
}
|
|
154
|
-
|
|
186
|
+
const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
|
|
187
|
+
session.update(activeStatus);
|
|
188
|
+
void deps.openControls?.(activeStatus).then(result => { if (result && !result.opened && result.terminal !== 'existing-tui')
|
|
189
|
+
recordRunnerWarning(`RadioCLI controls are unavailable or unverified: ${result.message}`); }).catch(error => { recordRunnerWarning(`RadioCLI controls could not open automatically: ${errorMessage(error)}`); });
|
|
155
190
|
onPlaybackSignal = () => { action = 'signal'; resolveAction('signal'); };
|
|
156
191
|
if (signalReceived)
|
|
157
192
|
onPlaybackSignal();
|
|
@@ -194,11 +229,13 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
194
229
|
return { status: 'failed', message: outcome.message };
|
|
195
230
|
}
|
|
196
231
|
finally {
|
|
232
|
+
listening = false;
|
|
197
233
|
unsubscribeSignals?.();
|
|
198
234
|
try {
|
|
199
235
|
await deps.player.stop();
|
|
200
236
|
}
|
|
201
237
|
catch { }
|
|
238
|
+
await handoffOutput?.catch(() => undefined);
|
|
202
239
|
if (!preserveSystemVolume)
|
|
203
240
|
try {
|
|
204
241
|
await systemVolumeLease?.release();
|
|
@@ -209,12 +246,19 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
209
246
|
}
|
|
210
247
|
catch { }
|
|
211
248
|
}
|
|
212
|
-
if (
|
|
249
|
+
if (historyStarted) {
|
|
213
250
|
try {
|
|
214
251
|
deps.store.checkpointActiveListeningSession(deps.now());
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
recordRunnerWarning(`Listening history could not be checkpointed: ${errorMessage(error)}`);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
215
257
|
deps.store.finishActiveListeningSession(deps.now());
|
|
216
258
|
}
|
|
217
|
-
catch {
|
|
259
|
+
catch (error) {
|
|
260
|
+
recordRunnerWarning(`Listening history could not be finished: ${errorMessage(error)}`);
|
|
261
|
+
}
|
|
218
262
|
}
|
|
219
263
|
try {
|
|
220
264
|
await lease?.release();
|
|
@@ -233,12 +277,14 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
233
277
|
catch { }
|
|
234
278
|
if (alarm && outcome) {
|
|
235
279
|
try {
|
|
236
|
-
deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: !preserveNextOverride });
|
|
280
|
+
deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: validTerminalOccurrence && !preserveNextOverride });
|
|
237
281
|
const latest = deps.store.getAlarm(alarmId);
|
|
238
282
|
if (alarm.schedule.type === 'once' && latest?.schedule.type === 'once' && latest.schedule.at === alarm.schedule.at && validTerminalOccurrence && !preserveNextOverride)
|
|
239
283
|
deps.store.toggleAlarm(alarmId, false);
|
|
240
284
|
}
|
|
241
|
-
catch {
|
|
285
|
+
catch (error) {
|
|
286
|
+
recordRunnerWarning(`Alarm outcome or completion state could not be saved: ${errorMessage(error)}`);
|
|
287
|
+
}
|
|
242
288
|
}
|
|
243
289
|
alarm = deps.store.getAlarm(alarmId);
|
|
244
290
|
if (alarm)
|
|
@@ -250,7 +296,7 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
250
296
|
}
|
|
251
297
|
catch { }
|
|
252
298
|
try {
|
|
253
|
-
releaseLock();
|
|
299
|
+
releaseLock(completeLock);
|
|
254
300
|
}
|
|
255
301
|
catch (error) {
|
|
256
302
|
try {
|
|
@@ -258,15 +304,16 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
258
304
|
}
|
|
259
305
|
catch { }
|
|
260
306
|
}
|
|
261
|
-
|
|
262
|
-
await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
|
|
263
|
-
}
|
|
264
|
-
catch (error) {
|
|
307
|
+
if (completeLock)
|
|
265
308
|
try {
|
|
266
|
-
deps.
|
|
309
|
+
await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
try {
|
|
313
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
267
316
|
}
|
|
268
|
-
catch { }
|
|
269
|
-
}
|
|
270
317
|
}
|
|
271
318
|
}
|
|
272
319
|
export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmRuntimeDirectory()) {
|
|
@@ -294,7 +341,10 @@ export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmR
|
|
|
294
341
|
}
|
|
295
342
|
}
|
|
296
343
|
writeFileSync(join(path, 'running'), String(process.pid), { mode: 0o600 });
|
|
297
|
-
return () => {
|
|
344
|
+
return (completed = true) => { if (!completed) {
|
|
345
|
+
rmSync(path, { recursive: true, force: true });
|
|
346
|
+
return;
|
|
347
|
+
} rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
|
|
298
348
|
}
|
|
299
349
|
export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirectory(), olderThanMs = 30 * 24 * 60 * 60_000, now = Date.now()) { const directory = join(root, 'locks'); if (!existsSync(directory))
|
|
300
350
|
return 0; let removed = 0; for (const name of readdirSync(directory)) {
|
|
@@ -307,9 +357,7 @@ export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirector
|
|
|
307
357
|
}
|
|
308
358
|
catch { }
|
|
309
359
|
} return removed; }
|
|
310
|
-
export function defaultAlarmRuntimeDirectory() {
|
|
311
|
-
return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
|
|
312
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
|
|
360
|
+
export function defaultAlarmRuntimeDirectory() { return platformPaths().runtime; }
|
|
313
361
|
export const defaultRunnerUtilities = { acquireLock: acquireOccurrenceLock, createSession: startActiveAlarmSession, wait: (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)), subscribeSignals: (handler) => { process.once('SIGTERM', handler); process.once('SIGHUP', handler); process.once('SIGINT', handler); return () => { process.off('SIGTERM', handler); process.off('SIGHUP', handler); process.off('SIGINT', handler); }; } };
|
|
314
362
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
315
363
|
function processAlive(pid) { try {
|
package/dist/alarms/schedule.js
CHANGED
|
@@ -11,10 +11,17 @@ export function isValidTimeZone(timezone) {
|
|
|
11
11
|
}
|
|
12
12
|
export function canonicalizeTimeZone(timezone) {
|
|
13
13
|
const value = timezone.trim();
|
|
14
|
-
|
|
14
|
+
try {
|
|
15
|
+
if (!value)
|
|
16
|
+
throw new Error('Empty timezone.');
|
|
17
|
+
// The constructor validates the zone and resolves its canonical name.
|
|
18
|
+
// Constructing twice made large-library validation expensive on Node 22
|
|
19
|
+
// Intel hosts; keep one native validation for every supplied value.
|
|
20
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
15
23
|
throw new Error(`Invalid IANA timezone: ${timezone}`);
|
|
16
24
|
}
|
|
17
|
-
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
18
25
|
}
|
|
19
26
|
export function canonicalizeAlarmTime(time) {
|
|
20
27
|
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|