@ciphore/radiocli 0.2.3 → 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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, 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 startRadioSession(handle, filePath = radioSessionPath()) {
7
8
  const ownerPath = `${filePath}.owner`;
8
9
  acquireOwner(ownerPath);
@@ -31,16 +32,10 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
31
32
  });
32
33
  });
33
34
  try {
34
- await new Promise((resolve, reject) => {
35
- server.once('error', reject);
36
- server.listen(0, '127.0.0.1', () => resolve());
37
- });
38
- const address = server.address();
39
- if (!address || typeof address === 'string')
40
- throw new Error('Unable to create RadioCLI control endpoint.');
35
+ const address = await listenLoopback(server);
41
36
  const discovery = {
42
37
  version: 1,
43
- host: '127.0.0.1',
38
+ host: address.host,
44
39
  port: address.port,
45
40
  token,
46
41
  pid: process.pid,
@@ -67,7 +62,7 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
67
62
  let discovery;
68
63
  try {
69
64
  discovery = JSON.parse(readFileSync(filePath, 'utf8'));
70
- if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) {
65
+ if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) {
71
66
  throw new Error('invalid');
72
67
  }
73
68
  }
@@ -79,7 +74,9 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
79
74
  const payload = JSON.stringify(command);
80
75
  return new Promise((resolve, reject) => {
81
76
  const req = request({
82
- host: '127.0.0.1',
77
+ host: discovery.host,
78
+ // Local control tokens must never be forwarded by an environment proxy.
79
+ agent: false,
83
80
  port: discovery.port,
84
81
  path: '/command',
85
82
  method: 'POST',
@@ -108,7 +105,9 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
108
105
  return { call, status: async () => (await call({ type: 'status' })).status };
109
106
  }
110
107
  catch {
111
- if (!processAlive(discovery.pid) || discoveryAgeMs(filePath, discovery) > 60_000) {
108
+ // A slow command does not prove that a playback host is dead. Retain both
109
+ // ownership records while its PID is alive so a second host cannot start.
110
+ if (!processAlive(discovery.pid)) {
112
111
  removeIfOwned(filePath, discovery);
113
112
  removeOwner(`${filePath}.owner`, discovery.pid);
114
113
  }
@@ -154,11 +153,7 @@ async function waitForSession(timeoutMs) {
154
153
  throw new Error('RadioCLI was launched but its control session did not become ready.');
155
154
  }
156
155
  function runtimeDirectory() {
157
- if (process.env.RADIOCLI_HOME)
158
- return join(process.env.RADIOCLI_HOME, 'runtime');
159
- if (process.platform === 'win32')
160
- return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime');
161
- return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli');
156
+ return platformPaths().runtime;
162
157
  }
163
158
  function acquireOwner(path) {
164
159
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -214,17 +209,6 @@ function processAlive(pid) {
214
209
  return error.code === 'EPERM';
215
210
  }
216
211
  }
217
- function discoveryAgeMs(path, discovery) {
218
- const created = Date.parse(discovery.createdAt);
219
- if (Number.isFinite(created))
220
- return Math.max(0, Date.now() - created);
221
- try {
222
- return Math.max(0, Date.now() - statSync(path).mtimeMs);
223
- }
224
- catch {
225
- return 0;
226
- }
227
- }
228
212
  function readBody(req) {
229
213
  return new Promise((resolve, reject) => {
230
214
  let body = '';
@@ -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); }); }
@@ -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()))