@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.
- package/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +36 -6
- package/README.md +54 -10
- package/dist/agent/headless-host.js +39 -17
- package/dist/agent/launcher.js +6 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/service.js +3 -3
- package/dist/agent/session.js +13 -29
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +56 -25
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +103 -38
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +203 -33
- package/dist/providers/cache.js +4 -26
- package/dist/providers/radio-browser.js +152 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +79 -151
- package/dist/storage/store.js +105 -52
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +33 -24
- package/dist/ui/App.js +173 -86
- package/dist/ui/AppContent.js +3 -3
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/screen-items.js +2 -2
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +14 -7
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +36 -21
- package/dist/ui/use-app-input.js +42 -22
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/update-check.js +8 -17
- package/package.json +1 -1
package/dist/agent/session.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync,
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createServer, request } from 'node:http';
|
|
4
|
-
import {
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
89
|
-
const
|
|
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
|
|
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
|
-
|
|
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() {
|
|
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); }); }
|
package/dist/alarms/guard.js
CHANGED
|
@@ -2,11 +2,13 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createServer, request } from 'node:http';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { AlarmPowerGuardStore } from './power-guard-store.js';
|
|
8
8
|
import { nextOccurrenceForAlarm } from './schedule.js';
|
|
9
9
|
import { defaultAlarmRuntimeDirectory } from './runner.js';
|
|
10
|
+
import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
|
|
11
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
10
12
|
export class AlarmGuardService {
|
|
11
13
|
store;
|
|
12
14
|
nodePath;
|
|
@@ -41,6 +43,8 @@ export class AlarmGuardService {
|
|
|
41
43
|
// A recurring alarm may have been edited/rescheduled while its old guard is
|
|
42
44
|
// alive. Stop that exact process tree before replacing its occurrence state.
|
|
43
45
|
await this.stopOwned(alarm.id);
|
|
46
|
+
if (this.hasOwnershipRecords(alarm.id))
|
|
47
|
+
throw new Error('Previous Alarm Guard ownership is unresolved; retain the alarm for repair before starting another guard.');
|
|
44
48
|
this.store.request(alarm.id, occurrenceAt);
|
|
45
49
|
const token = randomBytes(32).toString('hex');
|
|
46
50
|
const child = this.spawn(this.nodePath, [this.cliPath, 'alarm', 'internal-guard-run', alarm.id, occurrenceAt, path, token], process.env);
|
|
@@ -73,19 +77,19 @@ export class AlarmGuardService {
|
|
|
73
77
|
return this.withOwnership(alarmId, () => this.stopOwned(alarmId));
|
|
74
78
|
const ids = [...new Set(this.pidFiles().map(readPid).filter((item) => Boolean(item)).map(item => item.alarmId))];
|
|
75
79
|
const results = await Promise.all(ids.map(id => this.withOwnership(id, () => this.stopOwned(id))));
|
|
76
|
-
return results.every(Boolean);
|
|
80
|
+
return results.every(Boolean) && !this.hasOwnershipRecords();
|
|
77
81
|
}
|
|
78
82
|
async stopOwned(alarmId) {
|
|
79
83
|
const targets = this.pidFiles()
|
|
80
|
-
.map(readPid)
|
|
81
|
-
.filter((item) => Boolean(item))
|
|
82
|
-
.filter(item => !alarmId || item.alarmId === alarmId);
|
|
84
|
+
.map(path => ({ path, owner: readPid(path) }))
|
|
85
|
+
.filter((item) => Boolean(item.owner))
|
|
86
|
+
.filter(item => !alarmId || item.owner.alarmId === alarmId);
|
|
83
87
|
if (!targets.length)
|
|
84
88
|
return false;
|
|
85
|
-
for (const current of targets) {
|
|
86
|
-
const path = this.path(current.alarmId, current.occurrenceAt);
|
|
89
|
+
for (const { path, owner: current } of targets) {
|
|
87
90
|
if (!(await this.verify(current))) {
|
|
88
|
-
|
|
91
|
+
if (!isAlive(current.pid))
|
|
92
|
+
rmSync(path, { force: true });
|
|
89
93
|
markIfCurrent(this.store, current, 'Guard ownership challenge failed; the PID was not signaled.');
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
@@ -108,17 +112,28 @@ export class AlarmGuardService {
|
|
|
108
112
|
this.store.markReleased(current.alarmId, new Date(), current.occurrenceAt);
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
|
-
return
|
|
115
|
+
return !this.hasOwnershipRecords(alarmId);
|
|
112
116
|
}
|
|
113
117
|
async status() {
|
|
114
118
|
const guards = [];
|
|
119
|
+
const unresolvedGuards = [];
|
|
115
120
|
for (const path of this.pidFiles()) {
|
|
116
121
|
const pid = readPid(path);
|
|
117
|
-
if (!pid)
|
|
122
|
+
if (!pid) {
|
|
123
|
+
const alarmId = alarmIdFromPath(path);
|
|
124
|
+
unresolvedGuards.push({ ...(alarmId ? { alarmId } : {}), message: 'Guard ownership metadata cannot be read; its record was retained for repair.' });
|
|
118
125
|
continue;
|
|
126
|
+
}
|
|
119
127
|
if (!(await this.verify(pid))) {
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
if (isAlive(pid.pid)) {
|
|
129
|
+
const message = 'Guard ownership cannot be verified; its record was retained and the PID was not signaled.';
|
|
130
|
+
unresolvedGuards.push({ alarmId: pid.alarmId, occurrenceAt: pid.occurrenceAt, pid: pid.pid, message });
|
|
131
|
+
markIfCurrent(this.store, pid, message);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
rmSync(path, { force: true });
|
|
135
|
+
markIfCurrent(this.store, pid, 'The guard process exited unexpectedly.');
|
|
136
|
+
}
|
|
122
137
|
continue;
|
|
123
138
|
}
|
|
124
139
|
guards.push({ active: true, alarmId: pid.alarmId, occurrenceAt: pid.occurrenceAt, pid: pid.pid });
|
|
@@ -126,7 +141,10 @@ export class AlarmGuardService {
|
|
|
126
141
|
return {
|
|
127
142
|
active: guards.length > 0,
|
|
128
143
|
guards,
|
|
129
|
-
|
|
144
|
+
unresolvedGuards,
|
|
145
|
+
message: unresolvedGuards.length
|
|
146
|
+
? `${guards.length} alarm guards verified active; ${unresolvedGuards.length} ownership records require repair.`
|
|
147
|
+
: guards.length ? `${guards.length} alarm guard${guards.length === 1 ? ' is' : 's are'} active.` : 'No alarm guard is active.'
|
|
130
148
|
};
|
|
131
149
|
}
|
|
132
150
|
path(alarmId, occurrenceAt) {
|
|
@@ -137,6 +155,13 @@ export class AlarmGuardService {
|
|
|
137
155
|
return [];
|
|
138
156
|
return readdirSync(this.directory).filter(name => name.endsWith('.json')).map(name => join(this.directory, name));
|
|
139
157
|
}
|
|
158
|
+
hasOwnershipRecords(alarmId) {
|
|
159
|
+
return this.pidFiles().some(path => {
|
|
160
|
+
const ownerId = readPid(path)?.alarmId ?? alarmIdFromPath(path);
|
|
161
|
+
// Unknown records require global repair, but cannot identify this alarm.
|
|
162
|
+
return !alarmId || ownerId === alarmId;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
140
165
|
async withOwnership(alarmId, work) { const lock = join(this.directory, `.lock-${Buffer.from(alarmId).toString('base64url')}`); mkdirSync(this.directory, { recursive: true, mode: 0o700 }); const deadline = Date.now() + 3000; while (true) {
|
|
141
166
|
try {
|
|
142
167
|
mkdirSync(lock, { mode: 0o700 });
|
|
@@ -240,7 +265,7 @@ function readPid(path) {
|
|
|
240
265
|
return undefined;
|
|
241
266
|
try {
|
|
242
267
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
243
|
-
if (!Number.isInteger(value.pid) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !value.alarmId || !/^[a-f0-9]{64}$/.test(value.token) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt)))
|
|
268
|
+
if (!Number.isInteger(value.pid) || value.pid < 1 || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || typeof value.alarmId !== 'string' || !value.alarmId || (value.host !== undefined && !isLoopbackHost(value.host)) || !/^[a-f0-9]{64}$/.test(value.token) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(value.occurrenceAt) || !Number.isFinite(Date.parse(value.occurrenceAt)))
|
|
244
269
|
throw new Error();
|
|
245
270
|
return value;
|
|
246
271
|
}
|
|
@@ -248,12 +273,20 @@ function readPid(path) {
|
|
|
248
273
|
return undefined;
|
|
249
274
|
}
|
|
250
275
|
}
|
|
276
|
+
function alarmIdFromPath(path) {
|
|
277
|
+
const name = basename(path, '.json');
|
|
278
|
+
const decoded = Buffer.from(name, 'base64url').toString();
|
|
279
|
+
if (Buffer.from(decoded).toString('base64url') !== name)
|
|
280
|
+
return undefined;
|
|
281
|
+
const separator = decoded.indexOf('\0');
|
|
282
|
+
return separator > 0 ? decoded.slice(0, separator) : undefined;
|
|
283
|
+
}
|
|
251
284
|
function writePrivate(path, value) {
|
|
252
285
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
253
286
|
const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
254
287
|
writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
|
|
255
288
|
renameSync(temp, path);
|
|
256
|
-
if (
|
|
289
|
+
if (nativeAdapters().posixPermissions)
|
|
257
290
|
chmodSync(path, 0o600);
|
|
258
291
|
}
|
|
259
292
|
function isAlive(pid) {
|
|
@@ -261,32 +294,41 @@ function isAlive(pid) {
|
|
|
261
294
|
process.kill(pid, 0);
|
|
262
295
|
return true;
|
|
263
296
|
}
|
|
264
|
-
catch {
|
|
265
|
-
return
|
|
297
|
+
catch (error) {
|
|
298
|
+
return error.code !== 'ESRCH';
|
|
266
299
|
}
|
|
267
300
|
}
|
|
268
301
|
function ownsArtifact(path, token) {
|
|
269
302
|
return readPid(path)?.token === token;
|
|
270
303
|
}
|
|
271
|
-
async function startGuardControl(path, token, alarmId, occurrenceAt, onStop) {
|
|
272
|
-
res.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
async function startGuardControl(path, token, alarmId, occurrenceAt, onStop) {
|
|
305
|
+
const server = createServer((req, res) => { if (req.headers.authorization !== `Bearer ${token}`) {
|
|
306
|
+
res.statusCode = 401;
|
|
307
|
+
res.end('{}');
|
|
308
|
+
return;
|
|
309
|
+
} if (req.method === 'POST' && req.url === '/stop') {
|
|
310
|
+
onStop();
|
|
311
|
+
res.end('{}');
|
|
312
|
+
return;
|
|
313
|
+
} if (req.method !== 'GET' || req.url !== '/challenge') {
|
|
314
|
+
res.statusCode = 404;
|
|
315
|
+
res.end('{}');
|
|
316
|
+
return;
|
|
317
|
+
} res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ alarmId, occurrenceAt, pid: process.pid })); });
|
|
318
|
+
const close = () => new Promise(resolve => server.close(() => resolve()));
|
|
319
|
+
try {
|
|
320
|
+
const { host, port } = await listenLoopback(server);
|
|
321
|
+
writePrivate(path, { alarmId, occurrenceAt, pid: process.pid, host, port, token });
|
|
322
|
+
return { close };
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
await close();
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
287
329
|
function challengeGuard(guard) { return callGuard(guard, 'GET', '/challenge', true); }
|
|
288
330
|
function requestGuardStop(guard) { return callGuard(guard, 'POST', '/stop', false); }
|
|
289
|
-
function callGuard(guard, method, path, validateIdentity) { return new Promise(resolve => { const req = request({ host: '127.0.0.1', port: guard.port, path, method, headers: { authorization: `Bearer ${guard.token}` } }, res => { let body = ''; res.on('data', value => body += String(value)); res.on('end', () => { if (!validateIdentity) {
|
|
331
|
+
function callGuard(guard, method, path, validateIdentity) { return new Promise(resolve => { const req = request({ host: guard.host ?? '127.0.0.1', port: guard.port, path, method, agent: false, headers: { authorization: `Bearer ${guard.token}` } }, res => { let body = ''; res.on('data', value => body += String(value)); res.on('end', () => { if (!validateIdentity) {
|
|
290
332
|
resolve(res.statusCode === 200);
|
|
291
333
|
return;
|
|
292
334
|
} try {
|
|
@@ -297,9 +339,10 @@ catch {
|
|
|
297
339
|
resolve(false);
|
|
298
340
|
} }); }); req.once('error', () => resolve(false)); req.setTimeout(500, () => req.destroy()); req.end(); }); }
|
|
299
341
|
export async function terminateGuardTree(pid, ops = { platform: process.platform, isAlive, kill: (target, signal) => process.kill(target, signal), taskkill: taskkillTree, wait: delay }) {
|
|
342
|
+
const windows = nativeAdapters(identifyPlatform({ platform: ops.platform })).ipc === 'named-pipe';
|
|
300
343
|
// Kill the entire detached tree first on Windows so a dying parent cannot
|
|
301
344
|
// orphan its PowerShell execution-state helper.
|
|
302
|
-
if (
|
|
345
|
+
if (windows) {
|
|
303
346
|
await ops.taskkill(pid, false);
|
|
304
347
|
}
|
|
305
348
|
else {
|
|
@@ -314,7 +357,7 @@ export async function terminateGuardTree(pid, ops = { platform: process.platform
|
|
|
314
357
|
await ops.wait(50);
|
|
315
358
|
if (!ops.isAlive(pid))
|
|
316
359
|
return;
|
|
317
|
-
if (
|
|
360
|
+
if (windows)
|
|
318
361
|
await ops.taskkill(pid, true);
|
|
319
362
|
else {
|
|
320
363
|
// The guard is a detached process-group leader; force-killing the negative
|
package/dist/alarms/inhibitor.js
CHANGED
|
@@ -1,34 +1,38 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
3
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
4
4
|
// The script contains no interpolated data. It keeps one process-level Windows
|
|
5
5
|
// execution-state request alive until the parent terminates it.
|
|
6
6
|
const windowsInhibitorScript = (parentPid) => `$s='[DllImport("kernel32.dll")]public static extern uint SetThreadExecutionState(uint e);';Add-Type -MemberDefinition $s -Name P -Namespace R;[R.P]::SetThreadExecutionState(0x80000001)|Out-Null;try{while(Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue){Start-Sleep -Seconds 2}}finally{[R.P]::SetThreadExecutionState(0x80000000)|Out-Null}`;
|
|
7
7
|
export function createPowerInhibitor(deps = {}) {
|
|
8
8
|
const platform = deps.platform ?? process.platform;
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
9
|
+
const env = deps.env ?? process.env;
|
|
10
|
+
const host = identifyPlatform({ platform, env });
|
|
11
|
+
const adapter = nativeAdapters(host).inhibitor;
|
|
12
|
+
const spawn = deps.spawn ?? ((command, args) => spawnDetached(command, args, env));
|
|
13
|
+
const spec = adapter === 'caffeinate'
|
|
12
14
|
? { command: 'caffeinate', args: ['-i', '-w', String(process.pid)], message: 'Prevents idle system sleep; the display may sleep.' }
|
|
13
|
-
:
|
|
15
|
+
: adapter === 'logind'
|
|
14
16
|
? { command: 'systemd-inhibit', args: ['--what=sleep', '--who=RadioCLI', '--why=Scheduled radio', '--mode=block', 'sh', '-c', 'while kill -0 "$1" 2>/dev/null; do sleep 2; done', 'radiocli-inhibitor', String(process.pid)], message: 'Uses a logind sleep inhibitor; explicit sleep and lid policy may override it.' }
|
|
15
|
-
:
|
|
17
|
+
: adapter === 'windows'
|
|
16
18
|
? { command: 'powershell.exe', args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsInhibitorScript(process.pid)], message: 'Prevents idle system sleep; explicit sleep and lid policy may override it.' }
|
|
17
19
|
: null;
|
|
18
20
|
let active = false;
|
|
19
|
-
const
|
|
21
|
+
const command = spec ? (deps.commandExists ? (deps.commandExists(spec.command) ? spec.command : null) : resolveCommandDetails(spec.command, { platform, env }).path) : null;
|
|
22
|
+
const supported = Boolean(command);
|
|
23
|
+
const message = !spec ? `Power inhibition is unsupported on ${host.id === 'unknown' ? platform : host.id}.` : !command ? `${spec.command} is unavailable; sleep protection cannot be acquired.` : spec.message;
|
|
20
24
|
return {
|
|
21
|
-
status: () => ({ supported, active, message
|
|
25
|
+
status: () => ({ supported, active, message }),
|
|
22
26
|
async acquire(_reason) {
|
|
23
|
-
if (!spec || !
|
|
24
|
-
throw new Error(
|
|
25
|
-
const child = spawn(
|
|
26
|
-
const exitedEarly = await
|
|
27
|
+
if (!spec || !command)
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
const child = spawn(command, spec.args);
|
|
30
|
+
const exitedEarly = await exitedWithin(child.exited, 50);
|
|
27
31
|
if (exitedEarly)
|
|
28
32
|
throw new Error(`${spec.command} exited before sleep inhibition became active.`);
|
|
29
33
|
active = true;
|
|
30
34
|
let released = false;
|
|
31
|
-
const unexpectedExit = child.exited.then(() => released ? new Promise(() => { }) : new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`));
|
|
35
|
+
const unexpectedExit = child.exited.then(() => { active = false; return released ? new Promise(() => { }) : new Error(`${spec.command} exited unexpectedly; sleep protection was lost.`); });
|
|
32
36
|
return { unexpectedExit, async release() { if (released)
|
|
33
37
|
return; released = true; active = false; child.kill('SIGTERM'); if (await exitedWithin(child.exited, 1000))
|
|
34
38
|
return; child.kill('SIGKILL'); if (!(await exitedWithin(child.exited, 500)))
|
|
@@ -36,13 +40,14 @@ export function createPowerInhibitor(deps = {}) {
|
|
|
36
40
|
}
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
return true;
|
|
42
|
-
return Boolean(process.env.PATH?.split(delimiter).some(path => existsSync(join(path, command)) || (process.platform === 'win32' && existsSync(join(path, `${command}.exe`)))));
|
|
43
|
-
}
|
|
44
|
-
function spawnDetached(command, args) {
|
|
45
|
-
const child = nodeSpawn(command, args, { stdio: 'ignore', windowsHide: true });
|
|
43
|
+
function spawnDetached(command, args, env) {
|
|
44
|
+
const child = nodeSpawn(command, args, { env, stdio: 'ignore', windowsHide: true });
|
|
46
45
|
return { pid: child.pid, kill: signal => child.kill(signal), exited: new Promise(resolve => { child.once('exit', resolve); child.once('error', resolve); }) };
|
|
47
46
|
}
|
|
48
|
-
async function exitedWithin(exited, milliseconds) {
|
|
47
|
+
async function exitedWithin(exited, milliseconds) { let timer; try {
|
|
48
|
+
return await Promise.race([exited.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), milliseconds); })]);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
if (timer)
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
} }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { platformPaths } from '../platform/paths.js';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
const instantSchema = z.string()
|
|
@@ -123,15 +123,7 @@ export class AlarmPowerGuardStore {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
function defaultAlarmPowerGuardPath() {
|
|
126
|
-
|
|
127
|
-
return join(process.env.RADIOCLI_HOME, 'runtime', 'alarm-power-guards.json');
|
|
128
|
-
if (process.platform === 'darwin') {
|
|
129
|
-
return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'alarm-power-guards.json');
|
|
130
|
-
}
|
|
131
|
-
if (process.platform === 'win32') {
|
|
132
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'alarm-power-guards.json');
|
|
133
|
-
}
|
|
134
|
-
return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'alarm-power-guards.json');
|
|
126
|
+
return join(platformPaths().alarmRuntime, 'alarm-power-guards.json');
|
|
135
127
|
}
|
|
136
128
|
function validDate(date, label) {
|
|
137
129
|
if (!Number.isFinite(date.getTime()))
|