@ciphore/radiocli 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -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 +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- 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/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- 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 +28 -21
- 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/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/setup.js
CHANGED
|
@@ -1,50 +1,102 @@
|
|
|
1
|
+
import { componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel } from './platform/packages.js';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
3
4
|
import { createInterface } from 'node:readline/promises';
|
|
4
|
-
import { clearCommandCache, commandExists } from './
|
|
5
|
+
import { clearCommandCache, commandExists, resolveCommand } from './platform/executables.js';
|
|
5
6
|
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
6
7
|
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
7
|
-
import { JsonLibraryStore } from './storage/store.js';
|
|
8
|
+
import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
|
|
8
9
|
import { defaultAgentControlSettings } from './types.js';
|
|
10
|
+
import { resolveTerminalCapabilities } from './platform/terminal.js';
|
|
11
|
+
import { isAirPlayPlatformSupported } from './player/airplay-policy.js';
|
|
12
|
+
import { probeAirPlayReadiness } from './player/airplay-readiness.js';
|
|
13
|
+
import { airPlaySetupVerification, hasSystemdSystemManager, linuxAirPlaySetupPlan } from './player/airplay-setup.js';
|
|
14
|
+
import { readSettingsWithoutMigration } from './storage/read-settings.js';
|
|
15
|
+
import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
|
|
16
|
+
import { verifyLocalPlayback } from './setup-playback.js';
|
|
9
17
|
const components = ['mpv', 'ffmpeg', 'vlc'];
|
|
10
|
-
const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
11
18
|
export async function runSetup(options = {}) {
|
|
12
19
|
const platform = options.platform ?? process.platform;
|
|
13
20
|
const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
|
|
21
|
+
const env = options.env ?? process.env;
|
|
22
|
+
const host = identifyPlatform({ platform, osRelease, env });
|
|
14
23
|
const input = options.input ?? process.stdin;
|
|
15
24
|
const output = options.output ?? process.stdout;
|
|
16
25
|
const hasCommand = options.hasCommand ?? commandExists;
|
|
17
26
|
const parsed = parseSetupArgs(options.args ?? []);
|
|
27
|
+
const linuxAirPlayEligible = platform === 'linux' && isAirPlayPlatformSupported(platform, env, host.release);
|
|
28
|
+
if (parsed.airplay !== null && !linuxAirPlayEligible)
|
|
29
|
+
throw new Error('--airplay and --no-airplay configure experimental native Linux AirPlay only. macOS keeps its existing AirPlay setup; this environment has no Linux opt-in.');
|
|
30
|
+
const prompt = options.prompt ?? ((question, defaultValue) => promptYesNo(input, output, question, defaultValue));
|
|
31
|
+
const libraryPath = () => options.libraryPath ?? defaultLibraryPath({ platform, env, home: env.HOME ?? env.USERPROFILE });
|
|
32
|
+
const probe = options.probeAirPlay ?? ((loadSender) => probeAirPlayReadiness({ platform, env, release: host.release, hasCommand, ...(loadSender ? {} : { sender: async () => ({ loaded: false, message: 'Not probed before approval.' }) }) }));
|
|
33
|
+
if (parsed.packageManager)
|
|
34
|
+
validateNativePackageManager(parsed.packageManager, host);
|
|
18
35
|
const installed = detectInstalledComponents(hasCommand);
|
|
19
|
-
const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
|
|
36
|
+
const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env);
|
|
20
37
|
writeHeader(output);
|
|
21
|
-
output.write(`System ${platformLabel(platform, osRelease)}
|
|
38
|
+
output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`);
|
|
22
39
|
output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
|
|
23
40
|
let selected = parsed.only ?? defaultComponents(platform, parsed.all);
|
|
24
41
|
if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
|
|
25
|
-
selected = await promptForComponents({ platform, installed,
|
|
42
|
+
selected = await promptForComponents({ platform, installed, output, prompt });
|
|
43
|
+
}
|
|
44
|
+
let airplay = parsed.airplay;
|
|
45
|
+
if (linuxAirPlayEligible && airplay === null && !parsed.yes && !parsed.only && isInteractive(input, output)) {
|
|
46
|
+
const enabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
|
|
47
|
+
const choice = await prompt(' AirPlay — experimental (FFmpeg, shared Avahi network discovery)', enabled);
|
|
48
|
+
// Declining the default on a new install leaves the library untouched.
|
|
49
|
+
if (choice || enabled)
|
|
50
|
+
airplay = choice;
|
|
26
51
|
}
|
|
27
52
|
let mcp = parsed.mcp;
|
|
28
53
|
if (mcp === null && !parsed.yes && isInteractive(input, output)) {
|
|
29
|
-
mcp = await
|
|
54
|
+
mcp = await prompt(' Agent control via MCP (detected coding agents)', true);
|
|
30
55
|
}
|
|
31
56
|
let agentUi = parsed.agentUi;
|
|
32
57
|
if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
|
|
33
58
|
const note = platform === 'darwin'
|
|
34
59
|
? ' (macOS will ask the agent app to control Terminal on first use)'
|
|
35
60
|
: ' (opens a separate terminal window)';
|
|
36
|
-
agentUi = await
|
|
37
|
-
}
|
|
38
|
-
const
|
|
61
|
+
agentUi = await prompt(` Open the RadioCLI TUI for agent playback${note}`, true);
|
|
62
|
+
}
|
|
63
|
+
const isRoot = (options.getUid ?? process.getuid)?.() === 0;
|
|
64
|
+
const elevation = packageManagerElevation(hasCommand, isRoot);
|
|
65
|
+
const plan = createSetupPlan({ platform, osRelease, env, packageManager, installed, selected, elevation });
|
|
66
|
+
const airPlayPlan = airplay === true ? linuxAirPlaySetupPlan({ manager: packageManager, elevation, hasCommand,
|
|
67
|
+
systemd: (options.isSystemd ?? hasSystemdSystemManager)(),
|
|
68
|
+
serviceReady: parsed.dryRun ? false : (await probe(false).catch(() => null))?.discoveryService === 'ready', existingCommands: plan.commands }) : null;
|
|
69
|
+
if (airPlayPlan)
|
|
70
|
+
plan.commands.push(...airPlayPlan.commands);
|
|
39
71
|
printPlan(plan, output);
|
|
72
|
+
if (airplay !== null)
|
|
73
|
+
output.write(` Linux AirPlay: ${airplay ? 'enable for this library after accepted setup; verify sender and discovery' : 'disable for this library; keep installed packages and shared services'}\n`);
|
|
74
|
+
for (const note of airPlayPlan?.notes ?? [])
|
|
75
|
+
output.write(` Note: ${note}\n`);
|
|
76
|
+
if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) {
|
|
77
|
+
output.write(' Note: Run package commands as root; sudo/doas unavailable.\n');
|
|
78
|
+
}
|
|
79
|
+
for (const note of packageManagerNotes(packageManager, host))
|
|
80
|
+
output.write(` Note: ${note}\n`);
|
|
81
|
+
const verify = async () => {
|
|
82
|
+
let linuxAirPlayEnabled = airplay === true;
|
|
83
|
+
if (platform === 'linux' && airplay === null) {
|
|
84
|
+
try {
|
|
85
|
+
linuxAirPlayEnabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
|
|
86
|
+
}
|
|
87
|
+
catch { /* Unreadable consent stays disabled. */ }
|
|
88
|
+
}
|
|
89
|
+
await printVerification(output, { platform, osRelease, env, release: host.release, hasCommand, getUid: options.getUid, linuxAirPlayEnabled, verifyPlayback: options.verifyPlayback });
|
|
90
|
+
};
|
|
40
91
|
const missing = plan.selected.filter(component => !plan.installed[component]);
|
|
41
|
-
if (missing.length === 0) {
|
|
92
|
+
if (missing.length === 0 && airplay === null) {
|
|
42
93
|
output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
|
|
43
94
|
await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
|
|
44
|
-
|
|
95
|
+
if (!parsed.dryRun)
|
|
96
|
+
await verify();
|
|
45
97
|
return;
|
|
46
98
|
}
|
|
47
|
-
if (!plan.packageManager) {
|
|
99
|
+
if (!plan.packageManager && missing.length > 0) {
|
|
48
100
|
if (parsed.dryRun) {
|
|
49
101
|
output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
|
|
50
102
|
await finishMcpSetup(mcp, agentUi, true, output);
|
|
@@ -57,11 +109,20 @@ export async function runSetup(options = {}) {
|
|
|
57
109
|
await finishMcpSetup(mcp, agentUi, true, output);
|
|
58
110
|
return;
|
|
59
111
|
}
|
|
60
|
-
|
|
61
|
-
|
|
112
|
+
const manual = missing.filter(component => !plan.commands.some(command => command.component === component));
|
|
113
|
+
if (manual.length)
|
|
114
|
+
throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`);
|
|
115
|
+
if (plan.packageManager === 'termux-pkg' && isRoot)
|
|
116
|
+
throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.');
|
|
117
|
+
if ((plan.packageManager && packageManagerNeedsRoot(plan.packageManager) || airPlayPlan?.commands.length) && plan.commands.length > 0 && !isRoot && !elevation) {
|
|
118
|
+
throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.');
|
|
119
|
+
}
|
|
120
|
+
if (plan.commands.length > 0 && parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
|
|
121
|
+
const program = packageManagerProgram(parsed.packageManager);
|
|
122
|
+
throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`);
|
|
62
123
|
}
|
|
63
124
|
if (!parsed.yes && isInteractive(input, output)) {
|
|
64
|
-
const confirmed = await
|
|
125
|
+
const confirmed = await prompt(airplay === null ? '\nInstall these components?' : '\nApply this setup plan and Linux AirPlay preference?', true);
|
|
65
126
|
if (!confirmed) {
|
|
66
127
|
output.write('Setup cancelled. No system packages were changed.\n');
|
|
67
128
|
return;
|
|
@@ -70,62 +131,59 @@ export async function runSetup(options = {}) {
|
|
|
70
131
|
else if (!parsed.yes) {
|
|
71
132
|
throw new Error('Setup needs an interactive terminal. Use --yes to approve the displayed commands or --dry-run to inspect them.');
|
|
72
133
|
}
|
|
73
|
-
if (plan.commands.some(command => command.program === 'sudo')) {
|
|
134
|
+
if (!options.runCommand && plan.commands.some(command => command.program === 'sudo')) {
|
|
74
135
|
output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
|
|
75
|
-
await runVisibleCommand('sudo', ['-v']);
|
|
136
|
+
await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
|
|
76
137
|
}
|
|
77
|
-
const execute = options.runCommand ?? runInstallCommand;
|
|
78
|
-
|
|
138
|
+
const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
|
|
139
|
+
if (plan.commands.length)
|
|
140
|
+
output.write('\nInstalling\n');
|
|
79
141
|
for (const command of plan.commands) {
|
|
80
142
|
await execute(command, output);
|
|
81
143
|
}
|
|
82
144
|
clearCommandCache();
|
|
145
|
+
if (airplay !== null) {
|
|
146
|
+
if (options.saveLinuxAirPlayEnabled)
|
|
147
|
+
options.saveLinuxAirPlayEnabled(airplay);
|
|
148
|
+
else
|
|
149
|
+
new JsonLibraryStore(libraryPath()).updateSettings({ linuxAirPlayEnabled: airplay });
|
|
150
|
+
output.write(`\nLinux AirPlay ${airplay ? 'enabled' : 'disabled'} for this library. Restart active RadioCLI sessions to apply this preference.\n`);
|
|
151
|
+
if (airplay) {
|
|
152
|
+
const readiness = await probe(true).catch(() => ({ eligible: true, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false, message: 'Readiness could not be verified. Run radiocli doctor.' }));
|
|
153
|
+
for (const line of airPlaySetupVerification(readiness))
|
|
154
|
+
output.write(` ${line}\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
83
157
|
output.write('\nSetup complete.\n');
|
|
84
158
|
await finishMcpSetup(mcp, agentUi, false, output);
|
|
85
|
-
|
|
159
|
+
await verify();
|
|
86
160
|
}
|
|
87
|
-
export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
|
|
161
|
+
export function createSetupPlan({ platform, osRelease = '', env = process.env, packageManager, installed, selected, elevation = 'sudo' }) {
|
|
162
|
+
if (packageManager)
|
|
163
|
+
validateNativePackageManager(packageManager, identifyPlatform({ platform, osRelease, env }));
|
|
88
164
|
const uniqueSelected = components.filter(component => selected.includes(component));
|
|
89
165
|
const missing = uniqueSelected.filter(component => !installed[component]);
|
|
90
|
-
const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : [];
|
|
91
|
-
if (packageManager
|
|
92
|
-
commands.unshift(
|
|
93
|
-
component: null,
|
|
94
|
-
label: 'Scoop extras bucket',
|
|
95
|
-
program: 'scoop',
|
|
96
|
-
args: ['bucket', 'add', 'extras'],
|
|
97
|
-
display: 'scoop bucket add extras'
|
|
98
|
-
});
|
|
99
|
-
}
|
|
166
|
+
const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, { elevation })).filter((command) => command !== null) : [];
|
|
167
|
+
if (packageManager)
|
|
168
|
+
commands.unshift(...packageInstallPrerequisites(packageManager, missing));
|
|
100
169
|
return {
|
|
101
170
|
platform,
|
|
102
|
-
platformLabel: platformLabel(platform, osRelease),
|
|
171
|
+
platformLabel: platformLabel(platform, osRelease, env),
|
|
103
172
|
packageManager,
|
|
104
173
|
installed,
|
|
105
174
|
selected: uniqueSelected,
|
|
106
175
|
commands
|
|
107
176
|
};
|
|
108
177
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
? ['apt']
|
|
119
|
-
: hasAny(ids, ['fedora', 'rhel', 'centos'])
|
|
120
|
-
? ['dnf']
|
|
121
|
-
: hasAny(ids, ['arch', 'manjaro'])
|
|
122
|
-
? ['pacman']
|
|
123
|
-
: hasAny(ids, ['alpine'])
|
|
124
|
-
? ['apk']
|
|
125
|
-
: hasAny(ids, ['opensuse', 'suse'])
|
|
126
|
-
? ['zypper']
|
|
127
|
-
: ['apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
128
|
-
return firstAvailable(preferred, hasCommand);
|
|
178
|
+
function validateNativePackageManager(manager, host) {
|
|
179
|
+
const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null;
|
|
180
|
+
if ((required && manager !== required)
|
|
181
|
+
|| (manager === 'termux-pkg' && host.id !== 'termux')
|
|
182
|
+
|| (manager === 'pkgman' && host.id !== 'haiku')
|
|
183
|
+
|| (manager === 'pkg' && host.id !== 'freebsd')
|
|
184
|
+
|| ['aix', 'android', 'unknown'].includes(host.id)) {
|
|
185
|
+
throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`);
|
|
186
|
+
}
|
|
129
187
|
}
|
|
130
188
|
export function parseSetupArgs(args) {
|
|
131
189
|
let all = false;
|
|
@@ -135,6 +193,7 @@ export function parseSetupArgs(args) {
|
|
|
135
193
|
let packageManager = null;
|
|
136
194
|
let mcp = null;
|
|
137
195
|
let agentUi = null;
|
|
196
|
+
let airplay = null;
|
|
138
197
|
for (let index = 0; index < args.length; index += 1) {
|
|
139
198
|
const arg = args[index];
|
|
140
199
|
if (arg === '--all')
|
|
@@ -143,6 +202,12 @@ export function parseSetupArgs(args) {
|
|
|
143
202
|
dryRun = true;
|
|
144
203
|
else if (arg === '--yes' || arg === '-y')
|
|
145
204
|
yes = true;
|
|
205
|
+
else if (arg === '--airplay' || arg === '--no-airplay') {
|
|
206
|
+
const enabled = arg === '--airplay';
|
|
207
|
+
if (airplay !== null && airplay !== enabled)
|
|
208
|
+
throw new Error('Use either --airplay or --no-airplay, not both.');
|
|
209
|
+
airplay = enabled;
|
|
210
|
+
}
|
|
146
211
|
else if (arg === '--mcp') {
|
|
147
212
|
if (mcp === false)
|
|
148
213
|
throw new Error('Use either --mcp or --no-mcp, not both.');
|
|
@@ -178,7 +243,7 @@ export function parseSetupArgs(args) {
|
|
|
178
243
|
throw new Error('Use either --all or --only, not both.');
|
|
179
244
|
if (agentUi !== null && mcp !== true)
|
|
180
245
|
throw new Error('--agent-ui and --headless-agent require --mcp.');
|
|
181
|
-
return { all, dryRun, yes, only, packageManager, mcp, agentUi };
|
|
246
|
+
return { all, dryRun, yes, only, packageManager, mcp, agentUi, airplay };
|
|
182
247
|
}
|
|
183
248
|
async function finishMcpSetup(enabled, agentUi, dryRun, output) {
|
|
184
249
|
if (enabled === null)
|
|
@@ -220,51 +285,6 @@ function parsePackageManager(value) {
|
|
|
220
285
|
}
|
|
221
286
|
return value;
|
|
222
287
|
}
|
|
223
|
-
function packageInstallCommand(manager, component) {
|
|
224
|
-
const packages = {
|
|
225
|
-
brew: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
226
|
-
winget: { mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC' },
|
|
227
|
-
scoop: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
228
|
-
choco: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
229
|
-
apt: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
230
|
-
dnf: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
231
|
-
pacman: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
232
|
-
apk: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
233
|
-
zypper: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' }
|
|
234
|
-
};
|
|
235
|
-
const packageName = packages[manager][component];
|
|
236
|
-
let program = manager;
|
|
237
|
-
let args;
|
|
238
|
-
if (manager === 'brew')
|
|
239
|
-
args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])];
|
|
240
|
-
else if (manager === 'winget')
|
|
241
|
-
args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements'];
|
|
242
|
-
else if (manager === 'scoop')
|
|
243
|
-
args = ['install', packageName];
|
|
244
|
-
else if (manager === 'choco')
|
|
245
|
-
args = ['install', packageName, '-y'];
|
|
246
|
-
else if (manager === 'apt') {
|
|
247
|
-
program = 'sudo';
|
|
248
|
-
args = ['apt-get', 'install', '-y', packageName];
|
|
249
|
-
}
|
|
250
|
-
else if (manager === 'dnf') {
|
|
251
|
-
program = 'sudo';
|
|
252
|
-
args = ['dnf', 'install', '-y', packageName];
|
|
253
|
-
}
|
|
254
|
-
else if (manager === 'pacman') {
|
|
255
|
-
program = 'sudo';
|
|
256
|
-
args = ['pacman', '-S', '--needed', '--noconfirm', packageName];
|
|
257
|
-
}
|
|
258
|
-
else if (manager === 'apk') {
|
|
259
|
-
program = 'sudo';
|
|
260
|
-
args = ['apk', 'add', packageName];
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
program = 'sudo';
|
|
264
|
-
args = ['zypper', '--non-interactive', 'install', packageName];
|
|
265
|
-
}
|
|
266
|
-
return { component, label: componentLabel(component), program, args, display: [program, ...args].join(' ') };
|
|
267
|
-
}
|
|
268
288
|
function defaultComponents(platform, all) {
|
|
269
289
|
if (all)
|
|
270
290
|
return [...components];
|
|
@@ -277,14 +297,14 @@ function detectInstalledComponents(hasCommand) {
|
|
|
277
297
|
vlc: hasCommand('vlc') || hasCommand('cvlc')
|
|
278
298
|
};
|
|
279
299
|
}
|
|
280
|
-
async function promptForComponents({ platform, installed,
|
|
300
|
+
async function promptForComponents({ platform, installed, output, prompt }) {
|
|
281
301
|
output.write('Choose components (installed items will be skipped):\n');
|
|
282
302
|
const selected = [];
|
|
283
|
-
if (await
|
|
303
|
+
if (await prompt(` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
|
|
284
304
|
selected.push('mpv');
|
|
285
|
-
if (await
|
|
305
|
+
if (await prompt(` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
|
|
286
306
|
selected.push('ffmpeg');
|
|
287
|
-
if (await
|
|
307
|
+
if (await prompt(` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
|
|
288
308
|
selected.push('vlc');
|
|
289
309
|
return selected;
|
|
290
310
|
}
|
|
@@ -302,19 +322,23 @@ async function promptYesNo(input, output, question, defaultValue) {
|
|
|
302
322
|
}
|
|
303
323
|
function printPlan(plan, output) {
|
|
304
324
|
output.write('\nInstallation plan\n');
|
|
325
|
+
for (const command of plan.commands.filter(command => command.component === null)) {
|
|
326
|
+
output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`);
|
|
327
|
+
}
|
|
305
328
|
for (const component of plan.selected) {
|
|
306
329
|
if (plan.installed[component])
|
|
307
330
|
output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
|
|
308
331
|
else {
|
|
309
332
|
const command = plan.commands.find(candidate => candidate.component === component);
|
|
310
|
-
output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ?
|
|
333
|
+
output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`);
|
|
311
334
|
}
|
|
312
335
|
}
|
|
313
336
|
if (plan.selected.length === 0)
|
|
314
337
|
output.write(' No components selected\n');
|
|
315
338
|
}
|
|
316
|
-
async function runInstallCommand(command, output) {
|
|
317
|
-
const
|
|
339
|
+
async function runInstallCommand(command, output, platform) {
|
|
340
|
+
const terminal = setupTerminal(output);
|
|
341
|
+
const interactive = terminal.interactive && !terminal.reduceMotion;
|
|
318
342
|
const startedAt = Date.now();
|
|
319
343
|
let timer;
|
|
320
344
|
let frame = 0;
|
|
@@ -330,7 +354,8 @@ async function runInstallCommand(command, output) {
|
|
|
330
354
|
}
|
|
331
355
|
try {
|
|
332
356
|
await new Promise((resolve, reject) => {
|
|
333
|
-
const
|
|
357
|
+
const invocation = packageCommandInvocation(command, platform, resolveCommand);
|
|
358
|
+
const child = spawn(invocation.program, invocation.args, { shell: false, stdio: ['inherit', 'pipe', 'pipe'] });
|
|
334
359
|
child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
|
|
335
360
|
child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
|
|
336
361
|
child.once('error', reject);
|
|
@@ -358,55 +383,57 @@ function progressFrame(label, frame, elapsedMs, output) {
|
|
|
358
383
|
const cycle = travel * 2;
|
|
359
384
|
const offset = frame % cycle;
|
|
360
385
|
const start = offset <= travel ? offset : cycle - offset;
|
|
361
|
-
const
|
|
362
|
-
|
|
386
|
+
const unicode = setupTerminal(output).unicode;
|
|
387
|
+
const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join('');
|
|
388
|
+
return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
|
|
363
389
|
}
|
|
364
|
-
function printVerification(output) {
|
|
390
|
+
async function printVerification(output, { platform, osRelease, env, release, hasCommand, getUid, linuxAirPlayEnabled, verifyPlayback = verifyLocalPlayback }) {
|
|
365
391
|
clearCommandCache();
|
|
366
|
-
const backends = detectPlaybackBackends();
|
|
392
|
+
const backends = detectPlaybackBackends({ platform, env, release, hasCommand, settings: { linuxAirPlayEnabled } });
|
|
367
393
|
output.write('\nVerification\n');
|
|
368
394
|
output.write(` Playback backends: ${backends.join(', ') || 'none'}\n`);
|
|
369
|
-
for (const line of playbackBackendStatusLines(backends).slice(0, 3))
|
|
395
|
+
for (const line of playbackBackendStatusLines(backends, platform, osRelease, { hasCommand, getUid }).slice(0, 3))
|
|
370
396
|
output.write(` ${line}\n`);
|
|
397
|
+
const rehearsals = await verifyPlayback(backends, { platform, env });
|
|
398
|
+
for (const rehearsal of rehearsals)
|
|
399
|
+
output.write(` decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}\n`);
|
|
400
|
+
if (rehearsals.length === 0)
|
|
401
|
+
output.write(' decoder=unverified No local playback backend is available for a rehearsal.\n');
|
|
402
|
+
output.write(' physical_output=unverified Play a station on your intended speakers, headphones, or AirPlay receiver to verify that path.\n');
|
|
403
|
+
if (rehearsals.some(item => item.status !== 'passed'))
|
|
404
|
+
output.write(' Playback needs attention. Run radiocli doctor --playback for a repeatable local check.\n');
|
|
371
405
|
output.write('\nRun radiocli to start listening.\n');
|
|
372
406
|
}
|
|
373
407
|
function writeHeader(output) {
|
|
374
408
|
output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`);
|
|
375
|
-
output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`);
|
|
376
|
-
}
|
|
377
|
-
function platformLabel(platform, osRelease) {
|
|
378
|
-
if (platform === 'darwin')
|
|
379
|
-
return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`;
|
|
380
|
-
if (platform === 'win32')
|
|
381
|
-
return 'Windows';
|
|
382
|
-
if (platform === 'linux')
|
|
383
|
-
return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux';
|
|
384
|
-
return platform;
|
|
385
|
-
}
|
|
386
|
-
function componentLabel(component) {
|
|
387
|
-
if (component === 'ffmpeg')
|
|
388
|
-
return 'FFmpeg / ffplay';
|
|
389
|
-
if (component === 'vlc')
|
|
390
|
-
return 'VLC fallback';
|
|
391
|
-
return 'mpv';
|
|
409
|
+
output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`);
|
|
392
410
|
}
|
|
393
411
|
function isInteractive(input, output) {
|
|
394
412
|
return Boolean(input.isTTY && output.isTTY);
|
|
395
413
|
}
|
|
396
414
|
function accent(output, value) {
|
|
397
|
-
|
|
415
|
+
const level = setupTerminal(output).colorLevel;
|
|
416
|
+
const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32';
|
|
417
|
+
return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
|
|
398
418
|
}
|
|
399
419
|
function successMark(output) {
|
|
400
|
-
return accent(output, '✓');
|
|
420
|
+
return accent(output, setupTerminal(output).unicode ? '✓' : '+');
|
|
401
421
|
}
|
|
402
422
|
function pendingMark(output) {
|
|
403
|
-
return accent(output, '◆');
|
|
423
|
+
return accent(output, setupTerminal(output).unicode ? '◆' : '*');
|
|
404
424
|
}
|
|
405
425
|
function failureMark(output) {
|
|
406
|
-
|
|
426
|
+
const terminal = setupTerminal(output);
|
|
427
|
+
const value = terminal.unicode ? '✗' : 'x';
|
|
428
|
+
const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31';
|
|
429
|
+
return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
|
|
407
430
|
}
|
|
408
|
-
function
|
|
409
|
-
|
|
431
|
+
function setupTerminal(output) {
|
|
432
|
+
const stream = output;
|
|
433
|
+
return resolveTerminalCapabilities(process.env, { isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.() });
|
|
434
|
+
}
|
|
435
|
+
function separator(output) {
|
|
436
|
+
return setupTerminal(output).unicode ? '·' : '-';
|
|
410
437
|
}
|
|
411
438
|
function clearLine() {
|
|
412
439
|
return '\u001b[2K';
|
|
@@ -428,35 +455,3 @@ async function runVisibleCommand(program, args) {
|
|
|
428
455
|
child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
|
|
429
456
|
});
|
|
430
457
|
}
|
|
431
|
-
function firstAvailable(values, hasCommand) {
|
|
432
|
-
return values.find(hasCommand) ?? null;
|
|
433
|
-
}
|
|
434
|
-
function readLinuxOsRelease(platform) {
|
|
435
|
-
if (platform !== 'linux' || !existsSync('/etc/os-release'))
|
|
436
|
-
return '';
|
|
437
|
-
try {
|
|
438
|
-
return readFileSync('/etc/os-release', 'utf8');
|
|
439
|
-
}
|
|
440
|
-
catch {
|
|
441
|
-
return '';
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
function linuxReleaseIds(osRelease) {
|
|
445
|
-
const ids = new Set();
|
|
446
|
-
for (const line of osRelease.split('\n')) {
|
|
447
|
-
const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
|
|
448
|
-
if (!match)
|
|
449
|
-
continue;
|
|
450
|
-
for (const value of match[2].replaceAll('"', '').split(/\s+/))
|
|
451
|
-
if (value.trim())
|
|
452
|
-
ids.add(value.trim().toLowerCase());
|
|
453
|
-
}
|
|
454
|
-
return ids;
|
|
455
|
-
}
|
|
456
|
-
function osReleaseValue(osRelease, key) {
|
|
457
|
-
const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`));
|
|
458
|
-
return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? '';
|
|
459
|
-
}
|
|
460
|
-
function hasAny(values, candidates) {
|
|
461
|
-
return candidates.some(candidate => values.has(candidate));
|
|
462
|
-
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
/** An owner is never evicted for being slow. Only a positively dead PID or an
|
|
6
|
+
* explicitly released generation can be reclaimed. Unknown/legacy locks fail
|
|
7
|
+
* closed; their age cannot distinguish a crashed writer from a suspended one. */
|
|
8
|
+
export function acquireFileLock(directory, timeoutMs = 1000) {
|
|
9
|
+
const marker = `owner-${process.pid}-${randomUUID()}`;
|
|
10
|
+
const deadline = performance.now() + timeoutMs;
|
|
11
|
+
while (true) {
|
|
12
|
+
let acquired = false;
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
15
|
+
acquired = true;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (codeOf(error) !== 'EEXIST')
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
if (acquired) {
|
|
22
|
+
try {
|
|
23
|
+
writeFileSync(join(directory, marker), '', { mode: 0o600, flag: 'wx' });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// A failed write can leave its newly created marker behind. Remove only
|
|
27
|
+
// our unique file; never clear a directory belonging to a later writer.
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(join(directory, marker));
|
|
30
|
+
}
|
|
31
|
+
catch { /* May not have been created. */ }
|
|
32
|
+
try {
|
|
33
|
+
rmdirSync(directory);
|
|
34
|
+
}
|
|
35
|
+
catch { /* Never remove another generation. */ }
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
let released = false;
|
|
39
|
+
return () => { if (!released) {
|
|
40
|
+
released = true;
|
|
41
|
+
finishLock(directory, marker);
|
|
42
|
+
} };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const entries = readdirSync(directory);
|
|
46
|
+
const owner = entries.length === 1 ? entries[0] : '';
|
|
47
|
+
const match = /^(owner|released)-([1-9]\d*)-([a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12})$/.exec(owner);
|
|
48
|
+
if (match && Number.isSafeInteger(Number(match[2])) && (match[1] === 'released' || isDead(Number(match[2])))) {
|
|
49
|
+
// Compare and claim the exact marker, never rename/delete the lock
|
|
50
|
+
// directory after an observation: a different writer may now own it.
|
|
51
|
+
const reaper = `owner-${process.pid}-${randomUUID()}`;
|
|
52
|
+
renameSync(join(directory, owner), join(directory, reaper));
|
|
53
|
+
finishLock(directory, reaper);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (codeOf(error) !== 'ENOENT')
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (performance.now() >= deadline) {
|
|
61
|
+
throw Object.assign(new Error('The library lock has a live or unknown owner.'), { code: 'EBUSY' });
|
|
62
|
+
}
|
|
63
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function isDead(pid) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return codeOf(error) === 'ESRCH';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function finishLock(directory, marker) {
|
|
76
|
+
const released = `released-${process.pid}-${randomUUID()}`;
|
|
77
|
+
try {
|
|
78
|
+
renameSync(join(directory, marker), join(directory, released));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
try {
|
|
82
|
+
removeOwnedLock(directory, marker);
|
|
83
|
+
}
|
|
84
|
+
catch { /* Preserve the transaction result. */ }
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Publish completion before cleanup so a failed unlink is recoverable even
|
|
88
|
+
// while this process is alive. Cleanup must not turn a commit into a failure.
|
|
89
|
+
try {
|
|
90
|
+
removeOwnedLock(directory, released);
|
|
91
|
+
}
|
|
92
|
+
catch { /* Recover on the next acquisition. */ }
|
|
93
|
+
}
|
|
94
|
+
function removeOwnedLock(directory, marker) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(join(directory, marker));
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (codeOf(error) === 'ENOENT')
|
|
100
|
+
return;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
// An empty directory can be concurrently replaced only by a legacy writer.
|
|
104
|
+
// rmdir cannot remove a new nonempty generation, unlike recursive rm.
|
|
105
|
+
try {
|
|
106
|
+
rmdirSync(directory);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? ''))
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function codeOf(error) { return error?.code; }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
/** Bounded, read-only preference inspection. Never creates or repairs a library. */
|
|
3
|
+
export function readSettingsWithoutMigration(path) {
|
|
4
|
+
if (!path)
|
|
5
|
+
return undefined;
|
|
6
|
+
let descriptor;
|
|
7
|
+
const limit = 2 * 1024 * 1024;
|
|
8
|
+
try {
|
|
9
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
10
|
+
const info = fstatSync(descriptor);
|
|
11
|
+
if (!info.isFile() || info.size > limit)
|
|
12
|
+
return undefined;
|
|
13
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
14
|
+
const size = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
15
|
+
if (size > limit)
|
|
16
|
+
return undefined;
|
|
17
|
+
const data = JSON.parse(buffer.toString('utf8', 0, size));
|
|
18
|
+
if (!record(data))
|
|
19
|
+
return undefined;
|
|
20
|
+
return data.settings === undefined ? {} : record(data.settings) ? data.settings : undefined;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return error.code === 'ENOENT' ? {} : undefined;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
if (descriptor !== undefined)
|
|
27
|
+
try {
|
|
28
|
+
closeSync(descriptor);
|
|
29
|
+
}
|
|
30
|
+
catch { /* Read-only best effort. */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function record(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
|