Source: ableton-prefs.js

import fs from 'node:fs/promises'
import _ from 'lodash-es'
import { platform } from 'node:os'
import path from 'node:path'

/**
 * @typedef {Object} VstPluginConfig
 * @property {boolean} isEnabled - Whether the plug-in format is enabled in Ableton Live.
 * @property {string|null} customPath - The user-configured custom search path, or `null`.
 * @property {boolean|null} isCustomPathEnabled - Whether the custom path is active, or `null` when undetermined.
 */

/**
 * @typedef {Object} AuPluginConfig
 * @property {boolean} isEnabled - Whether Audio Units are enabled in Ableton Live.
 */

/**
 * @typedef {Object} PluginConfig
 * @property {VstPluginConfig} vst3 - VST3 plug-in configuration.
 * @property {VstPluginConfig} vst2 - VST2 plug-in configuration.
 * @property {AuPluginConfig} au - Audio Unit configuration.
 */

/**
 * Parses an Ableton Live binary preferences file to extract plug-in configuration.
 *
 * The constructor is async: it must be `await`-ed to get a fully initialised instance.
 *
 * @example
 * const prefs = await new AbletonPrefs('/path/to/Ableton/Live 12.0.0/Preferences.cfg')
 * console.log(prefs.PluginConfig)
 */
export class AbletonPrefs {
	#_bytes = null
	#_text = null
	#_vst3Config = null
	#_auConfig = null
	#_vst2Config = null
	#_bytesSegment = null
	#_strSegment = null
	#_os = null

	/**
	 * @param {string} path - Absolute path to the Ableton Live preferences file.
	 * @returns {Promise<AbletonPrefs>} A fully initialised `AbletonPrefs` instance.
	 * @throws {Error} When the file does not exist or cannot be read.
	 */
	constructor(path) {
		this.path = path
		this.os = platform()
		return (async () => {
			try {
				await fs.access(path)
			} catch (Err) {
				throw new Error(`File not found: ${path}`)
			}

			// grab the bytes as Uint8Array from the filesystem
			this.#_bytes = await fs.readFile(path)
			// convert a text representation of the bytes, easier for simpler processing
			this.#_text = new TextDecoder('utf-8').decode(this.#_bytes)

			this.#_strSegment = this.#_getPluginManagerSegment(this.#_text)

			return this
		})()
	}

	/**
	 * Aggregated plug-in configuration parsed from the preferences file.
	 * Triggers parsing of VST3, VST2, and AU sections on first access.
	 * @type {PluginConfig}
	 */
	get PluginConfig() {
		this.getVst3Configuration()
		this.getVst2Configuration()
		this.getAuConfiguration()

		return {
			vst3: this.#_vst3Config,
			vst2: this.#_vst2Config,
			au: this.#_auConfig,
		}
	}

	/**
	 * The raw bytes of the preferences file as a `Uint8Array`.
	 * @type {Uint8Array}
	 */
	get bytes() {
		return this.#_bytes
	}

	/**
	 * Parses and returns the Audio Unit configuration from the preferences file.
	 * @returns {AuPluginConfig}
	 */
	getAuConfiguration() {
		this.#_auConfig = {
			isEnabled: this.#_strSegment.includes('AuFolder'),
		}

		return this.#_auConfig
	}

	/**
	 * Parses and returns the VST2 plug-in configuration from the preferences file.
	 *
	 * **Note:** This method currently fails on Windows.
	 * Edge cases not yet handled:
	 * - VST2 system path disabled but custom directory enabled
	 * - VST2 system path enabled but custom directory disabled
	 *
	 * @returns {VstPluginConfig}
	 */
	getVst2Configuration() {
		// does the segment include PlugScanInfo data?
		const isEnabled = this.#_strSegment.includes('PlugScanInfo')
		let vstIndexes = this.findAllOccurrences('VstManager', this.#_bytes)
		let segment = this.#_bytes.slice(vstIndexes[1], vstIndexes[1] + 300)
		let _str = this.bytesToStringArr(segment)

		let customPath = null,
			isCustomPathEnabled = null

		if (segment[14] === 0) {
			isCustomPathEnabled = false
		} else if (segment[14] === 28 || segment[14] === 41) {
			isCustomPathEnabled = true
			customPath = this.extractVst2CustomPath(segment)
		}

		this.#_vst2Config = {
			isEnabled,
			customPath,
			isCustomPathEnabled,
		}

		return this.#_vst2Config
	}

	/**
	 * Extracts a VST3 custom search path from a byte slice of the preferences file.
	 * Works for both macOS and Windows preferences files.
	 *
	 * Windows example: `Vst3Preferences\x02\x01\x01)C:/Users/aniso/OneDrive/Desktop/hack/vst3\x15`
	 * macOS example:   `Vst3Preferences\x02\x01\x01#/Users/jeff/Desktop/tmp/vst3-custom\x15`
	 *
	 * @param {Uint8Array} bytes - A byte slice starting at the `Vst3Preferences` marker.
	 * @returns {string|false} The extracted path string, or `false` when no custom path is set.
	 */
	extractVst3CustomPath(bytes) {
		// console.log('bytes', bytes)

		let textSegment = this.bytesToStringArr(bytes)
		let filtered = textSegment.filter((char) => {
			if (char !== '\\x00') return true
		})

		let _filtered = filtered.join('')

		// console.log('filtered', _filtered)

		// Unified regex that matches both Windows and macOS paths
		// - Windows: C:/path/to/folder or C:\path\to\folder (with support for spaces and colons in path)
		// - macOS: /path/to/folder
		// Both terminated by \x15
		const regex = /(?:[)#])([A-Z]:[\/\\][\w\/\\\-\s:]+?|\/[\w\/\-\s]+?)\\x15/

		const match = regex.exec(_filtered)

		// console.log('match!', match)

		if (match && match[1]) {
			return match[1]
		} else {
			return false
		}
	}

	/**
	 * Extracts a VST2 custom search path from a byte slice of the preferences file.
	 *
	 * **Note:** This method currently fails on Windows.
	 *
	 * @param {Uint8Array} bytes - A byte slice starting at the second `VstManager` marker.
	 * @returns {string|undefined} The extracted path string, or `undefined` when no custom path is found.
	 */
	extractVst2CustomPath(bytes) {
		let textSegment = this.bytesToStringArr(bytes)
		let match = null

		let filtered = textSegment
			.filter((char) => {
				if (char !== '\\x00') return true
			})
			.join('')

		if (bytes[14] === 0) {
			return null
		} else if (bytes[14] === 28) {
			match = filtered.match(/(\/[\w\/\-]+?)\\x01/)
		} else if (bytes[14] === 41) {
			match = filtered.match(/\)(C\:\/[\w\/]+?)\)/)
		}

		if (match) {
			return match[1]
		}
	}

	/**
	 * Parses and returns the VST3 plug-in configuration from the preferences file.
	 * @returns {VstPluginConfig}
	 * @throws {Error} When the byte at index 15 of the VST3 segment has an unexpected value.
	 */
	getVst3Configuration() {
		// segment for vst3 detection
		let indexes = this.findAllOccurrences('Vst3Preferences', this.#_bytes)
		let bytes = this.#_bytes.slice(indexes[1], indexes[1] + 200)

		if (!bytes[15] === 2) {
			throw new Error('Unexpected byte value at index 15, expected 2')
		}

		const SYSTEM_ENABLED_BYTE = 19
		const CUSTOM_ENABLED_BYTE = 20

		const isEnabled = bytes[SYSTEM_ENABLED_BYTE] === 1
		const isCustomPathEnabled = bytes[CUSTOM_ENABLED_BYTE] === 1

		let customPath = this.extractVst3CustomPath(bytes)

		this.#_vst3Config = {
			isEnabled,
			isCustomPathEnabled,
			customPath,
		}

		return this.#_vst3Config
	}

	/**
	 * Converts a `Uint8Array` (or any byte-like array) to an array of strings where
	 * printable ASCII characters (32–126) are represented as themselves and all other
	 * bytes are represented as hex escape sequences (e.g. `\x0f`).
	 *
	 * @param {Uint8Array} bytes - The bytes to convert.
	 * @param {Object} [options={}] - Formatting options.
	 * @param {string} [options.hexPrefix='\\x'] - Prefix for non-printable bytes (e.g. `'0x'`, `'%'`).
	 * @param {boolean} [options.lowercase=true] - When `true`, hex digits are lowercased.
	 * @returns {string[]} Array of character or hex-escape strings, one element per byte.
	 */
	bytesToStringArr(bytes, options = {}) {
		const {
			hexPrefix = '\\x', // alternatives: '0x', '%'
			lowercase = true,
		} = options

		let out = []

		bytes.forEach((byte) => {
			// Printable ASCII characters: 32-126
			// console.log('byte', byte)
			if (byte >= 32 && byte <= 126) {
				// console.log('str', String.fromCharCode(byte))
				out.push(String.fromCharCode(byte))
				// return String.fromCharCode(byte)
			} else {
				// Non-printable: convert to hex
				const hex = byte.toString(16).padStart(2, '0')
				// console.log('hex', hex)
				out.push(`${hexPrefix}${lowercase ? hex : hex.toUpperCase()}`)
			}
		})

		return out
	}

	/**
	 * Finds all byte-offsets at which the UTF-8 encoding of `needle` appears in `bytes`.
	 * Overlapping matches are skipped (each match advances past the full pattern length).
	 *
	 * @param {string} needle - The string pattern to search for.
	 * @param {Uint8Array} bytes - The buffer to search within.
	 * @returns {number[]} Array of starting byte offsets where `needle` was found.
	 */
	findAllOccurrences(needle, bytes) {
		const pattern = new TextEncoder().encode(needle)
		const matches = []

		outer: for (let i = 0; i <= bytes.length - pattern.length; i++) {
			for (let j = 0; j < pattern.length; j++) {
				if (bytes[i + j] !== pattern[j]) continue outer
			}
			matches.push(i)
			i += pattern.length - 1 // skip ahead (optional: remove to find overlapping matches)
		}

		return matches
	}

	#_getPluginManagerSegment() {
		const segmentStart = this.#_text.indexOf('PluginManager')
		if (segmentStart === -1) {
			throw new Error('PluginManager segment not found')
		}

		const segmentEnd = this.#_text.indexOf('SongPrefData', segmentStart)

		return this.#_text.slice(segmentStart, segmentEnd)
	}
}