@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
@@ -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
- rmSync(path, { force: true });
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 targets.every(item => !isAlive(item.pid));
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
- rmSync(path, { force: true });
121
- markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.');
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
- message: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.'
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 (process.platform !== 'win32')
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 false;
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) { const server = createServer((req, res) => { if (req.headers.authorization !== `Bearer ${token}`) {
272
- res.statusCode = 401;
273
- res.end('{}');
274
- return;
275
- } if (req.method === 'POST' && req.url === '/stop') {
276
- onStop();
277
- res.end('{}');
278
- return;
279
- } if (req.method !== 'GET' || req.url !== '/challenge') {
280
- res.statusCode = 404;
281
- res.end('{}');
282
- return;
283
- } res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ alarmId, occurrenceAt, pid: process.pid })); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); if (!address || typeof address === 'string') {
284
- server.close();
285
- throw new Error('Unable to create Guard ownership endpoint.');
286
- } writePrivate(path, { alarmId, occurrenceAt, pid: process.pid, port: address.port, token }); return { close: () => new Promise(resolve => server.close(() => resolve())) }; }
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 (ops.platform === 'win32') {
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 (ops.platform === 'win32')
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
@@ -1,34 +1,38 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { delimiter, join } from 'node:path';
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 spawn = deps.spawn ?? spawnDetached;
10
- const exists = deps.commandExists ?? executableExists;
11
- const spec = platform === 'darwin'
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
- : platform === 'linux'
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
- : platform === 'win32'
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 supported = Boolean(spec && exists(spec.command));
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: spec?.message ?? `Power inhibition is unsupported on ${platform}.` }),
25
+ status: () => ({ supported, active, message }),
22
26
  async acquire(_reason) {
23
- if (!spec || !supported)
24
- throw new Error(spec?.message ?? `Power inhibition is unsupported on ${platform}.`);
25
- const child = spawn(spec.command, spec.args);
26
- const exitedEarly = await Promise.race([child.exited.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 50))]);
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 executableExists(command) {
40
- if (existsSync(command))
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) { return Promise.race([exited.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 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 { homedir } from 'node:os';
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
- if (process.env.RADIOCLI_HOME)
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()))
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
2
+ import { platformPaths } from '../platform/paths.js';
3
3
  import { dirname, join } from 'node:path';
4
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
- let action;
133
- let keepPlaying = false;
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
- session = created.value;
151
- void deps.openControls?.(activeStatus).catch(error => { try {
152
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}` });
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
- catch { } });
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 (listening) {
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
- try {
262
- await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
263
- }
264
- catch (error) {
307
+ if (completeLock)
265
308
  try {
266
- deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
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 () => { rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
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() { if (process.env.RADIOCLI_HOME)
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 {
@@ -11,10 +11,17 @@ export function isValidTimeZone(timezone) {
11
11
  }
12
12
  export function canonicalizeTimeZone(timezone) {
13
13
  const value = timezone.trim();
14
- if (!isValidTimeZone(value)) {
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());