@open-audio-stack/core
Version:
Open-source audio plugin management software
487 lines (486 loc) • 18.5 kB
JavaScript
import AdmZip from 'adm-zip';
import { execFileSync, execSync, spawn } from 'child_process';
import { createReadStream, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
import { createHash } from 'crypto';
import { unpack } from '7zip-min';
import stream from 'stream/promises';
import { globSync } from 'glob';
import { moveSync } from 'fs-extra/esm';
import os from 'os';
import * as tar from 'tar';
import path, { dirname } from 'path';
import yaml from 'js-yaml';
import { ZodIssueCode } from 'zod';
import { SystemType } from '../types/SystemType.js';
import { fileURLToPath } from 'url';
import sudoPrompt from '@vscode/sudo-prompt';
import { getSystem } from './utilsLocal.js';
import { log } from './utils.js';
import mime from 'mime-types';
export async function archiveExtract(filePath, dirPath) {
log('⎋', dirPath);
const fileName = path.basename(filePath).toLowerCase();
const ext = path.extname(filePath).trim().toLowerCase();
const tarExtensions = ['.tar', '.gz', '.tgz', '.xz', '.bz2', '.tbz2'];
const tarCompoundExtensions = ['.tar.gz', '.tar.xz', '.tar.bz2'];
const isTarFile = tarExtensions.includes(ext) || tarCompoundExtensions.some(compoundExt => fileName.endsWith(compoundExt));
if (ext === '.zip') {
const zip = new AdmZip(filePath);
try {
return zip.extractAllTo(dirPath);
}
catch (error) {
// Handle Windows special character issues by extracting files manually
if (getSystem() === SystemType.Win && error.message?.includes('ENOENT')) {
log('⚠️', 'Extracting files manually due to special characters in filenames');
const entries = zip.getEntries();
entries.forEach(entry => {
const sanitizedName = entry.entryName.replace(/[<>:"|?*]/g, '_').replace(/[\r\n]/g, '');
if (!entry.isDirectory) {
const outputPath = path.join(dirPath, sanitizedName);
dirCreate(path.dirname(outputPath));
writeFileSync(outputPath, entry.getData());
}
else {
dirCreate(path.join(dirPath, sanitizedName));
}
});
return;
}
}
}
else if (isTarFile) {
return await tar.extract({
file: filePath,
cwd: dirPath,
});
}
else if (ext === '.7z') {
return new Promise((resolve, reject) => {
unpack(filePath, dirPath, (err2) => {
if (err2)
return reject(new Error(`7z extraction failed: ${err2 && err2.message ? err2.message : String(err2)}`));
return resolve();
});
});
}
}
export function dirApp(dirName = 'open-audio-stack') {
if (getSystem() === SystemType.Win)
return process.env.APPDATA || path.join(os.homedir(), dirName);
else if (getSystem() === SystemType.Mac)
return path.join(os.homedir(), 'Library', 'Preferences', dirName);
return path.join(os.homedir(), '.local', 'share', dirName);
}
export function dirContains(parentDir, childDir) {
return path.normalize(childDir).startsWith(path.normalize(parentDir));
}
export function dirCreate(dir) {
if (!dirExists(dir)) {
log('+', dir);
mkdirSync(dir, { recursive: true });
return dir;
}
return false;
}
export function dirDelete(dir) {
if (dirExists(dir)) {
log('-', dir);
return rmSync(dir, { recursive: true });
}
return false;
}
export function dirEmpty(dir) {
const files = readdirSync(dir);
return files.length === 0 || (files.length === 1 && files[0] === '.DS_Store');
}
export function dirExists(dir) {
return existsSync(dir);
}
export function dirIs(dir) {
return statSync(dir).isDirectory();
}
export function dirMove(dir, dirNew) {
if (dirExists(dir)) {
log('-', dir);
log('+', dirNew);
return moveSync(dir, dirNew, { overwrite: true });
}
return false;
}
export function dirOpen(dir) {
let command = '';
if (process.env.CI)
return Buffer.from('');
if (getSystem() === SystemType.Win)
command = 'start ""';
else if (getSystem() === SystemType.Mac)
command = 'open';
else
command = 'xdg-open';
log('⎋', `${command} "${dir}"`);
return execSync(`${command} "${dir}"`);
}
export function dirPackage(pkg) {
const parts = pkg.slug.split('/');
parts.push(pkg.version);
return path.join(...parts);
}
export function dirPlugins() {
if (getSystem() === SystemType.Win)
return path.join('Program Files', 'Common Files');
else if (getSystem() === SystemType.Mac)
return path.join(os.homedir(), 'Library', 'Audio', 'Plug-ins');
return path.join('usr', 'local', 'lib');
}
export function dirPresets() {
if (getSystem() === SystemType.Win)
return path.join(os.homedir(), 'Documents', 'VST3 Presets');
else if (getSystem() === SystemType.Mac)
return path.join(os.homedir(), 'Library', 'Audio', 'Presets');
return path.join(os.homedir(), '.vst3', 'presets');
}
export function dirProjects() {
// Windows throws permissions errors if you scan hidden folders
// Therefore set to a more specific path than Documents
if (getSystem() === SystemType.Win)
return path.join(os.homedir(), 'Documents', 'Audio');
else if (getSystem() === SystemType.Mac)
return path.join(os.homedir(), 'Documents', 'Audio');
return path.join(os.homedir(), 'Documents', 'Audio');
}
export function dirApps() {
if (getSystem() === SystemType.Win)
return path.join(os.homedir(), 'AppData', 'Local', 'Programs');
else if (getSystem() === SystemType.Mac)
return path.join('/Applications');
return path.join('/usr', 'local', 'bin');
}
export function dirRead(dir, options) {
log('⌕', dir);
// Glob now expects forward slashes on Windows
// Convert backslashes from path.join() to forwardslashes
if (getSystem() === SystemType.Win) {
dir = dir.replace(/\\/g, '/');
}
// Ignore Mac files in Contents folders
// Filter out any paths not starting with the base directory
// This is to prevent issues with symlinks.
const baseDir = dir.includes('*') ? dir.split('*')[0] : dir;
const allPaths = globSync(dir, {
ignore: [`${baseDir}/**/*.{app,component,lv2,vst,vst3}/**/*`],
realpath: true,
...options,
});
// Glob input paths use forward slashes.
// Glob output paths are system-specific.
const baseDirCrossPlatform = baseDir.split('/').join(path.sep);
return allPaths.filter(p => p.startsWith(baseDirCrossPlatform));
}
export function dirRename(dir, dirNew) {
if (dirExists(dir)) {
return moveSync(dir, dirNew, { overwrite: true });
}
return false;
}
export function fileCreate(filePath, data) {
log('+', filePath);
return writeFileSync(filePath, data);
}
export function fileCreateJson(filePath, data) {
return fileCreate(filePath, JSON.stringify(data, null, 2));
}
export function fileCreateYaml(filePath, data) {
return fileCreate(filePath, yaml.dump(data));
}
export function fileDate(filePath) {
return statSync(filePath).mtime;
}
export function fileDelete(filePath) {
if (fileExists(filePath)) {
log('-', filePath);
return unlinkSync(filePath);
}
return false;
}
export function fileExec(filePath) {
return chmodSync(filePath, '755');
}
export function fileExists(filePath) {
return existsSync(filePath);
}
export async function fileHash(filePath, algorithm = 'sha256') {
log('⎋', filePath);
const input = createReadStream(filePath);
const hash = createHash(algorithm);
await stream.pipeline(input, hash);
return hash.digest('hex');
}
export function fileInstall(filePath) {
if (process.env.CI)
return Buffer.from('');
const ext = path.extname(filePath).toLowerCase();
let command = null;
switch (ext) {
case '.dmg':
command = `hdiutil attach -nobrowse "${filePath}" && sudo installer -pkg "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)" -target / && hdiutil detach "$(dirname "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)")"`;
break;
case '.pkg':
command = `sudo installer -pkg "${filePath}" -target /`;
break;
case '.deb':
command = `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`;
break;
case '.rpm':
command = `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`;
break;
case '.exe':
command = `start /wait "" "${filePath}" /quiet /norestart`;
break;
case '.msi':
command = `msiexec /i "${filePath}" /quiet /norestart`;
break;
default:
throw new Error(`Unsupported file format: ${ext}`);
}
log('⎋', command);
return execSync(command, { stdio: 'inherit' });
}
export function fileMove(filePath, newPath) {
if (fileExists(filePath)) {
log('-', filePath);
log('+', newPath);
return moveSync(filePath, newPath, { overwrite: true });
}
return false;
}
export function filesMove(dirSource, dirTarget, dirSub, formatDir) {
const filesAndFolders = dirRead(`${dirSource}/**/*`);
log('filesAndFolders', filesAndFolders);
// First pass: identify bundle directories (app, clap, vst3, lv2, etc.)
const bundleDirs = new Set();
filesAndFolders.forEach(f => {
if (dirIs(f)) {
// Check if this is a macOS application bundle or plugin bundle
if (fileExists(path.join(f, 'Contents', 'Info.plist'))) {
bundleDirs.add(f);
}
// Check if this is an LV2 plugin folder
if (fileExists(path.join(f, 'manifest.ttl'))) {
bundleDirs.add(f);
}
}
});
const files = filesAndFolders.filter(f => {
// Exclude files/folders that are inside bundle directories
for (const bundleDir of bundleDirs) {
if (f.startsWith(bundleDir + path.sep)) {
return false; // This path is inside a bundle, exclude it
}
}
// Include regular files (not directories).
if (!dirIs(f))
return true;
// Include bundle directories themselves (already identified above).
if (bundleDirs.has(f))
return true;
// Otherwise ignore.
return false;
});
const filesMoved = [];
log('files', files);
// For each file, move to correct folder based on type
files.forEach((fileSource) => {
const fileExt = path.extname(fileSource).slice(1).toLowerCase();
let fileExtTarget = formatDir[fileExt];
// Use mime-type detection as fallback for unmapped extensions
if (!fileExtTarget) {
const mimeType = mime.lookup(fileSource) || '';
if (!mimeType || mimeType.startsWith('application/')) {
fileExtTarget = 'App';
}
}
// If this is not a supported file format, then ignore.
if (fileExtTarget === undefined)
return log(`${fileSource} - ${fileExt || 'no extension'} not mapped to a installation folder, skipping.`);
const fileTarget = path.join(dirTarget, fileExtTarget, dirSub, path.basename(fileSource));
if (fileExists(fileTarget))
return log(`${fileSource} - ${fileTarget} already exists, skipping.`);
dirCreate(path.dirname(fileTarget));
fileMove(fileSource, fileTarget);
// Set executable permissions for executable file types
if (fileExt === 'app') {
// For .app bundles, find and set permissions on the actual executable
const executablePath = path.join(fileTarget, 'Contents', 'MacOS', path.basename(fileTarget, '.app'));
if (fileExists(executablePath)) {
fileExec(executablePath);
}
}
else if (['elf', 'exe', ''].includes(fileExt)) {
fileExec(fileTarget);
}
filesMoved.push(fileTarget);
});
return filesMoved;
}
export function fileOpen(filePath, options = []) {
if (process.env.CI)
return Buffer.from('');
if (getSystem() === SystemType.Mac) {
const isExecutable = !path.extname(filePath);
if (isExecutable) {
// Use spawn for executables with stdio inherit to show output
log('⎋', `spawn "${filePath}" ${options.join(' ')}`);
const child = spawn(filePath, options, { stdio: 'inherit' });
return child;
}
else {
log('⎋', `open "${filePath}"`);
return execSync(`open "${filePath}"`);
}
}
let command = '';
if (getSystem() === SystemType.Win)
command = 'start ""';
else
command = 'xdg-open';
log('⎋', `${command} "${filePath}"`);
return execSync(`${command} "${filePath}"`);
}
export function fileRead(filePath) {
log('⎋', filePath);
return readFileSync(filePath, 'utf8');
}
export function fileReadJson(filePath) {
if (fileExists(filePath)) {
log('⎋', filePath);
return JSON.parse(readFileSync(filePath, 'utf8').toString());
}
return false;
}
export function fileReadString(filePath) {
log('⎋', filePath);
return readFileSync(filePath, 'utf8').toString();
}
export function fileReadYaml(filePath) {
const file = fileReadString(filePath);
return yaml.load(file);
}
export function fileSize(filePath) {
return statSync(filePath).size;
}
export function isAdmin() {
if (process.platform === 'win32') {
try {
execFileSync('net', ['session'], { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
else {
return process && process.getuid ? process.getuid() === 0 : false;
}
}
export async function fileValidateMetadata(filePath, fileMetadata) {
const errors = [];
const hash = await fileHash(filePath);
if (fileMetadata.sha256 !== hash) {
errors.push({
code: ZodIssueCode.invalid_type,
expected: fileMetadata.sha256,
message: 'Required',
path: ['sha256'],
received: hash,
});
}
if (fileMetadata.size !== fileSize(filePath)) {
errors.push({
code: ZodIssueCode.invalid_type,
expected: String(fileMetadata.size),
message: 'Required',
path: ['size'],
received: String(fileSize(filePath)),
});
}
return errors;
}
export function getPlatform() {
if (getSystem() === SystemType.Win)
return SystemType.Win;
else if (getSystem() === SystemType.Mac)
return SystemType.Mac;
return SystemType.Linux;
}
export function runCliAsAdmin(args) {
return new Promise((resolve, reject) => {
const filename = fileURLToPath(import.meta.url).replace('src/', 'build/');
const dirPathClean = dirname(filename).replace('app.asar', 'app.asar.unpacked');
const script = path.join(dirPathClean, 'admin.js');
log(`Running as admin: node "${script}" ${args}`);
const cmd = `node "${script}" ${args}`;
sudoPrompt.exec(cmd, { name: 'Open Audio Stack' }, (error, stdout, stderr) => {
// Convert stdout/stderr buffers to strings for inspection
const stdoutStr = stdout ? (typeof stdout === 'string' ? stdout : stdout.toString()) : '';
const stderrStr = stderr ? (typeof stderr === 'string' ? stderr : stderr.toString()) : '';
const out = stdoutStr + stderrStr;
log(out);
// Try to parse structured JSON output from the admin script first.
// Admin script outputs JSON on its own line after a newline, so look for the last JSON object.
const lines = out.split('\n');
let jsonPayload = null;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line)
continue; // Skip empty lines
try {
jsonPayload = JSON.parse(line);
break; // Found valid JSON, stop searching backwards
}
catch {
// This line is not JSON, continue searching
}
}
// If we found JSON output from admin script, prioritize it over sudoPrompt error
if (jsonPayload) {
if (jsonPayload && (jsonPayload.status === 'ok' || jsonPayload.code === 0)) {
return resolve();
}
const errMsg = jsonPayload && jsonPayload.message ? jsonPayload.message : JSON.stringify(jsonPayload);
return reject(new Error(`runCliAsAdmin: admin command reported error: ${errMsg}`));
}
// If no JSON found, check for sudoPrompt error
if (error) {
const msg = `runCliAsAdmin: admin command failed: ${error && error.message ? error.message : String(error)}${stderrStr ? `\nstderr: ${stderrStr}` : ''}`;
const err = new Error(msg);
err.code = error && error.code ? error.code : undefined;
return reject(err);
}
return reject(new Error(`runCliAsAdmin: admin command did not report completion. stdout: ${stdoutStr} stderr: ${stderrStr}`));
});
});
}
export function zipCreate(filesPath, zipPath) {
if (fileExists(zipPath)) {
unlinkSync(zipPath);
}
const zip = new AdmZip();
const pathList = dirRead(filesPath);
pathList.forEach(pathItem => {
log('⎋', pathItem);
try {
if (dirIs(pathItem)) {
zip.addLocalFolder(pathItem, path.basename(pathItem));
}
else {
zip.addLocalFile(pathItem);
}
}
catch (error) {
log(error);
}
});
log('+', zipPath);
return zip.writeZip(zipPath);
}