UNPKG

@open-audio-stack/core

Version:
487 lines (486 loc) 18.5 kB
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); }