@ciphore/radiocli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
package/dist/alarms/scheduler.js
CHANGED
|
@@ -8,7 +8,11 @@ import { nextOccurrenceForAlarm } from './schedule.js';
|
|
|
8
8
|
import { AlarmRuntimeHealthStore } from './runtime-health.js';
|
|
9
9
|
import { AlarmGuardService } from './guard.js';
|
|
10
10
|
import { AlarmPowerGuardStore } from './power-guard-store.js';
|
|
11
|
-
import {
|
|
11
|
+
import { detectGraphicalTerminal } from '../platform/terminals.js';
|
|
12
|
+
import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
|
|
13
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
14
|
+
import { launchEnvironment, nodeLaunchCommand } from '../platform/launch-command.js';
|
|
15
|
+
import { powershellCommand } from '../platform/shell.js';
|
|
12
16
|
export class SchedulerService {
|
|
13
17
|
adapter;
|
|
14
18
|
now;
|
|
@@ -94,7 +98,7 @@ export class SchedulerService {
|
|
|
94
98
|
throw error;
|
|
95
99
|
} this.health.remove(alarmId); }
|
|
96
100
|
async statusAll(alarms) { return mapConcurrent(alarms, 4, async (alarm) => { const occurrence = nextOccurrenceForAlarm(alarm, this.now()); return { alarmId: alarm.id, nextOccurrence: occurrence?.toISOString() ?? null, native: occurrence ? await this.adapter.status(alarm.id) : { installed: false, healthy: true, message: 'No enabled future occurrence; no native job is expected.' }, health: this.health.get(alarm.id) }; }); }
|
|
97
|
-
async runtimeStatus(alarms = []) {
|
|
101
|
+
async runtimeStatus(alarms = []) { const [capabilities, status] = await Promise.all([this.adapter.probeCapabilities?.() ?? this.doctor(), this.statusAll(alarms)]); return { capabilities, entries: this.health.list(), alarms: status }; }
|
|
98
102
|
async reconcileGuard(alarm) { if (!this.guard)
|
|
99
103
|
return; try {
|
|
100
104
|
const requested = alarm.enabled && alarm.reliability.keepAwakeUntilAlarm;
|
|
@@ -111,7 +115,7 @@ export class SchedulerService {
|
|
|
111
115
|
} }
|
|
112
116
|
async ensureGuardAbsent(alarmId) { if (!this.guard)
|
|
113
117
|
return; const stopped = await this.guard.stop(alarmId); if (stopped)
|
|
114
|
-
return; const status = await this.guard.status?.(); if (status && !status.guards.some(item => item.alarmId === alarmId))
|
|
118
|
+
return; const status = await this.guard.status?.(); if (status && !status.guards.some(item => item.alarmId === alarmId) && !status.unresolvedGuards?.some(item => !item.alarmId || item.alarmId === alarmId))
|
|
115
119
|
return; const message = 'Alarm Guard teardown could not be verified; the alarm definition was retained for repair.'; this.health.record({ alarmId, component: 'power', healthy: false, active: true, message }); throw new GuardReconcileError(message); }
|
|
116
120
|
}
|
|
117
121
|
class GuardReconcileError extends Error {
|
|
@@ -132,29 +136,27 @@ export function createSchedulerAdapter(deps = {}) {
|
|
|
132
136
|
const nodePath = deps.nodePath ?? process.execPath;
|
|
133
137
|
const cliPath = deps.cliPath ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
134
138
|
const env = deps.env ?? process.env;
|
|
139
|
+
const host = identifyPlatform({ platform, env });
|
|
140
|
+
const policy = nativeAdapters(host);
|
|
135
141
|
const write = deps.writeFile ?? ((path, contents) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }); });
|
|
136
142
|
const removeFile = deps.removeFile ?? (path => rmSync(path, { force: true }));
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
143
|
+
const resolve = (command) => resolveCommandDetails(command, { platform, env, home }).path;
|
|
144
|
+
const run = deps.run ?? ((command, args) => runCommand(resolve(command) ?? command, args));
|
|
145
|
+
const commandExists = deps.commandExists ?? (command => resolve(command) !== null);
|
|
146
|
+
const common = { platform, home, nodePath, cliPath, env, terminal: detectGraphicalTerminal(platform, env), write, removeFile, run };
|
|
147
|
+
if (policy.scheduler === 'launchd')
|
|
141
148
|
return launchdAdapter(common);
|
|
142
|
-
if (
|
|
149
|
+
if (policy.scheduler === 'task-scheduler')
|
|
143
150
|
return windowsAdapter(common);
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
if (policy.scheduler === 'systemd') {
|
|
152
|
+
if (commandExists('systemctl'))
|
|
153
|
+
return systemdAdapter(common);
|
|
154
|
+
return unavailableAdapter('systemctl is unavailable; systemd job registration and removal cannot be verified. Repair artifacts were retained.');
|
|
155
|
+
}
|
|
156
|
+
return unsupportedAdapter(`Alarm scheduling is not supported on ${host.id === 'unknown' ? host.platform : host.id}.`);
|
|
149
157
|
}
|
|
150
158
|
function runtimeEnvironment(common) {
|
|
151
|
-
|
|
152
|
-
for (const key of ['RADIOCLI_HOME', 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR']) {
|
|
153
|
-
const value = common.env[key];
|
|
154
|
-
if (value)
|
|
155
|
-
result[key] = value;
|
|
156
|
-
}
|
|
157
|
-
return result;
|
|
159
|
+
return launchEnvironment(common.env, { platform: common.platform, includeDesktop: true, terminal: common.terminal });
|
|
158
160
|
}
|
|
159
161
|
const jobName = (id) => `io.radiocli.alarm.${createHash('sha256').update(id).digest('hex').slice(0, 20)}`;
|
|
160
162
|
const invocationArgs = (common, id, occurrence, internalCommand = 'internal-run') => [common.cliPath, 'alarm', internalCommand, id, occurrence.toISOString()];
|
|
@@ -170,38 +172,41 @@ function launchdAdapter(common) {
|
|
|
170
172
|
extras = readdirSync(directory).filter(name => name.startsWith(prefix) && name.endsWith('.plist')).map(name => join(directory, name));
|
|
171
173
|
}
|
|
172
174
|
catch { } return [pathFor(id), ...extras]; };
|
|
173
|
-
const installJob = async (alarm, occurrence, label, path, replace) => { const local = localParts(occurrence); const entries = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join(''); const envArgs = `<key>EnvironmentVariables</key><dict>${entries}</dict>`; const args =
|
|
175
|
+
const installJob = async (alarm, occurrence, label, path, replace) => { const local = localParts(occurrence); const entries = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join(''); const envArgs = `<key>EnvironmentVariables</key><dict>${entries}</dict>`; const args = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence, 'internal-launchd'), runtimeEnvironment(common)).map(value => `<string>${xml(value)}</string>`).join(''); common.write(path, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args}</array>${envArgs}<key>StartCalendarInterval</key><dict><key>Month</key><integer>${local.month}</integer><key>Day</key><integer>${local.day}</integer><key>Hour</key><integer>${local.hour}</integer><key>Minute</key><integer>${local.minute}</integer></dict><key>ProcessType</key><string>Interactive</string><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string><!-- launchd repeats this calendar pattern annually; the idempotent runner gates, removes, or reschedules it. --></dict></plist>\n`); if (replace)
|
|
174
176
|
await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]).catch(() => undefined); ensureSuccess(await common.run('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootstrap'); };
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
async removeFromRunner() { },
|
|
181
|
-
async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); const path = existsSync(specific) ? specific : pathFor(id); const label = path.split('/').at(-1).slice(0, -6); let failure; try {
|
|
182
|
-
const result = await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]);
|
|
183
|
-
if (result.code !== 0)
|
|
184
|
-
failure = new Error(`launchctl bootout failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
|
|
177
|
+
const removeJob = async (path) => {
|
|
178
|
+
const label = path.split('/').at(-1).slice(0, -6);
|
|
179
|
+
let failure;
|
|
180
|
+
try {
|
|
181
|
+
ensureSuccess(await common.run('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}`, path]), 'launchctl bootout');
|
|
185
182
|
}
|
|
186
183
|
catch (error) {
|
|
187
184
|
failure = error;
|
|
188
|
-
}
|
|
185
|
+
}
|
|
186
|
+
if (failure) {
|
|
189
187
|
let status;
|
|
190
188
|
try {
|
|
191
189
|
status = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]);
|
|
192
190
|
}
|
|
193
191
|
catch (error) {
|
|
194
|
-
throw new Error(`Unable to verify
|
|
192
|
+
throw new Error(`Unable to verify launchd job removal: ${errorMessage(error)}`);
|
|
195
193
|
}
|
|
196
194
|
if (status.code === 0)
|
|
197
|
-
throw new Error(`
|
|
195
|
+
throw new Error(`launchd job is still loaded: ${errorMessage(failure)}`);
|
|
198
196
|
if (!isLaunchdServiceNotFound(status, label))
|
|
199
|
-
throw new Error(`Unable to verify
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
197
|
+
throw new Error(`Unable to verify launchd job absence: ${commandMessage(status)}`);
|
|
198
|
+
}
|
|
199
|
+
common.removeFile(path);
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
capabilities: () => ({ name: 'launchd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'launchd runs after wake when possible; exact wake is unavailable. Resync after changing the host timezone.' }),
|
|
203
|
+
async install(alarm, occurrence) { await installJob(alarm, occurrence, jobName(alarm.id), pathFor(alarm.id), true); },
|
|
204
|
+
async installFromRunner(alarm, occurrence) { const label = occurrenceLabel(alarm.id, occurrence); const existing = await common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${label}`]); if (existing.code === 0)
|
|
205
|
+
return; await installJob(alarm, occurrence, label, occurrencePath(alarm.id, occurrence), false); },
|
|
206
|
+
async removeFromRunner() { },
|
|
207
|
+
async completeOccurrence(id, occurrence) { const specific = occurrencePath(id, occurrence); await removeJob(existsSync(specific) ? specific : pathFor(id)); },
|
|
208
|
+
async remove(id) { for (const path of pathsFor(id))
|
|
209
|
+
await removeJob(path); },
|
|
205
210
|
async status(id) { const paths = pathsFor(id).filter(existsSync); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); if (!paths.length)
|
|
206
211
|
return { installed: false, healthy: false, message: 'LaunchAgent artifact missing' }; const results = await Promise.all(paths.map(path => common.run('launchctl', ['print', `gui/${process.getuid?.() ?? ''}/${path.split('/').at(-1).slice(0, -6)}`]))); const registered = results.some(result => result.code === 0); return { installed: registered, healthy: registered && executable, message: !registered ? 'launchd registration missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
|
|
207
212
|
};
|
|
@@ -209,38 +214,175 @@ function launchdAdapter(common) {
|
|
|
209
214
|
function systemdAdapter(common) {
|
|
210
215
|
const base = join(common.home, '.config', 'systemd', 'user');
|
|
211
216
|
const names = (id) => ({ service: `${jobName(id)}.service`, timer: `${jobName(id)}.timer` });
|
|
217
|
+
const capabilities = () => ({ name: 'systemd', supported: true, exactWake: false, catchUpAfterWake: true, message: 'systemd tooling detected; user-manager readiness is checked during registration and diagnostics. Persistent user timers can catch up after login/wake; WakeSystem is not claimed without system privileges.' });
|
|
218
|
+
const probeCapabilities = async () => {
|
|
219
|
+
try {
|
|
220
|
+
const result = await boundedProbe(common.run('systemctl', ['--user', 'show', '--property=Version', '--value']));
|
|
221
|
+
ensureSuccess(result, 'systemd user manager probe');
|
|
222
|
+
return { ...capabilities(), message: 'The systemd user manager is reachable. Persistent timers can catch up after login/wake; WakeSystem is not claimed without system privileges.' };
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return { ...capabilities(), supported: false, catchUpAfterWake: false, message: `The systemd user manager is unavailable: ${errorMessage(error)}` };
|
|
226
|
+
}
|
|
227
|
+
};
|
|
212
228
|
return {
|
|
213
|
-
capabilities
|
|
214
|
-
|
|
215
|
-
async
|
|
216
|
-
|
|
229
|
+
capabilities,
|
|
230
|
+
probeCapabilities,
|
|
231
|
+
async install(alarm, occurrence) { const readiness = await probeCapabilities(); if (!readiness.supported)
|
|
232
|
+
throw new Error(readiness.message); const n = names(alarm.id); const env = Object.entries(runtimeEnvironment(common)).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}\n`).join(''); const cmd = systemdExecCommand(nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common))); common.write(join(base, n.service), `[Unit]\nDescription=RadioCLI alarm ${unitText(alarm.label)}\n[Service]\nType=exec\n${env}ExecStart=${cmd}\n`); common.write(join(base, n.timer), `[Unit]\nDescription=RadioCLI scheduled radio ${unitText(alarm.label)}\n[Timer]\nOnCalendar=${systemdCalendar(occurrence)}\nPersistent=true\nAccuracySec=1s\nUnit=${n.service}\n[Install]\nWantedBy=timers.target\n`); ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload'); ensureSuccess(await common.run('systemctl', ['--user', 'enable', '--now', n.timer]), 'systemctl enable'); },
|
|
233
|
+
async remove(id) {
|
|
234
|
+
const n = names(id);
|
|
235
|
+
await removeWithVerification(() => common.run('systemctl', ['--user', 'disable', '--now', n.timer]), () => common.run('systemctl', ['--user', 'show', n.timer, '--property=LoadState', '--property=ActiveState']), result => /^LoadState=not-found\r?$/m.test(result.stdout) && /^ActiveState=inactive\r?$/m.test(result.stdout), 'systemd timer');
|
|
236
|
+
common.removeFile(join(base, n.timer));
|
|
237
|
+
common.removeFile(join(base, n.service));
|
|
238
|
+
ensureSuccess(await common.run('systemctl', ['--user', 'daemon-reload']), 'systemctl daemon-reload after removal');
|
|
239
|
+
},
|
|
240
|
+
async status(id) { const readiness = await probeCapabilities(); if (!readiness.supported)
|
|
241
|
+
return { installed: false, healthy: false, message: readiness.message }; const n = names(id); const [enabled, active] = await Promise.all([common.run('systemctl', ['--user', 'is-enabled', n.timer]), common.run('systemctl', ['--user', 'is-active', n.timer])]); const artifact = existsSync(join(base, n.timer)) && existsSync(join(base, n.service)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: enabled.code === 0 && artifact, healthy: enabled.code === 0 && active.code === 0 && artifact && executable, message: enabled.code !== 0 ? 'user timer not enabled' : active.code !== 0 ? 'user timer is not active' : !artifact ? 'systemd unit artifact missing' : !executable ? 'RadioCLI executable missing' : 'enabled, active, and executable' }; }
|
|
217
242
|
};
|
|
218
243
|
}
|
|
219
244
|
function windowsAdapter(common) {
|
|
220
245
|
const xmlPath = (id) => join(common.env.LOCALAPPDATA ?? join(common.home, 'AppData', 'Local'), 'RadioCLI', 'scheduler', `${jobName(id)}.xml`);
|
|
221
246
|
return {
|
|
222
|
-
capabilities: () => ({ supported: true, exactWake: false, catchUpAfterWake: true, message: 'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.' }),
|
|
223
|
-
async install(alarm, occurrence) {
|
|
224
|
-
|
|
247
|
+
capabilities: () => ({ name: 'Task Scheduler', supported: true, exactWake: false, catchUpAfterWake: true, message: 'Task Scheduler can request wake when hardware and policy permit, but exact wake cannot be guaranteed. Resync after changing the host timezone; a logged-in audio session is required. Alarm playback has no scheduler execution-time cutoff.' }),
|
|
248
|
+
async install(alarm, occurrence) {
|
|
249
|
+
const name = `\\RadioCLI\\${jobName(alarm.id)}`;
|
|
250
|
+
const direct = nodeLaunchCommand(common.nodePath, invocationArgs(common, alarm.id, occurrence), runtimeEnvironment(common));
|
|
251
|
+
// Task Scheduler expands %NAME% in Path and Arguments itself. Only its
|
|
252
|
+
// standard PowerShell path and fixed flags/encoded data cross that boundary.
|
|
253
|
+
const command = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
254
|
+
const args = powershellCommand(direct).join(' ');
|
|
255
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?><Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><RegistrationInfo><Description>${xml(alarm.label)}</Description></RegistrationInfo><Triggers><TimeTrigger><StartBoundary>${windowsLocalBoundary(occurrence)}</StartBoundary><Enabled>true</Enabled></TimeTrigger></Triggers><Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><StartWhenAvailable>true</StartWhenAvailable><WakeToRun>${alarm.reliability.wakeIfSupported}</WakeToRun><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy></Settings><Actions Context="Author"><Exec><Command>${xml(command)}</Command><Arguments>${xml(args)}</Arguments></Exec></Actions></Task>`;
|
|
256
|
+
const path = xmlPath(alarm.id);
|
|
257
|
+
common.write(path, body);
|
|
258
|
+
ensureSuccess(await common.run('schtasks.exe', ['/Create', '/TN', name, '/XML', path, '/F']), 'schtasks create');
|
|
259
|
+
},
|
|
260
|
+
async remove(id) {
|
|
261
|
+
const name = `\\RadioCLI\\${jobName(id)}`;
|
|
262
|
+
const removal = await settledOperation(async () => {
|
|
263
|
+
const result = await boundedWindowsCommand(() => common.run('schtasks.exe', ['/Delete', '/TN', name, '/F']), 'deletion');
|
|
264
|
+
if (result.code !== 0)
|
|
265
|
+
throw new Error(`Task Scheduler job removal failed: ${windowsCommandMessage(result)}`);
|
|
266
|
+
});
|
|
267
|
+
let state;
|
|
268
|
+
try {
|
|
269
|
+
const result = await boundedWindowsCommand(() => common.run('powershell.exe', windowsTaskLookupCommand(name)), 'absence lookup');
|
|
270
|
+
state = windowsTaskState(result, name);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
throw new Error(`Unable to verify Task Scheduler job removal; repair artifacts were retained. ${removal.error ?? ''} ${errorMessage(error)}`);
|
|
274
|
+
}
|
|
275
|
+
if (state.state !== 'absent')
|
|
276
|
+
throw new Error(`Unable to verify Task Scheduler job absence; repair artifacts were retained. ${removal.error ?? ''} ${state.message}`);
|
|
277
|
+
common.removeFile(xmlPath(id));
|
|
278
|
+
},
|
|
225
279
|
async status(id) { const result = await common.run('schtasks.exe', ['/Query', '/TN', `\\RadioCLI\\${jobName(id)}`]); const artifact = existsSync(xmlPath(id)); const executable = existsSync(common.nodePath) && existsSync(common.cliPath); return { installed: result.code === 0 && artifact, healthy: result.code === 0 && artifact && executable, message: result.code !== 0 ? 'task not registered' : !artifact ? 'task XML artifact missing' : !executable ? 'RadioCLI executable missing' : 'registered and executable' }; }
|
|
226
280
|
};
|
|
227
281
|
}
|
|
282
|
+
function windowsTaskLookupCommand(taskPath) {
|
|
283
|
+
// Only GetTask failures can establish absence. A missing COM service or root
|
|
284
|
+
// folder must remain a repairable error, regardless of the UI language.
|
|
285
|
+
const encodedPath = Buffer.from(taskPath, 'utf8').toString('base64');
|
|
286
|
+
const script = [
|
|
287
|
+
"$ErrorActionPreference='Stop'",
|
|
288
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
289
|
+
'[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)',
|
|
290
|
+
`$radiocliTaskPath=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
|
|
291
|
+
"$radiocliResult=@{taskPath=$radiocliTaskPath;stage='connect';status='error';hresult=0;message=''}",
|
|
292
|
+
"try{$radiocliService=New-Object -ComObject 'Schedule.Service';$null=$radiocliService.Connect();$radiocliResult.stage='root';$radiocliFolder=$radiocliService.GetFolder('\\');$radiocliResult.stage='lookup';$null=$radiocliFolder.GetTask($radiocliTaskPath);$radiocliResult.status='present'}catch{$radiocliException=$_.Exception;$radiocliResult.message=$radiocliException.ToString();for($radiocliDepth=0;$null -ne $radiocliException.InnerException -and $radiocliDepth -lt 16;$radiocliDepth++){$radiocliException=$radiocliException.InnerException};$radiocliResult.hresult=$radiocliException.HResult}",
|
|
293
|
+
'[Console]::WriteLine(($radiocliResult | ConvertTo-Json -Compress))',
|
|
294
|
+
'exit 0'
|
|
295
|
+
].join(';');
|
|
296
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
297
|
+
}
|
|
298
|
+
function windowsTaskState(result, taskPath) {
|
|
299
|
+
const unknown = (message) => ({ state: 'unknown', message: `${message} ${windowsCommandMessage(result)}` });
|
|
300
|
+
if (result.code !== 0)
|
|
301
|
+
return unknown('Task Scheduler lookup process failed.');
|
|
302
|
+
let value;
|
|
303
|
+
try {
|
|
304
|
+
value = JSON.parse(result.stdout);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return unknown('Malformed Task Scheduler lookup response.');
|
|
308
|
+
}
|
|
309
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
310
|
+
return unknown('Malformed Task Scheduler lookup response.');
|
|
311
|
+
const response = value;
|
|
312
|
+
if (response.taskPath !== taskPath || !['connect', 'root', 'lookup'].includes(String(response.stage)) ||
|
|
313
|
+
!['present', 'error'].includes(String(response.status)) || typeof response.hresult !== 'number' ||
|
|
314
|
+
!Number.isInteger(response.hresult) || response.hresult < -2147483648 || response.hresult > 2147483647 || typeof response.message !== 'string') {
|
|
315
|
+
return unknown('Invalid or unrelated Task Scheduler lookup response.');
|
|
316
|
+
}
|
|
317
|
+
if (response.status === 'present' && response.stage === 'lookup' && response.hresult === 0)
|
|
318
|
+
return { state: 'present', message: 'Task Scheduler job is still registered.' };
|
|
319
|
+
// HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND), from the
|
|
320
|
+
// exact GetTask lookup. Never interpret schtasks exit 1 or localized text.
|
|
321
|
+
if (response.status === 'error' && response.stage === 'lookup' && [-2147024894, -2147024893].includes(response.hresult))
|
|
322
|
+
return { state: 'absent', message: response.message };
|
|
323
|
+
return unknown(`Task Scheduler ${response.stage} HRESULT 0x${(response.hresult >>> 0).toString(16).padStart(8, '0')}: ${response.message}`);
|
|
324
|
+
}
|
|
325
|
+
function windowsCommandMessage(result) { return [`exit ${result.code}`, result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(' '); }
|
|
326
|
+
async function boundedWindowsCommand(work, label) {
|
|
327
|
+
let timer;
|
|
328
|
+
try {
|
|
329
|
+
return await Promise.race([Promise.resolve().then(work), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Task Scheduler ${label} timed out.`)), 10_000); })]);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
if (timer)
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
228
336
|
function unsupportedAdapter(message) { return { capabilities: () => ({ supported: false, exactWake: false, catchUpAfterWake: false, message }), install: async () => { throw new Error(message); }, remove: async () => { }, status: async () => ({ installed: false, healthy: false, message }) }; }
|
|
229
|
-
function
|
|
337
|
+
function unavailableAdapter(message) {
|
|
338
|
+
return { ...unsupportedAdapter(message), remove: async () => { throw new Error(message); } };
|
|
339
|
+
}
|
|
340
|
+
function runCommand(command, args) { return new Promise(resolve => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000 }); let stdout = ''; let stderr = ''; child.stdout.on('data', v => stdout += String(v)); child.stderr.on('data', v => stderr += String(v)); child.on('error', e => resolve({ code: 127, stdout, stderr: e.message })); child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); }); }
|
|
341
|
+
async function boundedProbe(probe) { let timer; try {
|
|
342
|
+
return await Promise.race([probe, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('User-manager probe timed out.')), 3_000); })]);
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
if (timer)
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
} }
|
|
348
|
+
async function removeWithVerification(remove, query, isAbsent, label) {
|
|
349
|
+
let failure;
|
|
350
|
+
try {
|
|
351
|
+
ensureSuccess(await remove(), `${label} removal`);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
failure = error;
|
|
355
|
+
}
|
|
356
|
+
if (!failure)
|
|
357
|
+
return;
|
|
358
|
+
let result;
|
|
359
|
+
try {
|
|
360
|
+
result = await query();
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
throw new Error(`Unable to verify ${label} removal: ${errorMessage(error)}`);
|
|
364
|
+
}
|
|
365
|
+
if (isAbsent(result))
|
|
366
|
+
return;
|
|
367
|
+
throw new Error(`Unable to verify ${label} absence; repair artifacts were retained. ${errorMessage(failure)} ${commandMessage(result)}`);
|
|
368
|
+
}
|
|
369
|
+
function commandMessage(result) { return (result.stderr || result.stdout || `exit ${result.code}`).trim(); }
|
|
230
370
|
function ensureSuccess(result, label) { if (result.code !== 0)
|
|
231
371
|
throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); }
|
|
232
372
|
function xml(value) { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
233
373
|
function systemdQuote(value) { if (/[\r\n\0]/.test(value))
|
|
234
374
|
throw new Error('Scheduler arguments cannot contain control characters.'); return `"${value.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; }
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
375
|
+
function systemdExecCommand(values) {
|
|
376
|
+
// ':' disables $VAR/${VAR} expansion for ExecStart. Environment= has
|
|
377
|
+
// different syntax: its dollar signs are already literal and must stay so.
|
|
378
|
+
return `:${values.map(systemdQuote).join(' ')}`;
|
|
379
|
+
}
|
|
238
380
|
function localParts(date) { return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds() }; }
|
|
239
381
|
function systemdCalendar(date) { return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; }
|
|
240
382
|
function windowsLocalBoundary(date) { const p = localParts(date); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}T${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}`; }
|
|
241
383
|
function unitText(value) { return value.replace(/[\r\n\0]/g, ' ').replaceAll('%', '%%').slice(0, 200); }
|
|
242
384
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
243
|
-
function isLaunchdServiceNotFound(result, label) { const output = `${result.stderr}\n${result.stdout}`;
|
|
385
|
+
function isLaunchdServiceNotFound(result, label) { const output = `${result.stderr}\n${result.stdout}`; const match = /could not find service "([^"]+)" in domain for\b/i.exec(output); return result.code !== 0 && match?.[1] === label; }
|
|
244
386
|
async function mapConcurrent(items, limit, work) { const results = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (true) {
|
|
245
387
|
const index = next;
|
|
246
388
|
next += 1;
|
|
@@ -48,7 +48,7 @@ export async function verifyAlarmSetup(scheduler, alarm, settings, onUpdate = ()
|
|
|
48
48
|
const [status] = await scheduler.statusAll([temporary]);
|
|
49
49
|
if (!status?.native.installed || !status.native.healthy)
|
|
50
50
|
throw new Error(status?.native.message ?? 'The native job could not be verified.');
|
|
51
|
-
set('scheduler', 'passed', `Registered and queried a disposable ${
|
|
51
|
+
set('scheduler', 'passed', `Registered and queried a disposable ${capability.name ?? 'native scheduler'} job.`);
|
|
52
52
|
if (alarm?.reliability.wakeIfSupported)
|
|
53
53
|
set('wake', capability.exactWake ? 'passed' : 'warning', capability.exactWake ? 'This scheduler reports exact wake support.' : `The OS accepted the job, but wake remains hardware and policy dependent. ${capability.message}`);
|
|
54
54
|
else
|
|
@@ -174,7 +174,6 @@ finally {
|
|
|
174
174
|
await server.close();
|
|
175
175
|
rmSync(root, { recursive: true, force: true });
|
|
176
176
|
} }
|
|
177
|
-
function platformSchedulerName() { return process.platform === 'darwin' ? 'launchd' : process.platform === 'win32' ? 'Task Scheduler' : 'systemd'; }
|
|
178
177
|
function friendlyTerminal(value) { return value.replace(/^darwin:/, '').replace(/^win32:/, '').replace(/^linux:/, ''); }
|
|
179
178
|
function messageOf(error) { return error instanceof Error ? error.message : String(error); }
|
|
180
179
|
function wait(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); }
|