Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 21 additions & 108 deletions src/classes/ManagerLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,15 @@ import {
fileCreate,
fileCreateJson,
fileCreateYaml,
fileExec,
fileExists,
fileHash,
fileInstall,
fileOpen,
fileReadJson,
fileReadYaml,
filesMove,
isAdmin,
runCliAsAdmin,
} from '../helpers/file.js';
import { installArchiveFile, installInstallerFile } from '../helpers/installTargets.js';
import { isValidGithubRepo, isValidSlug, isValidVersion, pathGetSlug, pathGetVersion } from '../helpers/utils.js';
import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js';
import { apiBuffer } from '../helpers/api.js';
Expand All @@ -38,11 +36,8 @@ import { ConfigInterface } from '../types/Config.js';
import { ConfigLocal } from './ConfigLocal.js';
import { packageCompatibleFiles, packageErrors, packageRecommendations } from '../helpers/package.js';
import { PresetInterface } from '../types/Preset.js';
import { presetFormatDir } from '../types/PresetFormat.js';
import { ProjectInterface } from '../types/Project.js';
import { projectFormatDir } from '../types/ProjectFormat.js';
import { FileFormat } from '../types/FileFormat.js';
import { PluginType } from '../types/PluginType.js';
import { SystemType } from '../types/SystemType.js';
import { packageLoadFile, packageSaveFile } from '../helpers/packageLocal.js';

Expand Down Expand Up @@ -354,16 +349,14 @@ export class ManagerLocal extends Manager {

// If installer, run the installer headless (without the user interface).
if (file.type === FileType.Installer) {
// Test time out if installing during tests.
if (isTests()) fileOpen(filePath);
else fileInstall(filePath);
// Currently we don't get a list of paths from the installer.
// Create empty directory and save package version information.
// Installers have to be manually uninstalled for now.
const dirTarget: string = path.join(this.typeDir, 'Installers', slug, versionNum);
dirCreate(dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
installedDirs.add(dirTarget);
installInstallerFile({
typeDir: this.typeDir,
slug,
versionNum,
pkgVersion,
filePath,
isTestsFn: isTests,
}).forEach(dir => installedDirs.add(dir));
}

// If archive, extract the archive to temporary directory, then move individual files.
Expand All @@ -375,98 +368,18 @@ export class ManagerLocal extends Manager {
slug,
versionNum,
);
const dirSub: string = path.join(slug, versionNum);
let formatDir: Record<string, string> = pluginFormatDir;
if (this.type === RegistryType.Apps) formatDir = pluginFormatDir;
else if (this.type === RegistryType.Presets) formatDir = presetFormatDir;
else if (this.type === RegistryType.Projects) formatDir = projectFormatDir;
await archiveExtract(filePath, dirSource);

// Move entire directory, maintaining the same folder structure.
if (pkgVersion.type === PluginType.Sampler) {
const dirTarget: string = path.join(this.typeDir, 'Samplers', dirSub);
dirCreate(dirTarget);
dirMove(dirSource, dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
installedDirs.add(dirTarget);
} else {
// Check if archive contains installer files (pkg, dmg) that should be run
const allFiles = dirRead(`${dirSource}/**/*`).filter(f => !dirIs(f));
const installerFiles = allFiles.filter(f => {
const ext = path.extname(f).toLowerCase();
return ext === '.pkg' || ext === '.dmg';
});

if (installerFiles.length > 0) {
// Run installer files found in archive
for (const installerFile of installerFiles) {
if (isTests()) fileOpen(installerFile);
else fileInstall(installerFile);
}
// Create directory and save package info for installer
const dirTarget: string = path.join(this.typeDir, 'Installers', dirSub);
dirCreate(dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
installedDirs.add(dirTarget);
} else if (this.type === RegistryType.Plugins) {
// For plugins, move files into type-specific subdirectories
const filesMoved: string[] = filesMove(dirSource, this.typeDir, dirSub, formatDir);
if (filesMoved.length === 0) {
throw new Error(`No compatible files found to install for ${slug}`);
}
filesMoved.forEach((fileMoved: string) => {
const fileJson: string = path.join(path.dirname(fileMoved), 'index.json');
fileCreateJson(fileJson, pkgVersion);
// A single archive can contain multiple formats (e.g. VST3 and CLAP), moved into
// different formatDir subdirectories - track each one, not just the first.
installedDirs.add(path.dirname(fileMoved));
});
} else {
// For apps/projects/presets, move entire directory without type subdirectories
const dirTarget: string = path.join(this.typeDir, dirSub);
dirCreate(dirTarget);
dirMove(dirSource, dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
installedDirs.add(dirTarget);
// Ensure executable permissions for likely executables inside moved app/project/preset
try {
const movedFiles = dirRead(path.join(dirTarget, '**', '*')).filter(f => !dirIs(f));
movedFiles.forEach((movedFile: string) => {
const ext = path.extname(movedFile).slice(1).toLowerCase();
if (['', 'elf', 'exe'].includes(ext)) {
try {
fileExec(movedFile);
} catch (err) {
this.log(`Failed to set exec on ${movedFile}:`, err);
}
}
});
} catch (err) {
this.log('Error while setting executable permissions:', err);
}
// Also handle macOS .app bundles: set exec on binaries in Contents/MacOS
try {
const appDirs = dirRead(path.join(dirTarget, '**', '*.app')).filter(d => dirIs(d));
appDirs.forEach((appDir: string) => {
try {
const macosBinPattern = path.join(appDir, 'Contents', 'MacOS', '**', '*');
const macosFiles = dirRead(macosBinPattern).filter(f => !dirIs(f));
macosFiles.forEach((binFile: string) => {
try {
fileExec(binFile);
} catch (err) {
this.log(`Failed to set exec on app binary ${binFile}:`, err);
}
});
} catch (err) {
this.log(`Error scanning .app contents for ${appDir}:`, err);
}
});
} catch (err) {
this.log(err);
}
}
}
const dirs = await installArchiveFile({
typeDir: this.typeDir,
type: this.type,
slug,
versionNum,
pkgVersion,
filePath,
dirSource,
isTestsFn: isTests,
log: (...args: any) => this.log(...args),
});
dirs.forEach(dir => installedDirs.add(dir));
}
}
} catch (err) {
Expand Down
210 changes: 210 additions & 0 deletions src/helpers/installTargets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import path from 'path';
import {
archiveExtract,
dirCreate,
dirIs,
dirMove,
dirRead,
fileCreateJson,
fileExec,
fileInstall,
fileOpen,
} from './file.js';
import { filesMove } from './file.js';
import { PackageVersion } from '../types/Package.js';
import { pluginFormatDir } from '../types/PluginFormat.js';
import { PluginType } from '../types/PluginType.js';
import { RegistryType } from '../types/Registry.js';

// Each install target function below handles exactly one of install()'s per-file-type
// destinations, and returns every directory it populated under `typeDir` (the live, user-facing
// install location) so the caller can track them for rollback on a later failure - see
// ManagerLocal.install(). Each is a small, self-contained unit: given a real (or temp-directory)
// filesystem and a package version, it can be called and its result asserted directly, without
// going through install()'s download/hash-check/elevation orchestration at all.

export interface InstallerFileParams {
typeDir: string;
slug: string;
versionNum: string;
pkgVersion: PackageVersion;
filePath: string;
isTestsFn: () => boolean;
}

// FileType.Installer: run the installer headless (or open it, under tests) and record a marker
// directory - installers install themselves outside typeDir, so there's nothing to move here;
// the directory only lets scan()/isPackageInstalled() know this version is present.
export function installInstallerFile(params: InstallerFileParams): string[] {
const { typeDir, slug, versionNum, pkgVersion, filePath, isTestsFn } = params;
// Test time out if installing during tests.
if (isTestsFn()) fileOpen(filePath);
else fileInstall(filePath);
// Currently we don't get a list of paths from the installer.
// Create empty directory and save package version information.
// Installers have to be manually uninstalled for now.
const dirTarget: string = path.join(typeDir, 'Installers', slug, versionNum);
dirCreate(dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
return [dirTarget];
}

export interface ArchiveFileParams {
typeDir: string;
type: RegistryType;
slug: string;
versionNum: string;
pkgVersion: PackageVersion;
filePath: string;
// Scratch directory to extract into - the caller computes this since it needs appDir/file.type,
// neither of which this module otherwise needs to know about.
dirSource: string;
isTestsFn: () => boolean;
log: (...args: any) => void;
}

// FileType.Archive: extract to the given scratch directory, then dispatch to whichever final
// destination applies to this package/archive - a Sampler moves as one opaque unit, an archive
// containing its own installer (.pkg/.dmg) runs it and just marks presence (like
// installInstallerFile above), a Plugins-type package's files get sorted into format-specific
// subdirectories, and everything else (apps/presets/projects) moves as a flat directory.
export async function installArchiveFile(params: ArchiveFileParams): Promise<string[]> {
const { typeDir, type, slug, versionNum, pkgVersion, filePath, dirSource, isTestsFn, log } = params;
const dirSub: string = path.join(slug, versionNum);
await archiveExtract(filePath, dirSource);

if (pkgVersion.type === PluginType.Sampler) {
return installSamplerArchive(typeDir, dirSource, dirSub, pkgVersion);
}

const embeddedInstallers = findEmbeddedInstallers(dirSource);
if (embeddedInstallers.length > 0) {
return installEmbeddedInstallers(typeDir, dirSub, embeddedInstallers, pkgVersion, isTestsFn);
}

if (type === RegistryType.Plugins) {
return installPluginFormats(typeDir, dirSource, dirSub, slug, pkgVersion);
}

return installFlatDirectory(typeDir, dirSource, dirSub, pkgVersion, log);
}

// Move entire directory, maintaining the same folder structure.
function installSamplerArchive(
typeDir: string,
dirSource: string,
dirSub: string,
pkgVersion: PackageVersion,
): string[] {
const dirTarget: string = path.join(typeDir, 'Samplers', dirSub);
dirCreate(dirTarget);
dirMove(dirSource, dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
return [dirTarget];
}

// A .pkg/.dmg found inside an otherwise-archive-typed download (e.g. a zip wrapping a macOS
// installer) - these still need to run as an installer rather than moving into typeDir directly.
function findEmbeddedInstallers(dirSource: string): string[] {
return dirRead(`${dirSource}/**/*`)
.filter(f => !dirIs(f))
.filter(f => ['.pkg', '.dmg'].includes(path.extname(f).toLowerCase()));
}

function installEmbeddedInstallers(
typeDir: string,
dirSub: string,
installerFiles: string[],
pkgVersion: PackageVersion,
isTestsFn: () => boolean,
): string[] {
// Run installer files found in archive
for (const installerFile of installerFiles) {
if (isTestsFn()) fileOpen(installerFile);
else fileInstall(installerFile);
}
// Create directory and save package info for installer
const dirTarget: string = path.join(typeDir, 'Installers', dirSub);
dirCreate(dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
return [dirTarget];
}

// For plugins, move files into type-specific subdirectories. Only ever called for
// RegistryType.Plugins (see installArchiveFile above) - pluginFormatDir is the only formatDir
// that can apply here, so unlike the pre-refactor code this doesn't branch on `type` to pick a
// formatDir: that branch could never actually select presetFormatDir/projectFormatDir in
// practice, since this function itself is only reached when type === Plugins.
function installPluginFormats(
typeDir: string,
dirSource: string,
dirSub: string,
slug: string,
pkgVersion: PackageVersion,
): string[] {
const filesMoved: string[] = filesMove(dirSource, typeDir, dirSub, pluginFormatDir);
if (filesMoved.length === 0) {
throw new Error(`No compatible files found to install for ${slug}`);
}
const dirsPopulated = new Set<string>();
filesMoved.forEach((fileMoved: string) => {
const fileJson: string = path.join(path.dirname(fileMoved), 'index.json');
fileCreateJson(fileJson, pkgVersion);
// A single archive can contain multiple formats (e.g. VST3 and CLAP), moved into different
// formatDir subdirectories - track each one, not just the first.
dirsPopulated.add(path.dirname(fileMoved));
});
return Array.from(dirsPopulated);
}

// For apps/projects/presets, move entire directory without type subdirectories.
function installFlatDirectory(
typeDir: string,
dirSource: string,
dirSub: string,
pkgVersion: PackageVersion,
log: (...args: any) => void,
): string[] {
const dirTarget: string = path.join(typeDir, dirSub);
dirCreate(dirTarget);
dirMove(dirSource, dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
// Ensure executable permissions for likely executables inside moved app/project/preset
try {
const movedFiles = dirRead(path.join(dirTarget, '**', '*')).filter(f => !dirIs(f));
movedFiles.forEach((movedFile: string) => {
const ext = path.extname(movedFile).slice(1).toLowerCase();
if (['', 'elf', 'exe'].includes(ext)) {
try {
fileExec(movedFile);
} catch (err) {
log(`Failed to set exec on ${movedFile}:`, err);
}
}
});
} catch (err) {
log('Error while setting executable permissions:', err);
}
// Also handle macOS .app bundles: set exec on binaries in Contents/MacOS
try {
const appDirs = dirRead(path.join(dirTarget, '**', '*.app')).filter(d => dirIs(d));
appDirs.forEach((appDir: string) => {
try {
const macosBinPattern = path.join(appDir, 'Contents', 'MacOS', '**', '*');
const macosFiles = dirRead(macosBinPattern).filter(f => !dirIs(f));
macosFiles.forEach((binFile: string) => {
try {
fileExec(binFile);
} catch (err) {
log(`Failed to set exec on app binary ${binFile}:`, err);
}
});
} catch (err) {
log(`Error scanning .app contents for ${appDir}:`, err);
}
});
} catch (err) {
log(err);
}
return [dirTarget];
}
Loading
Loading