@ciphore/radiocli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { createServer, request } from 'node:http';
4
- import { homedir } from 'node:os';
4
+ import { platformPaths } from '../platform/paths.js';
5
5
  import { dirname, join } from 'node:path';
6
+ import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
6
7
  export async function startActiveAlarmSession(initial, handlers) {
7
8
  const filePath = handlers.filePath ?? defaultActiveAlarmPath(initial.alarmId, initial.scheduledAt);
8
9
  const token = randomBytes(32).toString('hex');
@@ -85,11 +86,8 @@ export async function startActiveAlarmSession(initial, handlers) {
85
86
  res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'request failed' }));
86
87
  }
87
88
  });
88
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); });
89
- const address = server.address();
90
- if (!address || typeof address === 'string')
91
- throw new Error('Unable to create alarm control endpoint.');
92
- const discovery = { version: 1, host: '127.0.0.1', port: address.port, token, pid: process.pid, alarmId: initial.alarmId, createdAt: new Date().toISOString() };
89
+ const address = await listenLoopback(server);
90
+ const discovery = { version: 1, host: address.host, port: address.port, token, pid: process.pid, alarmId: initial.alarmId, createdAt: new Date().toISOString() };
93
91
  const temp = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
94
92
  try {
95
93
  mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
@@ -116,13 +114,15 @@ export async function connectActiveAlarm(filePath) {
116
114
  let discovery;
117
115
  try {
118
116
  discovery = JSON.parse(readFileSync(filePath, 'utf8'));
119
- if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token)
117
+ if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token)
120
118
  throw new Error('invalid');
121
119
  }
122
120
  catch {
123
121
  return null;
124
122
  }
125
- const call = async (method, path, body) => { const payload = body === undefined ? '' : JSON.stringify(body); return new Promise((resolve, reject) => { const req = request({ host: '127.0.0.1', port: discovery.port, path, method, headers: { authorization: `Bearer ${discovery.token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } }, res => { let text = ''; res.on('data', value => text += String(value)); res.on('end', () => res.statusCode && res.statusCode < 300 ? resolve(text ? JSON.parse(text) : {}) : reject(new Error('Alarm control request failed.'))); }); req.once('error', reject); req.setTimeout(1000, () => req.destroy(new Error('Alarm control request timed out.'))); req.end(payload); }); };
123
+ // A fresh direct agent keeps local control tokens out of environment proxies.
124
+ // Handoff can wait for the shared output lock (30s); discovery keeps its short deadline.
125
+ const call = async (method, path, body) => { const payload = body === undefined ? '' : JSON.stringify(body); return new Promise((resolve, reject) => { const req = request({ host: discovery.host, agent: false, port: discovery.port, path, method, headers: { authorization: `Bearer ${discovery.token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } }, res => { let text = ''; res.on('data', value => text += String(value)); res.on('end', () => res.statusCode && res.statusCode < 300 ? resolve(text ? JSON.parse(text) : {}) : reject(new Error('Alarm control request failed.'))); }); req.once('error', reject); req.setTimeout(path === '/handoff' ? 35_000 : 1000, () => req.destroy(new Error('Alarm control request timed out.'))); req.end(payload); }); };
126
126
  try {
127
127
  await call('GET', '/status');
128
128
  }
@@ -135,10 +135,7 @@ export async function connectActiveAlarm(filePath) {
135
135
  }
136
136
  export async function connectActiveAlarms(directory = defaultActiveAlarmDirectory()) { if (!existsSync(directory))
137
137
  return []; const paths = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => join(directory, name)); const clients = await Promise.all(paths.map(path => connectActiveAlarm(path))); return clients.filter((client) => Boolean(client)); }
138
- function defaultActiveAlarmDirectory() { if (process.env.RADIOCLI_HOME)
139
- return join(process.env.RADIOCLI_HOME, 'runtime', 'active-alarms'); if (process.platform === 'darwin')
140
- return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'active-alarms'); if (process.platform === 'win32')
141
- return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'active-alarms'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'active-alarms'); }
138
+ function defaultActiveAlarmDirectory() { return join(platformPaths().alarmRuntime, 'active-alarms'); }
142
139
  function defaultActiveAlarmPath(alarmId = 'active', occurrenceAt = 'current') { return join(defaultActiveAlarmDirectory(), `${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`); }
143
140
  function readBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', value => { body += String(value); if (body.length > 4096)
144
141
  req.destroy(new Error('Request too large.')); }); req.on('end', () => resolve(body)); req.on('error', reject); }); }
@@ -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
- await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
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
- await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
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 station = stations.find(item => stationKey(item) === key); if (!station)
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];
@@ -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
+ } }
@@ -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
+ }
@@ -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()))